package main import ( "bytes" "context" "errors" "strings" "testing" "time" "chub/internal/domain" "chub/internal/platform/browser" "chub/internal/platform/config" "chub/internal/ui" ) func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) { var out bytes.Buffer if code := runCLI([]string{"wat"}, &out, &out); code != 2 || !strings.Contains(out.String(), `"invalid_argument"`) { t.Fatalf("code=%d output=%s", code, out.String()) } } func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote} outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666}, 1) if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 { t.Fatalf("Start() = %#v, %v", outcome, err) } if resolver.kind != domain.BrowserEdge || resolver.configured != `C:\Configured\msedge.exe` { t.Fatalf("resolver input = %s, %q", resolver.kind, resolver.configured) } if launcher.spec.Kind != domain.BrowserEdge || launcher.spec.Executable != resolver.path || launcher.spec.TargetURL != "" || launcher.spec.RemoteDebugPort != 9666 { t.Fatalf("launch spec = %#v", launcher.spec) } } func TestInstanceStarterUsesPreferredPortAndSkipsOtherInstancesReservations(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} remote := &fakeRemoteDebugInspector{ endpoint: browser.RemoteDebugEndpoint{Port: 9668}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable, allocatedPorts: []int{9667, 9668}, } starter := instanceStarter{ launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, } outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, PreferredRemoteDebugPort: 9666}, ui.SettingsState{RemoteDebugStartPort: 9777, ReservedRemoteDebugPorts: []int{9667}}, 1) if err != nil || outcome.RemoteDebugPort != 9668 || launcher.spec.RemoteDebugPort != 9668 { t.Fatalf("Start() = %#v, launch = %#v, error = %v", outcome, launcher.spec, err) } if len(remote.allocationStarts) != 2 || remote.allocationStarts[0] != 9666 || remote.allocationStarts[1] != 9668 { t.Fatalf("allocator starts = %#v", remote.allocationStarts) } } func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} resolver := &fakeExecutableResolver{path: `C:\Browser\chrome.exe`} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} proxies := newProxyDirectory() proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}}) starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, proxyResolver: proxies} _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" { t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err) } _, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 2) if err == nil || launcher.calls != 1 { t.Fatalf("missing proxy error = %v, launch calls = %d", err, launcher.calls) } } func TestInstanceStarterRegistersOnlyLaunchedProcessWithExitMonitor(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} monitor := &fakeManagedExitObserver{} starter := instanceStarter{launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, monitor: monitor} _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 7) if err != nil || monitor.watchID != "chrome-a" || monitor.watchGeneration != 7 || monitor.watchPID != 4242 { t.Fatalf("monitor watch = %#v, error = %v", monitor, err) } } func TestInstanceStopperGracefullyStopsOnlyVerifiedManagedProfile(t *testing.T) { managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true} stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: time.Second, waitInterval: time.Millisecond} err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1) if err != nil || managed.stopCalls != 1 || managed.force || managed.userDataDir != `C:\profiles\managed` { t.Fatalf("Stop() = %v, calls=%d force=%v dir=%q", err, managed.stopCalls, managed.force, managed.userDataDir) } } func TestInstanceStopperMarksExpectedManagedExit(t *testing.T) { managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true} monitor := &fakeManagedExitObserver{expectReturn: true} stopper := instanceStopper{launcher: managed, managedProfiles: managed, monitor: monitor, waitTimeout: time.Second, waitInterval: time.Millisecond} err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 9) if err != nil || monitor.expectID != "managed" || monitor.expectGeneration != 9 || monitor.expectPID != 4242 || monitor.clearCalls != 0 { t.Fatalf("expected stop monitor = %#v, error = %v", monitor, err) } } func TestManagedExitMonitorReportsExpectedExit(t *testing.T) { results := make(chan ui.ManagedInstanceExit, 1) monitor := newManagedExitMonitor(func(result ui.ManagedInstanceExit) { results <- result }, nil) defer monitor.Close() handle := &controlledProcessHandle{pid: 4242, done: make(chan processWaitResult, 1)} monitor.Watch("managed", 3, handle) if !monitor.ExpectStop("managed", 3, 4242) { t.Fatal("expected monitored process to accept expected stop") } handle.done <- processWaitResult{code: 17} select { case result := <-results: if result.InstanceID != "managed" || result.LaunchGeneration != 3 || result.PID != 4242 || result.ExitCode != 17 || !result.ExpectedStop { t.Fatalf("exit result = %#v", result) } case <-time.After(time.Second): t.Fatal("managed exit monitor did not report process exit") } } func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) { managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4243, Source: "chub_registry"}} stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond} err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1) if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 { t.Fatalf("mismatch Stop() = %v, calls=%d", err, managed.stopCalls) } err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`}, 1) if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 { t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls) } } func TestInstanceStopperDoesNotEscalateWhenGracefulCloseTimesOut(t *testing.T) { managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}} stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond} err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1) if err == nil || managed.stopCalls != 1 || managed.force { t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force) } } func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}} starter := instanceStarter{ launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceMessageWindow}}, remoteDebug: remote, portAllocator: remote, } outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow { t.Fatalf("Start() = %#v, %v", outcome, err) } if launcher.calls != 0 { t.Fatalf("external association launched %d processes", launcher.calls) } } func TestInstanceStarterRejectsUnverifiedExternalProfile(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} remote := &fakeRemoteDebugInspector{inspectErr: browser.ErrRemoteDebugEndpointUnavailable} starter := instanceStarter{ launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceLock}}, remoteDebug: remote, portAllocator: remote, } _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) if !errors.Is(err, domain.ErrProfileOccupied) { t.Fatalf("Start() error = %v", err) } if launcher.calls != 0 { t.Fatalf("unverified external profile launched %d processes", launcher.calls) } } func TestMergeInstanceConfigPreservesExistingLaunchOptions(t *testing.T) { existing := []config.Instance{{ID: "one", Name: "旧名称", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\old`, ProfileDirectory: "Default", ProxyServer: "https://proxy.local:8443", ExtraArgs: []string{"--window-size=1280,900"}}}} updated := mergeInstanceConfig(existing, []ui.InstanceRow{{ID: "one", Name: "新名称", Browser: "Edge", UserDataDir: `C:\profiles\new`, TargetURL: "https://example.com"}}) if len(updated) != 1 { t.Fatalf("instances = %d, want 1", len(updated)) } got := updated[0] if got.Name != "新名称" || got.Launch.Kind != domain.BrowserEdge || got.Launch.UserDataDir != `C:\profiles\new` || got.Launch.TargetURL != "https://example.com" { t.Fatalf("updated instance = %#v", got) } if got.Launch.ProfileDirectory != "Default" || got.Launch.ProxyServer != "https://proxy.local:8443" || len(got.Launch.ExtraArgs) != 1 { t.Fatalf("existing launch options were lost: %#v", got.Launch) } } func TestMergeInstanceConfigPersistsProxySelection(t *testing.T) { updated := mergeInstanceConfig(nil, []ui.InstanceRow{{ID: "one", Name: "实例", Browser: "Chrome", UserDataDir: `C:\profiles\one`, ProxyID: "proxy-sg"}}) if len(updated) != 1 || updated[0].ProxyID != "proxy-sg" || updated[0].Launch.ProxyServer != "" { t.Fatalf("merged proxy = %#v", updated) } } func TestInstanceStatusRefresherChecksOnlyConfiguredRows(t *testing.T) { remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9777}} refresher := instanceStatusRefresher{ managedProfiles: fakeManagedProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 5432, Source: "chub_registry"}}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, } rows := []ui.InstanceRow{{ID: "managed", Browser: "Chrome", UserDataDir: `C:\profiles\managed`, RemoteDebugPort: 9777}} results, err := refresher.Refresh(context.Background(), rows) if err != nil || len(results) != 1 { t.Fatalf("Refresh() = %#v, %v", results, err) } got := results[0] if got.ID != "managed" || got.Status != "运行中" || got.PID != 5432 || got.RemoteDebugPort != 9777 || got.OccupancySource != "chub_registry" { t.Fatalf("refresh result = %#v", got) } } func TestInstanceStatusRefresherAssociatesExternalWithoutLifecycleControl(t *testing.T) { remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}} refresher := instanceStatusRefresher{ managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceMessageWindow}}, remoteDebug: remote, } results, err := refresher.Refresh(context.Background(), []ui.InstanceRow{{ID: "external", Browser: "Edge", UserDataDir: `C:\profiles\external`}}) if err != nil || len(results) != 1 { t.Fatalf("Refresh() = %#v, %v", results, err) } got := results[0] if got.Status != "外部已关联" || got.PID != 16108 || got.RemoteDebugPort != 9668 || got.OccupancySource != browser.ProfileSourceMessageWindow { t.Fatalf("external refresh result = %#v", got) } } type fakeExecutableResolver struct { path string kind domain.BrowserKind configured string } func (r *fakeExecutableResolver) Resolve(_ context.Context, kind domain.BrowserKind, configured string) (string, error) { r.kind = kind r.configured = configured return r.path, nil } type fakeProcessLauncher struct { spec domain.LaunchSpec handle browser.ProcessHandle calls int } func (l *fakeProcessLauncher) Start(_ context.Context, spec domain.LaunchSpec) (browser.ProcessHandle, error) { l.spec = spec l.calls++ return l.handle, nil } type fakeProcessHandle struct{ pid int } func (p fakeProcessHandle) PID() int { return p.pid } func (fakeProcessHandle) Wait(context.Context) (int, error) { return 0, nil } type processWaitResult struct { code int err error } type controlledProcessHandle struct { pid int done chan processWaitResult } func (p *controlledProcessHandle) PID() int { return p.pid } func (p *controlledProcessHandle) Wait(ctx context.Context) (int, error) { select { case result := <-p.done: return result.code, result.err case <-ctx.Done(): return 0, ctx.Err() } } type fakeManagedExitObserver struct { watchID string watchGeneration uint64 watchPID int expectID string expectGeneration uint64 expectPID int expectReturn bool clearCalls int } func (m *fakeManagedExitObserver) Watch(id string, generation uint64, handle browser.ProcessHandle) { m.watchID = id m.watchGeneration = generation if handle != nil { m.watchPID = handle.PID() } } func (m *fakeManagedExitObserver) ExpectStop(id string, generation uint64, pid int) bool { m.expectID = id m.expectGeneration = generation m.expectPID = pid return m.expectReturn } func (m *fakeManagedExitObserver) ClearExpectedStop(string, uint64, int) { m.clearCalls++ } type fakeManagedProfileInspector struct{ use browser.ProfileUse } func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) { return i.use, nil } type fakeManagedStopper struct { use browser.ProfileUse stopCalls int force bool userDataDir string releaseOnStop bool } func (s *fakeManagedStopper) InspectProfile(context.Context, string) (browser.ProfileUse, error) { return s.use, nil } func (s *fakeManagedStopper) StopProfile(_ context.Context, userDataDir string, force bool) error { s.stopCalls++ s.force = force s.userDataDir = userDataDir if s.releaseOnStop { s.use.Occupied = false } return nil } type fakeExternalProfileInspector struct { use browser.ProfileUse err error } func (i fakeExternalProfileInspector) InspectProfile(context.Context, domain.BrowserKind, string, int) (browser.ProfileUse, error) { return i.use, i.err } type fakeRemoteDebugInspector struct { endpoint browser.RemoteDebugEndpoint inspectErr error waitErr error port int allocatedPorts []int allocationStarts []int } func (i *fakeRemoteDebugInspector) InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) { return i.endpoint, i.inspectErr } func (i *fakeRemoteDebugInspector) WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) { return i.endpoint, i.waitErr } func (i *fakeRemoteDebugInspector) InspectRemoteDebugPort(context.Context, domain.BrowserKind, int) (browser.RemoteDebugEndpoint, error) { return i.endpoint, i.inspectErr } func (i *fakeRemoteDebugInspector) WaitForRemoteDebugPort(context.Context, domain.BrowserKind, int) (browser.RemoteDebugEndpoint, error) { return i.endpoint, i.waitErr } func (i *fakeRemoteDebugInspector) FindAvailableRemoteDebugPort(_ context.Context, start int) (int, error) { i.allocationStarts = append(i.allocationStarts, start) if len(i.allocatedPorts) > 0 { port := i.allocatedPorts[0] i.allocatedPorts = i.allocatedPorts[1:] return port, nil } if i.port != 0 { return i.port, nil } return start, nil }