package ui import ( "context" "errors" "path/filepath" "strings" "sync/atomic" "testing" "time" "gioui.org/layout" "gioui.org/widget/material" ) func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) { shell := NewShell(material.NewTheme()) seen := map[string]bool{} for _, row := range shell.rows { seen[row.Status] = true } for _, status := range []string{"运行中", "启动中", "外部已关联", "已退出"} { if !seen[status] { t.Fatalf("status %q is missing", status) } } } func TestShellReportsSafeStartupIssue(t *testing.T) { shell := NewShell(material.NewTheme()) shell.ReportStartupIssue(" 程序目录中的 config.json 无法使用;请检查目录权限或恢复旧配置。 ") if shell.instanceFeedback != "程序目录中的 config.json 无法使用;请检查目录权限或恢复旧配置。" { t.Fatalf("startup feedback = %q", shell.instanceFeedback) } } func TestShellInstanceListScrollsVertically(t *testing.T) { shell := NewShell(material.NewTheme()) if shell.list.Axis != layout.Vertical { t.Fatalf("instance list axis = %v, want vertical", shell.list.Axis) } } func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *testing.T) { shell := NewShell(material.NewTheme()) if len(shell.rows) < 2 { t.Fatal("expected fixture rows") } for _, row := range shell.rows { if row.ID == "" || row.Name == "" || row.Browser == "" || row.UserDataDir == "" || row.Status == "" { t.Fatalf("incomplete instance row: %#v", row) } } first := shell.startClickFor(shell.rows[0].ID) second := shell.startClickFor(shell.rows[1].ID) if first == second { t.Fatal("rows share a start button state") } if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) { t.Fatal("rows share a delete button state") } if shell.editClickFor(shell.rows[0].ID) == shell.editClickFor(shell.rows[1].ID) { t.Fatal("rows share an edit button state") } if shell.rowClickFor(shell.rows[0].ID) == shell.rowClickFor(shell.rows[1].ID) { t.Fatal("rows share a row interaction state") } } func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) { got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceProxyColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight if got != 100 { t.Fatalf("instance column weights = %d, want 100", got) } if instanceDirectoryColumnWeight <= instanceNameColumnWeight { t.Fatal("user data directory must have the widest instance-list column") } if instanceProxyColumnWeight >= instanceDirectoryColumnWeight { t.Fatal("proxy name must remain a compact display-only column") } if instanceActionColumnWeight <= instanceStatusColumnWeight { t.Fatal("actions column must fit both compact icon commands") } } func TestShellDeletesOnlyTheRequestedInstance(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[1] remaining := shell.rows[2].ID shell.startClickFor(target.ID) shell.deleteClickFor(target.ID) shell.deleteInstance(target.ID) if len(shell.rows) != 3 { t.Fatalf("instance count = %d, want 3", len(shell.rows)) } for _, row := range shell.rows { if row.ID == target.ID { t.Fatalf("deleted instance %q remains in the list", target.ID) } } if _, exists := shell.startClicks[target.ID]; exists { t.Fatal("deleted instance start control was not released") } if _, exists := shell.deleteClicks[target.ID]; exists { t.Fatal("deleted instance delete control was not released") } if shell.rows[1].ID != remaining { t.Fatalf("remaining instance id = %q, want %q", shell.rows[1].ID, remaining) } } func TestShellDeletesOnlyAfterConfirmation(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[1] changes := 0 shell.OnInstancesChanged(func([]InstanceRow) { changes++ }) shell.requestDelete(target.ID) if shell.pendingDeleteID != target.ID { t.Fatalf("pending delete = %q, want %q", shell.pendingDeleteID, target.ID) } if len(shell.rows) != 4 { t.Fatalf("instance count changed before confirmation: %d", len(shell.rows)) } shell.cancelDelete() if shell.pendingDeleteID != "" || len(shell.rows) != 4 || changes != 0 { t.Fatalf("cancel delete state = pending %q, rows %d, changes %d", shell.pendingDeleteID, len(shell.rows), changes) } shell.requestDelete(target.ID) shell.confirmDelete() if shell.pendingDeleteID != "" || len(shell.rows) != 3 || changes != 1 { t.Fatalf("confirm delete state = pending %q, rows %d, changes %d", shell.pendingDeleteID, len(shell.rows), changes) } } func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] started := make(chan InstanceRow, 1) shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) { started <- row return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil }, nil) shell.requestStart(target.ID) if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "启动中" { t.Fatalf("start status = %#v, want 启动中", row) } select { case got := <-started: if got.ID != target.ID { t.Fatalf("started instance = %q, want %q", got.ID, target.ID) } case <-time.After(time.Second): t.Fatal("start callback was not invoked") } var result instanceStartResult select { case result = <-shell.startResults: case <-time.After(time.Second): t.Fatal("start result was not produced") } shell.startResults <- result shell.consumeStartResults() if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "运行中" || row.PID != 4242 || row.RemoteDebugPort != 9666 { t.Fatalf("completed start status = %#v, want 运行中", row) } } func TestShellAssignsAndRecommendsUnreservedPreferredPorts(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetSettings(SettingsState{RemoteDebugStartPort: 9777}) shell.SetInstances([]InstanceRow{ {ID: "preferred", PreferredRemoteDebugPort: 9777}, {ID: "runtime", RemoteDebugPort: 9778}, {ID: "legacy"}, {ID: "legacy-second"}, }) if shell.rows[1].PreferredRemoteDebugPort != 9779 || shell.rows[2].PreferredRemoteDebugPort != 9780 || shell.rows[3].PreferredRemoteDebugPort != 9781 { t.Fatalf("assigned preferred ports = %#v", shell.rows) } if got := shell.recommendRemoteDebugPort(""); got != 9782 { t.Fatalf("recommended port = %d, want 9782", got) } reserved := shell.reservedRemoteDebugPorts("legacy") if len(reserved) != 4 || reserved[0] != 9777 || reserved[1] != 9779 || reserved[2] != 9778 || reserved[3] != 9781 { t.Fatalf("reserved ports = %#v", reserved) } } func TestInstancePortLabelDistinguishesSuggestedAndActualPorts(t *testing.T) { cases := []struct { row InstanceRow want string }{ {InstanceRow{Status: "已退出", PreferredRemoteDebugPort: 9666}, "建议 9666"}, {InstanceRow{Status: "启动中", PreferredRemoteDebugPort: 9667}, "准备 9667"}, {InstanceRow{Status: "运行中", PreferredRemoteDebugPort: 9666, RemoteDebugPort: 9668}, "实际 9668"}, {InstanceRow{Status: "外部已关联", RemoteDebugPort: 9669}, "外部 9669"}, } for _, test := range cases { if got := instancePortLabel(test.row); got != test.want { t.Fatalf("instancePortLabel(%#v) = %q, want %q", test.row, got, test.want) } } } func TestShellPersistsFallbackPortAfterSuccessfulManagedStart(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetInstances([]InstanceRow{{ID: "instance", Name: "实例", Status: "启动中", PreferredRemoteDebugPort: 9666}}) changes := 0 shell.OnInstancesChanged(func([]InstanceRow) { changes++ }) shell.startStates["instance"] = &instanceStartState{request: 1, running: true} shell.startResults <- instanceStartResult{ id: "instance", request: 1, outcome: InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9668, Source: "chub_registry"}, } shell.consumeStartResults() row, ok := shell.instanceRow("instance") if !ok || row.Status != "运行中" || row.RemoteDebugPort != 9668 || row.PreferredRemoteDebugPort != 9668 { t.Fatalf("fallback result = %#v", row) } if changes != 1 { t.Fatalf("instance change notifications = %d, want 1", changes) } } func TestShellRejectsReservedPreferredPortWhenCreatingOrEditing(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetInstances([]InstanceRow{ {ID: "one", Name: "一号", Browser: "Chrome", UserDataDir: `C:\profiles\one`, Status: "已退出", PreferredRemoteDebugPort: 9666}, {ID: "two", Name: "二号", Browser: "Edge", UserDataDir: `C:\profiles\two`, Status: "已退出", PreferredRemoteDebugPort: 9667}, }) shell.instanceName.SetText("三号") shell.instanceDir.SetText(`C:\profiles\three`) shell.instancePort.SetText("9666") shell.createInstanceFromForm() if len(shell.rows) != 2 || !strings.Contains(shell.formFeedback, "端口 9666") { t.Fatalf("create duplicate port = rows %#v, feedback %q", shell.rows, shell.formFeedback) } shell.beginEdit("one") shell.editPreferredPort.SetText("9667") shell.saveEdit() row, _ := shell.instanceRow("one") if row.PreferredRemoteDebugPort != 9666 || !strings.Contains(shell.editFeedback, "端口 9667") { t.Fatalf("edit duplicate port = row %#v, feedback %q", row, shell.editFeedback) } } func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] entered := make(chan struct{}, 1) release := make(chan struct{}) var calls atomic.Int32 shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) { calls.Add(1) entered <- struct{}{} <-release return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil }, nil) shell.requestStart(target.ID) shell.requestStart(target.ID) select { case <-entered: case <-time.After(time.Second): t.Fatal("start callback was not invoked") } if calls.Load() != 1 { t.Fatalf("start calls = %d, want 1", calls.Load()) } close(release) select { case result := <-shell.startResults: shell.startResults <- result shell.consumeStartResults() case <-time.After(time.Second): t.Fatal("start result was not produced") } } func TestShellStopsManagedInstanceAsynchronouslyAndRestoresStartAction(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] stopped := make(chan InstanceRow, 1) shell.OnStopInstance(func(_ context.Context, row InstanceRow, _ uint64) error { stopped <- row return nil }, nil) shell.requestInstanceAction(target.ID) if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "停止中" { t.Fatalf("stop status = %#v, want 停止中", row) } select { case got := <-stopped: if got.ID != target.ID || got.Status != "运行中" { t.Fatalf("stopped row = %#v", got) } case <-time.After(time.Second): t.Fatal("stop callback was not invoked") } var result instanceStopResult select { case result = <-shell.stopResults: case <-time.After(time.Second): t.Fatal("stop result was not produced") } shell.stopResults <- result shell.consumeStopResults() row, ok := shell.instanceRow(target.ID) if !ok || row.Status != "已退出" || row.PID != 0 || row.RemoteDebugPort != 0 || instanceActionFor(row).stop { t.Fatalf("completed stop row = %#v", row) } } func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] entered := make(chan struct{}, 1) release := make(chan struct{}) var calls atomic.Int32 shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { calls.Add(1) entered <- struct{}{} <-release return nil }, nil) shell.requestInstanceAction(target.ID) shell.requestInstanceAction(target.ID) select { case <-entered: case <-time.After(time.Second): t.Fatal("stop callback was not invoked") } if calls.Load() != 1 { t.Fatalf("stop calls = %d, want 1", calls.Load()) } close(release) select { case result := <-shell.stopResults: shell.stopResults <- result shell.consumeStopResults() case <-time.After(time.Second): t.Fatal("stop result was not produced") } external := shell.rows[2] var externalStops atomic.Int32 shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { externalStops.Add(1); return nil }, nil) shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState, uint64) (InstanceStartOutcome, error) { return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil }, nil) shell.requestInstanceAction(external.ID) if externalStops.Load() != 0 { t.Fatal("external association used stop callback") } } func TestShellRestoresManagedStatusAfterStopFailure(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { return errors.New("permission denied") }, nil) shell.requestInstanceAction(target.ID) var result instanceStopResult select { case result = <-shell.stopResults: case <-time.After(time.Second): t.Fatal("stop result was not produced") } shell.stopResults <- result shell.consumeStopResults() row, ok := shell.instanceRow(target.ID) if !ok || row.Status != "运行中" || !strings.Contains(shell.instanceFeedback, "失败") { t.Fatalf("failed stop row = %#v, feedback=%q", row, shell.instanceFeedback) } } func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) { shell := NewShell(material.NewTheme()) started := make(chan struct{}, 1) shell.OnPathSearch(func(ctx context.Context, field PathField, current string) (string, error) { started <- struct{}{} <-ctx.Done() return "", ctx.Err() }, nil) shell.togglePathSearch(PathChromeExecutable) if got := shell.searchButtonLabel(PathChromeExecutable); got != "取消" { t.Fatalf("chrome search label = %q, want 取消", got) } if got := shell.searchButtonLabel(PathEdgeExecutable); got != "搜索" { t.Fatalf("edge search label = %q, want 搜索", got) } select { case <-started: case <-time.After(time.Second): t.Fatal("search did not start") } shell.togglePathSearch(PathChromeExecutable) if got := shell.searchButtonLabel(PathChromeExecutable); got != "搜索" { t.Fatalf("chrome search label = %q after cancel, want 搜索", got) } } func TestShellAppliesLatestPathSearchResult(t *testing.T) { shell := NewShell(material.NewTheme()) shell.OnPathSearch(func(context.Context, PathField, string) (string, error) { return `C:\Browser\chrome.exe`, nil }, nil) shell.togglePathSearch(PathChromeExecutable) var result pathSearchResult select { case result = <-shell.searchResults: case <-time.After(time.Second): t.Fatal("search did not produce a result") } shell.searchResults <- result shell.consumeSearchResults() if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` { t.Fatalf("chrome path = %q", got) } } func TestShellSettingsCanBeRestored(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetSettings(SettingsState{ChromePath: "chrome", EdgePath: "edge", DefaultDir: "profiles", LogDir: "logs", RemoteDebugStartPort: 9777, CloseOnExit: false}) if shell.chromePath.Text() != "chrome" || shell.edgePath.Text() != "edge" || shell.dataDir.Text() != "profiles" || shell.logDir.Text() != "logs" || shell.remoteDebugPort.Text() != "9777" || shell.closeOnExit.Value { t.Fatalf("settings were not restored") } } func TestShellNormalizesRemoteDebugStartPort(t *testing.T) { shell := NewShell(material.NewTheme()) shell.remoteDebugPort.SetText("") settings, err := shell.settingsState() if err != nil || settings.RemoteDebugStartPort != 9666 { t.Fatalf("empty port settings = %#v, error = %v", settings, err) } shell.remoteDebugPort.SetText("80") if _, err := shell.settingsState(); err == nil { t.Fatal("invalid remote debug port was accepted") } } func TestShellMarksExternalAssociationWithoutManagingLifecycle(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[2] shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) { return InstanceStartOutcome{PID: 16108, RemoteDebugPort: 9668, External: true, Source: "browser_message_window"}, nil }, nil) shell.requestStart(target.ID) var result instanceStartResult select { case result = <-shell.startResults: case <-time.After(time.Second): t.Fatal("external association result was not produced") } shell.startResults <- result shell.consumeStartResults() row, ok := shell.instanceRow(target.ID) if !ok || row.Status != "外部已关联" || row.PID != 16108 || row.RemoteDebugPort != 9668 || row.OccupancySource != "browser_message_window" { t.Fatalf("external association = %#v", row) } } func TestShellKeepsSettingsStateAcrossPageSwitch(t *testing.T) { shell := NewShell(material.NewTheme()) shell.page = pageSettings shell.dataDir.SetText(`D:\profiles\current`) shell.pathFeedback = "正在搜索默认 User Data Dir…" shell.page = pageInstances shell.page = pageSettings if got := shell.dataDir.Text(); got != `D:\profiles\current` { t.Fatalf("data dir = %q", got) } if got := shell.pathFeedback; got != "正在搜索默认 User Data Dir…" { t.Fatalf("path feedback = %q", got) } } func TestShellIgnoresCancelledPathSearchResult(t *testing.T) { shell := NewShell(material.NewTheme()) started := make(chan struct{}, 1) shell.OnPathSearch(func(ctx context.Context, field PathField, current string) (string, error) { started <- struct{}{} <-ctx.Done() return `C:\stale\chrome.exe`, nil }, nil) shell.togglePathSearch(PathChromeExecutable) select { case <-started: case <-time.After(time.Second): t.Fatal("search did not start") } shell.togglePathSearch(PathChromeExecutable) select { case result := <-shell.searchResults: shell.searchResults <- result case <-time.After(time.Second): t.Fatal("cancelled search did not return") } shell.consumeSearchResults() if got := shell.chromePath.Text(); got == `C:\stale\chrome.exe` { t.Fatal("cancelled result overwrote the editor") } } func TestShellAppliesSelectedInstanceDirectory(t *testing.T) { shell := NewShell(material.NewTheme()) shell.OnChooseDirectory(func(context.Context) (string, error) { return `C:\profiles\new-instance`, nil }, nil) shell.chooseInstanceDirectory() var result directoryPickResult select { case result = <-shell.directoryResults: case <-time.After(time.Second): t.Fatal("directory picker did not produce a result") } shell.directoryResults <- result shell.consumeDirectoryResults() if got := shell.instanceDir.Text(); got != `C:\profiles\new-instance` { t.Fatalf("instance dir = %q", got) } } func TestShellRefreshesConfiguredInstancesAsynchronously(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[3] seen := make(chan []InstanceRow, 1) shell.OnRefreshInstances(func(_ context.Context, rows []InstanceRow) ([]InstanceRefreshResult, error) { seen <- rows return []InstanceRefreshResult{{ID: target.ID, Status: "运行中", PID: 5432, RemoteDebugPort: 9777, OccupancySource: "chub_registry"}}, nil }, nil) shell.requestRefresh() if !shell.refreshClickState.running { t.Fatal("refresh did not enter running state") } select { case rows := <-seen: if len(rows) != len(shell.rows) || rows[0].ID != shell.rows[0].ID { t.Fatalf("refresh snapshot = %#v", rows) } case <-time.After(time.Second): t.Fatal("refresh callback was not invoked") } var result instanceRefreshResult select { case result = <-shell.refreshResults: case <-time.After(time.Second): t.Fatal("refresh result was not produced") } shell.refreshResults <- result shell.consumeRefreshResults() row, ok := shell.instanceRow(target.ID) if !ok || shell.refreshClickState.running || row.Status != "运行中" || row.PID != 5432 || row.RemoteDebugPort != 9777 { t.Fatalf("refreshed row = %#v, running=%v", row, shell.refreshClickState.running) } } func TestShellIgnoresRefreshResultAfterEditableFieldsChange(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[3] shell.OnRefreshInstances(func(_ context.Context, _ []InstanceRow) ([]InstanceRefreshResult, error) { return []InstanceRefreshResult{{ID: target.ID, Status: "运行中", PID: 5432}}, nil }, nil) shell.requestRefresh() var result instanceRefreshResult select { case result = <-shell.refreshResults: case <-time.After(time.Second): t.Fatal("refresh result was not produced") } shell.rows[3].Name = "已编辑的实例" shell.refreshResults <- result shell.consumeRefreshResults() row, ok := shell.instanceRow(target.ID) if !ok || row.Status != "已退出" || row.PID != 0 { t.Fatalf("stale refresh overwrote edited row: %#v", row) } } func TestShellReadsTabsAsynchronouslyAndRestoresEditFocus(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] seen := make(chan InstanceRow, 1) shell.OnInspectTabs(func(_ context.Context, row InstanceRow) ([]CDPTarget, error) { seen <- row return []CDPTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}, nil }, nil) shell.beginEdit(target.ID) shell.requestTabs() if !shell.tabsOpen || !shell.tabsLoading || shell.tabsInstanceID != target.ID { t.Fatalf("tabs state after request = open %v loading %v instance %q", shell.tabsOpen, shell.tabsLoading, shell.tabsInstanceID) } select { case row := <-seen: if row.ID != target.ID || row.RemoteDebugPort != target.RemoteDebugPort { t.Fatalf("tabs snapshot = %#v", row) } case <-time.After(time.Second): t.Fatal("tab inspector was not invoked") } var result instanceTabsResult select { case result = <-shell.tabsResults: case <-time.After(time.Second): t.Fatal("tab inspector did not produce a result") } shell.tabsResults <- result shell.consumeTabsResults() if shell.tabsLoading || shell.tabsFeedback != "" || len(shell.tabsTargets) != 1 || shell.tabsTargets[0].Title != "运营后台" { t.Fatalf("tabs result state = loading %v feedback %q targets %#v", shell.tabsLoading, shell.tabsFeedback, shell.tabsTargets) } shell.closeTabs(true) if shell.tabsOpen || !shell.tabsReturnFocus { t.Fatalf("tabs close state = open %v return focus %v", shell.tabsOpen, shell.tabsReturnFocus) } } func TestShellRejectsStaleTabsResultWhenRuntimeChanges(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] shell.OnInspectTabs(func(_ context.Context, _ InstanceRow) ([]CDPTarget, error) { return []CDPTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}, nil }, nil) shell.beginEdit(target.ID) shell.requestTabs() var result instanceTabsResult select { case result = <-shell.tabsResults: case <-time.After(time.Second): t.Fatal("tab inspector did not produce a result") } shell.rows[0].RemoteDebugPort++ shell.tabsResults <- result shell.consumeTabsResults() if len(shell.tabsTargets) != 0 || !strings.Contains(shell.tabsFeedback, "状态或调试端口已经变化") { t.Fatalf("stale tabs result state = targets %#v feedback %q", shell.tabsTargets, shell.tabsFeedback) } } func TestShellAllowsTabInspectionOnlyForVerifiedRuntimeRows(t *testing.T) { shell := NewShell(material.NewTheme()) if !shell.canInspectTabs(shell.rows[0]) || !shell.canInspectTabs(shell.rows[2]) { t.Fatal("verified running and external rows should allow read-only tabs inspection") } for _, row := range []InstanceRow{ {Status: "启动中", RemoteDebugPort: 9666}, {Status: "已退出", RemoteDebugPort: 9666}, {Status: "运行中", RemoteDebugPort: 0}, } { if shell.canInspectTabs(row) { t.Fatalf("row unexpectedly allows tab inspection: %#v", row) } } } func TestShellEditsExitedInstanceAndPreservesRuntimePort(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[3] updatedDir := t.TempDir() changes := 0 shell.OnInstancesChanged(func([]InstanceRow) { changes++ }) shell.beginEdit(target.ID) shell.editName.SetText("已更新实例") shell.editDir.SetText(updatedDir) shell.editURL.SetText("https://example.com/updated") shell.editBrowserKind.Value = "chrome" shell.saveEdit() row, ok := shell.instanceRow(target.ID) if !ok || shell.editingID != "" || changes != 1 || row.Name != "已更新实例" || row.Browser != "Chrome" || row.UserDataDir != filepath.Clean(updatedDir) { t.Fatalf("saved instance = %#v, changes=%d", row, changes) } if row.TargetURL != "https://example.com/updated" { t.Fatalf("target URL = %q", row.TargetURL) } } func TestShellLocksIdentityFieldsForRunningInstance(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] shell.beginEdit(target.ID) shell.editName.SetText("运行中更新名称") shell.editDir.SetText(t.TempDir()) shell.editBrowserKind.Value = "edge" shell.saveEdit() row, ok := shell.instanceRow(target.ID) if !ok || row.Name != "运行中更新名称" || row.UserDataDir != target.UserDataDir || row.Browser != target.Browser { t.Fatalf("running instance identity changed: %#v", row) } } func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) { shell := NewShell(material.NewTheme()) shell.beginEdit(shell.rows[3].ID) shell.editName.SetText("未保存的更改") shell.cancelEdit() if !shell.pendingEditDiscard || shell.editingID == "" { t.Fatalf("dirty edit cancellation state = discard %v editing %q", shell.pendingEditDiscard, shell.editingID) } } func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) if label := shell.proxyLabel("proxy-sg"); label != "新加坡 · http://127.0.0.1:8080" { t.Fatalf("proxy label = %q, want name and complete proxy address", label) } if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡" { t.Fatalf("proxy display name = %q", display) } shell.instanceName.SetText("新实例") shell.instanceDir.SetText(t.TempDir()) shell.createProxyID = "proxy-sg" shell.createInstanceFromForm() created := shell.rows[len(shell.rows)-1] if created.ProxyID != "proxy-sg" { t.Fatalf("created proxy id = %q", created.ProxyID) } shell.beginEdit(created.ID) shell.editProxyID = "" shell.saveEdit() row, ok := shell.instanceRow(created.ID) if !ok || row.ProxyID != "" { t.Fatalf("edited proxy row = %#v", row) } } func TestShellPreventsDeletingReferencedProxy(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) shell.rows[0].ProxyID = "proxy-sg" shell.settingsProxyID = "proxy-sg" shell.proxyName.SetText("新加坡") shell.proxyServer.SetText("http://127.0.0.1:8080") changes := 0 shell.OnProxiesChanged(func([]ProxyOption) { changes++ }) shell.requestProxyDelete() if len(shell.proxies) != 1 || changes != 0 || !strings.Contains(shell.proxyFeedback, "不能删除") { t.Fatalf("referenced delete state = proxies %#v, changes %d, feedback %q", shell.proxies, changes, shell.proxyFeedback) } shell.rows[0].ProxyID = "" shell.requestProxyDelete() if shell.pendingProxyDelete != "proxy-sg" { t.Fatalf("pending proxy deletion = %q", shell.pendingProxyDelete) } shell.cancelProxyDelete() if shell.pendingProxyDelete != "" || len(shell.proxies) != 1 || changes != 0 || shell.settingsProxyID != "proxy-sg" || shell.proxyName.Text() != "新加坡" || shell.proxyServer.Text() != "http://127.0.0.1:8080" { t.Fatalf("cancelled delete state = proxies %#v, changes %d, selection %q, name %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } shell.requestProxyDelete() shell.confirmProxyDelete() if len(shell.proxies) != 0 || changes != 1 || shell.settingsProxyID != "" || shell.proxyName.Text() != "" || shell.proxyServer.Text() != "" { t.Fatalf("unreferenced delete state = proxies %#v, changes %d, selection %q, name %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } } func TestShellSavesProxyFromAddressPicker(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "旧名称", Server: "http://127.0.0.1:8080"}}) changes := 0 shell.OnProxiesChanged(func([]ProxyOption) { changes++ }) shell.proxyPicker.target = proxyPickerSettings shell.selectPickerProxy("proxy-sg") if shell.settingsProxyID != "proxy-sg" || shell.proxyName.Text() != "旧名称" || shell.proxyServer.Text() != "http://127.0.0.1:8080" { t.Fatalf("selected proxy = %q, name %q, server %q", shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } shell.proxyName.SetText("新加坡出口") shell.proxyServer.SetText("http://127.0.0.1:8081") shell.saveProxy() if len(shell.proxies) != 1 || shell.proxies[0].ID != "proxy-sg" || shell.proxies[0].Name != "新加坡出口" || shell.proxies[0].Server != "http://127.0.0.1:8081" || changes != 1 { t.Fatalf("updated proxy = %#v, changes %d", shell.proxies, changes) } if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡出口" { t.Fatalf("updated proxy display name = %q", display) } shell.proxyPicker.target = proxyPickerSettings shell.selectPickerProxy("") if shell.settingsProxyID != "" || shell.proxyName.Text() != "" || shell.proxyServer.Text() != "" { t.Fatalf("new proxy state = id %q, name %q, server %q", shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } shell.proxyName.SetText("东京出口") shell.proxyServer.SetText("http://127.0.0.1:8082") shell.saveProxy() if len(shell.proxies) != 2 || shell.settingsProxyID == "" || shell.proxies[1].Name != "东京出口" || shell.proxies[1].Server != "http://127.0.0.1:8082" || changes != 2 { t.Fatalf("created proxy = %#v, selected %q, changes %d", shell.proxies, shell.settingsProxyID, changes) } } func TestShellRejectsDuplicateProxyNameWithoutReplacingSelectedProfile(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetProxies([]ProxyOption{ {ID: "proxy-sg", Name: "新加坡出口", Server: "http://127.0.0.1:8080"}, {ID: "proxy-jp", Name: "东京出口", Server: "http://127.0.0.1:8081"}, }) shell.proxyPicker.target = proxyPickerSettings shell.selectPickerProxy("proxy-jp") shell.proxyName.SetText(" 新加坡出口 ") shell.proxyServer.SetText("http://127.0.0.1:8082") shell.saveProxy() if !strings.Contains(shell.proxyFeedback, "名称已存在") || shell.proxies[1].Name != "东京出口" || shell.proxies[1].Server != "http://127.0.0.1:8081" { t.Fatalf("duplicate name state = proxies %#v, feedback %q", shell.proxies, shell.proxyFeedback) } } func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) { shell := NewShell(material.NewTheme()) root := filepath.Join(t.TempDir(), "user_data_dirs") shell.SetDefaultInstanceUserDataRoot(root) shell.beginCreate() firstID := shell.pendingCreateID firstDir := shell.instanceDir.Text() if firstID == "" || firstDir != filepath.Join(root, firstID) { t.Fatalf("first default directory = %q for id %q", firstDir, firstID) } shell.instanceName.SetText("第一个实例") shell.createInstanceFromForm() created := shell.rows[len(shell.rows)-1] if created.ID != firstID || created.UserDataDir != firstDir { t.Fatalf("created default instance = %#v", created) } shell.beginCreate() secondID := shell.pendingCreateID secondDir := shell.instanceDir.Text() if secondID == "" || secondID == firstID || secondDir != filepath.Join(root, secondID) || secondDir == firstDir { t.Fatalf("second default directory = %q for id %q, first %q", secondDir, secondID, firstDir) } } func TestShellMarksUnexpectedManagedExitAndRestoresStartFocus(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] shell.startStates[target.ID] = &instanceStartState{request: 7} shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 7, PID: target.PID, ExitCode: 0}) shell.consumeManagedExitResults() shell.presentUnexpectedExitIfReady() row, ok := shell.instanceRow(target.ID) if !ok || row.Status != "已退出" || row.PID != 0 || row.RemoteDebugPort != 0 || row.OccupancySource != "" || !shell.unexpectedExitOpen || len(shell.unexpectedExits) != 1 { t.Fatalf("unexpected exit state = row %#v, dialog %v, notices %#v", row, shell.unexpectedExitOpen, shell.unexpectedExits) } if mode := instanceActionFor(row); !mode.enabled || mode.stop || mode.label != "启动" { t.Fatalf("action after unexpected exit = %#v", mode) } shell.dismissUnexpectedExitNotice() if shell.unexpectedExitOpen || len(shell.unexpectedExits) != 0 || shell.focusStartID != target.ID { t.Fatalf("dismissed unexpected exit state = open %v, notices %#v, focus %q", shell.unexpectedExitOpen, shell.unexpectedExits, shell.focusStartID) } } func TestShellDoesNotNotifyExpectedOrStaleManagedExit(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] shell.startStates[target.ID] = &instanceStartState{request: 3} shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 2, PID: target.PID, ExitCode: 0}) shell.consumeManagedExitResults() if row, _ := shell.instanceRow(target.ID); row.Status != "运行中" || len(shell.unexpectedExits) != 0 { t.Fatalf("stale exit changed row = %#v, notices %#v", row, shell.unexpectedExits) } shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 3, PID: target.PID, ExitCode: 0, ExpectedStop: true}) shell.consumeManagedExitResults() shell.presentUnexpectedExitIfReady() if row, _ := shell.instanceRow(target.ID); row.Status != "已退出" || len(shell.unexpectedExits) != 0 || shell.unexpectedExitOpen { t.Fatalf("expected exit state = row %#v, notices %#v, dialog %v", row, shell.unexpectedExits, shell.unexpectedExitOpen) } } func TestShellAppliesManagedExitThatArrivesDuringStart(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] target.Status = "启动中" target.PID = 0 target.RemoteDebugPort = 0 shell.rows[0] = target shell.startStates[target.ID] = &instanceStartState{request: 5, running: true} shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 5, PID: 5123, ExitCode: 1}) shell.consumeManagedExitResults() if len(shell.pendingManagedExit) != 1 { t.Fatalf("pending managed exits = %#v", shell.pendingManagedExit) } shell.startStates[target.ID].running = false shell.setInstanceRuntime(target.ID, "运行中", 5123, 9666, "chub_registry") if !shell.applyPendingManagedExit(target.ID, 5, 5123) { t.Fatal("pending managed exit was not applied") } shell.presentUnexpectedExitIfReady() if row, _ := shell.instanceRow(target.ID); row.Status != "已退出" || !shell.unexpectedExitOpen { t.Fatalf("quick exit after start = row %#v, dialog %v", row, shell.unexpectedExitOpen) } } func TestShellAggregatesManagedExitNoticesUntilOtherModalCloses(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetInstances([]InstanceRow{ {ID: "chrome", Name: "运营 Chrome", Browser: "Chrome", UserDataDir: t.TempDir(), PID: 4001, RemoteDebugPort: 9666, OccupancySource: "chub_registry", Status: "运行中"}, {ID: "edge", Name: "审核 Edge", Browser: "Edge", UserDataDir: t.TempDir(), PID: 4002, RemoteDebugPort: 9667, OccupancySource: "chub_registry", Status: "运行中"}, }) shell.startStates["chrome"] = &instanceStartState{request: 1} shell.startStates["edge"] = &instanceStartState{request: 2} shell.editingID = "editing" shell.ReportManagedExit(ManagedInstanceExit{InstanceID: "chrome", LaunchGeneration: 1, PID: 4001}) shell.ReportManagedExit(ManagedInstanceExit{InstanceID: "edge", LaunchGeneration: 2, PID: 4002}) shell.consumeManagedExitResults() shell.presentUnexpectedExitIfReady() if shell.unexpectedExitOpen || len(shell.unexpectedExits) != 2 { t.Fatalf("queued notices = open %v, notices %#v", shell.unexpectedExitOpen, shell.unexpectedExits) } shell.editingID = "" shell.presentUnexpectedExitIfReady() if !shell.unexpectedExitOpen || len(shell.unexpectedExits) != 2 { t.Fatalf("aggregated notices = open %v, notices %#v", shell.unexpectedExitOpen, shell.unexpectedExits) } }