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 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 + 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 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) (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 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) (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) 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) 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) error { externalStops.Add(1); return nil }, nil) shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState) (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) 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) (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 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"}}) 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" changes := 0 shell.OnProxiesChanged(func([]ProxyOption) { changes++ }) shell.deleteProxy("proxy-sg") 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.deleteProxy("proxy-sg") if len(shell.proxies) != 0 || changes != 1 { t.Fatalf("unreferenced delete state = proxies %#v, changes %d", shell.proxies, changes) } }