Files
cdp_hub/internal/ui/shell_test.go
T

484 lines
16 KiB
Go

package ui
import (
"context"
"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 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)
}
}