From f010f2c3d3f4b66b8a6810243359dc39b498f4a8 Mon Sep 17 00:00:00 2001
From: ant-black <1016930479@qq.com>
Date: Tue, 23 Jun 2026 17:08:38 +0800
Subject: [PATCH] test: remove stale integration tests
---
backend/app_instance_errors_test.go | 106 ----
backend/app_instance_open_url_test.go | 265 ----------
backend/app_instance_start_helpers_test.go | 171 -------
backend/app_instance_start_ready_test.go | 246 ----------
backend/app_instance_start_test.go | 364 --------------
...automation_script_http_e2e_helpers_test.go | 341 -------------
backend/automation_script_http_e2e_test.go | 258 ----------
...ion_script_run_integration_helpers_test.go | 249 ----------
.../automation_script_run_integration_test.go | 434 -----------------
.../automation_script_run_launch_api_test.go | 59 ---
backend/browser_process_discovery_test.go | 200 --------
backend/browser_start_targets_test.go | 172 -------
backend/test/launchcode/dao_prop_test.go | 221 ---------
backend/test/launchcode/server_auth_test.go | 81 ----
.../server_automation_public_hook_test.go | 454 ------------------
.../launchcode/server_automation_runs_test.go | 108 -----
.../test/launchcode/server_automation_test.go | 322 -------------
backend/test/launchcode/server_params_test.go | 314 ------------
.../launchcode/server_profile_create_test.go | 277 -----------
.../launchcode/server_profile_manage_test.go | 369 --------------
.../launchcode/server_profile_runtime_test.go | 312 ------------
backend/test/launchcode/server_prop_test.go | 274 -----------
backend/test/launchcode/server_proxy_test.go | 168 -------
.../server_runtime_selector_test.go | 168 -------
.../launchcode/server_runtime_session_test.go | 220 ---------
.../launchcode/server_selector_modes_test.go | 214 ---------
.../test/launchcode/server_selector_test.go | 346 -------------
.../test/launchcode/service_setcode_test.go | 47 --
28 files changed, 6760 deletions(-)
delete mode 100644 backend/app_instance_errors_test.go
delete mode 100644 backend/app_instance_open_url_test.go
delete mode 100644 backend/app_instance_start_helpers_test.go
delete mode 100644 backend/app_instance_start_ready_test.go
delete mode 100644 backend/app_instance_start_test.go
delete mode 100644 backend/automation_script_http_e2e_helpers_test.go
delete mode 100644 backend/automation_script_http_e2e_test.go
delete mode 100644 backend/automation_script_run_integration_helpers_test.go
delete mode 100644 backend/automation_script_run_integration_test.go
delete mode 100644 backend/automation_script_run_launch_api_test.go
delete mode 100644 backend/browser_process_discovery_test.go
delete mode 100644 backend/browser_start_targets_test.go
delete mode 100644 backend/test/launchcode/dao_prop_test.go
delete mode 100644 backend/test/launchcode/server_auth_test.go
delete mode 100644 backend/test/launchcode/server_automation_public_hook_test.go
delete mode 100644 backend/test/launchcode/server_automation_runs_test.go
delete mode 100644 backend/test/launchcode/server_automation_test.go
delete mode 100644 backend/test/launchcode/server_params_test.go
delete mode 100644 backend/test/launchcode/server_profile_create_test.go
delete mode 100644 backend/test/launchcode/server_profile_manage_test.go
delete mode 100644 backend/test/launchcode/server_profile_runtime_test.go
delete mode 100644 backend/test/launchcode/server_prop_test.go
delete mode 100644 backend/test/launchcode/server_proxy_test.go
delete mode 100644 backend/test/launchcode/server_runtime_selector_test.go
delete mode 100644 backend/test/launchcode/server_runtime_session_test.go
delete mode 100644 backend/test/launchcode/server_selector_modes_test.go
delete mode 100644 backend/test/launchcode/server_selector_test.go
delete mode 100644 backend/test/launchcode/service_setcode_test.go
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*)
- const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
- const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
- const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
- const signature = lines.slice(-2).join('\n')
-
- return {
- notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
- notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
- mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
- senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
- recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
- subject,
- verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
- signature,
- }
- })
-
- return {
- ok: true,
- permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
- permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
- summary: '已提取测试邮件内容',
- ...result,
- }
-}`
-
-var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
-
-var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
- automationHTTPMailProbeScriptTextRaw,
- "summary: 'mail probe extracted message',",
-)
diff --git a/backend/automation_script_http_e2e_test.go b/backend/automation_script_http_e2e_test.go
deleted file mode 100644
index d10bac71..00000000
--- a/backend/automation_script_http_e2e_test.go
+++ /dev/null
@@ -1,258 +0,0 @@
-package backend
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "strings"
- "testing"
-
- "ant-chrome/backend/internal/automation"
- "ant-chrome/backend/internal/config"
-)
-
-type automationHTTPProfileCreateResponse struct {
- OK bool `json:"ok"`
- Created bool `json:"created"`
- Launched bool `json:"launched"`
- ProfileID string `json:"profileId"`
- LaunchCode string `json:"launchCode"`
-}
-
-type automationHTTPScriptsResponse struct {
- OK bool `json:"ok"`
- Data struct {
- Count int `json:"count"`
- Items []struct {
- ID string `json:"id"`
- } `json:"items"`
- } `json:"data"`
-}
-
-type automationHTTPRunResponse struct {
- OK bool `json:"ok"`
- Data struct {
- Run struct {
- Status string `json:"status"`
- Summary string `json:"summary"`
- Error string `json:"error"`
- ResultText string `json:"resultText"`
- } `json:"run"`
- } `json:"data"`
-}
-
-type automationHTTPHookEnvelopeResponse struct {
- OK bool `json:"ok"`
- Status string `json:"status"`
- Summary string `json:"summary"`
- Result map[string]interface{} `json:"result"`
-}
-
-type automationHTTPLaunchLogsResponse struct {
- OK bool `json:"ok"`
- Items []json.RawMessage `json:"items"`
-}
-
-func TestAutomationScriptRunHTTPReturnsSavedMailProbeScript(t *testing.T) {
- nodePath := lookupAutomationHTTPProbeNode(t)
- chromePath := lookupAutomationHTTPProbeChrome(t)
- repoRoot := automationHTTPRepoRoot(t)
-
- tempRoot := t.TempDir()
- cfg := config.DefaultConfig()
- cfg.Logging.FileEnabled = false
- cfg.LaunchServer.Port = automationHTTPFreePort(t)
- cfg.Automation.Enabled = true
- cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
- cfg.Automation.SystemNodePath = nodePath
- cfg.Automation.HeadlessDefault = true
- if err := cfg.Save(filepath.Join(tempRoot, "config.yaml")); err != nil {
- t.Fatalf("save config failed: %v", err)
- }
-
- if err := prepareAutomationHTTPRuntime(tempRoot, repoRoot, cfg.Automation.RuntimeVersion); err != nil {
- t.Fatalf("prepare runtime failed: %v", err)
- }
-
- fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _, _ = io.WriteString(w, automationHTTPMailFixtureHTML)
- }))
- defer fixtureServer.Close()
-
- app := NewApp(tempRoot)
- Start(app, nil)
- defer Stop(app, nil)
-
- if err := app.BrowserCoreSave(BrowserCoreInput{
- CoreId: "system-chrome",
- CoreName: "System Chrome",
- CorePath: chromePath,
- IsDefault: true,
- }); err != nil {
- t.Fatalf("save core failed: %v", err)
- }
- if err := app.BrowserCoreSetDefault("system-chrome"); err != nil {
- t.Fatalf("set default core failed: %v", err)
- }
-
- baseURL, ok := app.GetLaunchServerInfo()["baseUrl"].(string)
- if !ok || strings.TrimSpace(baseURL) == "" {
- t.Fatalf("launch server baseUrl missing: %+v", app.GetLaunchServerInfo())
- }
-
- var createResp automationHTTPProfileCreateResponse
- if err := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/profiles", map[string]any{
- "profile": map[string]any{
- "profileName": "mail-probe",
- "launchArgs": []string{
- "--headless=new",
- "--disable-gpu",
- "--no-first-run",
- "--no-default-browser-check",
- "--window-size=1440,1024",
- },
- },
- "launchCode": "MAIL01",
- }, &createResp); err != nil {
- t.Fatalf("create profile via http failed: %v", err)
- }
- if !createResp.OK || !createResp.Created || createResp.LaunchCode != "MAIL01" {
- t.Fatalf("unexpected create response: %+v", createResp)
- }
-
- savedScript, err := app.AutomationScriptSave(automation.ScriptRecord{
- ID: "mail-probe-script",
- Name: "测试邮件探针",
- Type: "playwright-cdp",
- Status: "ready",
- EntryFile: "index.cjs",
- ScriptText: automationHTTPMailProbeScriptText,
- })
- if err != nil {
- t.Fatalf("save mail probe script failed: %v", err)
- }
- if savedScript == nil {
- t.Fatalf("expected mail probe script to be saved")
- }
- savedScript.PublicAPI = automation.ScriptPublicAPIConfig{
- Enabled: true,
- Method: "POST",
- Path: "mail/probe-message",
- RequestMode: "params-only",
- ResponseMode: "envelope",
- TimeoutMs: 120000,
- }
- savedScript.SelectorText = fmt.Sprintf("{\n \"code\": %q\n}", createResp.LaunchCode)
- if _, err := app.AutomationScriptSave(*savedScript); err != nil {
- t.Fatalf("save mail probe public api config failed: %v", err)
- }
-
- var scriptsResp automationHTTPScriptsResponse
- if err := automationHTTPRequestJSON(http.MethodGet, baseURL+"/api/automation/scripts", nil, &scriptsResp); err != nil {
- t.Fatalf("list scripts via http failed: %v", err)
- }
- if !scriptsResp.OK || scriptsResp.Data.Count == 0 {
- t.Fatalf("unexpected scripts response: %+v", scriptsResp)
- }
- if !automationHTTPHasScript(scriptsResp.Data.Items, "mail-probe-script") {
- t.Fatalf("saved mail probe script missing: %+v", scriptsResp)
- }
-
- var runResp automationHTTPRunResponse
- runErr := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/automation/scripts/run", map[string]any{
- "scriptId": "mail-probe-script",
- "selector": map[string]any{
- "code": createResp.LaunchCode,
- },
- "params": map[string]any{
- "inboxUrl": fixtureServer.URL,
- "timeoutMs": 45000,
- },
- "timeoutMs": 120000,
- }, &runResp)
- if runErr != nil {
- t.Fatalf("run script via http failed: %v", runErr)
- }
-
- parsed := make(map[string]any)
- if text := strings.TrimSpace(runResp.Data.Run.ResultText); text != "" {
- if err := json.Unmarshal([]byte(text), &parsed); err != nil {
- t.Fatalf("parse run result failed: %v; result=%s", err, text)
- }
- if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 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)
- }
- }
-}