test: remove stale integration tests

This commit is contained in:
ant-black
2026-06-23 17:08:38 +08:00
parent be3cfad357
commit f010f2c3d3
28 changed files with 0 additions and 6760 deletions
-106
View File
@@ -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")
}
}
-265
View File
@@ -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
}
-171
View File
@@ -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)
}
}
-246
View File
@@ -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
}
-364
View File
@@ -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 未在调试端口关闭后结束")
}
}
@@ -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 = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mail Fixture</title>
<script>
window.__notificationProbe = {
supported: typeof Notification !== 'undefined',
requested: false,
result: '',
error: '',
};
document.addEventListener('DOMContentLoaded', () => {
if (typeof Notification === 'undefined' || typeof Notification.requestPermission !== 'function') {
document.documentElement.setAttribute('data-notification-probe', 'unsupported');
return;
}
window.__notificationProbe.requested = true;
Notification.requestPermission()
.then((result) => {
window.__notificationProbe.result = String(result || '');
document.documentElement.setAttribute('data-notification-probe', window.__notificationProbe.result || 'empty');
})
.catch((error) => {
window.__notificationProbe.error = String(error && error.message ? error.message : error);
document.documentElement.setAttribute('data-notification-probe', 'error');
});
});
</script>
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f6f7fb; }
main { display: flex; gap: 20px; padding: 24px; min-height: 100vh; box-sizing: border-box; }
.sidebar { width: 32%; min-width: 320px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 20px; box-sizing: border-box; }
.viewer { width: 60%; min-height: 420px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 24px; box-sizing: border-box; }
input { width: 100%; height: 42px; padding: 0 12px; font-size: 16px; box-sizing: border-box; }
[role="row"] { margin-top: 16px; min-height: 56px; border: 1px solid #c8cfdd; border-radius: 10px; padding: 16px; cursor: pointer; background: #fafbff; }
p { margin: 0 0 12px; line-height: 1.55; }
h1 { margin: 0 0 16px; font-size: 28px; }
</style>
</head>
<body>
<main>
<section class="sidebar">
<div role="dialog" tabindex="-1" data-focus-root="1" class="overlay no-outline" data-testid="overlay-button" id="advanced-search-overlay-14">
<input
type="search"
readonly
title="关键词"
placeholder="搜索邮件"
value=""
aria-label="Search messages"
data-testid="search-keyword"
class="input-element w-full cursor-text"
/>
</div>
<div role="row">target@example.com ChatGPT verification code 429792</div>
</section>
<article role="article" class="viewer">
<h1>Your ChatGPT verification code</h1>
<p>From: ChatGPT &lt;noreply@tm.openai.com&gt;</p>
<p>To: target@example.com</p>
<p>Hello,</p>
<p>Your verification code is 429792.</p>
<p>Please use this code to continue signing in.</p>
<p>Best regards</p>
<p>ChatGPT</p>
</article>
</main>
</body>
</html>`
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',",
)
-258
View File
@@ -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"),
}))
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
-200
View File
@@ -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)
}
}
-172
View File
@@ -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"},
})
}
-221
View File
@@ -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 和 LaunchCodeUpsert 后:
// - 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)
}
}
}
@@ -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())
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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), &params); 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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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())
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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())
}
})
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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)
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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)
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%s", w.Code, w.Body.String())
}
afterCount := len(mgr.List())
if afterCount != beforeCount {
t.Fatalf("launchCode 冲突后应回滚创建: before=%d after=%d", beforeCount, afterCount)
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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, ""
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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)
}
}
-274
View File
@@ -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
// 对于任意存在的 LaunchCodeGET /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
// 对于任意不存在的 codeGET /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")
}
}
@@ -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,实际 %dbody=%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())
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "matchMode must be unique or first") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%s", w.Code, w.Body.String())
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%s", w.Code, w.Body.String())
}
if len(starter.started) != 0 {
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
}
}
@@ -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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != profileA.ProfileId {
t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
}
}
@@ -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)
}
}
}