mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
test: remove stale integration tests
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <noreply@tm.openai.com></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',",
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
// Feature: instance-launch-code, Property 2: persistence round-trip
|
||||
// Validates: Requirements 1.3, 5.1, 5.3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var testDBSequence uint64
|
||||
|
||||
func setupLaunchCodeSchema(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建索引失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestDB 创建内存 SQLite 数据库并执行建表迁移
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:launchcode_test_%d?mode=memory&cache=shared", atomic.AddUint64(&testDBSequence, 1)))
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
setupLaunchCodeSchema(t, db)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func newIsolatedTestDB(t *testing.T) (*sql.DB, func()) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:launchcode_prop_%d?mode=memory&cache=shared", atomic.AddUint64(&testDBSequence, 1)))
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
setupLaunchCodeSchema(t, db)
|
||||
return db, func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
// newFileTestDB 创建基于文件的 SQLite 数据库(用于需要独立隔离的测试)
|
||||
func newFileTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "launchcode_test_*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时数据库文件失败: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
dbPath := f.Name()
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建索引失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
os.Remove(dbPath)
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// TestProperty2_PersistenceRoundTrip
|
||||
// Property 2: 持久化 Round-Trip
|
||||
// 对于任意 ProfileId 和 LaunchCode,Upsert 后:
|
||||
// - FindProfileId(code) 返回相同的 profileId
|
||||
// - FindCode(profileId) 返回相同的 code
|
||||
func TestProperty2_PersistenceRoundTrip(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("Upsert 后 FindProfileId 返回正确 profileId", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db, cleanup := newIsolatedTestDB(t)
|
||||
defer cleanup()
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindProfileId(code)
|
||||
return err == nil && got == profileId
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.Property("Upsert 后 FindCode 返回正确 code", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db, cleanup := newIsolatedTestDB(t)
|
||||
defer cleanup()
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindCode(profileId)
|
||||
return err == nil && got == code
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.Property("Upsert 幂等:相同 profileId 更新 code 后查询返回新 code", prop.ForAll(
|
||||
func(profileId, code1, code2 string) bool {
|
||||
if code1 == code2 {
|
||||
return true // 跳过相同 code 的情况
|
||||
}
|
||||
db, cleanup := newIsolatedTestDB(t)
|
||||
defer cleanup()
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code1); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := dao.Upsert(profileId, code2); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindCode(profileId)
|
||||
return err == nil && got == code2
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty2_DeleteRemovesMapping
|
||||
// Property 2 补充:Delete 后查询应返回 not found
|
||||
func TestProperty2_DeleteRemovesMapping(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("Delete 后 FindCode 返回错误", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db, cleanup := newIsolatedTestDB(t)
|
||||
defer cleanup()
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := dao.Delete(profileId); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := dao.FindCode(profileId)
|
||||
return err != nil
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty2_LoadAllRoundTrip
|
||||
// Property 2 补充:LoadAll 返回所有已写入的映射
|
||||
func TestProperty2_LoadAllRoundTrip(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
// 写入一批映射
|
||||
entries := map[string]string{}
|
||||
for i := 0; i < 10; i++ {
|
||||
profileId := fmt.Sprintf("profile-%02d", i)
|
||||
code := fmt.Sprintf("CODE%02d", i)
|
||||
entries[profileId] = code
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
t.Fatalf("Upsert 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := dao.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAll 失败: %v", err)
|
||||
}
|
||||
|
||||
for profileId, code := range entries {
|
||||
got, ok := loaded[profileId]
|
||||
if !ok {
|
||||
t.Errorf("LoadAll 缺少 profileId=%s", profileId)
|
||||
continue
|
||||
}
|
||||
if got != code {
|
||||
t.Errorf("LoadAll profileId=%s: 期望 code=%s,实际=%s", profileId, code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
|
||||
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
|
||||
t.Fatalf("instance 转换错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
|
||||
t.Fatalf("targetInput 转换错误: %s", targetInput)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Status != "success" {
|
||||
t.Fatalf("hook 响应错误: %+v", resp)
|
||||
}
|
||||
if resp.Data["verificationCode"] != "429792" {
|
||||
t.Fatalf("expected data payload, got %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookSupportsInstanceModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
expectedMode string
|
||||
expectedSelector bool
|
||||
expectedInput string
|
||||
useScriptTarget bool
|
||||
}{
|
||||
{
|
||||
name: "rotate",
|
||||
body: `{
|
||||
"instance":{"type":"rotate","selector":{"groupId":"group-a","tags":["chatgpt"]}},
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
expectedMode: "rotate",
|
||||
expectedInput: `"groupId":"group-a"`,
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
body: `{
|
||||
"instance":{"type":"create","templateSelector":{"code":"TEMPLATE_001"},"createNameTemplate":"ChatGPT-Image-${timestamp}"},
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
expectedMode: "create",
|
||||
expectedInput: `"createNameTemplate":"ChatGPT-Image-${timestamp}"`,
|
||||
},
|
||||
{
|
||||
name: "script-default",
|
||||
body: `{
|
||||
"instance":{"type":"script-default"},
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
useScriptTarget: true,
|
||||
},
|
||||
{
|
||||
name: "legacy-code",
|
||||
body: `{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
expectedSelector: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-first-message",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.TargetMode != tc.expectedMode {
|
||||
t.Fatalf("targetMode 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector != tc.useScriptTarget {
|
||||
t.Fatalf("useScriptSelector 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if tc.expectedInput != "" {
|
||||
targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput)
|
||||
if !strings.Contains(targetInput, tc.expectedInput) {
|
||||
t.Fatalf("targetInput 转换错误: %s", targetInput)
|
||||
}
|
||||
}
|
||||
if tc.expectedSelector && starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("legacy selectorText 转换错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-result-only",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 45000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-hook-2",
|
||||
ScriptID: "proton-mail-first-message",
|
||||
ScriptName: "Proton 邮件搜索并读取最新邮件",
|
||||
Status: "success",
|
||||
Summary: "已返回最新命中邮件内容",
|
||||
ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 60000 {
|
||||
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
|
||||
t.Fatalf("expected data payload, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "grok-image-generate-download",
|
||||
Name: "Grok 生成图片并下载",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/grok-generate-download",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 300000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-grok-image",
|
||||
ScriptID: "grok-image-generate-download",
|
||||
ScriptName: "Grok 生成图片并下载",
|
||||
Status: "success",
|
||||
Summary: "Grok 图片已生成并下载",
|
||||
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
|
||||
t.Fatalf("下载字段缺失: %+v", resp)
|
||||
}
|
||||
if _, exists := data["steps"]; exists {
|
||||
t.Fatalf("不应返回冗余 steps: %+v", resp)
|
||||
}
|
||||
if _, exists := data["runId"]; exists {
|
||||
t.Fatalf("不应返回 runId: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "image-generate",
|
||||
Name: "图片生成",
|
||||
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/generate",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 300000,
|
||||
RequestBodyText: `{"instance":{"type":"existing","selector":{"code":"{{code}}"}},"params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
|
||||
Variables: []automation.ScriptPublicAPIVariable{
|
||||
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
|
||||
{Name: "outputFileName", DefaultValue: "generated-image.png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
|
||||
t.Fatalf("变量模板应生成 instance target: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
|
||||
t.Fatalf("变量模板 targetInput 错误: %s", targetInput)
|
||||
}
|
||||
|
||||
var params map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil {
|
||||
t.Fatalf("解析 paramsText 失败: %v", err)
|
||||
}
|
||||
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
|
||||
t.Fatalf("变量未映射到 params: %+v", params)
|
||||
}
|
||||
selectors, ok := params["selectors"].(map[string]interface{})
|
||||
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
|
||||
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if params["timeoutMs"] != float64(300000) {
|
||||
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 300000 {
|
||||
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "disabled-hook",
|
||||
Name: "Disabled Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: false,
|
||||
Path: "mail/disabled-hook",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook",
|
||||
Name: "Strict Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
|
||||
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp.OK || resp.Error.Code != "invalid_request" {
|
||||
t.Fatalf("错误 envelope 不正确: %+v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook-timeout",
|
||||
Name: "Strict Hook Timeout",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook-timeout",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/automation"
|
||||
)
|
||||
|
||||
func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.runs = []automation.ScriptRunRecord{
|
||||
{ID: "run-1", ScriptID: "script-a", Status: "success"},
|
||||
{ID: "run-2", ScriptID: "script-b", Status: "failed"},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunListLimit != 1 {
|
||||
t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Count int `json:"count"`
|
||||
Items []automation.ScriptRunRecord `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
|
||||
t.Fatalf("runs 响应错误: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
|
||||
t.Run("invalid-json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selector-must-be-object", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"selector":"BUYER_001"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout-must-be-in-range", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"timeoutMs":1800001
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/automation"
|
||||
)
|
||||
|
||||
type mockAutomationStarter struct {
|
||||
*mockStarterWithParams
|
||||
scripts []automation.ScriptRecord
|
||||
runs []automation.ScriptRunRecord
|
||||
lastGetID string
|
||||
lastRunRequest automation.ScriptRunRequest
|
||||
lastRunListLimit int
|
||||
runResult *automation.ScriptRunRecord
|
||||
getErr error
|
||||
listErr error
|
||||
runErr error
|
||||
runListErr error
|
||||
}
|
||||
|
||||
func newMockAutomationStarter() *mockAutomationStarter {
|
||||
return &mockAutomationStarter{
|
||||
mockStarterWithParams: newMockStarterWithParams(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockAutomationStarter) AutomationScriptList() ([]automation.ScriptRecord, error) {
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
return append([]automation.ScriptRecord(nil), m.scripts...), nil
|
||||
}
|
||||
|
||||
func (m *mockAutomationStarter) AutomationScriptGet(scriptID string) (*automation.ScriptRecord, error) {
|
||||
m.lastGetID = scriptID
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
for _, item := range m.scripts {
|
||||
if item.ID == scriptID {
|
||||
record := item
|
||||
return &record, nil
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (m *mockAutomationStarter) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) (*automation.ScriptRunRecord, error) {
|
||||
m.lastRunRequest = input
|
||||
if m.runErr != nil {
|
||||
return nil, m.runErr
|
||||
}
|
||||
if m.runResult == nil {
|
||||
return &automation.ScriptRunRecord{
|
||||
ID: "run-default",
|
||||
ScriptID: input.ScriptID,
|
||||
Status: "success",
|
||||
}, nil
|
||||
}
|
||||
|
||||
record := *m.runResult
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (m *mockAutomationStarter) AutomationScriptRunList(limit int) ([]automation.ScriptRunRecord, error) {
|
||||
m.lastRunListLimit = limit
|
||||
if m.runListErr != nil {
|
||||
return nil, m.runListErr
|
||||
}
|
||||
|
||||
items := append([]automation.ScriptRunRecord(nil), m.runs...)
|
||||
if limit > 0 && len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func automationRunTargetInputJSON(t *testing.T, value interface{}) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal targetInput failed: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "news-query-txt",
|
||||
Name: "查询新闻并写 TXT",
|
||||
Description: "测试脚本",
|
||||
Type: "playwright-cdp",
|
||||
Status: "ready",
|
||||
EntryFile: "index.cjs",
|
||||
Tags: []string{"Playwright", "新闻"},
|
||||
SelectorText: `{"code":"BUYER_001"}`,
|
||||
ParamsText: `{"keyword":"OpenAI","limit":10}`,
|
||||
ScriptText: `module.exports.run = async () => ({ ok: true })`,
|
||||
Notes: "note",
|
||||
CreatedAt: "2026-04-08T10:00:00Z",
|
||||
UpdatedAt: "2026-04-08T11:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "scriptText") {
|
||||
t.Fatalf("公共脚本列表不应返回脚本文本: %s", w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Selector map[string]interface{} `json:"selector"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
|
||||
t.Fatalf("响应结构错误: %+v", resp)
|
||||
}
|
||||
item := resp.Data.Items[0]
|
||||
if item.ID != "news-query-txt" || item.Type != "playwright-cdp" || item.Status != "ready" {
|
||||
t.Fatalf("脚本元数据错误: %+v", item)
|
||||
}
|
||||
if item.Selector["code"] != "BUYER_001" {
|
||||
t.Fatalf("selector 解析错误: %+v", item.Selector)
|
||||
}
|
||||
if item.Params["keyword"] != "OpenAI" {
|
||||
t.Fatalf("params 解析错误: %+v", item.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptDetailEndpointReturnsSingleScript(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
PackageFormat: "ant-automation-script",
|
||||
ManifestVersion: 1,
|
||||
ID: "news-query-txt",
|
||||
Name: "查询新闻并写 TXT",
|
||||
Description: "测试脚本",
|
||||
Type: "playwright-cdp",
|
||||
Status: "ready",
|
||||
EntryFile: "index.cjs",
|
||||
Tags: []string{"Playwright", "新闻"},
|
||||
SelectorText: `{"code":"BUYER_001"}`,
|
||||
ParamsText: `{"keyword":"OpenAI","limit":10}`,
|
||||
ScriptText: `module.exports.run = async () => ({ ok: true })`,
|
||||
Notes: "note",
|
||||
Source: automation.ScriptSource{
|
||||
Type: "git",
|
||||
URI: "https://example.com/repo.git",
|
||||
Ref: "main",
|
||||
},
|
||||
CreatedAt: "2026-04-08T10:00:00Z",
|
||||
UpdatedAt: "2026-04-08T11:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/news-query-txt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastGetID != "news-query-txt" {
|
||||
t.Fatalf("scriptId 路径解析错误: %s", starter.lastGetID)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "scriptText") {
|
||||
t.Fatalf("公共脚本详情不应返回脚本文本: %s", w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Item struct {
|
||||
ID string `json:"id"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
ManifestVersion int `json:"manifestVersion"`
|
||||
Source automation.ScriptSource `json:"source"`
|
||||
Selector map[string]interface{} `json:"selector"`
|
||||
} `json:"item"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
item := resp.Data.Item
|
||||
if !resp.OK || item.ID != "news-query-txt" {
|
||||
t.Fatalf("详情响应错误: %+v", resp)
|
||||
}
|
||||
if item.PackageFormat != "ant-automation-script" || item.ManifestVersion != 1 {
|
||||
t.Fatalf("详情元数据错误: %+v", item)
|
||||
}
|
||||
if item.Source.Type != "git" || item.Source.URI != "https://example.com/repo.git" {
|
||||
t.Fatalf("source 返回错误: %+v", item.Source)
|
||||
}
|
||||
if item.Selector["code"] != "BUYER_001" {
|
||||
t.Fatalf("selector 解析错误: %+v", item.Selector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptDetailEndpointReturnsNotFound(t *testing.T) {
|
||||
handler := buildTestHandlerWithManager(newInMemoryService(), newMockAutomationStarter(), nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/missing-script", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "script not found") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-1",
|
||||
ScriptID: "news-query-txt",
|
||||
Status: "success",
|
||||
ResultText: `{"ok":true,"summary":"done","result":{"subject":"Hello","contentText":"Mail body"}}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"selector":{"code":"BUYER_001"},
|
||||
"params":{"keyword":"OpenAI"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.ScriptID != "news-query-txt" {
|
||||
t.Fatalf("scriptId 传递错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("对象参数应关闭脚本默认 selector/params: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"keyword":"OpenAI"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Result map[string]interface{} `json:"result"`
|
||||
Run struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"run"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Data.Run.ID != "run-1" || resp.Data.Run.Status != "success" {
|
||||
t.Fatalf("run 响应错误: %+v", resp)
|
||||
}
|
||||
if resp.Data.Result["subject"] != "Hello" || resp.Data.Result["contentText"] != "Mail body" {
|
||||
t.Fatalf("expected parsed result payload, got %+v", resp.Data.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{"scriptId":"news-query-txt"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !starter.lastRunRequest.UseScriptSelector || !starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("缺省时应回退到脚本默认 selector/params: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.SelectorText != "" || starter.lastRunRequest.ParamsText != "" {
|
||||
t.Fatalf("缺省时不应透传 selectorText/paramsText: %+v", starter.lastRunRequest)
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
type mockStarterWithParams struct {
|
||||
profiles map[string]*browser.Profile
|
||||
lastProfile string
|
||||
started []string
|
||||
lastParams launchcode.LaunchRequestParams
|
||||
}
|
||||
|
||||
func newMockStarterWithParams() *mockStarterWithParams {
|
||||
return &mockStarterWithParams{profiles: make(map[string]*browser.Profile)}
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) addProfile(p *browser.Profile) {
|
||||
m.profiles[p.ProfileId] = p
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
m.lastProfile = profileId
|
||||
m.started = append(m.started, profileId)
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
|
||||
m.lastProfile = profileId
|
||||
m.started = append(m.started, profileId)
|
||||
m.lastParams = params
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func TestLaunchWithParams(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-automation",
|
||||
ProfileName: "automation",
|
||||
Pid: 321,
|
||||
DebugPort: 9555,
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-automation")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
body := map[string]interface{}{
|
||||
"code": code,
|
||||
"launchArgs": []string{"--window-size=1280,800", "--lang=en-US"},
|
||||
"startUrls": []string{"https://example.com"},
|
||||
"skipDefaultStartUrls": true,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "profile-automation" {
|
||||
t.Fatalf("profileId 传递错误: %s", starter.lastProfile)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTemporaryProxyParams(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-temporary-proxy",
|
||||
ProfileName: "temporary-proxy",
|
||||
ProxyId: "stored-proxy",
|
||||
ProxyConfig: "http://127.0.0.1:18080",
|
||||
Pid: 322,
|
||||
DebugPort: 9556,
|
||||
DebugReady: true,
|
||||
Running: true,
|
||||
LastStartAt: "2026-05-09T00:00:00Z",
|
||||
LastError: "",
|
||||
LaunchCode: "",
|
||||
RuntimeWarning: "",
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-temporary-proxy")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
body := map[string]interface{}{
|
||||
"code": code,
|
||||
"proxyConfig": " http://127.0.0.1:28080 ",
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastParams.ProxyConfig != "http://127.0.0.1:28080" {
|
||||
t.Fatalf("一次性 proxyConfig 未透传或未归一化: %+v", starter.lastParams)
|
||||
}
|
||||
profile := starter.profiles["profile-temporary-proxy"]
|
||||
if profile.ProxyConfig != "http://127.0.0.1:18080" || profile.ProxyId != "stored-proxy" {
|
||||
t.Fatalf("启动接口不应覆盖实例原代理: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsUsingCodeAsKeywordFallback(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-automation-keyword-fallback",
|
||||
ProfileName: "automation-keyword-fallback",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 654,
|
||||
DebugPort: 9666,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
manager := newSelectorTestManager(profile)
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
body := map[string]interface{}{
|
||||
"code": "buyer-001",
|
||||
"launchArgs": []string{"--window-size=1280,800", "--lang=en-US"},
|
||||
"startUrls": []string{"https://example.com"},
|
||||
"skipDefaultStartUrls": true,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsUsingCodeAsKeywordFallbackPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-params-code-fuzzy",
|
||||
ProfileName: "automation-fuzzy",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 655,
|
||||
DebugPort: 9667,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-params-code-exact",
|
||||
ProfileName: "automation-exact",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 656,
|
||||
DebugPort: 9668,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
body := map[string]interface{}{
|
||||
"code": "buyer-001",
|
||||
"launchArgs": []string{"--window-size=1280,800", "--lang=en-US"},
|
||||
"startUrls": []string{"https://example.com"},
|
||||
"skipDefaultStartUrls": true,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsBadRequest(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
t.Run("invalid-json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString("{bad json}"))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing-code", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"launchArgs":["--incognito"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLaunchLogsEndpoint(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-log-test",
|
||||
ProfileName: "log-test",
|
||||
Pid: 456,
|
||||
DebugPort: 9666,
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-log-test")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
payload := bytes.NewBufferString(`{"code":"` + code + `","launchArgs":["--incognito"]}`)
|
||||
|
||||
reqLaunch := httptest.NewRequest(http.MethodPost, "/api/launch", payload)
|
||||
reqLaunch.Header.Set("Content-Type", "application/json")
|
||||
wLaunch := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLaunch, reqLaunch)
|
||||
if wLaunch.Code != http.StatusOK {
|
||||
t.Fatalf("调用 launch 失败: %d", wLaunch.Code)
|
||||
}
|
||||
|
||||
reqLogs := httptest.NewRequest(http.MethodGet, "/api/launch/logs?limit=10", nil)
|
||||
wLogs := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLogs, reqLogs)
|
||||
if wLogs.Code != http.StatusOK {
|
||||
t.Fatalf("查询 logs 失败: %d", wLogs.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Items []launchcode.LaunchCallRecord `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(wLogs.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析 logs 响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
t.Fatal("logs 响应 ok=false")
|
||||
}
|
||||
if len(resp.Items) == 0 {
|
||||
t.Fatal("logs 为空,期望至少一条记录")
|
||||
}
|
||||
if resp.Items[0].Path != "/api/launch" {
|
||||
t.Fatalf("最新记录 path 不正确: %s", resp.Items[0].Path)
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
type managerBackedStarter struct {
|
||||
mgr *browser.Manager
|
||||
started []string
|
||||
lastParams launchcode.LaunchRequestParams
|
||||
}
|
||||
|
||||
func (m *managerBackedStarter) StartInstance(profileID string) (*browser.Profile, error) {
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, fmt.Errorf("profile not found: %s", profileID)
|
||||
}
|
||||
|
||||
m.started = append(m.started, profileID)
|
||||
profile.Running = true
|
||||
profile.Pid = 4000 + len(m.started)
|
||||
profile.DebugPort = 9300 + len(m.started)
|
||||
profile.LastStartAt = time.Now().Format(time.RFC3339)
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *managerBackedStarter) StartInstanceWithParams(profileID string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
|
||||
m.lastParams = params
|
||||
return m.StartInstance(profileID)
|
||||
}
|
||||
|
||||
func newProfileCreateTestManager(t *testing.T, configure func(*config.Config)) *browser.Manager {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
if configure != nil {
|
||||
configure(cfg)
|
||||
}
|
||||
return browser.NewManager(cfg, t.TempDir())
|
||||
}
|
||||
|
||||
func TestCreateProfileAPIStoresProxyAndMetadata(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, func(cfg *config.Config) {
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{
|
||||
ProxyId: "proxy-us",
|
||||
ProxyName: "US Residential",
|
||||
ProxyConfig: "socks5://127.0.0.1:1080",
|
||||
},
|
||||
}
|
||||
})
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
payload := bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "buyer-001",
|
||||
"userDataDir": "buyers/buyer-001",
|
||||
"proxyId": "proxy-us",
|
||||
"launchArgs": ["--lang=en-US"],
|
||||
"tags": ["电商", "北美"],
|
||||
"keywords": ["buyer-001", "amazon"],
|
||||
"groupId": "group-sales-us"
|
||||
},
|
||||
"launchCode": "buyer_001"
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("期望 201,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Created bool `json:"created"`
|
||||
Launched bool `json:"launched"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
Profile *browser.Profile `json:"profile"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK || !resp.Created || resp.Launched {
|
||||
t.Fatalf("响应状态错误: %+v", resp)
|
||||
}
|
||||
if resp.Profile == nil {
|
||||
t.Fatalf("响应缺少 profile: %+v", resp)
|
||||
}
|
||||
if resp.LaunchCode != "BUYER_001" {
|
||||
t.Fatalf("launchCode 未归一化: %s", resp.LaunchCode)
|
||||
}
|
||||
if resp.Profile.ProxyId != "proxy-us" {
|
||||
t.Fatalf("proxyId 不正确: %+v", resp.Profile)
|
||||
}
|
||||
if resp.Profile.ProxyConfig != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("proxyConfig 未按代理池解析: %+v", resp.Profile)
|
||||
}
|
||||
if resp.Profile.GroupId != "group-sales-us" {
|
||||
t.Fatalf("groupId 不正确: %+v", resp.Profile)
|
||||
}
|
||||
if len(resp.Profile.Tags) != 2 || len(resp.Profile.Keywords) != 2 {
|
||||
t.Fatalf("tags/keywords 不正确: %+v", resp.Profile)
|
||||
}
|
||||
|
||||
resolvedProfileID, err := svc.Resolve("BUYER_001")
|
||||
if err != nil {
|
||||
t.Fatalf("launchCode 未写入服务: %v", err)
|
||||
}
|
||||
if resolvedProfileID != resp.ProfileID {
|
||||
t.Fatalf("launchCode 绑定的 profileId 错误: got=%s want=%s", resolvedProfileID, resp.ProfileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfileAPIAutoLaunchPassesStartParams(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
payload := bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "buyer-002",
|
||||
"proxyConfig": "http://user:pass@127.0.0.1:8080",
|
||||
"launchArgs": ["--disable-sync"],
|
||||
"keywords": ["buyer-002"]
|
||||
},
|
||||
"autoLaunch": true,
|
||||
"start": {
|
||||
"launchArgs": ["--window-size=1280,800", "--lang=en-US"],
|
||||
"startUrls": ["https://example.com/order"],
|
||||
"skipDefaultStartUrls": true
|
||||
}
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("期望 201,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 1 {
|
||||
t.Fatalf("应自动启动 1 次,实际 %+v", starter.started)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("一次性 launchArgs 未透传: %+v", starter.lastParams)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com/order" {
|
||||
t.Fatalf("startUrls 未透传: %+v", starter.lastParams)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatalf("skipDefaultStartUrls 未透传: %+v", starter.lastParams)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Created bool `json:"created"`
|
||||
Launched bool `json:"launched"`
|
||||
CDPURL string `json:"cdpUrl"`
|
||||
DebugPort int `json:"debugPort"`
|
||||
Profile *browser.Profile `json:"profile"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK || !resp.Created || !resp.Launched {
|
||||
t.Fatalf("响应状态错误: %+v", resp)
|
||||
}
|
||||
if resp.Profile == nil || !resp.Profile.Running {
|
||||
t.Fatalf("自动启动后的 profile 状态错误: %+v", resp)
|
||||
}
|
||||
if resp.DebugPort == 0 || resp.CDPURL == "" {
|
||||
t.Fatalf("缺少调试端口/CDP 地址: %+v", resp)
|
||||
}
|
||||
if resp.Profile.ProxyConfig != "http://user:pass@127.0.0.1:8080" {
|
||||
t.Fatalf("直连代理配置未保存: %+v", resp.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfileAPIRejectsMissingProfile(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profiles", bytes.NewBufferString(`{"launchCode":"buyer_003"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfileAPIRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, func(cfg *config.Config) {
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "proxy-us", ProxyName: "US Residential", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
}
|
||||
})
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profiles", bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "buyer-003",
|
||||
"proxyId": "missing-proxy-id"
|
||||
}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
existing, err := mgr.Create(browser.ProfileInput{ProfileName: "existing"})
|
||||
if err != nil {
|
||||
t.Fatalf("预创建实例失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(existing.ProfileId, "BUYER_DUP"); err != nil {
|
||||
t.Fatalf("预设 launchCode 失败: %v", err)
|
||||
}
|
||||
|
||||
beforeCount := len(mgr.List())
|
||||
|
||||
payload := bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "new-buyer",
|
||||
"keywords": ["new-buyer"]
|
||||
},
|
||||
"launchCode": "BUYER_DUP"
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profiles", payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
afterCount := len(mgr.List())
|
||||
if afterCount != beforeCount {
|
||||
t.Fatalf("launchCode 冲突后应回滚创建: before=%d after=%d", beforeCount, afterCount)
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestListProfilesAPIIncludesLaunchCodes(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, func(cfg *config.Config) {
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{
|
||||
ProxyId: "proxy-us",
|
||||
ProxyName: "US Residential",
|
||||
ProxyConfig: "socks5://127.0.0.1:1080",
|
||||
},
|
||||
}
|
||||
})
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
first, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-a",
|
||||
ProxyId: "proxy-us",
|
||||
Tags: []string{"电商"},
|
||||
Keywords: []string{"buyer-a"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
second, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-b",
|
||||
ProxyConfig: "http://127.0.0.1:8080",
|
||||
Keywords: []string{"buyer-b"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(first.ProfileId, "BUYER_A"); err != nil {
|
||||
t.Fatalf("设置 launchCode 失败: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/profiles", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Items []browser.Profile `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("列表响应错误: %+v", resp)
|
||||
}
|
||||
|
||||
seen := make(map[string]browser.Profile, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
if item.LaunchCode == "" {
|
||||
t.Fatalf("列表应返回 launchCode: %+v", resp.Items)
|
||||
}
|
||||
seen[item.ProfileId] = item
|
||||
}
|
||||
if _, ok := seen[first.ProfileId]; !ok {
|
||||
t.Fatalf("列表缺少第一个实例: %+v", resp.Items)
|
||||
}
|
||||
if _, ok := seen[second.ProfileId]; !ok {
|
||||
t.Fatalf("列表缺少第二个实例: %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfileAPIReturnsProfileByID(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
profile, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-get",
|
||||
ProxyConfig: "http://127.0.0.1:8080",
|
||||
Tags: []string{"北美"},
|
||||
Keywords: []string{"buyer-get"},
|
||||
GroupId: "group-get",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(profile.ProfileId, "BUYER_GET"); err != nil {
|
||||
t.Fatalf("设置 launchCode 失败: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/profiles/"+profile.ProfileId, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
Profile *browser.Profile `json:"profile"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Profile == nil {
|
||||
t.Fatalf("查询响应错误: %+v", resp)
|
||||
}
|
||||
if resp.LaunchCode != "BUYER_GET" || resp.Profile.GroupId != "group-get" {
|
||||
t.Fatalf("查询字段错误: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileAPIUpdatesFieldsAndAutoLaunches(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, func(cfg *config.Config) {
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{
|
||||
ProxyId: "proxy-us",
|
||||
ProxyName: "US Residential",
|
||||
ProxyConfig: "socks5://127.0.0.1:1080",
|
||||
},
|
||||
}
|
||||
})
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
profile, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-old",
|
||||
ProxyConfig: "http://127.0.0.1:8080",
|
||||
Keywords: []string{"buyer-old"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(profile.ProfileId, "BUYER_OLD"); err != nil {
|
||||
t.Fatalf("设置 launchCode 失败: %v", err)
|
||||
}
|
||||
|
||||
payload := bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "buyer-new",
|
||||
"userDataDir": "buyers/buyer-new",
|
||||
"proxyId": "proxy-us",
|
||||
"launchArgs": ["--lang=en-US"],
|
||||
"tags": ["电商", "北美"],
|
||||
"keywords": ["buyer-new", "amazon"],
|
||||
"groupId": "group-sales-us"
|
||||
},
|
||||
"launchCode": "BUYER_NEW",
|
||||
"autoLaunch": true,
|
||||
"start": {
|
||||
"launchArgs": ["--window-size=1280,800"],
|
||||
"startUrls": ["https://example.com/order"],
|
||||
"skipDefaultStartUrls": true
|
||||
}
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+profile.ProfileId, payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 1 {
|
||||
t.Fatalf("更新后应自动启动 1 次,实际 %+v", starter.started)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com/order" {
|
||||
t.Fatalf("startUrls 未透传: %+v", starter.lastParams)
|
||||
}
|
||||
|
||||
updated, status, errMsg := handlerProfileSnapshot(t, mgr, svc, profile.ProfileId)
|
||||
if errMsg != "" || status != http.StatusOK {
|
||||
t.Fatalf("读取更新后实例失败: status=%d err=%s", status, errMsg)
|
||||
}
|
||||
if updated.ProfileName != "buyer-new" || updated.ProxyId != "proxy-us" || updated.ProxyConfig != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("更新未生效: %+v", updated)
|
||||
}
|
||||
if updated.GroupId != "group-sales-us" || updated.LaunchCode != "BUYER_NEW" || !updated.Running {
|
||||
t.Fatalf("更新后的分组/launchCode/运行状态错误: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileAPIRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, func(cfg *config.Config) {
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "proxy-us", ProxyName: "US Residential", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
}
|
||||
})
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
profile, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-old",
|
||||
ProxyId: "proxy-us",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+profile.ProfileId, bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "buyer-new",
|
||||
"proxyId": "missing-proxy-id"
|
||||
}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
current, status, errMsg := handlerProfileSnapshot(t, mgr, svc, profile.ProfileId)
|
||||
if errMsg != "" || status != http.StatusOK {
|
||||
t.Fatalf("读取实例失败: status=%d err=%s", status, errMsg)
|
||||
}
|
||||
if current.ProfileName != "buyer-old" || current.ProxyId != "proxy-us" || current.ProxyConfig != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("失败请求不应污染原配置: %+v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileAPIRollsBackOnDuplicateLaunchCode(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
first, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-first",
|
||||
ProxyConfig: "http://127.0.0.1:8080",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
second, err := mgr.Create(browser.ProfileInput{
|
||||
ProfileName: "buyer-second",
|
||||
ProxyConfig: "http://127.0.0.1:9090",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(first.ProfileId, "BUYER_FIRST"); err != nil {
|
||||
t.Fatalf("设置 launchCode 失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(second.ProfileId, "BUYER_SECOND"); err != nil {
|
||||
t.Fatalf("设置 launchCode 失败: %v", err)
|
||||
}
|
||||
|
||||
payload := bytes.NewBufferString(`{
|
||||
"profile": {
|
||||
"profileName": "buyer-first-updated",
|
||||
"proxyConfig": "http://127.0.0.1:10080",
|
||||
"keywords": ["buyer-first-updated"]
|
||||
},
|
||||
"launchCode": "BUYER_SECOND"
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/profiles/"+first.ProfileId, payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
current, status, errMsg := handlerProfileSnapshot(t, mgr, svc, first.ProfileId)
|
||||
if errMsg != "" || status != http.StatusOK {
|
||||
t.Fatalf("读取回滚后实例失败: status=%d err=%s", status, errMsg)
|
||||
}
|
||||
if current.ProfileName != "buyer-first" || current.ProxyConfig != "http://127.0.0.1:8080" || current.LaunchCode != "BUYER_FIRST" {
|
||||
t.Fatalf("launchCode 冲突后应回滚更新: %+v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfileAPIRemovesProfileAndLaunchCode(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
profile, err := mgr.Create(browser.ProfileInput{ProfileName: "buyer-delete"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode(profile.ProfileId, "BUYER_DELETE"); err != nil {
|
||||
t.Fatalf("设置 launchCode 失败: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/profiles/"+profile.ProfileId, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if _, ok := mgr.Profiles[profile.ProfileId]; ok {
|
||||
t.Fatalf("实例删除后仍存在于内存: %s", profile.ProfileId)
|
||||
}
|
||||
if _, err := svc.Resolve("BUYER_DELETE"); err == nil {
|
||||
t.Fatal("删除后 launchCode 仍可解析")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfileAPIRejectsRunningProfile(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
mgr := newProfileCreateTestManager(t, nil)
|
||||
starter := &managerBackedStarter{mgr: mgr}
|
||||
handler := buildTestHandlerWithManager(svc, starter, mgr)
|
||||
|
||||
profile, err := mgr.Create(browser.ProfileInput{ProfileName: "buyer-running"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试实例失败: %v", err)
|
||||
}
|
||||
mgr.Profiles[profile.ProfileId].Running = true
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/profiles/"+profile.ProfileId, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if _, ok := mgr.Profiles[profile.ProfileId]; !ok {
|
||||
t.Fatalf("运行中实例不应被删除: %s", profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func handlerProfileSnapshot(t *testing.T, mgr *browser.Manager, svc interface {
|
||||
EnsureCode(profileID string) (string, error)
|
||||
}, profileID string) (*browser.Profile, int, string) {
|
||||
t.Helper()
|
||||
|
||||
mgr.Mutex.Lock()
|
||||
profile, ok := mgr.Profiles[profileID]
|
||||
var snapshot browser.Profile
|
||||
if ok && profile != nil {
|
||||
snapshot = *profile
|
||||
}
|
||||
mgr.Mutex.Unlock()
|
||||
if !ok {
|
||||
return nil, http.StatusNotFound, "profile not found"
|
||||
}
|
||||
if snapshot.LaunchCode == "" {
|
||||
if code, err := svc.EnsureCode(snapshot.ProfileId); err == nil {
|
||||
snapshot.LaunchCode = code
|
||||
}
|
||||
}
|
||||
return &snapshot, http.StatusOK, ""
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
)
|
||||
|
||||
type lifecycleStarter struct {
|
||||
mgr *browser.Manager
|
||||
started []string
|
||||
stopped []string
|
||||
}
|
||||
|
||||
func newLifecycleStarter(mgr *browser.Manager) *lifecycleStarter {
|
||||
return &lifecycleStarter{mgr: mgr}
|
||||
}
|
||||
|
||||
func (m *lifecycleStarter) StartInstance(profileID string) (*browser.Profile, error) {
|
||||
m.mgr.Mutex.Lock()
|
||||
defer m.mgr.Mutex.Unlock()
|
||||
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
m.started = append(m.started, profileID)
|
||||
profile.Running = true
|
||||
profile.DebugReady = true
|
||||
profile.Pid = 7000 + len(m.started)
|
||||
profile.DebugPort = 9600 + len(m.started)
|
||||
profile.RuntimeWarning = ""
|
||||
profile.LastError = ""
|
||||
profile.LastStartAt = time.Now().Format(time.RFC3339)
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *lifecycleStarter) StatusInstance(profileID string) (*browser.Profile, error) {
|
||||
m.mgr.Mutex.Lock()
|
||||
defer m.mgr.Mutex.Unlock()
|
||||
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *lifecycleStarter) StopInstance(profileID string) (*browser.Profile, error) {
|
||||
m.mgr.Mutex.Lock()
|
||||
defer m.mgr.Mutex.Unlock()
|
||||
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
m.stopped = append(m.stopped, profileID)
|
||||
profile.Running = false
|
||||
profile.DebugReady = false
|
||||
profile.Pid = 0
|
||||
profile.DebugPort = 0
|
||||
profile.RuntimeWarning = ""
|
||||
profile.LastStopAt = time.Now().Format(time.RFC3339)
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func TestProfileStatusEndpointReturnsRuntimePayload(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-runtime-status",
|
||||
ProfileName: "Runtime Status",
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newLifecycleStarter(manager)
|
||||
|
||||
code, err := svc.SetCode(profile.ProfileId, "runtime_status")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
|
||||
reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
wLaunch := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLaunch, reqLaunch)
|
||||
if wLaunch.Code != http.StatusOK {
|
||||
t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String())
|
||||
}
|
||||
|
||||
reqStatus := httptest.NewRequest(http.MethodGet, "/api/profiles/"+profile.ProfileId+"/status", nil)
|
||||
wStatus := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wStatus, reqStatus)
|
||||
|
||||
if wStatus.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", wStatus.Code, wStatus.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
Running bool `json:"running"`
|
||||
Active bool `json:"active"`
|
||||
DebugReady bool `json:"debugReady"`
|
||||
CDPURL string `json:"cdpUrl"`
|
||||
DirectDebugURL string `json:"directDebugUrl"`
|
||||
Profile *browser.Profile `json:"profile"`
|
||||
}
|
||||
if err := json.NewDecoder(wStatus.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK || resp.ProfileID != profile.ProfileId {
|
||||
t.Fatalf("响应不正确: %+v", resp)
|
||||
}
|
||||
if resp.LaunchCode != "RUNTIME_STATUS" {
|
||||
t.Fatalf("launchCode 不正确: %+v", resp)
|
||||
}
|
||||
if !resp.Running || !resp.Active || !resp.DebugReady {
|
||||
t.Fatalf("运行态字段不正确: %+v", resp)
|
||||
}
|
||||
if resp.CDPURL == "" || resp.DirectDebugURL == "" {
|
||||
t.Fatalf("应返回可连接的 CDP 信息: %+v", resp)
|
||||
}
|
||||
if resp.Profile == nil || !resp.Profile.Running || !resp.Profile.DebugReady {
|
||||
t.Fatalf("嵌套 profile 运行态不正确: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeActiveEndpointReportsCurrentTarget(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-runtime-active",
|
||||
ProfileName: "Runtime Active",
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newLifecycleStarter(manager)
|
||||
|
||||
code, err := svc.SetCode(profile.ProfileId, "runtime_active")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
|
||||
reqBefore := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil)
|
||||
wBefore := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wBefore, reqBefore)
|
||||
if wBefore.Code != http.StatusOK {
|
||||
t.Fatalf("未激活前查询失败: status=%d body=%s", wBefore.Code, wBefore.Body.String())
|
||||
}
|
||||
|
||||
var before struct {
|
||||
OK bool `json:"ok"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
if err := json.NewDecoder(wBefore.Body).Decode(&before); err != nil {
|
||||
t.Fatalf("解析未激活响应失败: %v", err)
|
||||
}
|
||||
if !before.OK || before.Active {
|
||||
t.Fatalf("未激活响应不正确: %+v", before)
|
||||
}
|
||||
|
||||
reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
wLaunch := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLaunch, reqLaunch)
|
||||
if wLaunch.Code != http.StatusOK {
|
||||
t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String())
|
||||
}
|
||||
|
||||
reqAfter := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil)
|
||||
wAfter := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wAfter, reqAfter)
|
||||
if wAfter.Code != http.StatusOK {
|
||||
t.Fatalf("激活后查询失败: status=%d body=%s", wAfter.Code, wAfter.Body.String())
|
||||
}
|
||||
|
||||
var after struct {
|
||||
OK bool `json:"ok"`
|
||||
Active bool `json:"active"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
CDPURL string `json:"cdpUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(wAfter.Body).Decode(&after); err != nil {
|
||||
t.Fatalf("解析激活响应失败: %v", err)
|
||||
}
|
||||
if !after.OK || !after.Active || after.ProfileID != profile.ProfileId {
|
||||
t.Fatalf("激活响应不正确: %+v", after)
|
||||
}
|
||||
if after.LaunchCode != "RUNTIME_ACTIVE" || after.CDPURL == "" {
|
||||
t.Fatalf("激活响应缺少 launchCode/CDP 地址: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileStopEndpointStopsAndClearsActiveTarget(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-runtime-stop",
|
||||
ProfileName: "Runtime Stop",
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newLifecycleStarter(manager)
|
||||
|
||||
code, err := svc.SetCode(profile.ProfileId, "runtime_stop")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
|
||||
reqLaunch := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
wLaunch := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLaunch, reqLaunch)
|
||||
if wLaunch.Code != http.StatusOK {
|
||||
t.Fatalf("启动实例失败: status=%d body=%s", wLaunch.Code, wLaunch.Body.String())
|
||||
}
|
||||
|
||||
reqStop := httptest.NewRequest(http.MethodPost, "/api/profiles/"+profile.ProfileId+"/stop", nil)
|
||||
wStop := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wStop, reqStop)
|
||||
|
||||
if wStop.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", wStop.Code, wStop.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Stopped bool `json:"stopped"`
|
||||
Running bool `json:"running"`
|
||||
Active bool `json:"active"`
|
||||
CDPURL string `json:"cdpUrl"`
|
||||
DirectDebugURL string `json:"directDebugUrl"`
|
||||
Profile *browser.Profile `json:"profile"`
|
||||
}
|
||||
if err := json.NewDecoder(wStop.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK || !resp.Stopped {
|
||||
t.Fatalf("停止响应不正确: %+v", resp)
|
||||
}
|
||||
if resp.Running || resp.Active {
|
||||
t.Fatalf("停止后运行态应关闭: %+v", resp)
|
||||
}
|
||||
if resp.CDPURL != "" || resp.DirectDebugURL != "" {
|
||||
t.Fatalf("停止后不应再暴露调试地址: %+v", resp)
|
||||
}
|
||||
if resp.Profile == nil || resp.Profile.Running || resp.Profile.DebugReady {
|
||||
t.Fatalf("嵌套 profile 停止态不正确: %+v", resp)
|
||||
}
|
||||
|
||||
reqProxy := httptest.NewRequest(http.MethodGet, "/json/version", nil)
|
||||
wProxy := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wProxy, reqProxy)
|
||||
if wProxy.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("停止后应清空 active target: status=%d body=%s", wProxy.Code, wProxy.Body.String())
|
||||
}
|
||||
|
||||
reqActive := httptest.NewRequest(http.MethodGet, "/api/runtime/active", nil)
|
||||
wActive := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wActive, reqActive)
|
||||
if wActive.Code != http.StatusOK {
|
||||
t.Fatalf("停止后查询 active 失败: status=%d body=%s", wActive.Code, wActive.Body.String())
|
||||
}
|
||||
|
||||
var activeResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
if err := json.NewDecoder(wActive.Body).Decode(&activeResp); err != nil {
|
||||
t.Fatalf("解析停止后 active 响应失败: %v", err)
|
||||
}
|
||||
if !activeResp.OK || activeResp.Active {
|
||||
t.Fatalf("停止后 active 响应不正确: %+v", activeResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileStopEndpointReturnsServiceUnavailableWhenRuntimeControlIsMissing(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-runtime-unsupported",
|
||||
ProfileName: "Runtime Unsupported",
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profiles/"+profile.ProfileId+"/stop", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp["error"] != "profile runtime control is not available" {
|
||||
t.Fatalf("错误信息不正确: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
// Feature: instance-launch-code, Property 6: valid code response structure
|
||||
// Feature: instance-launch-code, Property 7: invalid code returns 404
|
||||
// Feature: instance-launch-code, Property 8: idempotent launch
|
||||
// Validates: Requirements 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// --- 测试辅助类型 ---
|
||||
|
||||
// mockStarter 模拟 BrowserStarter,记录调用次数
|
||||
type mockStarter struct {
|
||||
profiles map[string]*browser.Profile
|
||||
callCounts map[string]int
|
||||
}
|
||||
|
||||
func newMockStarter() *mockStarter {
|
||||
return &mockStarter{
|
||||
profiles: make(map[string]*browser.Profile),
|
||||
callCounts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStarter) addProfile(p *browser.Profile) {
|
||||
m.profiles[p.ProfileId] = p
|
||||
}
|
||||
|
||||
func (m *mockStarter) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
m.callCounts[profileId]++
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// buildTestHandler 构建一个可直接用于 httptest 的 handler(绕过 localhost 中间件)
|
||||
// 通过直接调用 server 内部 handler 的方式,使用 httptest.NewRecorder 测试路由逻辑
|
||||
func buildTestHandler(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter) http.Handler {
|
||||
srv := launchcode.NewLaunchServer(svc, starter, nil, 0)
|
||||
return launchcode.NewTestHandler(srv)
|
||||
}
|
||||
|
||||
// newInMemoryService 创建一个使用内存 DAO 的 LaunchCodeService
|
||||
func newInMemoryService() *launchcode.LaunchCodeService {
|
||||
dao := launchcode.NewMemoryLaunchCodeDAO()
|
||||
return launchcode.NewLaunchCodeService(dao)
|
||||
}
|
||||
|
||||
// --- Property 6: 有效 Code 返回正确响应结构 ---
|
||||
|
||||
// genNonEmptyAlpha 生成长度 1-32 的字母字符串(不使用 SuchThat 过滤)
|
||||
func genNonEmptyAlpha() gopter.Gen {
|
||||
return gen.SliceOfN(8, gen.RuneRange('a', 'z')).Map(func(runes []rune) string {
|
||||
return string(runes)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty6_ValidCodeResponseStructure
|
||||
// 对于任意存在的 LaunchCode,GET /api/launch/{code} 应返回:
|
||||
// - HTTP 200
|
||||
// - Content-Type: application/json
|
||||
// - 响应体含 ok:true, profileId, profileName, pid, debugPort
|
||||
func TestProperty6_ValidCodeResponseStructure(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("有效 code 返回 200 及正确响应结构", prop.ForAll(
|
||||
func(profileId, profileName string, pid, debugPort int) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: profileName,
|
||||
Pid: pid,
|
||||
DebugPort: debugPort,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profileId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, _ := resp["ok"].(bool)
|
||||
gotProfileId, _ := resp["profileId"].(string)
|
||||
gotProfileName, _ := resp["profileName"].(string)
|
||||
_, hasPid := resp["pid"]
|
||||
_, hasDebugPort := resp["debugPort"]
|
||||
|
||||
return ok &&
|
||||
gotProfileId == profileId &&
|
||||
gotProfileName == profileName &&
|
||||
hasPid && hasDebugPort
|
||||
},
|
||||
genNonEmptyAlpha(),
|
||||
genNonEmptyAlpha(),
|
||||
gen.IntRange(1000, 99999),
|
||||
gen.IntRange(9000, 9999),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- Property 7: 无效 Code 返回 404 ---
|
||||
|
||||
// genInvalidCode 生成一定不存在于空 service 中的 code(小写字母,不符合 A-Z0-9 格式)
|
||||
func genInvalidCode() gopter.Gen {
|
||||
// 生成 4 位小写字母字符串,永远不会匹配 [A-Z0-9]{6} 格式的有效 code
|
||||
return gen.SliceOfN(4, gen.RuneRange('a', 'z')).Map(func(runes []rune) string {
|
||||
return string(runes)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty7_InvalidCodeReturns404
|
||||
// 对于任意不存在的 code,GET /api/launch/{code} 应返回:
|
||||
// - HTTP 404
|
||||
// - Content-Type: application/json
|
||||
// - 响应体含 ok:false 和 error 字段
|
||||
func TestProperty7_InvalidCodeReturns404(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("不存在的 code 返回 404", prop.ForAll(
|
||||
func(code string) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, _ := resp["ok"].(bool)
|
||||
_, hasError := resp["error"]
|
||||
return !ok && hasError
|
||||
},
|
||||
genInvalidCode(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- Property 8: 重复唤起的幂等性 ---
|
||||
|
||||
// TestProperty8_IdempotentLaunch
|
||||
// 对于已运行的实例,连续两次 GET /api/launch/{code}:
|
||||
// - 两次均返回 HTTP 200
|
||||
// - 两次返回的 pid 相同(不重新启动)
|
||||
func TestProperty8_IdempotentLaunch(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("重复唤起返回相同 pid,不重新启动", prop.ForAll(
|
||||
func(profileId string, pid int) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: "test-profile",
|
||||
Pid: pid,
|
||||
DebugPort: 9222,
|
||||
Running: true,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profileId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
// 第一次请求
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w1, req1)
|
||||
|
||||
// 第二次请求
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
if w1.Code != http.StatusOK || w2.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp1, resp2 map[string]interface{}
|
||||
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pid1, _ := resp1["pid"].(float64)
|
||||
pid2, _ := resp2["pid"].(float64)
|
||||
|
||||
// 两次 pid 相同,且 StartInstance 被调用了 2 次(幂等由 starter 保证返回同一 profile)
|
||||
return pid1 == pid2 && pid1 == float64(pid)
|
||||
},
|
||||
genNonEmptyAlpha(),
|
||||
gen.IntRange(1000, 99999),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- 健康检查单元测试 ---
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
ok, _ := resp["ok"].(bool)
|
||||
if !ok {
|
||||
t.Error("期望 ok=true")
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
)
|
||||
|
||||
func mustDebugPortFromURL(t *testing.T, rawURL string) int {
|
||||
t.Helper()
|
||||
|
||||
hostPort := strings.TrimPrefix(rawURL, "http://")
|
||||
host, portText, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试 URL 失败: %v", err)
|
||||
}
|
||||
if host == "" {
|
||||
t.Fatalf("测试 URL host 为空: %s", rawURL)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试端口失败: %v", err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func TestCDPProxyReturnsUnavailableWithoutActiveTarget(t *testing.T) {
|
||||
handler := buildTestHandler(newInMemoryService(), newMockStarter())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/json/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp["error"] != "no active browser debug target" {
|
||||
t.Fatalf("错误信息不正确: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPProxySwitchesToLatestLaunchedProfile(t *testing.T) {
|
||||
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"Browser":"Mock-A"}`))
|
||||
}))
|
||||
defer serverA.Close()
|
||||
|
||||
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"Browser":"Mock-B"}`))
|
||||
}))
|
||||
defer serverB.Close()
|
||||
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Profile A",
|
||||
Pid: 1001,
|
||||
DebugPort: mustDebugPortFromURL(t, serverA.URL),
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Profile B",
|
||||
Pid: 1002,
|
||||
DebugPort: mustDebugPortFromURL(t, serverB.URL),
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
|
||||
codeA, err := svc.EnsureCode(profileA.ProfileId)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode(A) 失败: %v", err)
|
||||
}
|
||||
codeB, err := svc.EnsureCode(profileB.ProfileId)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode(B) 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
for _, tc := range []struct {
|
||||
code string
|
||||
wantMarker string
|
||||
}{
|
||||
{code: codeA, wantMarker: "Mock-A"},
|
||||
{code: codeB, wantMarker: "Mock-B"},
|
||||
} {
|
||||
launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+tc.code, nil)
|
||||
launchResp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(launchResp, launchReq)
|
||||
if launchResp.Code != http.StatusOK {
|
||||
t.Fatalf("启动请求失败: code=%s status=%d body=%s", tc.code, launchResp.Code, launchResp.Body.String())
|
||||
}
|
||||
|
||||
proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil)
|
||||
proxyResp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(proxyResp, proxyReq)
|
||||
if proxyResp.Code != http.StatusOK {
|
||||
t.Fatalf("代理请求失败: code=%s status=%d body=%s", tc.code, proxyResp.Code, proxyResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(proxyResp.Body.String(), tc.wantMarker) {
|
||||
t.Fatalf("代理未切换到最新实例: want=%s body=%s", tc.wantMarker, proxyResp.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPProxySkipsPendingDebugProfile(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-pending",
|
||||
ProfileName: "Profile Pending",
|
||||
Running: true,
|
||||
Pid: 2001,
|
||||
DebugPort: 9777,
|
||||
DebugReady: false,
|
||||
RuntimeWarning: "debug pending",
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profile.ProfileId)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
launchReq := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
launchResp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(launchResp, launchReq)
|
||||
if launchResp.Code != http.StatusOK {
|
||||
t.Fatalf("启动请求失败: status=%d body=%s", launchResp.Code, launchResp.Body.String())
|
||||
}
|
||||
|
||||
var launchPayload map[string]interface{}
|
||||
if err := json.NewDecoder(launchResp.Body).Decode(&launchPayload); err != nil {
|
||||
t.Fatalf("解析启动响应失败: %v", err)
|
||||
}
|
||||
if ready, _ := launchPayload["debugReady"].(bool); ready {
|
||||
t.Fatalf("pending 实例不应被标记为 debugReady: %+v", launchPayload)
|
||||
}
|
||||
|
||||
proxyReq := httptest.NewRequest(http.MethodGet, "/json/version", nil)
|
||||
proxyResp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(proxyResp, proxyReq)
|
||||
if proxyResp.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("pending 实例不应成为活动 CDP target: status=%d body=%s", proxyResp.Code, proxyResp.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
)
|
||||
|
||||
func TestRuntimeStatusWithCodeFallbackReturnsConflictByDefault(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "runtime-status-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Running: true,
|
||||
DebugReady: true,
|
||||
DebugPort: 9411,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "runtime-status-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Running: true,
|
||||
DebugReady: true,
|
||||
DebugPort: 9412,
|
||||
}
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
starter := newLifecycleStarter(manager)
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"code":"shop"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "matchMode=first") {
|
||||
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStatusWithMatchModeFirstReturnsStableTarget(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "runtime-status-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Running: true,
|
||||
DebugReady: true,
|
||||
DebugPort: 9412,
|
||||
Pid: 3002,
|
||||
}
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "runtime-status-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Running: true,
|
||||
DebugReady: true,
|
||||
DebugPort: 9411,
|
||||
Pid: 3001,
|
||||
}
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
starter := newLifecycleStarter(manager)
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"code":"shop","matchMode":"first"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Running bool `json:"running"`
|
||||
DebugReady bool `json:"debugReady"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK || resp.ProfileID != profileA.ProfileId || resp.ProfileName != profileA.ProfileName {
|
||||
t.Fatalf("响应不正确: %+v", resp)
|
||||
}
|
||||
if !resp.Running || !resp.DebugReady {
|
||||
t.Fatalf("运行态字段不正确: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStopWithExactLaunchCode(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "runtime-stop-code",
|
||||
ProfileName: "Runtime Stop By Code",
|
||||
Running: true,
|
||||
DebugReady: true,
|
||||
DebugPort: 9511,
|
||||
Pid: 4001,
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newLifecycleStarter(manager)
|
||||
|
||||
if _, err := svc.SetCode(profile.ProfileId, "runtime-stop-code"); err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/stop", bytes.NewBufferString(`{"code":"runtime-stop-code"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Stopped bool `json:"stopped"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
Running bool `json:"running"`
|
||||
DebugReady bool `json:"debugReady"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK || !resp.Stopped || resp.ProfileID != profile.ProfileId {
|
||||
t.Fatalf("停止响应不正确: %+v", resp)
|
||||
}
|
||||
if resp.LaunchCode != "RUNTIME-STOP-CODE" {
|
||||
t.Fatalf("launchCode 不正确: %+v", resp)
|
||||
}
|
||||
if resp.Running || resp.DebugReady {
|
||||
t.Fatalf("停止后运行态不正确: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStatusRejectsMatchModeAll(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
manager := newSelectorTestManager()
|
||||
starter := newLifecycleStarter(manager)
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/status", bytes.NewBufferString(`{"keyword":"shop","matchMode":"all"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "matchMode must be unique or first") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
type sessionStarter struct {
|
||||
mgr *browser.Manager
|
||||
waitReady bool
|
||||
waitErr error
|
||||
lastParams launchcode.LaunchRequestParams
|
||||
}
|
||||
|
||||
func newSessionStarter(mgr *browser.Manager, waitReady bool) *sessionStarter {
|
||||
return &sessionStarter{
|
||||
mgr: mgr,
|
||||
waitReady: waitReady,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sessionStarter) StartInstance(profileID string) (*browser.Profile, error) {
|
||||
return m.StartInstanceWithParams(profileID, launchcode.LaunchRequestParams{})
|
||||
}
|
||||
|
||||
func (m *sessionStarter) StartInstanceWithParams(profileID string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
|
||||
m.lastParams = params
|
||||
m.mgr.Mutex.Lock()
|
||||
defer m.mgr.Mutex.Unlock()
|
||||
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
profile.Running = true
|
||||
profile.DebugReady = false
|
||||
profile.DebugPort = 9666
|
||||
profile.Pid = 4321
|
||||
profile.RuntimeWarning = "debug pending"
|
||||
profile.LastError = ""
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *sessionStarter) StatusInstance(profileID string) (*browser.Profile, error) {
|
||||
m.mgr.Mutex.Lock()
|
||||
defer m.mgr.Mutex.Unlock()
|
||||
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *sessionStarter) WaitInstanceDebugReady(profileID string, debugPort int, timeout time.Duration) (*browser.Profile, bool, error) {
|
||||
if m.waitErr != nil {
|
||||
return nil, false, m.waitErr
|
||||
}
|
||||
|
||||
m.mgr.Mutex.Lock()
|
||||
defer m.mgr.Mutex.Unlock()
|
||||
|
||||
profile, ok := m.mgr.Profiles[profileID]
|
||||
if !ok || profile == nil {
|
||||
return nil, false, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
if m.waitReady {
|
||||
profile.Running = true
|
||||
profile.DebugReady = true
|
||||
profile.DebugPort = debugPort
|
||||
profile.RuntimeWarning = ""
|
||||
return profile, true, nil
|
||||
}
|
||||
|
||||
return profile, false, nil
|
||||
}
|
||||
|
||||
func TestRuntimeSessionWaitsUntilDebugReady(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "runtime-session-ready",
|
||||
ProfileName: "Runtime Session Ready",
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newSessionStarter(manager, true)
|
||||
|
||||
if _, err := svc.SetCode(profile.ProfileId, "runtime-session-ready"); err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{
|
||||
"code":"runtime-session-ready",
|
||||
"timeoutMs":5000,
|
||||
"launchArgs":["--window-size=1400,900"],
|
||||
"startUrls":["https://example.com"],
|
||||
"skipDefaultStartUrls":true
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 1 || starter.lastParams.LaunchArgs[0] != "--window-size=1400,900" {
|
||||
t.Fatalf("launchArgs 透传错误: %+v", starter.lastParams)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 透传错误: %+v", starter.lastParams)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatalf("skipDefaultStartUrls 透传错误: %+v", starter.lastParams)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Ready bool `json:"ready"`
|
||||
WaitTimedOut bool `json:"waitTimedOut"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Active bool `json:"active"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
DebugReady bool `json:"debugReady"`
|
||||
CDPURL string `json:"cdpUrl"`
|
||||
DirectDebugURL string `json:"directDebugUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || !resp.Ready || resp.WaitTimedOut || resp.Retryable {
|
||||
t.Fatalf("ready 响应不正确: %+v", resp)
|
||||
}
|
||||
if !resp.Active || !resp.DebugReady || resp.ProfileID != profile.ProfileId {
|
||||
t.Fatalf("会话状态不正确: %+v", resp)
|
||||
}
|
||||
if resp.LaunchCode != "RUNTIME-SESSION-READY" {
|
||||
t.Fatalf("launchCode 不正确: %+v", resp)
|
||||
}
|
||||
if resp.CDPURL == "" || resp.DirectDebugURL == "" {
|
||||
t.Fatalf("应返回可接管地址: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSessionReturnsAcceptedWhileDebugIsPending(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "runtime-session-pending",
|
||||
ProfileName: "Runtime Session Pending",
|
||||
}
|
||||
manager := newSelectorTestManager(profile)
|
||||
starter := newSessionStarter(manager, false)
|
||||
|
||||
if _, err := svc.SetCode(profile.ProfileId, "runtime-session-pending"); err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{
|
||||
"code":"runtime-session-pending",
|
||||
"timeoutMs":1000
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("期望 202,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Ready bool `json:"ready"`
|
||||
WaitTimedOut bool `json:"waitTimedOut"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Active bool `json:"active"`
|
||||
DebugReady bool `json:"debugReady"`
|
||||
RuntimeWarning string `json:"runtimeWarning"`
|
||||
CDPURL string `json:"cdpUrl"`
|
||||
DirectDebugURL string `json:"directDebugUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Ready || !resp.WaitTimedOut || !resp.Retryable {
|
||||
t.Fatalf("pending 响应不正确: %+v", resp)
|
||||
}
|
||||
if resp.Active || resp.DebugReady {
|
||||
t.Fatalf("pending 会话不应标记为 active/debugReady: %+v", resp)
|
||||
}
|
||||
if resp.RuntimeWarning != "debug pending" {
|
||||
t.Fatalf("runtimeWarning 不正确: %+v", resp)
|
||||
}
|
||||
if resp.CDPURL != "" || resp.DirectDebugURL != "" {
|
||||
t.Fatalf("pending 会话不应返回可接管地址: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSessionRejectsMatchModeAll(t *testing.T) {
|
||||
manager := newSelectorTestManager()
|
||||
handler := buildTestHandlerWithManager(newInMemoryService(), newSessionStarter(manager, true), manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/runtime/session", bytes.NewBufferString(`{"keyword":"shop","matchMode":"all"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
)
|
||||
|
||||
func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "matchMode=first") {
|
||||
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-get-code-only",
|
||||
ProfileName: "Buyer Account 02",
|
||||
Keywords: []string{"buyer-002"},
|
||||
Pid: 1004,
|
||||
DebugPort: 9447,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeFirst(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 2 {
|
||||
t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
|
||||
}
|
||||
if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
|
||||
t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
IsActive bool `json:"isActive"`
|
||||
} `json:"items"`
|
||||
ActiveProfileID string `json:"activeProfileId"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("批量启动响应错误: %+v", resp)
|
||||
}
|
||||
if resp.ActiveProfileID != profileB.ProfileId {
|
||||
t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
|
||||
}
|
||||
if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
|
||||
t.Fatalf("items 顺序错误: %+v", resp.Items)
|
||||
}
|
||||
if resp.Items[0].IsActive || !resp.Items[1].IsActive {
|
||||
t.Fatalf("isActive 标记错误: %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 0 {
|
||||
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
func buildTestHandlerWithManager(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter, mgr *browser.Manager) http.Handler {
|
||||
srv := launchcode.NewLaunchServer(svc, starter, mgr, 0)
|
||||
return launchcode.NewTestHandler(srv)
|
||||
}
|
||||
|
||||
func newSelectorTestManager(profiles ...*browser.Profile) *browser.Manager {
|
||||
items := make(map[string]*browser.Profile, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
items[profile.ProfileId] = profile
|
||||
}
|
||||
return &browser.Manager{
|
||||
Profiles: items,
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithKeywordSelector(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-keyword",
|
||||
ProfileName: "Amazon US",
|
||||
GroupId: "group-sales",
|
||||
Tags: []string{"电商", "北美"},
|
||||
Keywords: []string{"amazon-us", "checkout", "buyer-account"},
|
||||
Pid: 9527,
|
||||
DebugPort: 9333,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
payload := bytes.NewBufferString(`{
|
||||
"selector": {
|
||||
"keyword": "checkout",
|
||||
"tags": ["电商"],
|
||||
"groupId": "group-sales"
|
||||
},
|
||||
"skipDefaultStartUrls": true
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", payload)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
ProfileID string `json:"profileId"`
|
||||
LaunchCode string `json:"launchCode"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.ProfileID != profile.ProfileId {
|
||||
t.Fatalf("响应不正确: %+v", resp)
|
||||
}
|
||||
if strings.TrimSpace(resp.LaunchCode) == "" {
|
||||
t.Fatalf("期望返回 resolved launchCode,实际为空: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelKeywordSelector(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-top-level",
|
||||
ProfileName: "Billing Ops",
|
||||
Keywords: []string{"billing", "invoice"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9444,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"keyword":"billing"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelKeyAliasSelector(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-top-level-key",
|
||||
ProfileName: "Buyer Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9445,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelKeyPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-key-fuzzy",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 1004,
|
||||
DebugPort: 9447,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-key-exact",
|
||||
ProfileName: "Z Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1005,
|
||||
DebugPort: 9448,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"key":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithNestedSelectorKeyPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-selector-key-fuzzy",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 1006,
|
||||
DebugPort: 9449,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-selector-key-exact",
|
||||
ProfileName: "Z Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1007,
|
||||
DebugPort: 9450,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"key":"buyer-001"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("selector.key 应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackToKeyword(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-top-level-code-fallback",
|
||||
ProfileName: "Buyer Account 01",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1003,
|
||||
DebugPort: 9446,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profile.ProfileId {
|
||||
t.Fatalf("code 关键字兜底命中实例错误: got=%s want=%s", starter.lastProfile, profile.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackPrefersExactKeywordMatch(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileFuzzy := &browser.Profile{
|
||||
ProfileId: "profile-code-fuzzy",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"buyer-001-old", "amazon"},
|
||||
Pid: 1008,
|
||||
DebugPort: 9451,
|
||||
}
|
||||
profileExact := &browser.Profile{
|
||||
ProfileId: "profile-code-exact",
|
||||
ProfileName: "Z Account",
|
||||
Keywords: []string{"buyer-001", "amazon"},
|
||||
Pid: 1009,
|
||||
DebugPort: 9452,
|
||||
}
|
||||
starter.addProfile(profileFuzzy)
|
||||
starter.addProfile(profileExact)
|
||||
manager := newSelectorTestManager(profileFuzzy, profileExact)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"buyer-001"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileExact.ProfileId {
|
||||
t.Fatalf("code 关键字兜底应优先命中精确关键字实例: got=%s want=%s", starter.lastProfile, profileExact.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithAmbiguousKeywordSelectorReturnsFirstByDefault(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("关键字多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user