2026-07-22 15:19:11 +08:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-07-22 17:02:59 +08:00
|
|
|
"context"
|
2026-07-25 15:53:31 +08:00
|
|
|
"errors"
|
2026-07-27 11:33:22 +08:00
|
|
|
"path/filepath"
|
2026-07-22 15:19:11 +08:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-07-25 17:10:20 +08:00
|
|
|
"time"
|
2026-07-22 17:02:59 +08:00
|
|
|
|
|
|
|
|
"chub/internal/domain"
|
|
|
|
|
"chub/internal/platform/browser"
|
|
|
|
|
"chub/internal/platform/config"
|
|
|
|
|
"chub/internal/ui"
|
2026-07-22 15:19:11 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
2026-07-27 11:33:22 +08:00
|
|
|
func TestRunCLIUsesPortableDefaultStore(t *testing.T) {
|
|
|
|
|
store, err := config.New(filepath.Join(t.TempDir(), "config.json"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.Save(config.File{Instances: []config.Instance{{ID: "portable", Name: "便携实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
restore := openDefaultConfig
|
|
|
|
|
openDefaultConfig = func() (*config.Store, error) { return store, nil }
|
|
|
|
|
t.Cleanup(func() { openDefaultConfig = restore })
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
if code := runCLI([]string{"list"}, &out, &out); code != 0 || !strings.Contains(out.String(), `"portable"`) {
|
|
|
|
|
t.Fatalf("code=%d output=%s", code, out.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunCLIReportsUnavailablePortableConfigWithoutDetails(t *testing.T) {
|
|
|
|
|
restore := openDefaultConfig
|
|
|
|
|
openDefaultConfig = func() (*config.Store, error) { return nil, config.ErrDefaultConfigUnavailable }
|
|
|
|
|
t.Cleanup(func() { openDefaultConfig = restore })
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
if code := runCLI([]string{"list"}, &out, &out); code != 6 || !strings.Contains(out.String(), `"default configuration is unavailable"`) {
|
|
|
|
|
t.Fatalf("code=%d output=%s", code, out.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
|
|
|
|
|
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
|
|
|
|
resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
|
2026-07-25 15:53:31 +08:00
|
|
|
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
|
|
|
|
|
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote}
|
2026-07-25 18:23:33 +08:00
|
|
|
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)
|
2026-07-25 15:53:31 +08:00
|
|
|
if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 {
|
|
|
|
|
t.Fatalf("Start() = %#v, %v", outcome, err)
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
|
|
|
|
if resolver.kind != domain.BrowserEdge || resolver.configured != `C:\Configured\msedge.exe` {
|
|
|
|
|
t.Fatalf("resolver input = %s, %q", resolver.kind, resolver.configured)
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
if launcher.spec.Kind != domain.BrowserEdge || launcher.spec.Executable != resolver.path || launcher.spec.TargetURL != "" || launcher.spec.RemoteDebugPort != 9666 {
|
2026-07-22 17:02:59 +08:00
|
|
|
t.Fatalf("launch spec = %#v", launcher.spec)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 09:29:10 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
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}
|
2026-07-25 18:23:33 +08:00
|
|
|
_, 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)
|
2026-07-25 17:04:26 +08:00
|
|
|
if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" {
|
|
|
|
|
t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err)
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
_, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 2)
|
2026-07-25 17:04:26 +08:00
|
|
|
if err == nil || launcher.calls != 1 {
|
|
|
|
|
t.Fatalf("missing proxy error = %v, launch calls = %d", err, launcher.calls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
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}
|
2026-07-25 18:23:33 +08:00
|
|
|
err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1)
|
2026-07-25 17:10:20 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
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}
|
2026-07-25 18:23:33 +08:00
|
|
|
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1)
|
2026-07-25 17:10:20 +08:00
|
|
|
if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
|
|
|
|
|
t.Fatalf("mismatch Stop() = %v, calls=%d", err, managed.stopCalls)
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`}, 1)
|
2026-07-25 17:10:20 +08:00
|
|
|
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}
|
2026-07-25 18:23:33 +08:00
|
|
|
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1)
|
2026-07-25 17:10:20 +08:00
|
|
|
if err == nil || managed.stopCalls != 1 || managed.force {
|
|
|
|
|
t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
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,
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
|
2026-07-25 15:53:31 +08:00
|
|
|
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,
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
|
2026-07-25 15:53:31 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
func TestInstanceTabsInspectorReadsOnlyVerifiedRuntimeRows(t *testing.T) {
|
|
|
|
|
remote := &fakeRemoteDebugInspector{targets: []browser.RemoteDebugTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}}
|
|
|
|
|
inspector := instanceTabsInspector{remoteDebug: remote}
|
|
|
|
|
targets, err := inspector.Inspect(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", Status: "运行中", RemoteDebugPort: 9666})
|
|
|
|
|
if err != nil || len(targets) != 1 || targets[0].ID != "page-1" || targets[0].URL != "https://example.com/orders" {
|
|
|
|
|
t.Fatalf("Inspect() = %#v, %v", targets, err)
|
|
|
|
|
}
|
|
|
|
|
if remote.targetCalls != 1 || remote.targetKind != domain.BrowserChrome || remote.targetPort != 9666 {
|
|
|
|
|
t.Fatalf("target inspector invocation = %#v", remote)
|
|
|
|
|
}
|
|
|
|
|
if _, err := inspector.Inspect(context.Background(), ui.InstanceRow{ID: "stopped", Browser: "Chrome", Status: "已退出", RemoteDebugPort: 9666}); err == nil {
|
|
|
|
|
t.Fatal("Inspect() accepted a non-running instance")
|
|
|
|
|
}
|
|
|
|
|
if remote.targetCalls != 1 {
|
|
|
|
|
t.Fatalf("non-running instance reached CDP target inspector %d times", remote.targetCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
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
|
2026-07-25 15:53:31 +08:00
|
|
|
calls int
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (l *fakeProcessLauncher) Start(_ context.Context, spec domain.LaunchSpec) (browser.ProcessHandle, error) {
|
|
|
|
|
l.spec = spec
|
2026-07-25 15:53:31 +08:00
|
|
|
l.calls++
|
2026-07-22 17:02:59 +08:00
|
|
|
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 }
|
2026-07-25 15:53:31 +08:00
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
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++ }
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
type fakeManagedProfileInspector struct{ use browser.ProfileUse }
|
|
|
|
|
|
|
|
|
|
func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) {
|
|
|
|
|
return i.use, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
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 {
|
2026-07-27 09:29:10 +08:00
|
|
|
endpoint browser.RemoteDebugEndpoint
|
|
|
|
|
inspectErr error
|
|
|
|
|
waitErr error
|
|
|
|
|
port int
|
|
|
|
|
allocatedPorts []int
|
|
|
|
|
allocationStarts []int
|
2026-07-27 10:01:07 +08:00
|
|
|
targets []browser.RemoteDebugTarget
|
|
|
|
|
targetErr error
|
|
|
|
|
targetKind domain.BrowserKind
|
|
|
|
|
targetPort int
|
|
|
|
|
targetCalls int
|
2026-07-25 15:53:31 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-27 09:29:10 +08:00
|
|
|
i.allocationStarts = append(i.allocationStarts, start)
|
|
|
|
|
if len(i.allocatedPorts) > 0 {
|
|
|
|
|
port := i.allocatedPorts[0]
|
|
|
|
|
i.allocatedPorts = i.allocatedPorts[1:]
|
|
|
|
|
return port, nil
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
if i.port != 0 {
|
|
|
|
|
return i.port, nil
|
|
|
|
|
}
|
|
|
|
|
return start, nil
|
|
|
|
|
}
|
2026-07-27 10:01:07 +08:00
|
|
|
|
|
|
|
|
func (i *fakeRemoteDebugInspector) ListRemoteDebugTargets(_ context.Context, kind domain.BrowserKind, port int) ([]browser.RemoteDebugTarget, error) {
|
|
|
|
|
i.targetCalls++
|
|
|
|
|
i.targetKind = kind
|
|
|
|
|
i.targetPort = port
|
|
|
|
|
return append([]browser.RemoteDebugTarget(nil), i.targets...), i.targetErr
|
|
|
|
|
}
|