Files
cdp_hub/internal/ui/shell_test.go
T

1019 lines
38 KiB
Go
Raw Normal View History

2026-07-22 15:20:10 +08:00
package ui
import (
2026-07-22 15:57:02 +08:00
"context"
"errors"
2026-07-25 16:36:26 +08:00
"path/filepath"
2026-07-25 17:04:26 +08:00
"strings"
"sync/atomic"
2026-07-22 15:20:10 +08:00
"testing"
2026-07-22 15:57:02 +08:00
"time"
2026-07-22 15:20:10 +08:00
2026-07-22 16:42:45 +08:00
"gioui.org/layout"
2026-07-22 15:20:10 +08:00
"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{"运行中", "启动中", "外部已关联", "已退出"} {
2026-07-22 15:20:10 +08:00
if !seen[status] {
t.Fatalf("status %q is missing", status)
}
}
}
2026-07-27 11:33:22 +08:00
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)
}
}
2026-07-22 16:42:45 +08:00
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)
}
}
2026-07-22 16:37:34 +08:00
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")
}
2026-07-22 16:37:34 +08:00
if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) {
t.Fatal("rows share a delete button state")
}
2026-07-25 16:36:26 +08:00
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")
}
}
2026-07-22 16:30:20 +08:00
func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) {
2026-07-27 12:09:54 +08:00
got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceProxyColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight
2026-07-22 16:30:20 +08:00
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")
}
2026-07-27 12:09:54 +08:00
if instanceProxyColumnWeight >= instanceDirectoryColumnWeight {
t.Fatal("proxy name must remain a compact display-only column")
}
2026-07-22 16:37:34 +08:00
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)
2026-07-22 16:30:20 +08:00
}
}
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)
2026-07-25 18:23:33 +08:00
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)
}
}
2026-07-27 09:29:10 +08:00
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
2026-07-25 18:23:33 +08:00
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)
2026-07-25 18:23:33 +08:00
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
2026-07-25 18:23:33 +08:00
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
2026-07-25 18:23:33 +08:00
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]
2026-07-25 18:23:33 +08:00
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)
}
}
2026-07-22 15:57:02 +08:00
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)
}
2026-07-27 22:46:31 +08:00
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已找到") {
t.Fatalf("chrome path feedback = %q", got)
}
}
func TestShellConfirmsMatchingPathSearchResultNearItsField(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.chromePath.SetText(`C:\Browser\chrome.exe`)
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.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已确认") || !strings.Contains(got, "当前路径可用") {
t.Fatalf("chrome path feedback = %q", got)
}
}
func TestShellAppliesSelectedExecutableToOnlyRequestedField(t *testing.T) {
shell := NewShell(material.NewTheme())
oldEdge := shell.edgePath.Text()
shell.OnChooseExecutable(func(context.Context) (string, error) {
return `C:\Browser\chrome.exe`, nil
}, nil)
shell.chooseExecutable(PathChromeExecutable)
if got := shell.pathPickButtonLabel(PathChromeExecutable); got != "选择中…" {
t.Fatalf("chrome picker label = %q", got)
}
var result executablePickResult
select {
case result = <-shell.executableResults:
case <-time.After(time.Second):
t.Fatal("executable picker did not produce a result")
}
shell.executableResults <- result
shell.consumeExecutableResults()
if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` {
t.Fatalf("chrome path = %q", got)
}
if got := shell.edgePath.Text(); got != oldEdge {
t.Fatalf("edge path was unexpectedly changed to %q", got)
}
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已选择") {
t.Fatalf("chrome picker feedback = %q", got)
}
}
func TestShellKeepsExecutableInputWhenSelectionIsCanceled(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.chromePath.SetText(`C:\Browser\keep.exe`)
shell.OnChooseExecutable(func(context.Context) (string, error) {
return "", context.Canceled
}, nil)
shell.chooseExecutable(PathChromeExecutable)
var result executablePickResult
select {
case result = <-shell.executableResults:
case <-time.After(time.Second):
t.Fatal("executable picker did not produce a result")
}
shell.executableResults <- result
shell.consumeExecutableResults()
if got := shell.chromePath.Text(); got != `C:\Browser\keep.exe` {
t.Fatalf("canceled picker changed chrome path to %q", got)
}
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已取消") {
t.Fatalf("chrome picker feedback = %q", got)
}
2026-07-22 15:57:02 +08:00
}
2026-07-22 15:20:10 +08:00
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 {
2026-07-22 15:20:10 +08:00
t.Fatalf("settings were not restored")
}
}
2026-07-22 15:57:02 +08:00
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]
2026-07-25 18:23:33 +08:00
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)
}
}
2026-07-22 15:57:02 +08:00
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)
}
}
2026-07-25 16:36:26 +08:00
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)
}
}
2026-07-27 10:01:07 +08:00
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)
}
}
}
2026-07-25 16:36:26 +08:00
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)
}
}
2026-07-25 17:04:26 +08:00
func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}})
2026-07-27 12:09:54 +08:00
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)
}
2026-07-25 17:04:26 +08:00
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"
2026-07-27 12:09:54 +08:00
shell.proxyName.SetText("新加坡")
shell.proxyServer.SetText("http://127.0.0.1:8080")
2026-07-25 17:04:26 +08:00
changes := 0
shell.OnProxiesChanged(func([]ProxyOption) { changes++ })
shell.requestProxyDelete()
2026-07-25 17:04:26 +08:00
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()
2026-07-27 12:09:54 +08:00
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()
2026-07-27 12:09:54 +08:00
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")
2026-07-27 12:09:54 +08:00
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())
}
2026-07-27 12:09:54 +08:00
shell.proxyName.SetText("新加坡出口")
shell.proxyServer.SetText("http://127.0.0.1:8081")
shell.saveProxy()
2026-07-27 12:09:54 +08:00
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)
}
2026-07-27 12:09:54 +08:00
if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡出口" {
t.Fatalf("updated proxy display name = %q", display)
}
shell.proxyPicker.target = proxyPickerSettings
shell.selectPickerProxy("")
2026-07-27 12:09:54 +08:00
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())
}
2026-07-27 12:09:54 +08:00
shell.proxyName.SetText("东京出口")
shell.proxyServer.SetText("http://127.0.0.1:8082")
shell.saveProxy()
2026-07-27 12:09:54 +08:00
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)
2026-07-25 17:04:26 +08:00
}
}
2026-07-25 18:23:33 +08:00
2026-07-27 12:09:54 +08:00
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)
}
}
2026-07-25 18:23:33 +08:00
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)
}
}