2026-07-22 15:20:10 +08:00
package ui
import (
2026-07-22 15:57:02 +08:00
"context"
2026-07-25 17:10:20 +08:00
"errors"
2026-07-25 16:36:26 +08:00
"path/filepath"
2026-07-25 17:04:26 +08:00
"strings"
2026-07-22 17:02:59 +08:00
"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
}
2026-07-25 15:53:31 +08:00
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 ) {
2026-07-22 16:22:59 +08:00
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:22:59 +08:00
}
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
}
}
2026-07-22 17:02:59 +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 )
}
}
2026-07-27 23:16:11 +08:00
func TestShellDeletesInstanceDataBeforeRemovingConfiguration ( t * testing . T ) {
shell := NewShell ( material . NewTheme ())
target := shell . rows [ 3 ]
deletions := 0
shell . OnDeleteInstanceData ( func ( _ context . Context , row InstanceRow ) error {
deletions ++
if row . ID != target . ID || row . UserDataDir != target . UserDataDir {
t . Fatalf ( "delete snapshot = %#v" , row )
}
return nil
}, nil )
shell . requestDelete ( target . ID )
shell . deleteData . Value = true
shell . confirmDelete ()
if ! shell . deleteDataRunning || len ( shell . rows ) != 4 {
t . Fatalf ( "data deletion started=%v rows=%d" , shell . deleteDataRunning , len ( shell . rows ))
}
var result instanceDataDeleteResult
select {
case result = <- shell . deleteDataResults :
case <- time . After ( time . Second ):
t . Fatal ( "data deletion did not produce a result" )
}
if deletions != 1 {
t . Fatalf ( "deletions = %d" , deletions )
}
shell . deleteDataResults <- result
shell . consumeInstanceDataDeleteResults ()
if shell . pendingDeleteID != "" || shell . deleteDataRunning || len ( shell . rows ) != 3 {
t . Fatalf ( "data deletion state = pending %q running=%v rows=%d" , shell . pendingDeleteID , shell . deleteDataRunning , len ( shell . rows ))
}
if _ , exists := shell . instanceRow ( target . ID ); exists || ! strings . Contains ( shell . instanceFeedback , "及其 User Data Dir 数据" ) {
t . Fatalf ( "data deletion did not remove target: rows=%#v feedback=%q" , shell . rows , shell . instanceFeedback )
}
}
func TestShellKeepsInstanceWhenDataDeletionFails ( t * testing . T ) {
shell := NewShell ( material . NewTheme ())
target := shell . rows [ 3 ]
shell . OnDeleteInstanceData ( func ( context . Context , InstanceRow ) error {
return errors . New ( "permission denied" )
}, nil )
shell . requestDelete ( target . ID )
shell . deleteData . Value = true
shell . confirmDelete ()
var result instanceDataDeleteResult
select {
case result = <- shell . deleteDataResults :
case <- time . After ( time . Second ):
t . Fatal ( "data deletion did not produce a result" )
}
shell . deleteDataResults <- result
shell . consumeInstanceDataDeleteResults ()
if ! shell . deleteData . Value || shell . pendingDeleteID != target . ID || shell . deleteDataRunning || len ( shell . rows ) != 4 {
t . Fatalf ( "failed deletion state = data=%v pending=%q running=%v rows=%d" , shell . deleteData . Value , shell . pendingDeleteID , shell . deleteDataRunning , len ( shell . rows ))
}
if ! strings . Contains ( shell . deleteDataFeedback , "未删除实例配置或 User Data Dir" ) {
t . Fatalf ( "failure feedback = %q" , shell . deleteDataFeedback )
}
}
func TestShellRejectsInstanceDataDeletionWhenStatusIsActive ( t * testing . T ) {
shell := NewShell ( material . NewTheme ())
target := shell . rows [ 0 ]
called := false
shell . OnDeleteInstanceData ( func ( context . Context , InstanceRow ) error {
called = true
return nil
}, nil )
shell . requestDelete ( target . ID )
shell . deleteData . Value = true
shell . confirmDelete ()
if called || shell . deleteDataRunning || len ( shell . rows ) != 4 {
t . Fatalf ( "active data deletion called=%v running=%v rows=%d" , called , shell . deleteDataRunning , len ( shell . rows ))
}
if ! strings . Contains ( shell . deleteDataFeedback , "当前状态不能删除" ) {
t . Fatalf ( "active deletion feedback = %q" , shell . deleteDataFeedback )
}
}
func TestShellDiscardsStaleInstanceDataDeletionResult ( t * testing . T ) {
shell := NewShell ( material . NewTheme ())
target := shell . rows [ 3 ]
shell . OnDeleteInstanceData ( func ( context . Context , InstanceRow ) error { return nil }, nil )
shell . requestDelete ( target . ID )
shell . deleteData . Value = true
shell . confirmDelete ()
var result instanceDataDeleteResult
select {
case result = <- shell . deleteDataResults :
case <- time . After ( time . Second ):
t . Fatal ( "data deletion did not produce a result" )
}
shell . rows [ 3 ]. UserDataDir = "C:\\profiles\\changed"
shell . deleteDataResults <- result
shell . consumeInstanceDataDeleteResults ()
if shell . pendingDeleteID != target . ID || len ( shell . rows ) != 4 || ! strings . Contains ( shell . deleteDataFeedback , "配置已变化" ) {
t . Fatalf ( "stale deletion state = pending=%q rows=%d feedback=%q" , shell . pendingDeleteID , len ( shell . rows ), shell . deleteDataFeedback )
}
}
2026-07-22 17:02:59 +08:00
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 ) {
2026-07-22 17:02:59 +08:00
started <- row
2026-07-25 15:53:31 +08:00
return InstanceStartOutcome { PID : 4242 , RemoteDebugPort : 9666 }, nil
2026-07-22 17:02:59 +08:00
}, 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 ()
2026-07-25 15:53:31 +08:00
if row , ok := shell . instanceRow ( target . ID ); ! ok || row . Status != "运行中" || row . PID != 4242 || row . RemoteDebugPort != 9666 {
2026-07-22 17:02:59 +08:00
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 )
}
}
2026-07-22 17:02:59 +08:00
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 ) {
2026-07-22 17:02:59 +08:00
calls . Add ( 1 )
entered <- struct {}{}
<- release
2026-07-25 15:53:31 +08:00
return InstanceStartOutcome { PID : 4242 , RemoteDebugPort : 9666 }, nil
2026-07-22 17:02:59 +08:00
}, 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" )
}
}
2026-07-25 17:10:20 +08:00
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 {
2026-07-25 17:10:20 +08:00
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 {
2026-07-25 17:10:20 +08:00
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 ) {
2026-07-25 17:10:20 +08:00
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 )
2026-07-25 17:10:20 +08:00
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 ())
2026-07-25 15:53:31 +08:00
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
2026-07-28 00:12:29 +08:00
func TestShellTracksPortableSettingsDirectoriesAndRestoresThem ( t * testing . T ) {
shell := NewShell ( material . NewTheme ())
root := t . TempDir ()
portableData := filepath . Join ( root , "user_data_dirs" )
portableLogs := filepath . Join ( root , "logs" )
shell . SetPortableDirectoryDefaults ( portableData , portableLogs )
shell . SetSettings ( SettingsState { DefaultDir : portableData , DefaultDirMode : "portable" , LogDir : portableLogs , LogDirMode : "portable" , RemoteDebugStartPort : 9666 , CloseOnExit : true })
shell . dataDir . SetText ( filepath . Join ( root , "custom" ))
state , err := shell . settingsState ()
if err != nil || state . DefaultDirMode != "custom" {
t . Fatalf ( "custom settings state = %#v, error = %v" , state , err )
}
shell . restorePortableDirectory ( PathDefaultUserData )
state , err = shell . settingsState ()
if err != nil || state . DefaultDir != portableData || state . DefaultDirMode != "portable" {
t . Fatalf ( "restored settings state = %#v, error = %v" , state , err )
}
}
func TestShellRejectsDuplicateInstanceNamesButAllowsEditingSelf ( t * testing . T ) {
shell := NewShell ( material . NewTheme ())
shell . SetInstances ([] InstanceRow {
{ ID : "one" , Name : "Audit" , Browser : "Chrome" , UserDataDir : `C:\profiles\one` , Status : "已退出" , PreferredRemoteDebugPort : 9666 },
{ ID : "two" , Name : "投放" , Browser : "Edge" , UserDataDir : `C:\profiles\two` , Status : "已退出" , PreferredRemoteDebugPort : 9667 },
})
shell . instanceName . SetText ( "AUDIT" )
shell . instanceDir . SetText ( `C:\profiles\three` )
shell . instancePort . SetText ( "9668" )
shell . createInstanceFromForm ()
if len ( shell . rows ) != 2 || ! strings . Contains ( shell . formFeedback , "实例名称" ) {
t . Fatalf ( "duplicate instance create = rows %#v, feedback %q" , shell . rows , shell . formFeedback )
}
shell . beginEdit ( "one" )
shell . editName . SetText ( "Audit" )
shell . editURL . SetText ( "https://example.com" )
shell . saveEdit ()
row , _ := shell . instanceRow ( "one" )
if row . TargetURL != "https://example.com" || shell . editingID != "" {
t . Fatalf ( "self edit = %#v, feedback %q" , row , shell . editFeedback )
}
}
2026-07-25 15:53:31 +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 ) {
2026-07-25 15:53:31 +08:00
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" )
}
}
2026-07-22 16:06:39 +08:00
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:57:04 +08:00
}
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"
2026-07-25 17:57:04 +08:00
shell . settingsProxyID = "proxy-sg"
2026-07-27 12:09:54 +08:00
shell . proxyName . SetText ( "新加坡" )
2026-07-25 17:57:04 +08:00
shell . proxyServer . SetText ( "http://127.0.0.1:8080" )
2026-07-25 17:04:26 +08:00
changes := 0
shell . OnProxiesChanged ( func ([] ProxyOption ) { changes ++ })
2026-07-25 17:57:04 +08:00
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 = ""
2026-07-25 17:57:04 +08:00
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 ())
2026-07-25 17:57:04 +08:00
}
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 ())
2026-07-25 17:57:04 +08:00
}
}
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-25 17:57:04 +08:00
}
2026-07-27 12:09:54 +08:00
shell . proxyName . SetText ( "新加坡出口" )
2026-07-25 17:57:04 +08:00
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 {
2026-07-25 17:57:04 +08:00
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 )
}
2026-07-25 17:57:04 +08:00
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-25 17:57:04 +08:00
}
2026-07-27 12:09:54 +08:00
shell . proxyName . SetText ( "东京出口" )
2026-07-25 17:57:04 +08:00
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 {
2026-07-25 17:57:04 +08:00
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 ()
2026-07-28 00:12:29 +08:00
if firstID == "" || filepath . Dir ( firstDir ) != root || ! strings . HasPrefix ( filepath . Base ( firstDir ), "instance-新实例-" ) {
2026-07-27 12:09:54 +08:00
t . Fatalf ( "first default directory = %q for id %q" , firstDir , firstID )
}
shell . instanceName . SetText ( "第一个实例" )
2026-07-28 00:12:29 +08:00
shell . syncDefaultCreateDirectory ()
firstDir = shell . instanceDir . Text ()
if ! strings . HasPrefix ( filepath . Base ( firstDir ), "instance-第一个实例-" ) {
t . Fatalf ( "named default directory = %q" , firstDir )
}
2026-07-27 12:09:54 +08:00
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 ()
2026-07-28 00:12:29 +08:00
if secondID == "" || secondID == firstID || filepath . Dir ( secondDir ) != root || secondDir == firstDir {
2026-07-27 12:09:54 +08:00
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 )
}
}