diff --git a/backend/app_instance_errors_test.go b/backend/app_instance_errors_test.go deleted file mode 100644 index c2505ef3..00000000 --- a/backend/app_instance_errors_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package backend - -import ( - "fmt" - "strings" - "testing" - "time" -) - -func TestDescribeChromeProcessStartError(t *testing.T) { - tests := []struct { - name string - err error - want string - }{ - { - name: "file not found", - err: fmt.Errorf("fork/exec C:\\chrome.exe: The system cannot find the file specified."), - want: "浏览器可执行文件不存在", - }, - { - name: "access denied", - err: fmt.Errorf("fork/exec C:\\chrome.exe: Access is denied."), - want: "系统拒绝启动浏览器进程", - }, - { - name: "invalid win32", - err: fmt.Errorf("%%1 is not a valid Win32 application"), - want: "与系统/架构不兼容", - }, - { - name: "linux exec format error", - err: fmt.Errorf("fork/exec /opt/chrome/chrome.exe: exec format error"), - want: "与系统/架构不兼容", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := describeChromeProcessStartError(`C:\chrome.exe`, tt.err) - if !strings.Contains(got, tt.want) { - t.Fatalf("expected %q to contain %q", got, tt.want) - } - }) - } -} - -func TestDescribeBrowserReadyTimeout(t *testing.T) { - got := describeBrowserReadyTimeout(9222, 10*time.Second) - if !strings.Contains(got, "调试端口 9222 未就绪") { - t.Fatalf("unexpected timeout message: %q", got) - } -} - -func TestDescribeBrowserReadyTimeoutWithoutPort(t *testing.T) { - got := describeBrowserReadyTimeout(0, 10*time.Second) - if !strings.Contains(got, "未获取到调试端口") { - t.Fatalf("unexpected timeout message: %q", got) - } -} - -func TestDescribeBrowserReadyFailureUsesExitDetail(t *testing.T) { - err := &browserStartupExitError{ - exitErr: fmt.Errorf("exit status 5"), - stderrTail: "sandbox initialization failed", - } - - got := describeBrowserReadyFailure(`C:\chrome.exe`, 9222, 10*time.Second, err) - if !strings.Contains(got, "sandbox initialization failed") { - t.Fatalf("expected exit detail in message, got %q", got) - } - if strings.Contains(got, "调试端口 9222 未就绪") { - t.Fatalf("expected exit detail message instead of timeout, got %q", got) - } -} - -func TestBrowserStartAttemptCountDefault(t *testing.T) { - if browserStartAttemptCount() != 5 { - t.Fatalf("expected default browser start attempts to be 5, got %d", browserStartAttemptCount()) - } -} - -func TestBrowserDebugPendingMessages(t *testing.T) { - warning := browserDebugPendingWarning(15 * time.Second) - if !strings.Contains(warning, "15 秒") || !strings.Contains(warning, "继续在后台连接") { - t.Fatalf("unexpected pending warning: %q", warning) - } - - notice := browserDebugPendingStartNotice(15 * time.Second) - if !strings.Contains(notice, "尚未完成接管") || !strings.Contains(notice, "稍后查看实例状态") { - t.Fatalf("unexpected pending start notice: %q", notice) - } -} - -func TestShouldRetryBrowserReadyFailure(t *testing.T) { - if !shouldRetryBrowserReadyFailure(fmt.Errorf("browser debug port 9222 not ready")) { - t.Fatal("expected timeout-like ready failure to be retryable") - } - - if shouldRetryBrowserReadyFailure(&browserStartupExitError{ - exitErr: fmt.Errorf("exit status 5"), - stderrTail: "missing libEGL.dll", - }) { - t.Fatal("expected process exit before ready to stop retrying") - } -} diff --git a/backend/app_instance_open_url_test.go b/backend/app_instance_open_url_test.go deleted file mode 100644 index 0617491b..00000000 --- a/backend/app_instance_open_url_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package backend - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "reflect" - goruntime "runtime" - "strings" - "testing" - "time" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" -) - -func TestBrowserInstanceOpenURLUsesCDPCreateTarget(t *testing.T) { - t.Parallel() - - server := newRecordedCDPServer(t) - defer server.Close() - - app := newBrowserOpenURLTestApp(t) - app.browserMgr.Profiles = map[string]*BrowserProfile{ - "profile-ready": { - ProfileId: "profile-ready", - ProfileName: "Ready Browser", - Running: true, - DebugReady: true, - DebugPort: server.Port(), - Pid: 12345, - }, - } - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - - ok, err := app.BrowserInstanceOpenUrl("profile-ready", "https://open.example/") - if err != nil { - t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err) - } - if !ok { - t.Fatal("expected BrowserInstanceOpenUrl to succeed") - } - - want := []recordedCDPCommand{ - {Scope: "browser", Method: "Target.createTarget", URL: "https://open.example/"}, - } - if got := server.Commands(); !reflect.DeepEqual(got, want) { - t.Fatalf("unexpected CDP command sequence:\n got=%v\nwant=%v", got, want) - } -} - -func TestBrowserInstanceOpenURLFallsBackToWindowWhenDebugPending(t *testing.T) { - app, exePath := newBrowserOpenURLTestAppWithCore(t) - - cmd := longLivedCommand(2 * time.Second) - if err := cmd.Start(); err != nil { - t.Fatalf("启动长生命周期测试进程失败: %v", err) - } - defer func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - } - }() - - profile := &BrowserProfile{ - ProfileId: "profile-pending", - ProfileName: "Pending Browser", - UserDataDir: "profile-pending", - Running: true, - DebugReady: false, - DebugPort: 0, - Pid: cmd.Process.Pid, - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd} - - expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile) - var gotPath string - var gotArgs []string - - originalStart := startBrowserWindowProcess - startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) { - gotPath = chromeBinaryPath - gotArgs = append([]string{}, args...) - return nil, nil - } - defer func() { - startBrowserWindowProcess = originalStart - }() - - ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://pending.example/") - if err != nil { - t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err) - } - if !ok { - t.Fatal("expected BrowserInstanceOpenUrl to succeed") - } - if gotPath != exePath { - t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath) - } - - wantArgs := []string{ - fmt.Sprintf("--user-data-dir=%s", expectedUserDataDir), - "https://pending.example/", - } - if !reflect.DeepEqual(gotArgs, wantArgs) { - t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs) - } -} - -func TestBrowserInstanceOpenURLFallsBackToWindowWhenCDPOpenFails(t *testing.T) { - app, exePath := newBrowserOpenURLTestAppWithCore(t) - - cmd := longLivedCommand(2 * time.Second) - if err := cmd.Start(); err != nil { - t.Fatalf("启动长生命周期测试进程失败: %v", err) - } - defer func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - } - }() - - profile := &BrowserProfile{ - ProfileId: "profile-ready-fallback", - ProfileName: "Fallback Browser", - UserDataDir: "profile-ready-fallback", - Running: true, - DebugReady: true, - DebugPort: freeLoopbackPort(t), - Pid: cmd.Process.Pid, - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd} - - expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile) - var gotPath string - var gotArgs []string - - originalStart := startBrowserWindowProcess - startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) { - gotPath = chromeBinaryPath - gotArgs = append([]string{}, args...) - return nil, nil - } - defer func() { - startBrowserWindowProcess = originalStart - }() - - ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://fallback.example/") - if err != nil { - t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err) - } - if !ok { - t.Fatal("expected BrowserInstanceOpenUrl to succeed") - } - if gotPath != exePath { - t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath) - } - - wantArgs := []string{ - fmt.Sprintf("--user-data-dir=%s", expectedUserDataDir), - "https://fallback.example/", - } - if !reflect.DeepEqual(gotArgs, wantArgs) { - t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs) - } -} - -func TestBrowserInstanceOpenURLMarksStaleProfileStopped(t *testing.T) { - t.Parallel() - - app := newBrowserOpenURLTestApp(t) - profile := &BrowserProfile{ - ProfileId: "profile-stale", - ProfileName: "Stale Browser", - Running: true, - DebugReady: false, - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - - ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://stale.example/") - if err == nil { - t.Fatal("expected BrowserInstanceOpenUrl to fail for stale runtime state") - } - if ok { - t.Fatal("expected BrowserInstanceOpenUrl to return false for stale runtime state") - } - if !strings.Contains(err.Error(), "运行状态已失效") { - t.Fatalf("unexpected error: %v", err) - } - if profile.Running { - t.Fatal("expected stale profile to be marked stopped") - } - if profile.DebugReady { - t.Fatal("expected stale profile debug state to be cleared") - } - if profile.DebugPort != 0 || profile.Pid != 0 { - t.Fatalf("expected runtime identifiers to be cleared, got debugPort=%d pid=%d", profile.DebugPort, profile.Pid) - } -} - -func newBrowserOpenURLTestApp(t *testing.T) *App { - t.Helper() - - cfg := config.DefaultConfig() - app := NewApp("") - app.config = cfg - app.browserMgr = browser.NewManager(cfg, t.TempDir()) - app.browserMgr.Profiles = make(map[string]*BrowserProfile) - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - return app -} - -func newBrowserOpenURLTestAppWithCore(t *testing.T) (*App, string) { - t.Helper() - - cfg := config.DefaultConfig() - exePath := createFakeBrowserExecutable(t) - cfg.Browser.Cores = []config.BrowserCore{ - { - CoreId: "core-open-url-test", - CoreName: "Open URL Test Core", - CorePath: exePath, - IsDefault: true, - }, - } - - app := NewApp("") - app.config = cfg - app.browserMgr = browser.NewManager(cfg, t.TempDir()) - app.browserMgr.Profiles = make(map[string]*BrowserProfile) - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - return app, exePath -} - -func createFakeBrowserExecutable(t *testing.T) string { - t.Helper() - - candidates := browser.CoreExecutableCandidates() - if len(candidates) == 0 { - t.Fatal("no core executable candidates available") - } - - baseDir := t.TempDir() - exePath := filepath.Join(baseDir, filepath.FromSlash(candidates[0])) - if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { - t.Fatalf("创建测试内核目录失败: %v", err) - } - - mode := os.FileMode(0o644) - content := []byte("test-browser") - if goruntime.GOOS != "windows" { - mode = 0o755 - content = []byte("#!/bin/sh\nexit 0\n") - } - if err := os.WriteFile(exePath, content, mode); err != nil { - t.Fatalf("写入测试内核可执行文件失败: %v", err) - } - return exePath -} diff --git a/backend/app_instance_start_helpers_test.go b/backend/app_instance_start_helpers_test.go deleted file mode 100644 index fa54e5f9..00000000 --- a/backend/app_instance_start_helpers_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package backend - -import ( - "fmt" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - goruntime "runtime" - "testing" - "time" -) - -func mustListenLoopback(t *testing.T) net.Listener { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("监听测试端口失败: %v", err) - } - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - _ = conn.Close() - } - }() - - return ln -} - -func listenerPort(t *testing.T, ln net.Listener) int { - t.Helper() - - tcpAddr, ok := ln.Addr().(*net.TCPAddr) - if !ok { - t.Fatalf("解析监听地址失败: %T", ln.Addr()) - } - return tcpAddr.Port -} - -func shortLivedCommand() *exec.Cmd { - if goruntime.GOOS == "windows" { - return exec.Command("cmd", "/c", "exit", "0") - } - return exec.Command("sh", "-c", "exit 0") -} - -func longLivedCommand(duration time.Duration) *exec.Cmd { - if goruntime.GOOS == "windows" { - seconds := int(duration / time.Second) - if seconds < 1 { - seconds = 1 - } - return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1)) - } - return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds())) -} - -func stderrFailingCommand(message string) *exec.Cmd { - if goruntime.GOOS == "windows" { - return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message)) - } - return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message)) -} - -func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd { - if goruntime.GOOS == "windows" { - seconds := int(holdFor / time.Second) - if seconds < 1 { - seconds = 1 - } - // ping -n N waits roughly N-1 seconds on Windows. - return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1)) - } - return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds())) -} - -func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { - t.Helper() - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if check() { - return - } - time.Sleep(100 * time.Millisecond) - } - t.Fatal("等待条件成立超时") -} - -func freeLoopbackPort(t *testing.T) int { - t.Helper() - - ln := mustListenLoopback(t) - port := listenerPort(t, ln) - _ = ln.Close() - return port -} - -type devToolsTestServer struct { - port int - server *http.Server - done chan struct{} -} - -func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("启动 DevTools 测试服务失败: %v", err) - } - - srv := &http.Server{Handler: handler} - done := make(chan struct{}) - go func() { - defer close(done) - _ = srv.Serve(ln) - }() - - return &devToolsTestServer{ - port: listenerPort(t, ln), - server: srv, - done: done, - } -} - -func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer { - t.Helper() - - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err) - } - - srv := &http.Server{Handler: handler} - done := make(chan struct{}) - go func() { - defer close(done) - _ = srv.Serve(ln) - }() - - return &devToolsTestServer{ - port: port, - server: srv, - done: done, - } -} - -func (s *devToolsTestServer) Close() error { - if s == nil || s.server == nil { - return nil - } - err := s.server.Close() - <-s.done - return err -} - -func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) { - t.Helper() - - content := fmt.Sprintf("%d\n/devtools/browser/test\n", port) - if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil { - t.Fatalf("写入 DevToolsActivePort 失败: %v", err) - } -} diff --git a/backend/app_instance_start_ready_test.go b/backend/app_instance_start_ready_test.go deleted file mode 100644 index 6fedc1c5..00000000 --- a/backend/app_instance_start_ready_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "net/http" - "os/exec" - "reflect" - "testing" - "time" -) - -func TestWaitForBrowserDebugReadyMarksProfileReady(t *testing.T) { - t.Parallel() - - port := freeLoopbackPort(t) - app := NewApp("") - app.browserMgr = browser.NewManager(config.DefaultConfig(), "") - app.browserMgr.Profiles = map[string]*BrowserProfile{ - "profile-ready": { - ProfileId: "profile-ready", - ProfileName: "Ready Browser", - Running: true, - DebugPort: port, - DebugReady: false, - RuntimeWarning: "pending", - LastStartAt: time.Now().Format(time.RFC3339), - }, - } - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - - serverReady := make(chan *devToolsTestServer, 1) - go func() { - time.Sleep(200 * time.Millisecond) - serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json/version": - _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) - case "/json/list": - _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) - default: - http.NotFound(w, r) - } - })) - }() - - snapshot, changed := app.waitForBrowserDebugReady("profile-ready", port, 2*time.Second) - server := <-serverReady - defer server.Close() - - if snapshot == nil { - t.Fatal("期望等待到调试接口就绪") - } - if !changed { - t.Fatal("期望调试接口就绪后标记实例状态变更") - } - if !snapshot.DebugReady { - t.Fatal("期望实例被标记为调试接口已就绪") - } - if snapshot.RuntimeWarning != "" { - t.Fatalf("期望调试接口就绪后清空警告,实际=%q", snapshot.RuntimeWarning) - } -} - -func TestSanitizeManagedLaunchArgsRemovesSystemManagedFlags(t *testing.T) { - t.Parallel() - - got, removed := sanitizeManagedLaunchArgs([]string{ - "--lang=en-US", - "--remote-debugging-port=9222", - "--user-data-dir", "D:\\profiles\\demo", - "--proxy-server", "http://127.0.0.1:9000", - "--remote-debugging-pipe", - "https://example.com", - }) - - wantArgs := []string{"--lang=en-US", "https://example.com"} - if !reflect.DeepEqual(got, wantArgs) { - t.Fatalf("sanitizeManagedLaunchArgs args mismatch: got=%v want=%v", got, wantArgs) - } - - wantRemoved := []string{ - "--remote-debugging-port", - "--user-data-dir", - "--proxy-server", - "--remote-debugging-pipe", - } - if !reflect.DeepEqual(removed, wantRemoved) { - t.Fatalf("sanitizeManagedLaunchArgs removed mismatch: got=%v want=%v", removed, wantRemoved) - } -} - -func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) { - t.Parallel() - - input := []string{"--lang=en-US", "--disable-sync", "https://example.com"} - got, removed := sanitizeManagedLaunchArgs(input) - if !reflect.DeepEqual(got, input) { - t.Fatalf("sanitizeManagedLaunchArgs should preserve unmanaged args: got=%v want=%v", got, input) - } - if len(removed) != 0 { - t.Fatalf("sanitizeManagedLaunchArgs should not report managed args, got=%v", removed) - } -} - -func TestResolveBrowserStartProxyUsesTemporaryProxyWithoutMutatingProfile(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - cfg.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "stored-proxy", ProxyName: "Stored", ProxyConfig: "http://127.0.0.1:18080"}, - {ProxyId: "runtime-proxy", ProxyName: "Runtime", ProxyConfig: "http://127.0.0.1:28080"}, - } - app := NewApp("") - app.config = cfg - app.browserMgr = browser.NewManager(cfg, t.TempDir()) - profile := &BrowserProfile{ - ProfileId: "profile-temporary-proxy", - ProfileName: "Temporary Proxy", - ProxyId: "stored-proxy", - ProxyConfig: "http://127.0.0.1:18080", - } - input := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "runtime-proxy", "") - - effectiveProxy, bridgeRef, releaseBridge, err := app.resolveBrowserStartProxy(input, profile) - if err != nil { - t.Fatalf("resolveBrowserStartProxy returned error: %v", err) - } - if effectiveProxy != "http://127.0.0.1:28080" { - t.Fatalf("expected temporary proxy, got %q", effectiveProxy) - } - if bridgeRef.valid() || releaseBridge { - t.Fatalf("plain HTTP proxy should not acquire bridge: ref=%+v release=%v", bridgeRef, releaseBridge) - } - if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" { - t.Fatalf("temporary proxy should not mutate profile: %+v", profile) - } - - fallbackInput := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "missing-proxy", "http://127.0.0.1:38080") - effectiveProxy, bridgeRef, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile) - if err != nil { - t.Fatalf("fallback temporary proxy returned error: %v", err) - } - if effectiveProxy != "http://127.0.0.1:38080" { - t.Fatalf("expected fallback temporary proxy config, got %q", effectiveProxy) - } - if bridgeRef.valid() || releaseBridge { - t.Fatalf("fallback HTTP proxy should not acquire bridge: ref=%+v release=%v", bridgeRef, releaseBridge) - } - if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" { - t.Fatalf("fallback temporary proxy should not mutate profile: %+v", profile) - } -} - -func TestAppendLaunchTargetsUsesConfiguredDefaultStartURLs(t *testing.T) { - t.Parallel() - - got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{"https://one.example/", "https://two.example/"}, false, false) - want := []string{"--disable-sync", "https://one.example/", "https://two.example/"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("appendLaunchTargets mismatch: got=%v want=%v", got, want) - } -} - -func TestAppendLaunchTargetsUsesBlankPageWhenSessionRestoreDisabled(t *testing.T) { - t.Parallel() - - got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, false) - want := []string{"--disable-sync", "about:blank"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("appendLaunchTargets should fall back to about:blank: got=%v want=%v", got, want) - } -} - -func TestAppendLaunchTargetsPreservesSessionRestoreWhenEnabled(t *testing.T) { - t.Parallel() - - got := appendLaunchTargets([]string{"--disable-sync"}, nil, []string{}, false, true) - want := []string{"--disable-sync"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("appendLaunchTargets should preserve session restore behavior: got=%v want=%v", got, want) - } -} - -func TestBuildBrowserLaunchArgsUsesNoProxyServerForDirectProxy(t *testing.T) { - t.Parallel() - - profile := &BrowserProfile{ - ProfileId: "profile-direct", - } - - got := buildBrowserLaunchArgs( - profile, - `D:\profiles\direct`, - 9222, - "direct://", - nil, - nil, - nil, - []string{"about:blank"}, - ) - - hasNoProxyServer := false - for _, arg := range got { - if arg == "--no-proxy-server" { - hasNoProxyServer = true - } - if arg == "--proxy-server=direct://" { - t.Fatalf("expected direct proxy launch args to avoid --proxy-server=direct://, got=%v", got) - } - } - if !hasNoProxyServer { - t.Fatalf("expected direct proxy to use --no-proxy-server, got=%v", got) - } -} - -func TestBuildBrowserLaunchArgsLoadsEnabledExtensions(t *testing.T) { - t.Parallel() - - profile := &BrowserProfile{ProfileId: "profile-extension"} - got := buildBrowserLaunchArgs( - profile, - `D:\profiles\extensions`, - 9333, - "", - []string{`D:\extensions\a`, `D:\extensions\b`}, - nil, - nil, - []string{"about:blank"}, - ) - - wantLoad := `--load-extension=D:\extensions\a,D:\extensions\b` - wantExcept := `--disable-extensions-except=D:\extensions\a,D:\extensions\b` - if !containsString(got, wantLoad) || !containsString(got, wantExcept) { - t.Fatalf("expected extension launch args, got=%v", got) - } -} - -func containsString(items []string, target string) bool { - for _, item := range items { - if item == target { - return true - } - } - return false -} diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go deleted file mode 100644 index 98be2571..00000000 --- a/backend/app_instance_start_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "errors" - "net/http" - "os/exec" - "reflect" - "strings" - "testing" - "time" -) - -func TestEnsureNewWindowLaunchArgAddsFlagOnce(t *testing.T) { - t.Parallel() - - got := ensureNewWindowLaunchArg([]string{"--lang=en-US"}) - want := []string{"--lang=en-US", "--new-window"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("ensureNewWindowLaunchArg 结果错误: got=%v want=%v", got, want) - } - - got = ensureNewWindowLaunchArg([]string{"--new-window", "--lang=en-US"}) - want = []string{"--new-window", "--lang=en-US"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("ensureNewWindowLaunchArg 不应重复追加: got=%v want=%v", got, want) - } -} - -func TestShouldPreferVisibleWindowForStartWithParams(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - startURLs []string - want bool - }{ - { - name: "nil start URLs", - startURLs: nil, - want: false, - }, - { - name: "empty start URLs", - startURLs: []string{}, - want: false, - }, - { - name: "blank start URLs", - startURLs: []string{" ", "\t"}, - want: false, - }, - { - name: "valid start URL", - startURLs: []string{"https://finance.sina.com.cn"}, - want: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := shouldPreferVisibleWindowForStartWithParams(tt.startURLs); got != tt.want { - t.Fatalf("shouldPreferVisibleWindowForStartWithParams() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestIsBrowserProfileLive(t *testing.T) { - t.Parallel() - - ln := mustListenLoopback(t) - defer ln.Close() - - profile := &BrowserProfile{ - Running: true, - DebugPort: listenerPort(t, ln), - } - if !isBrowserProfileLive(profile, nil) { - t.Fatal("期望存活中的调试端口被识别为运行中实例") - } - - if isBrowserProfileLive(&BrowserProfile{Running: true, DebugPort: 0}, nil) { - t.Fatal("debugPort=0 不应被识别为运行中实例") - } -} - -func TestIsBrowserProfileLiveKeepsPendingDebugProcessAlive(t *testing.T) { - t.Parallel() - - cmd := longLivedCommand(2 * time.Second) - if err := cmd.Start(); err != nil { - t.Fatalf("启动长生命周期测试进程失败: %v", err) - } - defer func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - } - }() - - profile := &BrowserProfile{ - Running: true, - Pid: cmd.Process.Pid, - DebugPort: 0, - DebugReady: false, - } - if !isBrowserProfileLive(profile, cmd) { - t.Fatal("期望调试接口未就绪但进程仍存活时识别为运行中实例") - } -} - -func TestWaitBrowserDebugPortStableKeepsListeningPort(t *testing.T) { - t.Parallel() - - server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json/version": - _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) - case "/json/list": - _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - if _, err := waitBrowserDebugPortStable(server.port, "", time.Second, 250*time.Millisecond, nil); err != nil { - t.Fatalf("waitBrowserDebugPortStable 返回错误: %v", err) - } -} - -func TestWaitBrowserDebugPortStableRejectsEphemeralPort(t *testing.T) { - t.Parallel() - - server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json/version": - _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) - case "/json/list": - _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) - default: - http.NotFound(w, r) - } - })) - port := server.port - time.AfterFunc(120*time.Millisecond, func() { - _ = server.Close() - }) - - _, err := waitBrowserDebugPortStable(port, "", time.Second, 400*time.Millisecond, nil) - if err == nil { - t.Fatal("期望短暂就绪后关闭的端口被判定为失败") - } -} - -func TestWaitBrowserDebugPortStableRejectsPlainTCPPort(t *testing.T) { - t.Parallel() - - ln := mustListenLoopback(t) - defer ln.Close() - - _, err := waitBrowserDebugPortStable(listenerPort(t, ln), "", 700*time.Millisecond, 250*time.Millisecond, nil) - if err == nil { - t.Fatal("期望仅开放 TCP 端口但无 DevTools HTTP 时启动失败") - } -} - -func TestWaitBrowserDebugPortStableDiscoversPortFromStderr(t *testing.T) { - t.Parallel() - - server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json/version": - _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) - case "/json/list": - _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - cmd := stderrPortCommand(server.port, 2*time.Second) - monitor, err := newBrowserProcessMonitor(cmd) - if err != nil { - t.Fatalf("初始化浏览器进程监控失败: %v", err) - } - if err := cmd.Start(); err != nil { - t.Fatalf("启动测试命令失败: %v", err) - } - monitor.Start() - - debugPort, err := waitBrowserDebugPortStable(0, "", 2*time.Second, 250*time.Millisecond, monitor) - if err != nil { - t.Fatalf("期望从 stderr 自动发现调试端口,实际错误: %v", err) - } - if debugPort != server.port { - t.Fatalf("期望发现调试端口 %d,实际=%d", server.port, debugPort) - } -} - -func TestWaitBrowserDebugPortStableDiscoversPortFromDevToolsFile(t *testing.T) { - t.Parallel() - - server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json/version": - _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) - case "/json/list": - _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - userDataDir := t.TempDir() - writeDevToolsActivePortFile(t, userDataDir, server.port) - - debugPort, err := waitBrowserDebugPortStable(0, userDataDir, time.Second, 250*time.Millisecond, nil) - if err != nil { - t.Fatalf("期望从 DevToolsActivePort 自动发现调试端口,实际错误: %v", err) - } - if debugPort != server.port { - t.Fatalf("期望发现调试端口 %d,实际=%d", server.port, debugPort) - } -} - -func TestWaitBrowserDebugPortStableReturnsProcessExitDetail(t *testing.T) { - t.Parallel() - - cmd := stderrFailingCommand("missing libEGL.dll") - monitor, err := newBrowserProcessMonitor(cmd) - if err != nil { - t.Fatalf("初始化浏览器进程监控失败: %v", err) - } - if err := cmd.Start(); err != nil { - t.Fatalf("启动测试命令失败: %v", err) - } - monitor.Start() - - startedAt := time.Now() - _, err = waitBrowserDebugPortStable(0, "", 2*time.Second, 250*time.Millisecond, monitor) - if err == nil { - t.Fatal("期望启动前退出被判定为失败") - } - if time.Since(startedAt) >= 2*time.Second { - t.Fatalf("期望在超时前返回进程退出错误,实际耗时=%s", time.Since(startedAt)) - } - - var exitErr *browserStartupExitError - if !errors.As(err, &exitErr) { - t.Fatalf("期望 browserStartupExitError,实际=%T %v", err, err) - } - if !strings.Contains(exitErr.Detail(), "missing libEGL.dll") { - t.Fatalf("期望 stderr 细节被捕获,实际=%q", exitErr.Detail()) - } -} - -func TestWaitBrowserDebugPortStableAllowsDebugPortAfterLauncherExit(t *testing.T) { - t.Parallel() - - port := freeLoopbackPort(t) - cmd := shortLivedCommand() - monitor, err := newBrowserProcessMonitor(cmd) - if err != nil { - t.Fatalf("初始化浏览器进程监控失败: %v", err) - } - if err := cmd.Start(); err != nil { - t.Fatalf("启动短命测试命令失败: %v", err) - } - monitor.Start() - - serverReady := make(chan *devToolsTestServer, 1) - go func() { - time.Sleep(300 * time.Millisecond) - serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/json/version": - _, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`)) - case "/json/list": - _, _ = w.Write([]byte(`[{"id":"page-1"}]`)) - default: - http.NotFound(w, r) - } - })) - }() - - debugPort, err := waitBrowserDebugPortStable(port, "", 100*time.Millisecond, 250*time.Millisecond, monitor) - server := <-serverReady - defer server.Close() - - if err != nil { - t.Fatalf("期望启动器退出后仍能等待到调试端口就绪,实际错误: %v", err) - } - if debugPort != port { - t.Fatalf("期望发现调试端口 %d,实际=%d", port, debugPort) - } -} - -func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) { - ln := mustListenLoopback(t) - port := listenerPort(t, ln) - - app := NewApp("") - app.browserMgr = browser.NewManager(config.DefaultConfig(), "") - app.browserMgr.Profiles = map[string]*BrowserProfile{ - "profile-detached": { - ProfileId: "profile-detached", - ProfileName: "Detached Browser", - Running: true, - DebugPort: port, - DebugReady: true, - Pid: 12345, - }, - } - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - - cmd := shortLivedCommand() - monitor, err := newBrowserProcessMonitor(cmd) - if err != nil { - t.Fatalf("初始化测试进程监控失败: %v", err) - } - if err := cmd.Start(); err != nil { - t.Fatalf("启动短命测试进程失败: %v", err) - } - monitor.Start() - app.browserMgr.BrowserProcesses["profile-detached"] = cmd - - done := make(chan struct{}) - go func() { - app.waitBrowserProcess("profile-detached", monitor) - close(done) - }() - - waitForCondition(t, 3*time.Second, func() bool { - app.browserMgr.Mutex.Lock() - defer app.browserMgr.Mutex.Unlock() - - profile := app.browserMgr.Profiles["profile-detached"] - _, tracked := app.browserMgr.BrowserProcesses["profile-detached"] - return profile != nil && profile.Running && !tracked - }) - - _ = ln.Close() - - waitForCondition(t, 4*time.Second, func() bool { - app.browserMgr.Mutex.Lock() - defer app.browserMgr.Mutex.Unlock() - - profile := app.browserMgr.Profiles["profile-detached"] - return profile != nil && !profile.Running && profile.DebugPort == 0 && profile.Pid == 0 - }) - - select { - case <-done: - case <-time.After(4 * time.Second): - t.Fatal("waitBrowserProcess 未在调试端口关闭后结束") - } -} diff --git a/backend/automation_script_http_e2e_helpers_test.go b/backend/automation_script_http_e2e_helpers_test.go deleted file mode 100644 index ae728bd5..00000000 --- a/backend/automation_script_http_e2e_helpers_test.go +++ /dev/null @@ -1,341 +0,0 @@ -package backend - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "os" - "path/filepath" - "regexp" - goruntime "runtime" - "strings" - "testing" - "time" -) - -func lookupAutomationHTTPProbeNode(t *testing.T) string { - t.Helper() - - const preferred = `D:\code\plugin\nodejs\node.exe` - if _, err := os.Stat(preferred); err == nil { - return preferred - } - return lookupAutomationTestNode(t) -} - -func lookupAutomationHTTPProbeChrome(t *testing.T) string { - t.Helper() - - const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe` - if _, err := os.Stat(preferred); err == nil { - return preferred - } - t.Skip("system chrome is not installed") - return "" -} - -func automationHTTPRepoRoot(t *testing.T) string { - t.Helper() - - _, file, _, ok := goruntime.Caller(0) - if !ok { - t.Fatal("resolve repo root failed") - } - return filepath.Dir(filepath.Dir(file)) -} - -func automationHTTPFreePort(t *testing.T) int { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("allocate port failed: %v", err) - } - defer ln.Close() - return ln.Addr().(*net.TCPAddr).Port -} - -func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error { - repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion)) - tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion)) - if _, err := os.Stat(repoRuntimeDir); err != nil { - return fmt.Errorf("repo runtime not found: %w", err) - } - if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil { - return err - } - if err := automationHTTPCopyFile( - filepath.Join(repoRuntimeDir, "runner.cjs"), - filepath.Join(tempRuntimeDir, "runner.cjs"), - ); err != nil { - return err - } - return automationHTTPCopyDir( - filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"), - filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"), - ) -} - -func automationHTTPCopyFile(src string, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } - return os.WriteFile(dst, data, 0o644) -} - -func automationHTTPCopyDir(src string, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - - relativePath, err := filepath.Rel(src, path) - if err != nil { - return err - } - targetPath := filepath.Join(dst, relativePath) - if info.IsDir() { - return os.MkdirAll(targetPath, 0o755) - } - - data, err := os.ReadFile(path) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return err - } - return os.WriteFile(targetPath, data, info.Mode()) - }) -} - -func automationHTTPRequestJSON(method string, url string, payload any, target any) error { - var body io.Reader - if payload != nil { - data, err := json.Marshal(payload) - if err != nil { - return err - } - body = bytes.NewReader(data) - } - - req, err := http.NewRequest(method, url, body) - if err != nil { - return err - } - if payload != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - raw, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw))) - } - if target == nil { - return nil - } - if err := json.Unmarshal(raw, target); err != nil { - return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw)) - } - return nil -} - -func automationHTTPHasScript(items []struct { - ID string `json:"id"` -}, scriptID string) bool { - for _, item := range items { - if strings.TrimSpace(item.ID) == scriptID { - return true - } - } - return false -} - -func automationHTTPStringValue(payload map[string]any, key string) string { - if payload == nil { - return "" - } - value, _ := payload[key].(string) - return strings.TrimSpace(value) -} - -func automationHTTPMarshal(t *testing.T, value any) string { - t.Helper() - - data, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal debug payload failed: %v", err) - } - return string(data) -} - -const automationHTTPMailFixtureHTML = ` - - - - Mail Fixture - - - - -
- -
-

Your ChatGPT verification code

-

From: ChatGPT <noreply@tm.openai.com>

-

To: target@example.com

-

Hello,

-

Your verification code is 429792.

-

Please use this code to continue signing in.

-

Best regards

-

ChatGPT

-
-
- -` - -const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => { - const normalizeText = (value) => String(value == null ? '' : value).trim() - const timeoutMs = Number.isFinite(Number(params.timeoutMs)) - ? Math.max(5000, Math.round(Number(params.timeoutMs))) - : 45000 - const inboxUrl = normalizeText(params.inboxUrl) - - if (!inboxUrl) { - throw new Error('inboxUrl is required') - } - - const session = await launch({ - selector, - skipDefaultStartUrls: true, - startUrls: [inboxUrl], - }) - const connection = await connect(session, { timeoutMs }) - const browser = connection.browser - if (!browser) { - throw new Error('browser connection is unavailable') - } - - const context = - connection.context || - browser.contexts()[0] || - (typeof browser.newContext === 'function' ? await browser.newContext() : null) - if (!context) { - throw new Error('browser context is unavailable') - } - - const opened = await openPage(connection, { - url: inboxUrl, - timeoutMs, - permissions: ['notifications'], - }) - const page = opened.page - await page.waitForLoadState('networkidle', { - timeout: Math.min(timeoutMs, 2500), - }).catch(() => {}) - - const result = await page.evaluate(() => { - const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim() - const article = document.querySelector('article') - const lines = Array.from(document.querySelectorAll('article p')) - .map((node) => normalizeText(node.textContent)) - .filter(Boolean) - const subject = normalizeText(document.querySelector('article h1')?.textContent) - const fromLine = lines.find((line) => line.startsWith('From:')) || '' - const toLine = lines.find((line) => line.startsWith('To:')) || '' - const articleText = normalizeText(article?.textContent) - const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s* 0 { - parsed = nested - } - } - if runResp.Data.Run.Status != "success" { - var logsResp automationHTTPLaunchLogsResponse - _ = automationHTTPRequestJSON(http.MethodGet, baseURL+"/api/launch/logs?limit=10", nil, &logsResp) - t.Fatalf("unexpected run response: status=%s summary=%s error=%s logs=%s", - runResp.Data.Run.Status, - runResp.Data.Run.Summary, - runResp.Data.Run.Error, - automationHTTPMarshal(t, logsResp), - ) - } - - if got := automationHTTPStringValue(parsed, "mailboxName"); got != "ChatGPT" { - t.Fatalf("unexpected mailboxName: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) - } - if got := automationHTTPStringValue(parsed, "senderEmail"); got != "noreply@tm.openai.com" { - t.Fatalf("unexpected senderEmail: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) - } - if got := automationHTTPStringValue(parsed, "recipientEmail"); got != "target@example.com" { - t.Fatalf("unexpected recipientEmail: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) - } - if got := automationHTTPStringValue(parsed, "verificationCode"); got != "429792" { - t.Fatalf("unexpected verificationCode: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) - } - if got := parsed["permissionApplied"]; got != true { - t.Fatalf("expected permissionApplied=true, got %#v parsed=%s", got, automationHTTPMarshal(t, parsed)) - } - if got := automationHTTPStringValue(parsed, "permissionOrigin"); got != fixtureServer.URL { - t.Fatalf("unexpected permissionOrigin: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) - } - signature := automationHTTPStringValue(parsed, "signature") - if !strings.Contains(signature, "Best regards") || !strings.Contains(signature, "ChatGPT") { - t.Fatalf("unexpected signature: %q parsed=%s", signature, automationHTTPMarshal(t, parsed)) - } - - var hookResp automationHTTPHookEnvelopeResponse - hookErr := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/automation/hooks/mail/probe-message", map[string]any{ - "params": map[string]any{ - "inboxUrl": fixtureServer.URL, - }, - "timeoutMs": 45000, - }, &hookResp) - if hookErr != nil { - t.Fatalf("run public hook via http failed: %v", hookErr) - } - if !hookResp.OK || hookResp.Status != "success" { - t.Fatalf("unexpected hook response: %+v", hookResp) - } - if got := automationHTTPStringValue(hookResp.Result, "verificationCode"); got != "429792" { - t.Fatalf("unexpected hook verificationCode: %q resp=%s", got, automationHTTPMarshal(t, hookResp)) - } - if got := automationHTTPStringValue(hookResp.Result, "senderEmail"); got != "noreply@tm.openai.com" { - t.Fatalf("unexpected hook senderEmail: %q resp=%s", got, automationHTTPMarshal(t, hookResp)) - } - - t.Logf("automation http result: %s", automationHTTPMarshal(t, map[string]any{ - "profileId": createResp.ProfileID, - "launchCode": createResp.LaunchCode, - "runStatus": runResp.Data.Run.Status, - "runSummary": runResp.Data.Run.Summary, - "hookStatus": hookResp.Status, - "hookSummary": hookResp.Summary, - "mailboxName": automationHTTPStringValue(parsed, "mailboxName"), - "senderEmail": automationHTTPStringValue(parsed, "senderEmail"), - "recipientEmail": automationHTTPStringValue(parsed, "recipientEmail"), - "verificationCode": automationHTTPStringValue(parsed, "verificationCode"), - "signature": signature, - "subject": automationHTTPStringValue(parsed, "subject"), - })) -} diff --git a/backend/automation_script_run_integration_helpers_test.go b/backend/automation_script_run_integration_helpers_test.go deleted file mode 100644 index 66cc55da..00000000 --- a/backend/automation_script_run_integration_helpers_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package backend - -import ( - "net/url" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" - - "ant-chrome/backend/internal/automation" - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/launchcode" -) - -func lookupAutomationTestNode(t *testing.T) string { - t.Helper() - - nodeExecPath, err := exec.LookPath("node") - if err != nil { - t.Skip("node is not installed") - } - return nodeExecPath -} - -func prepareAutomationTestRuntime(t *testing.T, manager *automation.Manager, playwrightVersion string) { - t.Helper() - - prepareAutomationTestRuntimeWithPlaywrightModule( - t, - manager, - playwrightVersion, - "module.exports = { chromium: {} }\n", - ) -} - -func prepareAutomationTestRuntimeWithPlaywrightModule(t *testing.T, manager *automation.Manager, playwrightVersion string, playwrightModuleSource string) { - t.Helper() - - state := manager.CurrentState() - - playwrightCoreDir := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(playwrightCoreDir, 0o755); err != nil { - t.Fatalf("create playwright-core dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(playwrightCoreDir, "package.json"), []byte("{\"name\":\"playwright-core\",\"version\":\""+playwrightVersion+"\"}\n"), 0o644); err != nil { - t.Fatalf("write playwright-core package failed: %v", err) - } - if err := os.WriteFile(filepath.Join(playwrightCoreDir, "index.js"), []byte(playwrightModuleSource), 0o644); err != nil { - t.Fatalf("write playwright-core stub failed: %v", err) - } - if err := os.WriteFile(state.RunnerPath, []byte(automationTestRunnerScript), 0o755); err != nil { - t.Fatalf("write runner script failed: %v", err) - } -} - -const automationTestConnectProbePlaywrightModule = `const http = require('http') - -module.exports = { - chromium: { - connectOverCDP: async (endpoint) => { - const target = new URL('/json/version', endpoint) - await new Promise((resolve, reject) => { - const req = http.get(target, (res) => { - res.resume() - res.on('end', () => { - const status = res.statusCode || 0 - if (status >= 200 && status < 300) { - resolve() - return - } - reject(new Error('cdp connect probe failed with http ' + String(status))) - }) - }) - req.on('error', reject) - }) - - return { - contexts: () => [{ - pages: () => [], - newPage: async () => ({}) - }], - close: async () => {} - } - } - } -} -` - -const automationTestRunnerScript = `const fs = require('fs') -const path = require('path') - -async function main() { - const payloadPath = process.argv[2] - const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8')) - const script = require(payload.ScriptPath) - const startedAt = new Date().toISOString() - const result = await script.run({ - selector: payload.Selector || {}, - params: payload.Params || {}, - artifact: (name) => { - const dir = payload.ArtifactDir || path.dirname(payload.ScriptPath) - fs.mkdirSync(dir, { recursive: true }) - return path.join(dir, name) - }, - log: () => {}, - launch: async () => ({ ok: true }), - connect: async () => ({ - browser: { contexts: () => [] }, - context: { - pages: () => [], - newPage: async () => ({}) - }, - page: null - }) - }) - - console.log(JSON.stringify({ - ok: result && result.ok !== false, - summary: result && result.summary ? String(result.summary) : '', - error: result && result.error ? String(result.error) : '', - startedAt, - finishedAt: new Date().toISOString(), - ...result - })) -} - -main().catch((error) => { - console.error(error && error.stack ? error.stack : String(error)) - process.exit(1) -}) -` - -type automationConcurrentRunResult struct { - run *automation.ScriptRunRecord - err error -} - -func newAutomationPlaywrightRunTestApp(t *testing.T, playwrightModuleSource string) (*App, func()) { - t.Helper() - - nodeExecPath := lookupAutomationTestNode(t) - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.Enabled = true - app.config.Automation.NodeSource = config.AutomationNodeSourceSystem - app.config.Automation.SystemNodePath = nodeExecPath - app.config.Automation.NodeVersion = "test-node" - app.config.Automation.PlaywrightCoreVersion = "1.59.0" - app.config.Automation.RuntimeVersion = "test-runtime" - app.browserMgr = browser.NewManager(app.config, app.appRoot) - app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - app.browserMgr.CodeProvider = app.launchCodeSvc - app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) - - prepareAutomationTestRuntimeWithPlaywrightModule( - t, - app.automationMgr, - app.config.Automation.PlaywrightCoreVersion, - playwrightModuleSource, - ) - - app.launchServer = launchcode.NewLaunchServer( - app.launchCodeSvc, - app, - app.browserMgr, - 0, - ) - if err := app.launchServer.Start(); err != nil { - t.Fatalf("start launch server failed: %v", err) - } - - return app, func() { - _ = app.launchServer.Stop() - } -} - -func automationTestServerPort(t *testing.T, rawURL string) int { - t.Helper() - - parsed, err := url.Parse(rawURL) - if err != nil { - t.Fatalf("parse server url failed: %v", err) - } - port, err := strconv.Atoi(parsed.Port()) - if err != nil { - t.Fatalf("parse server port failed: %v", err) - } - return port -} - -func createAutomationRunningProfileWithCode(t *testing.T, app *App, name string, code string, debugPort int) *browser.Profile { - t.Helper() - - profile, err := app.browserMgr.Create(browser.ProfileInput{ - ProfileName: name, - }) - if err != nil { - t.Fatalf("create profile failed: %v", err) - } - if profile == nil { - t.Fatal("create profile returned nil") - } - if strings.TrimSpace(code) != "" { - if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, code); err != nil { - t.Fatalf("set code failed: %v", err) - } - } - - app.browserMgr.Profiles[profile.ProfileId].Running = true - app.browserMgr.Profiles[profile.ProfileId].DebugReady = true - app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort - app.browserMgr.Profiles[profile.ProfileId].Pid = 12345 - - return profile -} - -func runAutomationScriptsConcurrently(t *testing.T, count int, runner func(index int) (*automation.ScriptRunRecord, error)) []automationConcurrentRunResult { - t.Helper() - - results := make([]automationConcurrentRunResult, count) - start := make(chan struct{}) - var wg sync.WaitGroup - - for index := 0; index < count; index++ { - index := index - wg.Add(1) - go func() { - defer wg.Done() - <-start - run, err := runner(index) - results[index] = automationConcurrentRunResult{ - run: run, - err: err, - } - }() - } - - time.Sleep(50 * time.Millisecond) - close(start) - wg.Wait() - - return results -} diff --git a/backend/automation_script_run_integration_test.go b/backend/automation_script_run_integration_test.go deleted file mode 100644 index 3b00ab2e..00000000 --- a/backend/automation_script_run_integration_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "os" - "strconv" - "strings" - "sync/atomic" - "testing" - - "ant-chrome/backend/internal/automation" - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/launchcode" -) - -func TestAutomationScriptRunWithOptionsExecutesPlaywrightScript(t *testing.T) { - nodeExecPath := lookupAutomationTestNode(t) - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.Enabled = true - app.config.Automation.NodeSource = config.AutomationNodeSourceSystem - app.config.Automation.SystemNodePath = nodeExecPath - app.config.Automation.NodeVersion = "test-node" - app.config.Automation.PlaywrightCoreVersion = "1.59.0" - app.config.Automation.RuntimeVersion = "test-runtime" - app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) - - prepareAutomationTestRuntime(t, app.automationMgr, app.config.Automation.PlaywrightCoreVersion) - - app.launchServer = launchcode.NewLaunchServer( - launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()), - nil, - nil, - 0, - ) - if err := app.launchServer.Start(); err != nil { - t.Fatalf("start launch server failed: %v", err) - } - defer func() { - _ = app.launchServer.Stop() - }() - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "playwright-success", - Name: "Playwright 成功脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "const fs = require('fs')\nmodule.exports.run = async ({ params, artifact }) => {\n const outputPath = artifact('result.txt')\n fs.writeFileSync(outputPath, String(params.message || 'default'), 'utf8')\n return { ok: true, summary: 'artifact ready', outputPath }\n}\n", - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ - ScriptID: saved.ID, - SelectorText: `{}`, - ParamsText: `{"message":"hello integration"}`, - UseScriptSelector: false, - UseScriptParams: false, - }) - if err != nil { - t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) - } - if run == nil { - t.Fatalf("AutomationScriptRunWithOptions returned nil result") - } - if run.Status != "success" { - t.Fatalf("expected success status, got %+v", run) - } - if run.Summary != "artifact ready" { - t.Fatalf("unexpected run summary: %q", run.Summary) - } - - var payload struct { - OK bool `json:"ok"` - Summary string `json:"summary"` - Artifacts []string `json:"artifacts"` - Result struct { - OutputPath string `json:"outputPath"` - } `json:"result"` - } - if err := json.Unmarshal([]byte(run.ResultText), &payload); err != nil { - t.Fatalf("unmarshal run result failed: %v; result=%s", err, run.ResultText) - } - if !payload.OK { - t.Fatalf("expected payload ok=true, got %+v", payload) - } - if payload.Result.OutputPath == "" { - t.Fatalf("expected outputPath in payload, got %+v result=%s", payload, run.ResultText) - } - if len(payload.Artifacts) != 1 || payload.Artifacts[0] != payload.Result.OutputPath { - t.Fatalf("expected artifacts to contain output path, got %+v", payload) - } - - data, err := os.ReadFile(payload.Result.OutputPath) - if err != nil { - t.Fatalf("read output artifact failed: %v", err) - } - if string(data) != "hello integration" { - t.Fatalf("unexpected artifact content: %q", string(data)) - } -} - -func TestAutomationScriptRunWithOptionsPrestartsStoredTargetForConnectOnlyScript(t *testing.T) { - nodeExecPath := lookupAutomationTestNode(t) - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.Enabled = true - app.config.Automation.NodeSource = config.AutomationNodeSourceSystem - app.config.Automation.SystemNodePath = nodeExecPath - app.config.Automation.NodeVersion = "test-node" - app.config.Automation.PlaywrightCoreVersion = "1.59.0" - app.config.Automation.RuntimeVersion = "test-runtime" - app.browserMgr = browser.NewManager(app.config, app.appRoot) - app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - app.browserMgr.CodeProvider = app.launchCodeSvc - app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) - - prepareAutomationTestRuntimeWithPlaywrightModule( - t, - app.automationMgr, - app.config.Automation.PlaywrightCoreVersion, - automationTestConnectProbePlaywrightModule, - ) - - var debugHits atomic.Int32 - debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - debugHits.Add(1) - if r.URL.Path != "/json/version" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "Browser": "Chrome/123.0.0.0", - }) - })) - defer debugServer.Close() - - debugURL, err := url.Parse(debugServer.URL) - if err != nil { - t.Fatalf("parse debug server url failed: %v", err) - } - debugPort, err := strconv.Atoi(debugURL.Port()) - if err != nil { - t.Fatalf("parse debug server port failed: %v", err) - } - - profile, err := app.browserMgr.Create(browser.ProfileInput{ - ProfileName: "buyer-connect-only", - }) - if err != nil { - t.Fatalf("create profile failed: %v", err) - } - if profile == nil { - t.Fatal("create profile returned nil") - } - app.browserMgr.Profiles[profile.ProfileId].Running = true - app.browserMgr.Profiles[profile.ProfileId].DebugReady = true - app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort - app.browserMgr.Profiles[profile.ProfileId].Pid = 12345 - - app.launchServer = launchcode.NewLaunchServer( - app.launchCodeSvc, - app, - app.browserMgr, - 0, - ) - if err := app.launchServer.Start(); err != nil { - t.Fatalf("start launch server failed: %v", err) - } - defer func() { - _ = app.launchServer.Stop() - }() - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "playwright-connect-stored-target", - Name: "Playwright Connect Stored Target", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "module.exports.run = async ({ connect }) => {\n" + - " const { browser } = await connect()\n" + - " return { ok: true, summary: 'connected through stored target', contextCount: browser.contexts().length }\n" + - "}\n", - TargetConfig: automation.ScriptTargetConfig{ - Mode: "existing", - Selector: automation.ScriptTargetSelector{ - ProfileID: profile.ProfileId, - }, - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ - ScriptID: saved.ID, - UseScriptSelector: true, - UseScriptParams: true, - }) - if err != nil { - t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) - } - if run == nil { - t.Fatalf("AutomationScriptRunWithOptions returned nil result") - } - if run.Status != "success" { - t.Fatalf("expected success status, got %+v", run) - } - if !strings.Contains(run.Summary, "connected through stored target") { - t.Fatalf("unexpected run summary: %q", run.Summary) - } - if !strings.Contains(run.ResultText, `"contextCount":1`) { - t.Fatalf("expected connect result payload, got %s", run.ResultText) - } - if debugHits.Load() == 0 { - t.Fatalf("expected connect() to hit active debug endpoint through launch server") - } -} - -func TestAutomationScriptRunWithOptionsPrestartsManualCodeTargetForConnectOnlyScript(t *testing.T) { - app, cleanup := newAutomationPlaywrightRunTestApp(t, automationTestConnectProbePlaywrightModule) - defer cleanup() - - var debugHits atomic.Int32 - debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - debugHits.Add(1) - if r.URL.Path != "/json/version" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "Browser": "Chrome/123.0.0.0", - }) - })) - defer debugServer.Close() - - profile := createAutomationRunningProfileWithCode( - t, - app, - "buyer-manual-code", - "BUYER_001", - automationTestServerPort(t, debugServer.URL), - ) - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "playwright-connect-manual-code", - Name: "Playwright Connect Manual Code", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "module.exports.run = async ({ connect }) => {\n" + - " const { browser } = await connect()\n" + - " return { ok: true, summary: 'connected through manual code', contextCount: browser.contexts().length }\n" + - "}\n", - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ - ScriptID: saved.ID, - SelectorText: `{"code":"BUYER_001"}`, - UseScriptSelector: false, - UseScriptParams: true, - }) - if err != nil { - t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) - } - if run == nil { - t.Fatalf("AutomationScriptRunWithOptions returned nil result") - } - if run.Status != "success" { - t.Fatalf("expected success status, got %+v", run) - } - if !strings.Contains(run.Summary, "connected through manual code") { - t.Fatalf("unexpected run summary: %q", run.Summary) - } - if !strings.Contains(run.ResultText, `"contextCount":1`) { - t.Fatalf("expected connect result payload, got %s", run.ResultText) - } - if debugHits.Load() == 0 { - t.Fatalf("expected connect() to hit active debug endpoint through launch server") - } - if app.launchServer == nil { - t.Fatal("expected launch server to be initialized") - } - activeProfileID, _, _ := app.launchServer.ActiveProfile() - if activeProfileID != profile.ProfileId { - t.Fatalf("expected active profile %s, got %s", profile.ProfileId, activeProfileID) - } -} - -func TestAutomationScriptRunWithOptionsAllowsSameScriptOnDifferentProfiles(t *testing.T) { - app, cleanup := newAutomationPlaywrightRunTestApp(t, "module.exports = { chromium: {} }\n") - defer cleanup() - - debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer debugServer.Close() - - debugPort := automationTestServerPort(t, debugServer.URL) - profileA := createAutomationRunningProfileWithCode(t, app, "buyer-a", "BUYER_A", debugPort) - profileB := createAutomationRunningProfileWithCode(t, app, "buyer-b", "BUYER_B", debugPort) - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "slow-shared-script", - Name: "Slow Shared Script", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 120))\n return { ok: true, summary: 'slow ok' }\n}\n", - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - results := runAutomationScriptsConcurrently(t, 2, func(index int) (*automation.ScriptRunRecord, error) { - profileID := profileA.ProfileId - if index == 1 { - profileID = profileB.ProfileId - } - return app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ - ScriptID: saved.ID, - SelectorText: fmt.Sprintf(`{"profileId":"%s"}`, profileID), - UseScriptSelector: false, - UseScriptParams: true, - TimeoutMs: 5000, - }) - }) - - for _, result := range results { - if result.err != nil { - t.Fatalf("AutomationScriptRunWithOptions returned error: %v", result.err) - } - if result.run == nil { - t.Fatal("AutomationScriptRunWithOptions returned nil result") - } - if result.run.Status != "success" { - t.Fatalf("expected both runs to succeed on different profiles, got %+v", result.run) - } - } -} - -func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *testing.T) { - app, cleanup := newAutomationPlaywrightRunTestApp(t, "module.exports = { chromium: {} }\n") - defer cleanup() - - debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer debugServer.Close() - - profile := createAutomationRunningProfileWithCode( - t, - app, - "buyer-shared", - "BUYER_SHARED", - automationTestServerPort(t, debugServer.URL), - ) - - firstScript, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "slow-script-a", - Name: "Slow Script A", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 120))\n return { ok: true, summary: 'slow ok a' }\n}\n", - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - secondScript, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "slow-script-b", - Name: "Slow Script B", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 120))\n return { ok: true, summary: 'slow ok b' }\n}\n", - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - results := runAutomationScriptsConcurrently(t, 2, func(index int) (*automation.ScriptRunRecord, error) { - scriptID := firstScript.ID - if index == 1 { - scriptID = secondScript.ID - } - return app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ - ScriptID: scriptID, - SelectorText: fmt.Sprintf(`{"profileId":"%s"}`, profile.ProfileId), - UseScriptSelector: false, - UseScriptParams: true, - TimeoutMs: 5000, - }) - }) - - successCount := 0 - failedCount := 0 - for _, result := range results { - if result.err != nil { - t.Fatalf("AutomationScriptRunWithOptions returned error: %v", result.err) - } - if result.run == nil { - t.Fatal("AutomationScriptRunWithOptions returned nil result") - } - switch result.run.Status { - case "success": - successCount++ - case "failed": - failedCount++ - if !strings.Contains(result.run.Error, "已有自动化任务在运行中") { - t.Fatalf("expected target lock failure, got %+v", result.run) - } - default: - t.Fatalf("unexpected run status: %+v", result.run) - } - } - if successCount != 1 || failedCount != 1 { - t.Fatalf("expected one success and one failure on same profile, got success=%d failed=%d results=%+v", successCount, failedCount, results) - } -} diff --git a/backend/automation_script_run_launch_api_test.go b/backend/automation_script_run_launch_api_test.go deleted file mode 100644 index 28d8c741..00000000 --- a/backend/automation_script_run_launch_api_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package backend - -import ( - "reflect" - "testing" -) - -func TestParseDualInstanceRuntimeParamsBackfillsDefaultStartURLs(t *testing.T) { - browsers, timeoutMs, err := parseDualInstanceRuntimeParams(`{"browsers":[{"code":"buyer_001"},{"code":"buyer_002"}]}`) - if err != nil { - t.Fatalf("parseDualInstanceRuntimeParams returned error: %v", err) - } - if timeoutMs != dualInstanceRuntimeDefaultTimeoutMs { - t.Fatalf("unexpected timeoutMs: got %d want %d", timeoutMs, dualInstanceRuntimeDefaultTimeoutMs) - } - if len(browsers) != 2 { - t.Fatalf("unexpected browser count: got %d want 2", len(browsers)) - } - - if !reflect.DeepEqual(browsers[0].StartURLs, []string{"https://finance.sina.com.cn/"}) { - t.Fatalf("unexpected browser[0] startUrls: %+v", browsers[0].StartURLs) - } - if !reflect.DeepEqual(browsers[1].StartURLs, []string{"https://map.baidu.com/"}) { - t.Fatalf("unexpected browser[1] startUrls: %+v", browsers[1].StartURLs) - } -} - -func TestParseDualInstanceRuntimeParamsKeepsProvidedStartURLs(t *testing.T) { - browsers, _, err := parseDualInstanceRuntimeParams(`{"browsers":[{"code":"buyer_001","startUrls":["https://example.com"]}]}`) - if err != nil { - t.Fatalf("parseDualInstanceRuntimeParams returned error: %v", err) - } - if len(browsers) != 1 { - t.Fatalf("unexpected browser count: got %d want 1", len(browsers)) - } - if !reflect.DeepEqual(browsers[0].StartURLs, []string{"https://example.com"}) { - t.Fatalf("unexpected startUrls: %+v", browsers[0].StartURLs) - } -} - -func TestParseDualInstanceRuntimeParamsUsesDefaultStartURLsForFallbackCodes(t *testing.T) { - browsers, _, err := parseDualInstanceRuntimeParams(`{}`) - if err != nil { - t.Fatalf("parseDualInstanceRuntimeParams returned error: %v", err) - } - if len(browsers) != 2 { - t.Fatalf("unexpected browser count: got %d want 2", len(browsers)) - } - - if browsers[0].Code != "BUYER_001" || browsers[1].Code != "BUYER_002" { - t.Fatalf("unexpected fallback codes: %+v", browsers) - } - if !reflect.DeepEqual(browsers[0].StartURLs, []string{"https://finance.sina.com.cn/"}) { - t.Fatalf("unexpected browser[0] startUrls: %+v", browsers[0].StartURLs) - } - if !reflect.DeepEqual(browsers[1].StartURLs, []string{"https://map.baidu.com/"}) { - t.Fatalf("unexpected browser[1] startUrls: %+v", browsers[1].StartURLs) - } -} diff --git a/backend/browser_process_discovery_test.go b/backend/browser_process_discovery_test.go deleted file mode 100644 index 2916a992..00000000 --- a/backend/browser_process_discovery_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package backend - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" -) - -func TestParseRemoteDebuggingPort(t *testing.T) { - t.Parallel() - - got := parseRemoteDebuggingPort(`chrome.exe --user-data-dir="D:\data\p1" --remote-debugging-port=49152`) - if got != 49152 { - t.Fatalf("expected port 49152, got %d", got) - } -} - -func TestBrowserInstanceStatusRecoversRunningProfileByUserDataDir(t *testing.T) { - server := newRecordedCDPServer(t) - defer server.Close() - - app := newRuntimeRecoveryTestApp(t) - profile := &BrowserProfile{ - ProfileId: "recover-status", - ProfileName: "Recover Status", - UserDataDir: "recover-status", - Running: false, - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - userDataDir := app.browserMgr.ResolveUserDataDir(profile) - writeDevToolsActivePort(t, userDataDir, server.Port()) - - snapshot, err := app.BrowserInstanceStatus(profile.ProfileId) - if err != nil { - t.Fatalf("BrowserInstanceStatus returned error: %v", err) - } - if snapshot == nil || !snapshot.Running || !snapshot.DebugReady { - t.Fatalf("expected recovered running profile, got %+v", snapshot) - } - if snapshot.DebugPort != server.Port() { - t.Fatalf("expected debug port %d, got %d", server.Port(), snapshot.DebugPort) - } -} - -func TestBrowserInstanceStartRecoversRunningProfileBeforeSessionCleanup(t *testing.T) { - server := newRecordedCDPServer(t) - defer server.Close() - - app := newRuntimeRecoveryTestApp(t) - profile := &BrowserProfile{ - ProfileId: "recover-start", - ProfileName: "Recover Start", - UserDataDir: "recover-start", - Running: false, - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - userDataDir := app.browserMgr.ResolveUserDataDir(profile) - writeDevToolsActivePort(t, userDataDir, server.Port()) - - snapshot, err := app.BrowserInstanceStart(profile.ProfileId) - if err != nil { - t.Fatalf("BrowserInstanceStart returned error: %v", err) - } - if snapshot == nil || !snapshot.Running || !snapshot.DebugReady { - t.Fatalf("expected recovered running profile, got %+v", snapshot) - } - if snapshot.DebugPort != server.Port() { - t.Fatalf("expected debug port %d, got %d", server.Port(), snapshot.DebugPort) - } -} - -func TestPrepareBrowserLaunchContextSkipsProcessScanWhenNoActivePort(t *testing.T) { - app := newRuntimeRecoveryTestApp(t) - profile := &BrowserProfile{ - ProfileId: "cold-start", - ProfileName: "Cold Start", - UserDataDir: "cold-start", - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - - originalFind := findBrowserUserDataProcesses - defer func() { findBrowserUserDataProcesses = originalFind }() - - findCalls := 0 - findBrowserUserDataProcesses = func(string) ([]browserUserDataProcess, error) { - findCalls++ - return nil, nil - } - - _, _, _, _, err := app.prepareBrowserLaunchContext(newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "", ""), profile, nil) - if err != nil { - t.Fatalf("prepareBrowserLaunchContext returned error: %v", err) - } - if findCalls != 0 { - t.Fatalf("cold start should not scan browser processes, got %d calls", findCalls) - } -} -func TestPrepareBrowserLaunchContextTerminatesProcessAndRetriesSessionCleanup(t *testing.T) { - app := newRuntimeRecoveryTestApp(t) - profile := &BrowserProfile{ - ProfileId: "recover-lock", - ProfileName: "Recover Lock", - UserDataDir: "recover-lock", - } - app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} - userDataDir := app.browserMgr.ResolveUserDataDir(profile) - sessionsDir := filepath.Join(userDataDir, "Default", "Sessions") - if err := os.MkdirAll(sessionsDir, 0o755); err != nil { - t.Fatalf("create sessions dir failed: %v", err) - } - - originalFind := findBrowserUserDataProcesses - originalTerminate := terminateBrowserUserDataProcess - originalClear := clearBrowserSessionRestoreData - defer func() { - findBrowserUserDataProcesses = originalFind - terminateBrowserUserDataProcess = originalTerminate - clearBrowserSessionRestoreData = originalClear - }() - - findBrowserUserDataProcesses = func(string) ([]browserUserDataProcess, error) { - return []browserUserDataProcess{{PID: 4321}}, nil - } - terminatedPID := 0 - terminateBrowserUserDataProcess = func(pid int, timeout time.Duration) error { - terminatedPID = pid - return nil - } - clearCalls := 0 - clearBrowserSessionRestoreData = func(string) error { - clearCalls++ - if clearCalls == 1 { - return errors.New("remove sessions dir: locked") - } - return nil - } - - _, _, _, _, err := app.prepareBrowserLaunchContext(newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "", ""), profile, nil) - if err != nil { - t.Fatalf("prepareBrowserLaunchContext returned error: %v", err) - } - if terminatedPID != 4321 { - t.Fatalf("expected terminating pid 4321, got %d", terminatedPID) - } - if clearCalls != 2 { - t.Fatalf("expected session cleanup to be retried once, got %d calls", clearCalls) - } -} - -func newRuntimeRecoveryTestApp(t *testing.T) *App { - t.Helper() - - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = "data" - cfg.Browser.RestoreLastSession = false - exePath := createRuntimeRecoveryFakeBrowserExecutable(t, appRoot) - cfg.Browser.Cores = []config.BrowserCore{ - {CoreId: "runtime-recovery-core", CoreName: "Runtime Recovery Core", CorePath: exePath, IsDefault: true}, - } - - app := NewApp(appRoot) - app.config = cfg - app.browserMgr = browser.NewManager(cfg, appRoot) - app.browserMgr.Profiles = make(map[string]*BrowserProfile) - app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) - return app -} - -func createRuntimeRecoveryFakeBrowserExecutable(t *testing.T, appRoot string) string { - t.Helper() - - path := filepath.Join(appRoot, "chrome", "chrome.exe") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("create fake browser dir failed: %v", err) - } - if err := os.WriteFile(path, []byte("fake"), 0o644); err != nil { - t.Fatalf("write fake browser failed: %v", err) - } - return filepath.Dir(path) -} - -func writeDevToolsActivePort(t *testing.T, userDataDir string, debugPort int) { - t.Helper() - - if err := os.MkdirAll(userDataDir, 0o755); err != nil { - t.Fatalf("create user data dir failed: %v", err) - } - content := fmt.Sprintf("%d\n/devtools/browser/test\n", debugPort) - if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0o644); err != nil { - t.Fatalf("write DevToolsActivePort failed: %v", err) - } -} diff --git a/backend/browser_start_targets_test.go b/backend/browser_start_targets_test.go deleted file mode 100644 index e660b983..00000000 --- a/backend/browser_start_targets_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package backend - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "reflect" - "strconv" - "strings" - "sync" - "testing" - - "github.com/gorilla/websocket" -) - -func TestBuildBrowserLaunchTargetsDefersConfiguredTargetsWhenLightStartEnabled(t *testing.T) { - t.Parallel() - - launchTargets, deferredTargets := buildBrowserLaunchTargets( - []string{"https://one.example/", "https://two.example/"}, - nil, - false, - false, - true, - ) - if !reflect.DeepEqual(launchTargets, []string{"about:blank"}) { - t.Fatalf("expected blank-page launch target, got %v", launchTargets) - } - if !reflect.DeepEqual(deferredTargets, []string{"https://one.example/", "https://two.example/"}) { - t.Fatalf("expected deferred targets to be preserved, got %v", deferredTargets) - } -} - -func TestBuildBrowserLaunchTargetsPreservesSessionRestoreWhenNoConfiguredTargets(t *testing.T) { - t.Parallel() - - launchTargets, deferredTargets := buildBrowserLaunchTargets(nil, nil, false, true, true) - if len(launchTargets) != 0 { - t.Fatalf("expected no launch targets when restore-last-session is enabled, got %v", launchTargets) - } - if len(deferredTargets) != 0 { - t.Fatalf("expected no deferred targets, got %v", deferredTargets) - } -} - -func TestOpenBrowserStartTargetsNavigatesFirstPageAndCreatesRemainingTargets(t *testing.T) { - t.Parallel() - - server := newRecordedCDPServer(t) - defer server.Close() - - if err := openBrowserStartTargets(server.Port(), []string{"https://one.example/", "https://two.example/"}); err != nil { - t.Fatalf("openBrowserStartTargets returned error: %v", err) - } - - want := []recordedCDPCommand{ - {Scope: "page", Method: "Page.navigate", URL: "https://one.example/"}, - {Scope: "browser", Method: "Target.createTarget", URL: "https://two.example/"}, - } - if got := server.Commands(); !reflect.DeepEqual(got, want) { - t.Fatalf("unexpected CDP command sequence:\n got=%v\nwant=%v", got, want) - } -} - -type recordedCDPCommand struct { - Scope string - Method string - URL string -} - -type recordedCDPServer struct { - server *httptest.Server - upgrader websocket.Upgrader - - mu sync.Mutex - commands []recordedCDPCommand -} - -func newRecordedCDPServer(t *testing.T) *recordedCDPServer { - t.Helper() - - recorder := &recordedCDPServer{ - upgrader: websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - }, - } - - mux := http.NewServeMux() - mux.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) { - wsURL := recorder.wsURL("/devtools/page/page-1") - payload := []map[string]any{ - { - "type": "page", - "webSocketDebuggerUrl": wsURL, - }, - } - _ = json.NewEncoder(w).Encode(payload) - }) - mux.HandleFunc("/json/version", func(w http.ResponseWriter, r *http.Request) { - payload := map[string]any{ - "Browser": "Chrome/142.0", - "webSocketDebuggerUrl": recorder.wsURL("/devtools/browser/browser-1"), - } - _ = json.NewEncoder(w).Encode(payload) - }) - mux.HandleFunc("/devtools/page/page-1", func(w http.ResponseWriter, r *http.Request) { - recorder.handleWebsocket(w, r, "page") - }) - mux.HandleFunc("/devtools/browser/browser-1", func(w http.ResponseWriter, r *http.Request) { - recorder.handleWebsocket(w, r, "browser") - }) - - recorder.server = httptest.NewServer(mux) - return recorder -} - -func (s *recordedCDPServer) Close() { - if s == nil || s.server == nil { - return - } - s.server.Close() -} - -func (s *recordedCDPServer) Port() int { - parsed, err := url.Parse(s.server.URL) - if err != nil { - return 0 - } - port, _ := strconv.Atoi(parsed.Port()) - return port -} - -func (s *recordedCDPServer) Commands() []recordedCDPCommand { - s.mu.Lock() - defer s.mu.Unlock() - return append([]recordedCDPCommand{}, s.commands...) -} - -func (s *recordedCDPServer) wsURL(path string) string { - return "ws" + strings.TrimPrefix(s.server.URL, "http") + path -} - -func (s *recordedCDPServer) handleWebsocket(w http.ResponseWriter, r *http.Request, scope string) { - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - var msg cdpMessage - if err := conn.ReadJSON(&msg); err != nil { - return - } - - command := recordedCDPCommand{ - Scope: scope, - Method: msg.Method, - } - if urlValue, _ := msg.Params["url"].(string); urlValue != "" { - command.URL = urlValue - } - - s.mu.Lock() - s.commands = append(s.commands, command) - s.mu.Unlock() - - _ = conn.WriteJSON(cdpResponse{ - Id: msg.Id, - Result: map[string]any{"targetId": "target-1"}, - }) -} diff --git a/backend/test/launchcode/dao_prop_test.go b/backend/test/launchcode/dao_prop_test.go deleted file mode 100644 index be4a8052..00000000 --- a/backend/test/launchcode/dao_prop_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package launchcode_test - -// Feature: instance-launch-code, Property 2: persistence round-trip -// Validates: Requirements 1.3, 5.1, 5.3 - -import ( - "database/sql" - "fmt" - "os" - "sync/atomic" - "testing" - - "ant-chrome/backend/internal/launchcode" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" - _ "modernc.org/sqlite" -) - -var testDBSequence uint64 - -func setupLaunchCodeSchema(t *testing.T, db *sql.DB) { - t.Helper() - _, err := db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes ( - profile_id TEXT PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`) - if err != nil { - t.Fatalf("建表失败: %v", err) - } - _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`) - if err != nil { - t.Fatalf("建索引失败: %v", err) - } -} - -// newTestDB 创建内存 SQLite 数据库并执行建表迁移 -func newTestDB(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", fmt.Sprintf("file:launchcode_test_%d?mode=memory&cache=shared", atomic.AddUint64(&testDBSequence, 1))) - if err != nil { - t.Fatalf("打开测试数据库失败: %v", err) - } - setupLaunchCodeSchema(t, db) - t.Cleanup(func() { db.Close() }) - return db -} - -func newIsolatedTestDB(t *testing.T) (*sql.DB, func()) { - t.Helper() - db, err := sql.Open("sqlite", fmt.Sprintf("file:launchcode_prop_%d?mode=memory&cache=shared", atomic.AddUint64(&testDBSequence, 1))) - if err != nil { - t.Fatalf("打开测试数据库失败: %v", err) - } - setupLaunchCodeSchema(t, db) - return db, func() { _ = db.Close() } -} - -// newFileTestDB 创建基于文件的 SQLite 数据库(用于需要独立隔离的测试) -func newFileTestDB(t *testing.T) *sql.DB { - t.Helper() - f, err := os.CreateTemp("", "launchcode_test_*.db") - if err != nil { - t.Fatalf("创建临时数据库文件失败: %v", err) - } - f.Close() - dbPath := f.Name() - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("打开测试数据库失败: %v", err) - } - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes ( - profile_id TEXT PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`) - if err != nil { - t.Fatalf("建表失败: %v", err) - } - _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`) - if err != nil { - t.Fatalf("建索引失败: %v", err) - } - t.Cleanup(func() { - db.Close() - os.Remove(dbPath) - }) - return db -} - -// TestProperty2_PersistenceRoundTrip -// Property 2: 持久化 Round-Trip -// 对于任意 ProfileId 和 LaunchCode,Upsert 后: -// - FindProfileId(code) 返回相同的 profileId -// - FindCode(profileId) 返回相同的 code -func TestProperty2_PersistenceRoundTrip(t *testing.T) { - properties := gopter.NewProperties(gopter.DefaultTestParameters()) - - properties.Property("Upsert 后 FindProfileId 返回正确 profileId", prop.ForAll( - func(profileId, code string) bool { - db, cleanup := newIsolatedTestDB(t) - defer cleanup() - dao := launchcode.NewSQLiteLaunchCodeDAO(db) - - if err := dao.Upsert(profileId, code); err != nil { - return false - } - got, err := dao.FindProfileId(code) - return err == nil && got == profileId - }, - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), - gen.RegexMatch(`[A-Z0-9]{6}`), - )) - - properties.Property("Upsert 后 FindCode 返回正确 code", prop.ForAll( - func(profileId, code string) bool { - db, cleanup := newIsolatedTestDB(t) - defer cleanup() - dao := launchcode.NewSQLiteLaunchCodeDAO(db) - - if err := dao.Upsert(profileId, code); err != nil { - return false - } - got, err := dao.FindCode(profileId) - return err == nil && got == code - }, - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), - gen.RegexMatch(`[A-Z0-9]{6}`), - )) - - properties.Property("Upsert 幂等:相同 profileId 更新 code 后查询返回新 code", prop.ForAll( - func(profileId, code1, code2 string) bool { - if code1 == code2 { - return true // 跳过相同 code 的情况 - } - db, cleanup := newIsolatedTestDB(t) - defer cleanup() - dao := launchcode.NewSQLiteLaunchCodeDAO(db) - - if err := dao.Upsert(profileId, code1); err != nil { - return false - } - if err := dao.Upsert(profileId, code2); err != nil { - return false - } - got, err := dao.FindCode(profileId) - return err == nil && got == code2 - }, - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), - gen.RegexMatch(`[A-Z0-9]{6}`), - gen.RegexMatch(`[A-Z0-9]{6}`), - )) - - properties.TestingRun(t) -} - -// TestProperty2_DeleteRemovesMapping -// Property 2 补充:Delete 后查询应返回 not found -func TestProperty2_DeleteRemovesMapping(t *testing.T) { - properties := gopter.NewProperties(gopter.DefaultTestParameters()) - - properties.Property("Delete 后 FindCode 返回错误", prop.ForAll( - func(profileId, code string) bool { - db, cleanup := newIsolatedTestDB(t) - defer cleanup() - dao := launchcode.NewSQLiteLaunchCodeDAO(db) - - if err := dao.Upsert(profileId, code); err != nil { - return false - } - if err := dao.Delete(profileId); err != nil { - return false - } - _, err := dao.FindCode(profileId) - return err != nil - }, - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }), - gen.RegexMatch(`[A-Z0-9]{6}`), - )) - - properties.TestingRun(t) -} - -// TestProperty2_LoadAllRoundTrip -// Property 2 补充:LoadAll 返回所有已写入的映射 -func TestProperty2_LoadAllRoundTrip(t *testing.T) { - db := newTestDB(t) - dao := launchcode.NewSQLiteLaunchCodeDAO(db) - - // 写入一批映射 - entries := map[string]string{} - for i := 0; i < 10; i++ { - profileId := fmt.Sprintf("profile-%02d", i) - code := fmt.Sprintf("CODE%02d", i) - entries[profileId] = code - if err := dao.Upsert(profileId, code); err != nil { - t.Fatalf("Upsert 失败: %v", err) - } - } - - loaded, err := dao.LoadAll() - if err != nil { - t.Fatalf("LoadAll 失败: %v", err) - } - - for profileId, code := range entries { - got, ok := loaded[profileId] - if !ok { - t.Errorf("LoadAll 缺少 profileId=%s", profileId) - continue - } - if got != code { - t.Errorf("LoadAll profileId=%s: 期望 code=%s,实际=%s", profileId, code, got) - } - } -} diff --git a/backend/test/launchcode/server_auth_test.go b/backend/test/launchcode/server_auth_test.go deleted file mode 100644 index 8eaeb27c..00000000 --- a/backend/test/launchcode/server_auth_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package launchcode_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "ant-chrome/backend/internal/launchcode" -) - -func buildAuthProtectedTestHandler() http.Handler { - srv := launchcode.NewLaunchServer(newInMemoryService(), newMockStarter(), nil, 0) - srv.SetAPIAuthConfig(launchcode.APIAuthConfig{ - Enabled: true, - APIKey: "secret-key", - Header: "X-Test-Api-Key", - }) - return launchcode.NewTestHandler(srv) -} - -func TestAPIAuthRejectsMissingKey(t *testing.T) { - handler := buildAuthProtectedTestHandler() - - req := httptest.NewRequest(http.MethodGet, "/api/health", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Fatalf("缺少 API Key 时应返回 401: got=%d body=%s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if resp["error"] != "unauthorized: invalid api key" { - t.Fatalf("错误信息不正确: %+v", resp) - } - if resp["authHeader"] != "X-Test-Api-Key" { - t.Fatalf("应返回当前使用的认证 Header: %+v", resp) - } -} - -func TestAPIAuthRejectsWrongKey(t *testing.T) { - handler := buildAuthProtectedTestHandler() - - req := httptest.NewRequest(http.MethodGet, "/api/health", nil) - req.Header.Set("X-Test-Api-Key", "wrong-key") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Fatalf("错误 API Key 时应返回 401: got=%d body=%s", w.Code, w.Body.String()) - } -} - -func TestAPIAuthAllowsCorrectKey(t *testing.T) { - handler := buildAuthProtectedTestHandler() - - req := httptest.NewRequest(http.MethodGet, "/api/health", nil) - req.Header.Set("X-Test-Api-Key", "secret-key") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("正确 API Key 时应返回 200: got=%d body=%s", w.Code, w.Body.String()) - } -} - -func TestAPIAuthDoesNotProtectCDPProxyRoutes(t *testing.T) { - handler := buildAuthProtectedTestHandler() - - req := httptest.NewRequest(http.MethodGet, "/json/version", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("CDP 路径不应被 API 认证拦截: got=%d body=%s", w.Code, w.Body.String()) - } -} diff --git a/backend/test/launchcode/server_automation_public_hook_test.go b/backend/test/launchcode/server_automation_public_hook_test.go deleted file mode 100644 index 7b5bf90a..00000000 --- a/backend/test/launchcode/server_automation_public_hook_test.go +++ /dev/null @@ -1,454 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/automation" -) - -func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "proton-mail-first-message", - Name: "Proton 邮件搜索并读取最新邮件", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "mail/proton-first-message", - RequestMode: "standard", - ResponseMode: "envelope", - TimeoutMs: 120000, - }, - }, - } - starter.runResult = &automation.ScriptRunRecord{ - ID: "run-hook-1", - ScriptID: "proton-mail-first-message", - ScriptName: "Proton 邮件搜索并读取最新邮件", - Status: "success", - Summary: "已返回最新命中邮件内容", - ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{ - "instance":{"type":"existing","selector":{"code":"BUYER_001"}}, - "params":{"recipientQuery":"target@example.com"} - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunRequest.ScriptID != "proton-mail-first-message" { - t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { - t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" { - t.Fatalf("instance 转换错误: %+v", starter.lastRunRequest) - } - if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) { - t.Fatalf("targetInput 转换错误: %s", targetInput) - } - if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` { - t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) - } - - var resp struct { - OK bool `json:"ok"` - Status string `json:"status"` - Data map[string]interface{} `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Status != "success" { - t.Fatalf("hook 响应错误: %+v", resp) - } - if resp.Data["verificationCode"] != "429792" { - t.Fatalf("expected data payload, got %+v", resp.Data) - } -} - -func TestAutomationPublicHookSupportsInstanceModes(t *testing.T) { - cases := []struct { - name string - body string - expectedMode string - expectedSelector bool - expectedInput string - useScriptTarget bool - }{ - { - name: "rotate", - body: `{ - "instance":{"type":"rotate","selector":{"groupId":"group-a","tags":["chatgpt"]}}, - "params":{"recipientQuery":"target@example.com"} - }`, - expectedMode: "rotate", - expectedInput: `"groupId":"group-a"`, - }, - { - name: "create", - body: `{ - "instance":{"type":"create","templateSelector":{"code":"TEMPLATE_001"},"createNameTemplate":"ChatGPT-Image-${timestamp}"}, - "params":{"recipientQuery":"target@example.com"} - }`, - expectedMode: "create", - expectedInput: `"createNameTemplate":"ChatGPT-Image-${timestamp}"`, - }, - { - name: "script-default", - body: `{ - "instance":{"type":"script-default"}, - "params":{"recipientQuery":"target@example.com"} - }`, - useScriptTarget: true, - }, - { - name: "legacy-code", - body: `{ - "code":"BUYER_001", - "params":{"recipientQuery":"target@example.com"} - }`, - expectedSelector: true, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "proton-mail-first-message", - Name: "Proton 邮件搜索并读取最新邮件", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "mail/proton-first-message", - RequestMode: "standard", - ResponseMode: "envelope", - TimeoutMs: 120000, - }, - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(tc.body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunRequest.TargetMode != tc.expectedMode { - t.Fatalf("targetMode 错误: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.UseScriptSelector != tc.useScriptTarget { - t.Fatalf("useScriptSelector 错误: %+v", starter.lastRunRequest) - } - if tc.expectedInput != "" { - targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput) - if !strings.Contains(targetInput, tc.expectedInput) { - t.Fatalf("targetInput 转换错误: %s", targetInput) - } - } - if tc.expectedSelector && starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` { - t.Fatalf("legacy selectorText 转换错误: %+v", starter.lastRunRequest) - } - }) - } -} - -func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "proton-mail-first-message", - Name: "Proton 邮件搜索并读取最新邮件", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "mail/proton-result-only", - RequestMode: "params-only", - ResponseMode: "result-only", - TimeoutMs: 45000, - }, - }, - } - starter.runResult = &automation.ScriptRunRecord{ - ID: "run-hook-2", - ScriptID: "proton-mail-first-message", - ScriptName: "Proton 邮件搜索并读取最新邮件", - Status: "success", - Summary: "已返回最新命中邮件内容", - ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{ - "code":"BUYER_001", - "params":{"recipientQuery":"target@example.com"} - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { - t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` { - t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) - } - if starter.lastRunRequest.TimeoutMs != 60000 { - t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest) - } - - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - data, ok := resp["data"].(map[string]interface{}) - if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" { - t.Fatalf("expected data payload, got %+v", resp) - } -} - -func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "grok-image-generate-download", - Name: "Grok 生成图片并下载", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "image/grok-generate-download", - RequestMode: "params-only", - ResponseMode: "result-only", - TimeoutMs: 300000, - }, - }, - } - starter.runResult = &automation.ScriptRunRecord{ - ID: "run-grok-image", - ScriptID: "grok-image-generate-download", - ScriptName: "Grok 生成图片并下载", - Status: "success", - Summary: "Grok 图片已生成并下载", - ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - data, ok := resp["data"].(map[string]interface{}) - if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" { - t.Fatalf("下载字段缺失: %+v", resp) - } - if _, exists := data["steps"]; exists { - t.Fatalf("不应返回冗余 steps: %+v", resp) - } - if _, exists := data["runId"]; exists { - t.Fatalf("不应返回 runId: %+v", resp) - } -} - -func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "image-generate", - Name: "图片生成", - ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`, - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "image/generate", - RequestMode: "standard", - ResponseMode: "envelope", - TimeoutMs: 300000, - RequestBodyText: `{"instance":{"type":"existing","selector":{"code":"{{code}}"}},"params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`, - Variables: []automation.ScriptPublicAPIVariable{ - {Name: "prompt", DefaultValue: "默认提示词", Required: true}, - {Name: "outputFileName", DefaultValue: "generated-image.png"}, - }, - }, - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{ - "code":"BUYER_001", - "params":{"prompt":"海边的机器人","outputFileName":"robot.png"} - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunRequest.UseScriptParams { - t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" { - t.Fatalf("变量模板应生成 instance target: %+v", starter.lastRunRequest) - } - if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) { - t.Fatalf("变量模板 targetInput 错误: %s", targetInput) - } - - var params map[string]interface{} - if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil { - t.Fatalf("解析 paramsText 失败: %v", err) - } - if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" { - t.Fatalf("变量未映射到 params: %+v", params) - } - selectors, ok := params["selectors"].(map[string]interface{}) - if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" { - t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params) - } - if params["timeoutMs"] != float64(300000) { - t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params) - } - if starter.lastRunRequest.TimeoutMs != 300000 { - t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest) - } -} - -func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "disabled-hook", - Name: "Disabled Hook", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: false, - Path: "mail/disabled-hook", - }, - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`)) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String()) - } -} - -func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "strict-hook", - Name: "Strict Hook", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "mail/strict-hook", - TimeoutMs: 120000, - }, - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - cases := []struct { - name string - body string - }{ - {name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`}, - {name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Error struct { - Code string `json:"code"` - } `json:"error"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if resp.OK || resp.Error.Code != "invalid_request" { - t.Fatalf("错误 envelope 不正确: %+v", resp) - } - }) - } -} - -func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "strict-hook-timeout", - Name: "Strict Hook Timeout", - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Method: "POST", - Path: "mail/strict-hook-timeout", - TimeoutMs: 120000, - }, - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } -} diff --git a/backend/test/launchcode/server_automation_runs_test.go b/backend/test/launchcode/server_automation_runs_test.go deleted file mode 100644 index a986a5f1..00000000 --- a/backend/test/launchcode/server_automation_runs_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/automation" -) - -func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.runs = []automation.ScriptRunRecord{ - {ID: "run-1", ScriptID: "script-a", Status: "success"}, - {ID: "run-2", ScriptID: "script-b", Status: "failed"}, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunListLimit != 1 { - t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit) - } - - var resp struct { - OK bool `json:"ok"` - Data struct { - Count int `json:"count"` - Items []automation.ScriptRunRecord `json:"items"` - } `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 { - t.Fatalf("runs 响应错误: %+v", resp) - } -} - -func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) { - handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) - } -} - -func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - handler := buildTestHandlerWithManager(svc, starter, nil) - - t.Run("invalid-json", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}")) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - }) - - t.Run("selector-must-be-object", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ - "scriptId":"news-query-txt", - "selector":"BUYER_001" - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "selector must be a JSON object") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } - }) - - t.Run("timeout-must-be-in-range", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ - "scriptId":"news-query-txt", - "timeoutMs":1800001 - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } - }) -} diff --git a/backend/test/launchcode/server_automation_test.go b/backend/test/launchcode/server_automation_test.go deleted file mode 100644 index 4087b70a..00000000 --- a/backend/test/launchcode/server_automation_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "ant-chrome/backend/internal/automation" -) - -type mockAutomationStarter struct { - *mockStarterWithParams - scripts []automation.ScriptRecord - runs []automation.ScriptRunRecord - lastGetID string - lastRunRequest automation.ScriptRunRequest - lastRunListLimit int - runResult *automation.ScriptRunRecord - getErr error - listErr error - runErr error - runListErr error -} - -func newMockAutomationStarter() *mockAutomationStarter { - return &mockAutomationStarter{ - mockStarterWithParams: newMockStarterWithParams(), - } -} - -func (m *mockAutomationStarter) AutomationScriptList() ([]automation.ScriptRecord, error) { - if m.listErr != nil { - return nil, m.listErr - } - return append([]automation.ScriptRecord(nil), m.scripts...), nil -} - -func (m *mockAutomationStarter) AutomationScriptGet(scriptID string) (*automation.ScriptRecord, error) { - m.lastGetID = scriptID - if m.getErr != nil { - return nil, m.getErr - } - for _, item := range m.scripts { - if item.ID == scriptID { - record := item - return &record, nil - } - } - return nil, os.ErrNotExist -} - -func (m *mockAutomationStarter) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) (*automation.ScriptRunRecord, error) { - m.lastRunRequest = input - if m.runErr != nil { - return nil, m.runErr - } - if m.runResult == nil { - return &automation.ScriptRunRecord{ - ID: "run-default", - ScriptID: input.ScriptID, - Status: "success", - }, nil - } - - record := *m.runResult - return &record, nil -} - -func (m *mockAutomationStarter) AutomationScriptRunList(limit int) ([]automation.ScriptRunRecord, error) { - m.lastRunListLimit = limit - if m.runListErr != nil { - return nil, m.runListErr - } - - items := append([]automation.ScriptRunRecord(nil), m.runs...) - if limit > 0 && len(items) > limit { - items = items[:limit] - } - return items, nil -} - -func automationRunTargetInputJSON(t *testing.T, value interface{}) string { - t.Helper() - data, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal targetInput failed: %v", err) - } - return string(data) -} - -func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - ID: "news-query-txt", - Name: "查询新闻并写 TXT", - Description: "测试脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "index.cjs", - Tags: []string{"Playwright", "新闻"}, - SelectorText: `{"code":"BUYER_001"}`, - ParamsText: `{"keyword":"OpenAI","limit":10}`, - ScriptText: `module.exports.run = async () => ({ ok: true })`, - Notes: "note", - CreatedAt: "2026-04-08T10:00:00Z", - UpdatedAt: "2026-04-08T11:00:00Z", - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if strings.Contains(w.Body.String(), "scriptText") { - t.Fatalf("公共脚本列表不应返回脚本文本: %s", w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Data struct { - Count int `json:"count"` - Items []struct { - ID string `json:"id"` - Type string `json:"type"` - Status string `json:"status"` - Selector map[string]interface{} `json:"selector"` - Params map[string]interface{} `json:"params"` - } `json:"items"` - } `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 { - t.Fatalf("响应结构错误: %+v", resp) - } - item := resp.Data.Items[0] - if item.ID != "news-query-txt" || item.Type != "playwright-cdp" || item.Status != "ready" { - t.Fatalf("脚本元数据错误: %+v", item) - } - if item.Selector["code"] != "BUYER_001" { - t.Fatalf("selector 解析错误: %+v", item.Selector) - } - if item.Params["keyword"] != "OpenAI" { - t.Fatalf("params 解析错误: %+v", item.Params) - } -} - -func TestAutomationScriptDetailEndpointReturnsSingleScript(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.scripts = []automation.ScriptRecord{ - { - PackageFormat: "ant-automation-script", - ManifestVersion: 1, - ID: "news-query-txt", - Name: "查询新闻并写 TXT", - Description: "测试脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "index.cjs", - Tags: []string{"Playwright", "新闻"}, - SelectorText: `{"code":"BUYER_001"}`, - ParamsText: `{"keyword":"OpenAI","limit":10}`, - ScriptText: `module.exports.run = async () => ({ ok: true })`, - Notes: "note", - Source: automation.ScriptSource{ - Type: "git", - URI: "https://example.com/repo.git", - Ref: "main", - }, - CreatedAt: "2026-04-08T10:00:00Z", - UpdatedAt: "2026-04-08T11:00:00Z", - }, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/news-query-txt", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastGetID != "news-query-txt" { - t.Fatalf("scriptId 路径解析错误: %s", starter.lastGetID) - } - if strings.Contains(w.Body.String(), "scriptText") { - t.Fatalf("公共脚本详情不应返回脚本文本: %s", w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Data struct { - Item struct { - ID string `json:"id"` - PackageFormat string `json:"packageFormat"` - ManifestVersion int `json:"manifestVersion"` - Source automation.ScriptSource `json:"source"` - Selector map[string]interface{} `json:"selector"` - } `json:"item"` - } `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - item := resp.Data.Item - if !resp.OK || item.ID != "news-query-txt" { - t.Fatalf("详情响应错误: %+v", resp) - } - if item.PackageFormat != "ant-automation-script" || item.ManifestVersion != 1 { - t.Fatalf("详情元数据错误: %+v", item) - } - if item.Source.Type != "git" || item.Source.URI != "https://example.com/repo.git" { - t.Fatalf("source 返回错误: %+v", item.Source) - } - if item.Selector["code"] != "BUYER_001" { - t.Fatalf("selector 解析错误: %+v", item.Selector) - } -} - -func TestAutomationScriptDetailEndpointReturnsNotFound(t *testing.T) { - handler := buildTestHandlerWithManager(newInMemoryService(), newMockAutomationStarter(), nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/missing-script", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "script not found") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } -} - -func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.runResult = &automation.ScriptRunRecord{ - ID: "run-1", - ScriptID: "news-query-txt", - Status: "success", - ResultText: `{"ok":true,"summary":"done","result":{"subject":"Hello","contentText":"Mail body"}}`, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ - "scriptId":"news-query-txt", - "selector":{"code":"BUYER_001"}, - "params":{"keyword":"OpenAI"} - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunRequest.ScriptID != "news-query-txt" { - t.Fatalf("scriptId 传递错误: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { - t.Fatalf("对象参数应关闭脚本默认 selector/params: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` { - t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText) - } - if starter.lastRunRequest.ParamsText != `{"keyword":"OpenAI"}` { - t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) - } - - var resp struct { - OK bool `json:"ok"` - Data struct { - Result map[string]interface{} `json:"result"` - Run struct { - ID string `json:"id"` - Status string `json:"status"` - } `json:"run"` - } `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Data.Run.ID != "run-1" || resp.Data.Run.Status != "success" { - t.Fatalf("run 响应错误: %+v", resp) - } - if resp.Data.Result["subject"] != "Hello" || resp.Data.Result["contentText"] != "Mail body" { - t.Fatalf("expected parsed result payload, got %+v", resp.Data.Result) - } -} - -func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{"scriptId":"news-query-txt"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !starter.lastRunRequest.UseScriptSelector || !starter.lastRunRequest.UseScriptParams { - t.Fatalf("缺省时应回退到脚本默认 selector/params: %+v", starter.lastRunRequest) - } - if starter.lastRunRequest.SelectorText != "" || starter.lastRunRequest.ParamsText != "" { - t.Fatalf("缺省时不应透传 selectorText/paramsText: %+v", starter.lastRunRequest) - } -} diff --git a/backend/test/launchcode/server_params_test.go b/backend/test/launchcode/server_params_test.go deleted file mode 100644 index 2ffd2912..00000000 --- a/backend/test/launchcode/server_params_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/launchcode" -) - -type mockStarterWithParams struct { - profiles map[string]*browser.Profile - lastProfile string - started []string - lastParams launchcode.LaunchRequestParams -} - -func newMockStarterWithParams() *mockStarterWithParams { - return &mockStarterWithParams{profiles: make(map[string]*browser.Profile)} -} - -func (m *mockStarterWithParams) addProfile(p *browser.Profile) { - m.profiles[p.ProfileId] = p -} - -func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profile, error) { - m.lastProfile = profileId - m.started = append(m.started, profileId) - p, ok := m.profiles[profileId] - if !ok { - return nil, http.ErrMissingFile - } - return p, nil -} - -func (m *mockStarterWithParams) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { - m.lastProfile = profileId - m.started = append(m.started, profileId) - m.lastParams = params - p, ok := m.profiles[profileId] - if !ok { - return nil, http.ErrMissingFile - } - return p, nil -} - -func TestLaunchWithParams(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - starter.addProfile(&browser.Profile{ - ProfileId: "profile-automation", - ProfileName: "automation", - Pid: 321, - DebugPort: 9555, - }) - - code, err := svc.EnsureCode("profile-automation") - if err != nil { - t.Fatalf("EnsureCode 失败: %v", err) - } - - handler := buildTestHandler(svc, starter) - body := map[string]interface{}{ - "code": code, - "launchArgs": []string{"--window-size=1280,800", "--lang=en-US"}, - "startUrls": []string{"https://example.com"}, - "skipDefaultStartUrls": true, - } - payload, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != "profile-automation" { - t.Fatalf("profileId 传递错误: %s", starter.lastProfile) - } - if len(starter.lastParams.LaunchArgs) != 2 { - t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs) - } - if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { - t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs) - } - if !starter.lastParams.SkipDefaultStartURLs { - t.Fatal("skipDefaultStartUrls 传递错误") - } -} - -func TestLaunchWithTemporaryProxyParams(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - starter.addProfile(&browser.Profile{ - ProfileId: "profile-temporary-proxy", - ProfileName: "temporary-proxy", - ProxyId: "stored-proxy", - ProxyConfig: "http://127.0.0.1:18080", - Pid: 322, - DebugPort: 9556, - DebugReady: true, - Running: true, - LastStartAt: "2026-05-09T00:00:00Z", - LastError: "", - LaunchCode: "", - RuntimeWarning: "", - }) - - code, err := svc.EnsureCode("profile-temporary-proxy") - if err != nil { - t.Fatalf("EnsureCode 失败: %v", err) - } - - handler := buildTestHandler(svc, starter) - body := map[string]interface{}{ - "code": code, - "proxyConfig": " http://127.0.0.1:28080 ", - } - payload, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastParams.ProxyConfig != "http://127.0.0.1:28080" { - t.Fatalf("一次性 proxyConfig 未透传或未归一化: %+v", starter.lastParams) - } - profile := starter.profiles["profile-temporary-proxy"] - if profile.ProxyConfig != "http://127.0.0.1:18080" || profile.ProxyId != "stored-proxy" { - t.Fatalf("启动接口不应覆盖实例原代理: %+v", profile) - } -} - -func TestLaunchWithParamsUsingCodeAsKeywordFallback(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - profile := &browser.Profile{ - ProfileId: "profile-automation-keyword-fallback", - ProfileName: "automation-keyword-fallback", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 654, - DebugPort: 9666, - } - starter.addProfile(profile) - - manager := newSelectorTestManager(profile) - handler := buildTestHandlerWithManager(svc, starter, manager) - body := map[string]interface{}{ - "code": "buyer-001", - "launchArgs": []string{"--window-size=1280,800", "--lang=en-US"}, - "startUrls": []string{"https://example.com"}, - "skipDefaultStartUrls": true, - } - payload, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profile.ProfileId { - t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) - } - if len(starter.lastParams.LaunchArgs) != 2 { - t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs) - } - if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { - t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs) - } - if !starter.lastParams.SkipDefaultStartURLs { - t.Fatal("skipDefaultStartUrls 传递错误") - } -} - -func TestLaunchWithParamsUsingCodeAsKeywordFallbackPrefersExactKeywordMatch(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - profileFuzzy := &browser.Profile{ - ProfileId: "profile-params-code-fuzzy", - ProfileName: "automation-fuzzy", - Keywords: []string{"buyer-001-old", "amazon"}, - Pid: 655, - DebugPort: 9667, - } - profileExact := &browser.Profile{ - ProfileId: "profile-params-code-exact", - ProfileName: "automation-exact", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 656, - DebugPort: 9668, - } - starter.addProfile(profileFuzzy) - starter.addProfile(profileExact) - - manager := newSelectorTestManager(profileFuzzy, profileExact) - handler := buildTestHandlerWithManager(svc, starter, manager) - body := map[string]interface{}{ - "code": "buyer-001", - "launchArgs": []string{"--window-size=1280,800", "--lang=en-US"}, - "startUrls": []string{"https://example.com"}, - "skipDefaultStartUrls": true, - } - payload, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileExact.ProfileId { - t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) - } - if len(starter.lastParams.LaunchArgs) != 2 { - t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs) - } - if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { - t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs) - } - if !starter.lastParams.SkipDefaultStartURLs { - t.Fatal("skipDefaultStartUrls 传递错误") - } -} - -func TestLaunchWithParamsBadRequest(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - handler := buildTestHandler(svc, starter) - - t.Run("invalid-json", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString("{bad json}")) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d", w.Code) - } - }) - - t.Run("missing-code", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"launchArgs":["--incognito"]}`)) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d", w.Code) - } - }) -} - -func TestLaunchLogsEndpoint(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - starter.addProfile(&browser.Profile{ - ProfileId: "profile-log-test", - ProfileName: "log-test", - Pid: 456, - DebugPort: 9666, - }) - - code, err := svc.EnsureCode("profile-log-test") - if err != nil { - t.Fatalf("EnsureCode 失败: %v", err) - } - - handler := buildTestHandler(svc, starter) - payload := bytes.NewBufferString(`{"code":"` + code + `","launchArgs":["--incognito"]}`) - - reqLaunch := httptest.NewRequest(http.MethodPost, "/api/launch", payload) - reqLaunch.Header.Set("Content-Type", "application/json") - wLaunch := httptest.NewRecorder() - handler.ServeHTTP(wLaunch, reqLaunch) - if wLaunch.Code != http.StatusOK { - t.Fatalf("调用 launch 失败: %d", wLaunch.Code) - } - - reqLogs := httptest.NewRequest(http.MethodGet, "/api/launch/logs?limit=10", nil) - wLogs := httptest.NewRecorder() - handler.ServeHTTP(wLogs, reqLogs) - if wLogs.Code != http.StatusOK { - t.Fatalf("查询 logs 失败: %d", wLogs.Code) - } - - var resp struct { - OK bool `json:"ok"` - Items []launchcode.LaunchCallRecord `json:"items"` - } - if err := json.NewDecoder(wLogs.Body).Decode(&resp); err != nil { - t.Fatalf("解析 logs 响应失败: %v", err) - } - if !resp.OK { - t.Fatal("logs 响应 ok=false") - } - if len(resp.Items) == 0 { - t.Fatal("logs 为空,期望至少一条记录") - } - if resp.Items[0].Path != "/api/launch" { - t.Fatalf("最新记录 path 不正确: %s", resp.Items[0].Path) - } -} diff --git a/backend/test/launchcode/server_profile_create_test.go b/backend/test/launchcode/server_profile_create_test.go deleted file mode 100644 index 9c84875f..00000000 --- a/backend/test/launchcode/server_profile_create_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/launchcode" -) - -type managerBackedStarter struct { - mgr *browser.Manager - started []string - lastParams launchcode.LaunchRequestParams -} - -func (m *managerBackedStarter) StartInstance(profileID string) (*browser.Profile, error) { - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, fmt.Errorf("profile not found: %s", profileID) - } - - m.started = append(m.started, profileID) - profile.Running = true - profile.Pid = 4000 + len(m.started) - profile.DebugPort = 9300 + len(m.started) - profile.LastStartAt = time.Now().Format(time.RFC3339) - return profile, nil -} - -func (m *managerBackedStarter) StartInstanceWithParams(profileID string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { - m.lastParams = params - return m.StartInstance(profileID) -} - -func newProfileCreateTestManager(t *testing.T, configure func(*config.Config)) *browser.Manager { - t.Helper() - - cfg := config.DefaultConfig() - if configure != nil { - configure(cfg) - } - return browser.NewManager(cfg, t.TempDir()) -} - -func TestCreateProfileAPIStoresProxyAndMetadata(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { - cfg.Browser.Proxies = []config.BrowserProxy{ - { - ProxyId: "proxy-us", - ProxyName: "US Residential", - ProxyConfig: "socks5://127.0.0.1:1080", - }, - } - }) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - payload := bytes.NewBufferString(`{ - "profile": { - "profileName": "buyer-001", - "userDataDir": "buyers/buyer-001", - "proxyId": "proxy-us", - "launchArgs": ["--lang=en-US"], - "tags": ["电商", "北美"], - "keywords": ["buyer-001", "amazon"], - "groupId": "group-sales-us" - }, - "launchCode": "buyer_001" - }`) - - req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("期望 201,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Created bool `json:"created"` - Launched bool `json:"launched"` - ProfileID string `json:"profileId"` - LaunchCode string `json:"launchCode"` - Profile *browser.Profile `json:"profile"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - - if !resp.OK || !resp.Created || resp.Launched { - t.Fatalf("响应状态错误: %+v", resp) - } - if resp.Profile == nil { - t.Fatalf("响应缺少 profile: %+v", resp) - } - if resp.LaunchCode != "BUYER_001" { - t.Fatalf("launchCode 未归一化: %s", resp.LaunchCode) - } - if resp.Profile.ProxyId != "proxy-us" { - t.Fatalf("proxyId 不正确: %+v", resp.Profile) - } - if resp.Profile.ProxyConfig != "socks5://127.0.0.1:1080" { - t.Fatalf("proxyConfig 未按代理池解析: %+v", resp.Profile) - } - if resp.Profile.GroupId != "group-sales-us" { - t.Fatalf("groupId 不正确: %+v", resp.Profile) - } - if len(resp.Profile.Tags) != 2 || len(resp.Profile.Keywords) != 2 { - t.Fatalf("tags/keywords 不正确: %+v", resp.Profile) - } - - resolvedProfileID, err := svc.Resolve("BUYER_001") - if err != nil { - t.Fatalf("launchCode 未写入服务: %v", err) - } - if resolvedProfileID != resp.ProfileID { - t.Fatalf("launchCode 绑定的 profileId 错误: got=%s want=%s", resolvedProfileID, resp.ProfileID) - } -} - -func TestCreateProfileAPIAutoLaunchPassesStartParams(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - payload := bytes.NewBufferString(`{ - "profile": { - "profileName": "buyer-002", - "proxyConfig": "http://user:pass@127.0.0.1:8080", - "launchArgs": ["--disable-sync"], - "keywords": ["buyer-002"] - }, - "autoLaunch": true, - "start": { - "launchArgs": ["--window-size=1280,800", "--lang=en-US"], - "startUrls": ["https://example.com/order"], - "skipDefaultStartUrls": true - } - }`) - - req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("期望 201,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.started) != 1 { - t.Fatalf("应自动启动 1 次,实际 %+v", starter.started) - } - if len(starter.lastParams.LaunchArgs) != 2 { - t.Fatalf("一次性 launchArgs 未透传: %+v", starter.lastParams) - } - if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com/order" { - t.Fatalf("startUrls 未透传: %+v", starter.lastParams) - } - if !starter.lastParams.SkipDefaultStartURLs { - t.Fatalf("skipDefaultStartUrls 未透传: %+v", starter.lastParams) - } - - var resp struct { - OK bool `json:"ok"` - Created bool `json:"created"` - Launched bool `json:"launched"` - CDPURL string `json:"cdpUrl"` - DebugPort int `json:"debugPort"` - Profile *browser.Profile `json:"profile"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - - if !resp.OK || !resp.Created || !resp.Launched { - t.Fatalf("响应状态错误: %+v", resp) - } - if resp.Profile == nil || !resp.Profile.Running { - t.Fatalf("自动启动后的 profile 状态错误: %+v", resp) - } - if resp.DebugPort == 0 || resp.CDPURL == "" { - t.Fatalf("缺少调试端口/CDP 地址: %+v", resp) - } - if resp.Profile.ProxyConfig != "http://user:pass@127.0.0.1:8080" { - t.Fatalf("直连代理配置未保存: %+v", resp.Profile) - } -} - -func TestCreateProfileAPIRejectsMissingProfile(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - req := httptest.NewRequest(http.MethodPost, "/api/profiles", bytes.NewBufferString(`{"launchCode":"buyer_003"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } -} - -func TestCreateProfileAPIRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { - cfg.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "proxy-us", ProxyName: "US Residential", ProxyConfig: "socks5://127.0.0.1:1080"}, - } - }) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - req := httptest.NewRequest(http.MethodPost, "/api/profiles", bytes.NewBufferString(`{ - "profile": { - "profileName": "buyer-003", - "proxyId": "missing-proxy-id" - } - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } -} - -func TestCreateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - existing, err := mgr.Create(browser.ProfileInput{ProfileName: "existing"}) - if err != nil { - t.Fatalf("预创建实例失败: %v", err) - } - if _, err := svc.SetCode(existing.ProfileId, "BUYER_DUP"); err != nil { - t.Fatalf("预设 launchCode 失败: %v", err) - } - - beforeCount := len(mgr.List()) - - payload := bytes.NewBufferString(`{ - "profile": { - "profileName": "new-buyer", - "keywords": ["new-buyer"] - }, - "launchCode": "BUYER_DUP" - }`) - - req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - - afterCount := len(mgr.List()) - if afterCount != beforeCount { - t.Fatalf("launchCode 冲突后应回滚创建: before=%d after=%d", beforeCount, afterCount) - } -} diff --git a/backend/test/launchcode/server_profile_manage_test.go b/backend/test/launchcode/server_profile_manage_test.go deleted file mode 100644 index 96ebab5a..00000000 --- a/backend/test/launchcode/server_profile_manage_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" -) - -func TestListProfilesAPIIncludesLaunchCodes(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { - cfg.Browser.Proxies = []config.BrowserProxy{ - { - ProxyId: "proxy-us", - ProxyName: "US Residential", - ProxyConfig: "socks5://127.0.0.1:1080", - }, - } - }) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - first, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-a", - ProxyId: "proxy-us", - Tags: []string{"电商"}, - Keywords: []string{"buyer-a"}, - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - second, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-b", - ProxyConfig: "http://127.0.0.1:8080", - Keywords: []string{"buyer-b"}, - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - if _, err := svc.SetCode(first.ProfileId, "BUYER_A"); err != nil { - t.Fatalf("设置 launchCode 失败: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/profiles", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Count int `json:"count"` - Items []browser.Profile `json:"items"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 { - t.Fatalf("列表响应错误: %+v", resp) - } - - seen := make(map[string]browser.Profile, len(resp.Items)) - for _, item := range resp.Items { - if item.LaunchCode == "" { - t.Fatalf("列表应返回 launchCode: %+v", resp.Items) - } - seen[item.ProfileId] = item - } - if _, ok := seen[first.ProfileId]; !ok { - t.Fatalf("列表缺少第一个实例: %+v", resp.Items) - } - if _, ok := seen[second.ProfileId]; !ok { - t.Fatalf("列表缺少第二个实例: %+v", resp.Items) - } -} - -func TestGetProfileAPIReturnsProfileByID(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - profile, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-get", - ProxyConfig: "http://127.0.0.1:8080", - Tags: []string{"北美"}, - Keywords: []string{"buyer-get"}, - GroupId: "group-get", - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - if _, err := svc.SetCode(profile.ProfileId, "BUYER_GET"); err != nil { - t.Fatalf("设置 launchCode 失败: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/profiles/"+profile.ProfileId, nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - LaunchCode string `json:"launchCode"` - Profile *browser.Profile `json:"profile"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Profile == nil { - t.Fatalf("查询响应错误: %+v", resp) - } - if resp.LaunchCode != "BUYER_GET" || resp.Profile.GroupId != "group-get" { - t.Fatalf("查询字段错误: %+v", resp) - } -} - -func TestUpdateProfileAPIUpdatesFieldsAndAutoLaunches(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { - cfg.Browser.Proxies = []config.BrowserProxy{ - { - ProxyId: "proxy-us", - ProxyName: "US Residential", - ProxyConfig: "socks5://127.0.0.1:1080", - }, - } - }) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - profile, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-old", - ProxyConfig: "http://127.0.0.1:8080", - Keywords: []string{"buyer-old"}, - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - if _, err := svc.SetCode(profile.ProfileId, "BUYER_OLD"); err != nil { - t.Fatalf("设置 launchCode 失败: %v", err) - } - - payload := bytes.NewBufferString(`{ - "profile": { - "profileName": "buyer-new", - "userDataDir": "buyers/buyer-new", - "proxyId": "proxy-us", - "launchArgs": ["--lang=en-US"], - "tags": ["电商", "北美"], - "keywords": ["buyer-new", "amazon"], - "groupId": "group-sales-us" - }, - "launchCode": "BUYER_NEW", - "autoLaunch": true, - "start": { - "launchArgs": ["--window-size=1280,800"], - "startUrls": ["https://example.com/order"], - "skipDefaultStartUrls": true - } - }`) - - req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+profile.ProfileId, payload) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.started) != 1 { - t.Fatalf("更新后应自动启动 1 次,实际 %+v", starter.started) - } - if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com/order" { - t.Fatalf("startUrls 未透传: %+v", starter.lastParams) - } - - updated, status, errMsg := handlerProfileSnapshot(t, mgr, svc, profile.ProfileId) - if errMsg != "" || status != http.StatusOK { - t.Fatalf("读取更新后实例失败: status=%d err=%s", status, errMsg) - } - if updated.ProfileName != "buyer-new" || updated.ProxyId != "proxy-us" || updated.ProxyConfig != "socks5://127.0.0.1:1080" { - t.Fatalf("更新未生效: %+v", updated) - } - if updated.GroupId != "group-sales-us" || updated.LaunchCode != "BUYER_NEW" || !updated.Running { - t.Fatalf("更新后的分组/launchCode/运行状态错误: %+v", updated) - } -} - -func TestUpdateProfileAPIRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, func(cfg *config.Config) { - cfg.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "proxy-us", ProxyName: "US Residential", ProxyConfig: "socks5://127.0.0.1:1080"}, - } - }) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - profile, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-old", - ProxyId: "proxy-us", - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - - req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+profile.ProfileId, bytes.NewBufferString(`{ - "profile": { - "profileName": "buyer-new", - "proxyId": "missing-proxy-id" - } - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - - current, status, errMsg := handlerProfileSnapshot(t, mgr, svc, profile.ProfileId) - if errMsg != "" || status != http.StatusOK { - t.Fatalf("读取实例失败: status=%d err=%s", status, errMsg) - } - if current.ProfileName != "buyer-old" || current.ProxyId != "proxy-us" || current.ProxyConfig != "socks5://127.0.0.1:1080" { - t.Fatalf("失败请求不应污染原配置: %+v", current) - } -} - -func TestUpdateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - first, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-first", - ProxyConfig: "http://127.0.0.1:8080", - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - second, err := mgr.Create(browser.ProfileInput{ - ProfileName: "buyer-second", - ProxyConfig: "http://127.0.0.1:9090", - }) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - if _, err := svc.SetCode(first.ProfileId, "BUYER_FIRST"); err != nil { - t.Fatalf("设置 launchCode 失败: %v", err) - } - if _, err := svc.SetCode(second.ProfileId, "BUYER_SECOND"); err != nil { - t.Fatalf("设置 launchCode 失败: %v", err) - } - - payload := bytes.NewBufferString(`{ - "profile": { - "profileName": "buyer-first-updated", - "proxyConfig": "http://127.0.0.1:10080", - "keywords": ["buyer-first-updated"] - }, - "launchCode": "BUYER_SECOND" - }`) - - req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+first.ProfileId, payload) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - - current, status, errMsg := handlerProfileSnapshot(t, mgr, svc, first.ProfileId) - if errMsg != "" || status != http.StatusOK { - t.Fatalf("读取回滚后实例失败: status=%d err=%s", status, errMsg) - } - if current.ProfileName != "buyer-first" || current.ProxyConfig != "http://127.0.0.1:8080" || current.LaunchCode != "BUYER_FIRST" { - t.Fatalf("launchCode 冲突后应回滚更新: %+v", current) - } -} - -func TestDeleteProfileAPIRemovesProfileAndLaunchCode(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - profile, err := mgr.Create(browser.ProfileInput{ProfileName: "buyer-delete"}) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - if _, err := svc.SetCode(profile.ProfileId, "BUYER_DELETE"); err != nil { - t.Fatalf("设置 launchCode 失败: %v", err) - } - - req := httptest.NewRequest(http.MethodDelete, "/api/profiles/"+profile.ProfileId, nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if _, ok := mgr.Profiles[profile.ProfileId]; ok { - t.Fatalf("实例删除后仍存在于内存: %s", profile.ProfileId) - } - if _, err := svc.Resolve("BUYER_DELETE"); err == nil { - t.Fatal("删除后 launchCode 仍可解析") - } -} - -func TestDeleteProfileAPIRejectsRunningProfile(t *testing.T) { - svc := newInMemoryService() - mgr := newProfileCreateTestManager(t, nil) - starter := &managerBackedStarter{mgr: mgr} - handler := buildTestHandlerWithManager(svc, starter, mgr) - - profile, err := mgr.Create(browser.ProfileInput{ProfileName: "buyer-running"}) - if err != nil { - t.Fatalf("创建测试实例失败: %v", err) - } - mgr.Profiles[profile.ProfileId].Running = true - - req := httptest.NewRequest(http.MethodDelete, "/api/profiles/"+profile.ProfileId, nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - if _, ok := mgr.Profiles[profile.ProfileId]; !ok { - t.Fatalf("运行中实例不应被删除: %s", profile.ProfileId) - } -} - -func handlerProfileSnapshot(t *testing.T, mgr *browser.Manager, svc interface { - EnsureCode(profileID string) (string, error) -}, profileID string) (*browser.Profile, int, string) { - t.Helper() - - mgr.Mutex.Lock() - profile, ok := mgr.Profiles[profileID] - var snapshot browser.Profile - if ok && profile != nil { - snapshot = *profile - } - mgr.Mutex.Unlock() - if !ok { - return nil, http.StatusNotFound, "profile not found" - } - if snapshot.LaunchCode == "" { - if code, err := svc.EnsureCode(snapshot.ProfileId); err == nil { - snapshot.LaunchCode = code - } - } - return &snapshot, http.StatusOK, "" -} diff --git a/backend/test/launchcode/server_profile_runtime_test.go b/backend/test/launchcode/server_profile_runtime_test.go deleted file mode 100644 index 515b8989..00000000 --- a/backend/test/launchcode/server_profile_runtime_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package launchcode_test - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "ant-chrome/backend/internal/browser" -) - -type lifecycleStarter struct { - mgr *browser.Manager - started []string - stopped []string -} - -func newLifecycleStarter(mgr *browser.Manager) *lifecycleStarter { - return &lifecycleStarter{mgr: mgr} -} - -func (m *lifecycleStarter) StartInstance(profileID string) (*browser.Profile, error) { - m.mgr.Mutex.Lock() - defer m.mgr.Mutex.Unlock() - - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, fmt.Errorf("profile not found") - } - - m.started = append(m.started, profileID) - profile.Running = true - profile.DebugReady = true - profile.Pid = 7000 + len(m.started) - profile.DebugPort = 9600 + len(m.started) - profile.RuntimeWarning = "" - profile.LastError = "" - profile.LastStartAt = time.Now().Format(time.RFC3339) - return profile, nil -} - -func (m *lifecycleStarter) StatusInstance(profileID string) (*browser.Profile, error) { - m.mgr.Mutex.Lock() - defer m.mgr.Mutex.Unlock() - - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, fmt.Errorf("profile not found") - } - return profile, nil -} - -func (m *lifecycleStarter) StopInstance(profileID string) (*browser.Profile, error) { - m.mgr.Mutex.Lock() - defer m.mgr.Mutex.Unlock() - - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, fmt.Errorf("profile not found") - } - - m.stopped = append(m.stopped, profileID) - profile.Running = false - profile.DebugReady = false - profile.Pid = 0 - profile.DebugPort = 0 - profile.RuntimeWarning = "" - profile.LastStopAt = time.Now().Format(time.RFC3339) - return profile, nil -} - -func TestProfileStatusEndpointReturnsRuntimePayload(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "profile-runtime-status", - ProfileName: "Runtime Status", - } - manager := newSelectorTestManager(profile) - starter := newLifecycleStarter(manager) - - code, err := svc.SetCode(profile.ProfileId, "runtime_status") - if err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - - handler := buildTestHandlerWithManager(svc, starter, manager) - - reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - wLaunch := httptest.NewRecorder() - handler.ServeHTTP(wLaunch, reqLaunch) - if wLaunch.Code != http.StatusOK { - t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String()) - } - - reqStatus := httptest.NewRequest(http.MethodGet, "/api/profiles/"+profile.ProfileId+"/status", nil) - wStatus := httptest.NewRecorder() - handler.ServeHTTP(wStatus, reqStatus) - - if wStatus.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", wStatus.Code, wStatus.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - ProfileID string `json:"profileId"` - LaunchCode string `json:"launchCode"` - Running bool `json:"running"` - Active bool `json:"active"` - DebugReady bool `json:"debugReady"` - CDPURL string `json:"cdpUrl"` - DirectDebugURL string `json:"directDebugUrl"` - Profile *browser.Profile `json:"profile"` - } - if err := json.NewDecoder(wStatus.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - - if !resp.OK || resp.ProfileID != profile.ProfileId { - t.Fatalf("响应不正确: %+v", resp) - } - if resp.LaunchCode != "RUNTIME_STATUS" { - t.Fatalf("launchCode 不正确: %+v", resp) - } - if !resp.Running || !resp.Active || !resp.DebugReady { - t.Fatalf("运行态字段不正确: %+v", resp) - } - if resp.CDPURL == "" || resp.DirectDebugURL == "" { - t.Fatalf("应返回可连接的 CDP 信息: %+v", resp) - } - if resp.Profile == nil || !resp.Profile.Running || !resp.Profile.DebugReady { - t.Fatalf("嵌套 profile 运行态不正确: %+v", resp) - } -} - -func TestRuntimeActiveEndpointReportsCurrentTarget(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "profile-runtime-active", - ProfileName: "Runtime Active", - } - manager := newSelectorTestManager(profile) - starter := newLifecycleStarter(manager) - - code, err := svc.SetCode(profile.ProfileId, "runtime_active") - if err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - - handler := buildTestHandlerWithManager(svc, starter, manager) - - reqBefore := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil) - wBefore := httptest.NewRecorder() - handler.ServeHTTP(wBefore, reqBefore) - if wBefore.Code != http.StatusOK { - t.Fatalf("未激活前查询失败: status=%d body=%s", wBefore.Code, wBefore.Body.String()) - } - - var before struct { - OK bool `json:"ok"` - Active bool `json:"active"` - } - if err := json.NewDecoder(wBefore.Body).Decode(&before); err != nil { - t.Fatalf("解析未激活响应失败: %v", err) - } - if !before.OK || before.Active { - t.Fatalf("未激活响应不正确: %+v", before) - } - - reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - wLaunch := httptest.NewRecorder() - handler.ServeHTTP(wLaunch, reqLaunch) - if wLaunch.Code != http.StatusOK { - t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String()) - } - - reqAfter := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil) - wAfter := httptest.NewRecorder() - handler.ServeHTTP(wAfter, reqAfter) - if wAfter.Code != http.StatusOK { - t.Fatalf("激活后查询失败: status=%d body=%s", wAfter.Code, wAfter.Body.String()) - } - - var after struct { - OK bool `json:"ok"` - Active bool `json:"active"` - ProfileID string `json:"profileId"` - LaunchCode string `json:"launchCode"` - CDPURL string `json:"cdpUrl"` - } - if err := json.NewDecoder(wAfter.Body).Decode(&after); err != nil { - t.Fatalf("解析激活响应失败: %v", err) - } - if !after.OK || !after.Active || after.ProfileID != profile.ProfileId { - t.Fatalf("激活响应不正确: %+v", after) - } - if after.LaunchCode != "RUNTIME_ACTIVE" || after.CDPURL == "" { - t.Fatalf("激活响应缺少 launchCode/CDP 地址: %+v", after) - } -} - -func TestProfileStopEndpointStopsAndClearsActiveTarget(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "profile-runtime-stop", - ProfileName: "Runtime Stop", - } - manager := newSelectorTestManager(profile) - starter := newLifecycleStarter(manager) - - code, err := svc.SetCode(profile.ProfileId, "runtime_stop") - if err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - - handler := buildTestHandlerWithManager(svc, starter, manager) - - reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - wLaunch := httptest.NewRecorder() - handler.ServeHTTP(wLaunch, reqLaunch) - if wLaunch.Code != http.StatusOK { - t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String()) - } - - reqStop := httptest.NewRequest(http.MethodPost, "/api/profiles/"+profile.ProfileId+"/stop", nil) - wStop := httptest.NewRecorder() - handler.ServeHTTP(wStop, reqStop) - - if wStop.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", wStop.Code, wStop.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Stopped bool `json:"stopped"` - Running bool `json:"running"` - Active bool `json:"active"` - CDPURL string `json:"cdpUrl"` - DirectDebugURL string `json:"directDebugUrl"` - Profile *browser.Profile `json:"profile"` - } - if err := json.NewDecoder(wStop.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - - if !resp.OK || !resp.Stopped { - t.Fatalf("停止响应不正确: %+v", resp) - } - if resp.Running || resp.Active { - t.Fatalf("停止后运行态应关闭: %+v", resp) - } - if resp.CDPURL != "" || resp.DirectDebugURL != "" { - t.Fatalf("停止后不应再暴露调试地址: %+v", resp) - } - if resp.Profile == nil || resp.Profile.Running || resp.Profile.DebugReady { - t.Fatalf("嵌套 profile 停止态不正确: %+v", resp) - } - - reqProxy := httptest.NewRequest(http.MethodGet, "/json/version", nil) - wProxy := httptest.NewRecorder() - handler.ServeHTTP(wProxy, reqProxy) - if wProxy.Code != http.StatusServiceUnavailable { - t.Fatalf("停止后应清空 active target: status=%d body=%s", wProxy.Code, wProxy.Body.String()) - } - - reqActive := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil) - wActive := httptest.NewRecorder() - handler.ServeHTTP(wActive, reqActive) - if wActive.Code != http.StatusOK { - t.Fatalf("停止后查询 active 失败: status=%d body=%s", wActive.Code, wActive.Body.String()) - } - - var activeResp struct { - OK bool `json:"ok"` - Active bool `json:"active"` - } - if err := json.NewDecoder(wActive.Body).Decode(&activeResp); err != nil { - t.Fatalf("解析停止后 active 响应失败: %v", err) - } - if !activeResp.OK || activeResp.Active { - t.Fatalf("停止后 active 响应不正确: %+v", activeResp) - } -} - -func TestProfileStopEndpointReturnsServiceUnavailableWhenRuntimeControlIsMissing(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "profile-runtime-unsupported", - ProfileName: "Runtime Unsupported", - } - manager := newSelectorTestManager(profile) - starter := newMockStarterWithParams() - starter.addProfile(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/profiles/"+profile.ProfileId+"/stop", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if resp["error"] != "profile runtime control is not available" { - t.Fatalf("错误信息不正确: %+v", resp) - } -} diff --git a/backend/test/launchcode/server_prop_test.go b/backend/test/launchcode/server_prop_test.go deleted file mode 100644 index 80cb2e0d..00000000 --- a/backend/test/launchcode/server_prop_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package launchcode_test - -// Feature: instance-launch-code, Property 6: valid code response structure -// Feature: instance-launch-code, Property 7: invalid code returns 404 -// Feature: instance-launch-code, Property 8: idempotent launch -// Validates: Requirements 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.4 - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/launchcode" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" -) - -// --- 测试辅助类型 --- - -// mockStarter 模拟 BrowserStarter,记录调用次数 -type mockStarter struct { - profiles map[string]*browser.Profile - callCounts map[string]int -} - -func newMockStarter() *mockStarter { - return &mockStarter{ - profiles: make(map[string]*browser.Profile), - callCounts: make(map[string]int), - } -} - -func (m *mockStarter) addProfile(p *browser.Profile) { - m.profiles[p.ProfileId] = p -} - -func (m *mockStarter) StartInstance(profileId string) (*browser.Profile, error) { - m.callCounts[profileId]++ - p, ok := m.profiles[profileId] - if !ok { - return nil, fmt.Errorf("profile not found: %s", profileId) - } - return p, nil -} - -// buildTestHandler 构建一个可直接用于 httptest 的 handler(绕过 localhost 中间件) -// 通过直接调用 server 内部 handler 的方式,使用 httptest.NewRecorder 测试路由逻辑 -func buildTestHandler(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter) http.Handler { - srv := launchcode.NewLaunchServer(svc, starter, nil, 0) - return launchcode.NewTestHandler(srv) -} - -// newInMemoryService 创建一个使用内存 DAO 的 LaunchCodeService -func newInMemoryService() *launchcode.LaunchCodeService { - dao := launchcode.NewMemoryLaunchCodeDAO() - return launchcode.NewLaunchCodeService(dao) -} - -// --- Property 6: 有效 Code 返回正确响应结构 --- - -// genNonEmptyAlpha 生成长度 1-32 的字母字符串(不使用 SuchThat 过滤) -func genNonEmptyAlpha() gopter.Gen { - return gen.SliceOfN(8, gen.RuneRange('a', 'z')).Map(func(runes []rune) string { - return string(runes) - }) -} - -// TestProperty6_ValidCodeResponseStructure -// 对于任意存在的 LaunchCode,GET /api/launch/{code} 应返回: -// - HTTP 200 -// - Content-Type: application/json -// - 响应体含 ok:true, profileId, profileName, pid, debugPort -func TestProperty6_ValidCodeResponseStructure(t *testing.T) { - properties := gopter.NewProperties(gopter.DefaultTestParameters()) - - properties.Property("有效 code 返回 200 及正确响应结构", prop.ForAll( - func(profileId, profileName string, pid, debugPort int) bool { - svc := newInMemoryService() - starter := newMockStarter() - - profile := &browser.Profile{ - ProfileId: profileId, - ProfileName: profileName, - Pid: pid, - DebugPort: debugPort, - } - starter.addProfile(profile) - - code, err := svc.EnsureCode(profileId) - if err != nil { - return false - } - - handler := buildTestHandler(svc, starter) - req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - return false - } - if !strings.Contains(w.Header().Get("Content-Type"), "application/json") { - return false - } - - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - return false - } - - ok, _ := resp["ok"].(bool) - gotProfileId, _ := resp["profileId"].(string) - gotProfileName, _ := resp["profileName"].(string) - _, hasPid := resp["pid"] - _, hasDebugPort := resp["debugPort"] - - return ok && - gotProfileId == profileId && - gotProfileName == profileName && - hasPid && hasDebugPort - }, - genNonEmptyAlpha(), - genNonEmptyAlpha(), - gen.IntRange(1000, 99999), - gen.IntRange(9000, 9999), - )) - - properties.TestingRun(t) -} - -// --- Property 7: 无效 Code 返回 404 --- - -// genInvalidCode 生成一定不存在于空 service 中的 code(小写字母,不符合 A-Z0-9 格式) -func genInvalidCode() gopter.Gen { - // 生成 4 位小写字母字符串,永远不会匹配 [A-Z0-9]{6} 格式的有效 code - return gen.SliceOfN(4, gen.RuneRange('a', 'z')).Map(func(runes []rune) string { - return string(runes) - }) -} - -// TestProperty7_InvalidCodeReturns404 -// 对于任意不存在的 code,GET /api/launch/{code} 应返回: -// - HTTP 404 -// - Content-Type: application/json -// - 响应体含 ok:false 和 error 字段 -func TestProperty7_InvalidCodeReturns404(t *testing.T) { - properties := gopter.NewProperties(gopter.DefaultTestParameters()) - - properties.Property("不存在的 code 返回 404", prop.ForAll( - func(code string) bool { - svc := newInMemoryService() - starter := newMockStarter() - - handler := buildTestHandler(svc, starter) - req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - return false - } - if !strings.Contains(w.Header().Get("Content-Type"), "application/json") { - return false - } - - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - return false - } - - ok, _ := resp["ok"].(bool) - _, hasError := resp["error"] - return !ok && hasError - }, - genInvalidCode(), - )) - - properties.TestingRun(t) -} - -// --- Property 8: 重复唤起的幂等性 --- - -// TestProperty8_IdempotentLaunch -// 对于已运行的实例,连续两次 GET /api/launch/{code}: -// - 两次均返回 HTTP 200 -// - 两次返回的 pid 相同(不重新启动) -func TestProperty8_IdempotentLaunch(t *testing.T) { - properties := gopter.NewProperties(gopter.DefaultTestParameters()) - - properties.Property("重复唤起返回相同 pid,不重新启动", prop.ForAll( - func(profileId string, pid int) bool { - svc := newInMemoryService() - starter := newMockStarter() - - profile := &browser.Profile{ - ProfileId: profileId, - ProfileName: "test-profile", - Pid: pid, - DebugPort: 9222, - Running: true, - } - starter.addProfile(profile) - - code, err := svc.EnsureCode(profileId) - if err != nil { - return false - } - - handler := buildTestHandler(svc, starter) - - // 第一次请求 - req1 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - w1 := httptest.NewRecorder() - handler.ServeHTTP(w1, req1) - - // 第二次请求 - req2 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - w2 := httptest.NewRecorder() - handler.ServeHTTP(w2, req2) - - if w1.Code != http.StatusOK || w2.Code != http.StatusOK { - return false - } - - var resp1, resp2 map[string]interface{} - if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil { - return false - } - if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil { - return false - } - - pid1, _ := resp1["pid"].(float64) - pid2, _ := resp2["pid"].(float64) - - // 两次 pid 相同,且 StartInstance 被调用了 2 次(幂等由 starter 保证返回同一 profile) - return pid1 == pid2 && pid1 == float64(pid) - }, - genNonEmptyAlpha(), - gen.IntRange(1000, 99999), - )) - - properties.TestingRun(t) -} - -// --- 健康检查单元测试 --- - -func TestHealthEndpoint(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarter() - handler := buildTestHandler(svc, starter) - - req := httptest.NewRequest(http.MethodGet, "/api/health", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d", w.Code) - } - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - ok, _ := resp["ok"].(bool) - if !ok { - t.Error("期望 ok=true") - } -} diff --git a/backend/test/launchcode/server_proxy_test.go b/backend/test/launchcode/server_proxy_test.go deleted file mode 100644 index 5eb6950c..00000000 --- a/backend/test/launchcode/server_proxy_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package launchcode_test - -import ( - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - - "ant-chrome/backend/internal/browser" -) - -func mustDebugPortFromURL(t *testing.T, rawURL string) int { - t.Helper() - - hostPort := strings.TrimPrefix(rawURL, "http://") - host, portText, err := net.SplitHostPort(hostPort) - if err != nil { - t.Fatalf("解析测试 URL 失败: %v", err) - } - if host == "" { - t.Fatalf("测试 URL host 为空: %s", rawURL) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("解析测试端口失败: %v", err) - } - return port -} - -func TestCDPProxyReturnsUnavailableWithoutActiveTarget(t *testing.T) { - handler := buildTestHandler(newInMemoryService(), newMockStarter()) - - req := httptest.NewRequest(http.MethodGet, "/json/version", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if resp["error"] != "no active browser debug target" { - t.Fatalf("错误信息不正确: %+v", resp) - } -} - -func TestCDPProxySwitchesToLatestLaunchedProfile(t *testing.T) { - serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/json/version" { - http.NotFound(w, r) - return - } - _, _ = w.Write([]byte(`{"Browser":"Mock-A"}`)) - })) - defer serverA.Close() - - serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/json/version" { - http.NotFound(w, r) - return - } - _, _ = w.Write([]byte(`{"Browser":"Mock-B"}`)) - })) - defer serverB.Close() - - svc := newInMemoryService() - starter := newMockStarter() - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Profile A", - Pid: 1001, - DebugPort: mustDebugPortFromURL(t, serverA.URL), - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Profile B", - Pid: 1002, - DebugPort: mustDebugPortFromURL(t, serverB.URL), - } - starter.addProfile(profileA) - starter.addProfile(profileB) - - codeA, err := svc.EnsureCode(profileA.ProfileId) - if err != nil { - t.Fatalf("EnsureCode(A) 失败: %v", err) - } - codeB, err := svc.EnsureCode(profileB.ProfileId) - if err != nil { - t.Fatalf("EnsureCode(B) 失败: %v", err) - } - - handler := buildTestHandler(svc, starter) - - for _, tc := range []struct { - code string - wantMarker string - }{ - {code: codeA, wantMarker: "Mock-A"}, - {code: codeB, wantMarker: "Mock-B"}, - } { - launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+tc.code, nil) - launchResp := httptest.NewRecorder() - handler.ServeHTTP(launchResp, launchReq) - if launchResp.Code != http.StatusOK { - t.Fatalf("启动请求失败: code=%s status=%d body=%s", tc.code, launchResp.Code, launchResp.Body.String()) - } - - proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil) - proxyResp := httptest.NewRecorder() - handler.ServeHTTP(proxyResp, proxyReq) - if proxyResp.Code != http.StatusOK { - t.Fatalf("代理请求失败: code=%s status=%d body=%s", tc.code, proxyResp.Code, proxyResp.Body.String()) - } - if !strings.Contains(proxyResp.Body.String(), tc.wantMarker) { - t.Fatalf("代理未切换到最新实例: want=%s body=%s", tc.wantMarker, proxyResp.Body.String()) - } - } -} - -func TestCDPProxySkipsPendingDebugProfile(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarter() - profile := &browser.Profile{ - ProfileId: "profile-pending", - ProfileName: "Profile Pending", - Running: true, - Pid: 2001, - DebugPort: 9777, - DebugReady: false, - RuntimeWarning: "debug pending", - } - starter.addProfile(profile) - - code, err := svc.EnsureCode(profile.ProfileId) - if err != nil { - t.Fatalf("EnsureCode 失败: %v", err) - } - - handler := buildTestHandler(svc, starter) - - launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil) - launchResp := httptest.NewRecorder() - handler.ServeHTTP(launchResp, launchReq) - if launchResp.Code != http.StatusOK { - t.Fatalf("启动请求失败: status=%d body=%s", launchResp.Code, launchResp.Body.String()) - } - - var launchPayload map[string]interface{} - if err := json.NewDecoder(launchResp.Body).Decode(&launchPayload); err != nil { - t.Fatalf("解析启动响应失败: %v", err) - } - if ready, _ := launchPayload["debugReady"].(bool); ready { - t.Fatalf("pending 实例不应被标记为 debugReady: %+v", launchPayload) - } - - proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil) - proxyResp := httptest.NewRecorder() - handler.ServeHTTP(proxyResp, proxyReq) - if proxyResp.Code != http.StatusServiceUnavailable { - t.Fatalf("pending 实例不应成为活动 CDP target: status=%d body=%s", proxyResp.Code, proxyResp.Body.String()) - } -} diff --git a/backend/test/launchcode/server_runtime_selector_test.go b/backend/test/launchcode/server_runtime_selector_test.go deleted file mode 100644 index 001fc7a2..00000000 --- a/backend/test/launchcode/server_runtime_selector_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/browser" -) - -func TestRuntimeStatusWithCodeFallbackReturnsConflictByDefault(t *testing.T) { - svc := newInMemoryService() - profileA := &browser.Profile{ - ProfileId: "runtime-status-a", - ProfileName: "A Account", - Keywords: []string{"shop"}, - Running: true, - DebugReady: true, - DebugPort: 9411, - } - profileB := &browser.Profile{ - ProfileId: "runtime-status-b", - ProfileName: "B Account", - Keywords: []string{"shop"}, - Running: true, - DebugReady: true, - DebugPort: 9412, - } - manager := newSelectorTestManager(profileA, profileB) - starter := newLifecycleStarter(manager) - handler := buildTestHandlerWithManager(svc, starter, manager) - - req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"code":"shop"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "matchMode=first") { - t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String()) - } -} - -func TestRuntimeStatusWithMatchModeFirstReturnsStableTarget(t *testing.T) { - svc := newInMemoryService() - profileB := &browser.Profile{ - ProfileId: "runtime-status-b", - ProfileName: "B Account", - Keywords: []string{"shop"}, - Running: true, - DebugReady: true, - DebugPort: 9412, - Pid: 3002, - } - profileA := &browser.Profile{ - ProfileId: "runtime-status-a", - ProfileName: "A Account", - Keywords: []string{"shop"}, - Running: true, - DebugReady: true, - DebugPort: 9411, - Pid: 3001, - } - manager := newSelectorTestManager(profileB, profileA) - starter := newLifecycleStarter(manager) - handler := buildTestHandlerWithManager(svc, starter, manager) - - req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"code":"shop","matchMode":"first"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - ProfileID string `json:"profileId"` - ProfileName string `json:"profileName"` - Running bool `json:"running"` - DebugReady bool `json:"debugReady"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - - if !resp.OK || resp.ProfileID != profileA.ProfileId || resp.ProfileName != profileA.ProfileName { - t.Fatalf("响应不正确: %+v", resp) - } - if !resp.Running || !resp.DebugReady { - t.Fatalf("运行态字段不正确: %+v", resp) - } -} - -func TestRuntimeStopWithExactLaunchCode(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "runtime-stop-code", - ProfileName: "Runtime Stop By Code", - Running: true, - DebugReady: true, - DebugPort: 9511, - Pid: 4001, - } - manager := newSelectorTestManager(profile) - starter := newLifecycleStarter(manager) - - if _, err := svc.SetCode(profile.ProfileId, "runtime-stop-code"); err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/runtime/stop", bytes.NewBufferString(`{"code":"runtime-stop-code"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Stopped bool `json:"stopped"` - ProfileID string `json:"profileId"` - LaunchCode string `json:"launchCode"` - Running bool `json:"running"` - DebugReady bool `json:"debugReady"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - - if !resp.OK || !resp.Stopped || resp.ProfileID != profile.ProfileId { - t.Fatalf("停止响应不正确: %+v", resp) - } - if resp.LaunchCode != "RUNTIME-STOP-CODE" { - t.Fatalf("launchCode 不正确: %+v", resp) - } - if resp.Running || resp.DebugReady { - t.Fatalf("停止后运行态不正确: %+v", resp) - } -} - -func TestRuntimeStatusRejectsMatchModeAll(t *testing.T) { - svc := newInMemoryService() - manager := newSelectorTestManager() - starter := newLifecycleStarter(manager) - handler := buildTestHandlerWithManager(svc, starter, manager) - - req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"keyword":"shop","matchMode":"all"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "matchMode must be unique or first") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } -} diff --git a/backend/test/launchcode/server_runtime_session_test.go b/backend/test/launchcode/server_runtime_session_test.go deleted file mode 100644 index 7493a71a..00000000 --- a/backend/test/launchcode/server_runtime_session_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/launchcode" -) - -type sessionStarter struct { - mgr *browser.Manager - waitReady bool - waitErr error - lastParams launchcode.LaunchRequestParams -} - -func newSessionStarter(mgr *browser.Manager, waitReady bool) *sessionStarter { - return &sessionStarter{ - mgr: mgr, - waitReady: waitReady, - } -} - -func (m *sessionStarter) StartInstance(profileID string) (*browser.Profile, error) { - return m.StartInstanceWithParams(profileID, launchcode.LaunchRequestParams{}) -} - -func (m *sessionStarter) StartInstanceWithParams(profileID string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { - m.lastParams = params - m.mgr.Mutex.Lock() - defer m.mgr.Mutex.Unlock() - - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, fmt.Errorf("profile not found") - } - - profile.Running = true - profile.DebugReady = false - profile.DebugPort = 9666 - profile.Pid = 4321 - profile.RuntimeWarning = "debug pending" - profile.LastError = "" - return profile, nil -} - -func (m *sessionStarter) StatusInstance(profileID string) (*browser.Profile, error) { - m.mgr.Mutex.Lock() - defer m.mgr.Mutex.Unlock() - - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, fmt.Errorf("profile not found") - } - return profile, nil -} - -func (m *sessionStarter) WaitInstanceDebugReady(profileID string, debugPort int, timeout time.Duration) (*browser.Profile, bool, error) { - if m.waitErr != nil { - return nil, false, m.waitErr - } - - m.mgr.Mutex.Lock() - defer m.mgr.Mutex.Unlock() - - profile, ok := m.mgr.Profiles[profileID] - if !ok || profile == nil { - return nil, false, fmt.Errorf("profile not found") - } - - if m.waitReady { - profile.Running = true - profile.DebugReady = true - profile.DebugPort = debugPort - profile.RuntimeWarning = "" - return profile, true, nil - } - - return profile, false, nil -} - -func TestRuntimeSessionWaitsUntilDebugReady(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "runtime-session-ready", - ProfileName: "Runtime Session Ready", - } - manager := newSelectorTestManager(profile) - starter := newSessionStarter(manager, true) - - if _, err := svc.SetCode(profile.ProfileId, "runtime-session-ready"); err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{ - "code":"runtime-session-ready", - "timeoutMs":5000, - "launchArgs":["--window-size=1400,900"], - "startUrls":["https://example.com"], - "skipDefaultStartUrls":true - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.lastParams.LaunchArgs) != 1 || starter.lastParams.LaunchArgs[0] != "--window-size=1400,900" { - t.Fatalf("launchArgs 透传错误: %+v", starter.lastParams) - } - if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" { - t.Fatalf("startUrls 透传错误: %+v", starter.lastParams) - } - if !starter.lastParams.SkipDefaultStartURLs { - t.Fatalf("skipDefaultStartUrls 透传错误: %+v", starter.lastParams) - } - - var resp struct { - OK bool `json:"ok"` - Ready bool `json:"ready"` - WaitTimedOut bool `json:"waitTimedOut"` - Retryable bool `json:"retryable"` - Active bool `json:"active"` - ProfileID string `json:"profileId"` - LaunchCode string `json:"launchCode"` - DebugReady bool `json:"debugReady"` - CDPURL string `json:"cdpUrl"` - DirectDebugURL string `json:"directDebugUrl"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || !resp.Ready || resp.WaitTimedOut || resp.Retryable { - t.Fatalf("ready 响应不正确: %+v", resp) - } - if !resp.Active || !resp.DebugReady || resp.ProfileID != profile.ProfileId { - t.Fatalf("会话状态不正确: %+v", resp) - } - if resp.LaunchCode != "RUNTIME-SESSION-READY" { - t.Fatalf("launchCode 不正确: %+v", resp) - } - if resp.CDPURL == "" || resp.DirectDebugURL == "" { - t.Fatalf("应返回可接管地址: %+v", resp) - } -} - -func TestRuntimeSessionReturnsAcceptedWhileDebugIsPending(t *testing.T) { - svc := newInMemoryService() - profile := &browser.Profile{ - ProfileId: "runtime-session-pending", - ProfileName: "Runtime Session Pending", - } - manager := newSelectorTestManager(profile) - starter := newSessionStarter(manager, false) - - if _, err := svc.SetCode(profile.ProfileId, "runtime-session-pending"); err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{ - "code":"runtime-session-pending", - "timeoutMs":1000 - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusAccepted { - t.Fatalf("期望 202,实际 %d,body=%s", w.Code, w.Body.String()) - } - - var resp struct { - OK bool `json:"ok"` - Ready bool `json:"ready"` - WaitTimedOut bool `json:"waitTimedOut"` - Retryable bool `json:"retryable"` - Active bool `json:"active"` - DebugReady bool `json:"debugReady"` - RuntimeWarning string `json:"runtimeWarning"` - CDPURL string `json:"cdpUrl"` - DirectDebugURL string `json:"directDebugUrl"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Ready || !resp.WaitTimedOut || !resp.Retryable { - t.Fatalf("pending 响应不正确: %+v", resp) - } - if resp.Active || resp.DebugReady { - t.Fatalf("pending 会话不应标记为 active/debugReady: %+v", resp) - } - if resp.RuntimeWarning != "debug pending" { - t.Fatalf("runtimeWarning 不正确: %+v", resp) - } - if resp.CDPURL != "" || resp.DirectDebugURL != "" { - t.Fatalf("pending 会话不应返回可接管地址: %+v", resp) - } -} - -func TestRuntimeSessionRejectsMatchModeAll(t *testing.T) { - manager := newSelectorTestManager() - handler := buildTestHandlerWithManager(newInMemoryService(), newSessionStarter(manager, true), manager) - req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{"keyword":"shop","matchMode":"all"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } -} diff --git a/backend/test/launchcode/server_selector_modes_test.go b/backend/test/launchcode/server_selector_modes_test.go deleted file mode 100644 index e377c40b..00000000 --- a/backend/test/launchcode/server_selector_modes_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/browser" -) - -func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Account A", - Keywords: []string{"shop", "checkout"}, - Pid: 1001, - DebugPort: 9441, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Account B", - Keywords: []string{"shop", "refund"}, - Pid: 1002, - DebugPort: 9442, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileA, profileB) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != "" { - t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile) - } - if !strings.Contains(w.Body.String(), "matchMode=first") { - t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String()) - } -} - -func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profile := &browser.Profile{ - ProfileId: "profile-get-code-only", - ProfileName: "Buyer Account 02", - Keywords: []string{"buyer-002"}, - Pid: 1004, - DebugPort: 9447, - } - starter.addProfile(profile) - manager := newSelectorTestManager(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != "" { - t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile) - } -} - -func TestLaunchWithMatchModeFirst(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "B Account", - Keywords: []string{"shop"}, - Pid: 2002, - DebugPort: 9552, - } - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "A Account", - Keywords: []string{"shop"}, - Pid: 2001, - DebugPort: 9551, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileB, profileA) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileA.ProfileId { - t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) - } -} - -func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "A Account", - Keywords: []string{"shop"}, - Pid: 2001, - DebugPort: 9551, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "B Account", - Keywords: []string{"shop"}, - Pid: 2002, - DebugPort: 9552, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileB, profileA) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.started) != 2 { - t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started) - } - if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId { - t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started) - } - - var resp struct { - OK bool `json:"ok"` - Count int `json:"count"` - Items []struct { - ProfileID string `json:"profileId"` - IsActive bool `json:"isActive"` - } `json:"items"` - ActiveProfileID string `json:"activeProfileId"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 { - t.Fatalf("批量启动响应错误: %+v", resp) - } - if resp.ActiveProfileID != profileB.ProfileId { - t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId) - } - if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId { - t.Fatalf("items 顺序错误: %+v", resp.Items) - } - if resp.Items[0].IsActive || !resp.Items[1].IsActive { - t.Fatalf("isActive 标记错误: %+v", resp.Items) - } -} - -func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Account A", - Keywords: []string{"shop", "checkout"}, - Pid: 1001, - DebugPort: 9441, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Account B", - Keywords: []string{"shop", "refund"}, - Pid: 1002, - DebugPort: 9442, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileA, profileB) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.started) != 0 { - t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started) - } -} diff --git a/backend/test/launchcode/server_selector_test.go b/backend/test/launchcode/server_selector_test.go deleted file mode 100644 index 0553eb6e..00000000 --- a/backend/test/launchcode/server_selector_test.go +++ /dev/null @@ -1,346 +0,0 @@ -package launchcode_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/launchcode" -) - -func buildTestHandlerWithManager(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter, mgr *browser.Manager) http.Handler { - srv := launchcode.NewLaunchServer(svc, starter, mgr, 0) - return launchcode.NewTestHandler(srv) -} - -func newSelectorTestManager(profiles ...*browser.Profile) *browser.Manager { - items := make(map[string]*browser.Profile, len(profiles)) - for _, profile := range profiles { - items[profile.ProfileId] = profile - } - return &browser.Manager{ - Profiles: items, - } -} - -func TestLaunchWithKeywordSelector(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profile := &browser.Profile{ - ProfileId: "profile-keyword", - ProfileName: "Amazon US", - GroupId: "group-sales", - Tags: []string{"电商", "北美"}, - Keywords: []string{"amazon-us", "checkout", "buyer-account"}, - Pid: 9527, - DebugPort: 9333, - } - starter.addProfile(profile) - manager := newSelectorTestManager(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - payload := bytes.NewBufferString(`{ - "selector": { - "keyword": "checkout", - "tags": ["电商"], - "groupId": "group-sales" - }, - "skipDefaultStartUrls": true - }`) - - req := httptest.NewRequest(http.MethodPost, "/api/launch", payload) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profile.ProfileId { - t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) - } - - var resp struct { - OK bool `json:"ok"` - ProfileID string `json:"profileId"` - LaunchCode string `json:"launchCode"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.ProfileID != profile.ProfileId { - t.Fatalf("响应不正确: %+v", resp) - } - if strings.TrimSpace(resp.LaunchCode) == "" { - t.Fatalf("期望返回 resolved launchCode,实际为空: %+v", resp) - } -} - -func TestLaunchWithTopLevelKeywordSelector(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profile := &browser.Profile{ - ProfileId: "profile-top-level", - ProfileName: "Billing Ops", - Keywords: []string{"billing", "invoice"}, - Pid: 1001, - DebugPort: 9444, - } - starter.addProfile(profile) - manager := newSelectorTestManager(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"keyword":"billing"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profile.ProfileId { - t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) - } -} - -func TestLaunchWithTopLevelKeyAliasSelector(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profile := &browser.Profile{ - ProfileId: "profile-top-level-key", - ProfileName: "Buyer Account", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 1002, - DebugPort: 9445, - } - starter.addProfile(profile) - manager := newSelectorTestManager(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profile.ProfileId { - t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) - } -} - -func TestLaunchWithTopLevelKeyPrefersExactKeywordMatch(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileFuzzy := &browser.Profile{ - ProfileId: "profile-key-fuzzy", - ProfileName: "Account A", - Keywords: []string{"buyer-001-old", "amazon"}, - Pid: 1004, - DebugPort: 9447, - } - profileExact := &browser.Profile{ - ProfileId: "profile-key-exact", - ProfileName: "Z Account", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 1005, - DebugPort: 9448, - } - starter.addProfile(profileFuzzy) - starter.addProfile(profileExact) - manager := newSelectorTestManager(profileFuzzy, profileExact) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileExact.ProfileId { - t.Fatalf("key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) - } -} - -func TestLaunchWithNestedSelectorKeyPrefersExactKeywordMatch(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileFuzzy := &browser.Profile{ - ProfileId: "profile-selector-key-fuzzy", - ProfileName: "Account A", - Keywords: []string{"buyer-001-old", "amazon"}, - Pid: 1006, - DebugPort: 9449, - } - profileExact := &browser.Profile{ - ProfileId: "profile-selector-key-exact", - ProfileName: "Z Account", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 1007, - DebugPort: 9450, - } - starter.addProfile(profileFuzzy) - starter.addProfile(profileExact) - manager := newSelectorTestManager(profileFuzzy, profileExact) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"key":"buyer-001"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileExact.ProfileId { - t.Fatalf("selector.key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) - } -} - -func TestLaunchWithTopLevelCodeFallbackToKeyword(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profile := &browser.Profile{ - ProfileId: "profile-top-level-code-fallback", - ProfileName: "Buyer Account 01", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 1003, - DebugPort: 9446, - } - starter.addProfile(profile) - manager := newSelectorTestManager(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profile.ProfileId { - t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId) - } -} - -func TestLaunchWithTopLevelCodeFallbackPrefersExactKeywordMatch(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileFuzzy := &browser.Profile{ - ProfileId: "profile-code-fuzzy", - ProfileName: "Account A", - Keywords: []string{"buyer-001-old", "amazon"}, - Pid: 1008, - DebugPort: 9451, - } - profileExact := &browser.Profile{ - ProfileId: "profile-code-exact", - ProfileName: "Z Account", - Keywords: []string{"buyer-001", "amazon"}, - Pid: 1009, - DebugPort: 9452, - } - starter.addProfile(profileFuzzy) - starter.addProfile(profileExact) - manager := newSelectorTestManager(profileFuzzy, profileExact) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileExact.ProfileId { - t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId) - } -} - -func TestLaunchWithAmbiguousKeywordSelectorReturnsFirstByDefault(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Account A", - Keywords: []string{"shop", "checkout"}, - Pid: 1001, - DebugPort: 9441, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Account B", - Keywords: []string{"shop", "refund"}, - Pid: 1002, - DebugPort: 9442, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileA, profileB) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileA.ProfileId { - t.Fatalf("关键字多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) - } -} - -func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Account A", - Keywords: []string{"shop", "checkout"}, - Pid: 1001, - DebugPort: 9441, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Account B", - Keywords: []string{"shop", "refund"}, - Pid: 1002, - DebugPort: 9442, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileA, profileB) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileA.ProfileId { - t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) - } -} diff --git a/backend/test/launchcode/service_setcode_test.go b/backend/test/launchcode/service_setcode_test.go deleted file mode 100644 index cdd803cf..00000000 --- a/backend/test/launchcode/service_setcode_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package launchcode_test - -import ( - "strings" - "testing" - - "ant-chrome/backend/internal/launchcode" -) - -func TestSetCodeAndResolveCaseInsensitive(t *testing.T) { - svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - code, err := svc.SetCode("p1", "demo_code") - if err != nil { - t.Fatalf("SetCode 失败: %v", err) - } - if code != "DEMO_CODE" { - t.Fatalf("期望 DEMO_CODE,实际 %s", code) - } - - profileID, err := svc.Resolve("demo_code") - if err != nil { - t.Fatalf("Resolve 失败: %v", err) - } - if profileID != "p1" { - t.Fatalf("期望 p1,实际 %s", profileID) - } -} - -func TestSetCodeConflict(t *testing.T) { - svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - if _, err := svc.SetCode("p1", "AAA111"); err != nil { - t.Fatalf("SetCode p1 失败: %v", err) - } - if _, err := svc.SetCode("p2", "AAA111"); err == nil { - t.Fatal("期望 code 冲突时报错") - } -} - -func TestSetCodeValidation(t *testing.T) { - svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - cases := []string{"", "a", "ab", "中文123", "abc!123", strings.Repeat("A", 40)} - for _, c := range cases { - if _, err := svc.SetCode("p1", c); err == nil { - t.Fatalf("期望非法 code 报错: %q", c) - } - } -}