diff --git a/backend/app_backup_archive_import.go b/backend/app_backup_archive_import.go index dee66083..42ce6f76 100644 --- a/backend/app_backup_archive_import.go +++ b/backend/app_backup_archive_import.go @@ -3,6 +3,7 @@ package backend import ( "ant-chrome/backend/internal/backup" "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/snapshot" "encoding/json" "fmt" "os" @@ -15,7 +16,7 @@ func backupExtractAndValidate(zipPath string) (string, backup.Manifest, error) { if err != nil { return "", backup.Manifest{}, err } - if err := unzipTo(zipPath, tmpDir); err != nil { + if err := snapshot.UnzipTo(zipPath, tmpDir); err != nil { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("解压备份包失败: %w", err) } diff --git a/backend/app_dashboard_api.go b/backend/app_dashboard_api.go index ec4d7fac..1964b195 100644 --- a/backend/app_dashboard_api.go +++ b/backend/app_dashboard_api.go @@ -1,9 +1,9 @@ package backend import ( - goruntime "runtime" - + "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/logger" + goruntime "runtime" ) func (a *App) GetDashboardStats() map[string]interface{} { @@ -11,36 +11,19 @@ func (a *App) GetDashboardStats() map[string]interface{} { if a.browserMgr != nil { profiles = a.browserMgr.List() } - totalInstances := len(profiles) - runningInstances := 0 - for _, profile := range profiles { - if profile.Running { - runningInstances++ - } - } - - proxyCount := 0 - coreCount := 0 - maxProfileLimit := 20 - if a.config != nil { - proxyCount = len(a.config.Browser.Proxies) - coreCount = len(a.config.Browser.Cores) - if a.config.App.MaxProfileLimit > 0 { - maxProfileLimit = a.config.App.MaxProfileLimit - } - } + stats := browser.BuildDashboardStats(profiles, a.config) var mem goruntime.MemStats goruntime.ReadMemStats(&mem) memUsedMB := float64(mem.Alloc) / 1024 / 1024 return map[string]interface{}{ - "totalInstances": totalInstances, - "runningInstances": runningInstances, - "proxyCount": proxyCount, - "coreCount": coreCount, + "totalInstances": stats.TotalInstances, + "runningInstances": stats.RunningInstances, + "proxyCount": stats.ProxyCount, + "coreCount": stats.CoreCount, "memUsedMB": int(memUsedMB), - "maxProfileLimit": maxProfileLimit, + "maxProfileLimit": stats.MaxProfileLimit, "appVersion": a.appVersion(), } } @@ -81,12 +64,5 @@ func (a *App) ClearAppLogs() { // GetRunningInstances 获取运行中实例的详细信息 func (a *App) GetRunningInstances() []BrowserProfile { - all := a.browserMgr.List() - result := make([]BrowserProfile, 0) - for _, profile := range all { - if profile.Running { - result = append(result, profile) - } - } - return result + return browser.RunningProfiles(a.browserMgr.List()) } diff --git a/backend/app_filesystem_api.go b/backend/app_filesystem_api.go index 64f6e924..6fc9739e 100644 --- a/backend/app_filesystem_api.go +++ b/backend/app_filesystem_api.go @@ -1,6 +1,7 @@ package backend import ( + "ant-chrome/backend/internal/fsutil" "ant-chrome/backend/internal/logger" "fmt" "os" @@ -14,21 +15,13 @@ import ( func (a *App) OpenUserDataDir(userDataDir string) error { log := logger.New("Browser") - userDataDir = strings.TrimSpace(userDataDir) - if userDataDir == "" { - return fmt.Errorf("用户数据目录不能为空") + userDataRoot := "" + if a.config != nil { + userDataRoot = a.config.Browser.UserDataRoot } - - var fullPath string - if filepath.IsAbs(userDataDir) { - fullPath = userDataDir - } else { - root := strings.TrimSpace(a.config.Browser.UserDataRoot) - if root == "" { - root = "data" - } - root = a.resolveAppPath(root) - fullPath = filepath.Join(root, userDataDir) + fullPath, err := fsutil.ResolveUserDataDir(a.resolveAppPath, userDataRoot, userDataDir) + if err != nil { + return err } if _, err := os.Stat(fullPath); os.IsNotExist(err) { @@ -57,16 +50,9 @@ func (a *App) OpenUserDataDir(userDataDir string) error { func (a *App) OpenCorePath(corePath string) error { log := logger.New("Browser") - corePath = strings.TrimSpace(corePath) - if corePath == "" { - return fmt.Errorf("内核路径不能为空") - } - - var fullPath string - if filepath.IsAbs(corePath) { - fullPath = corePath - } else { - fullPath = a.resolveAppPath(corePath) + fullPath, err := fsutil.ResolveExistingPath(a.resolveAppPath, corePath, "内核路径不能为空") + if err != nil { + return err } if _, err := os.Stat(fullPath); os.IsNotExist(err) { diff --git a/backend/app_instance_start_helpers_test.go b/backend/app_instance_start_helpers_test.go new file mode 100644 index 00000000..fa54e5f9 --- /dev/null +++ b/backend/app_instance_start_helpers_test.go @@ -0,0 +1,171 @@ +package backend + +import ( + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "testing" + "time" +) + +func mustListenLoopback(t *testing.T) net.Listener { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("监听测试端口失败: %v", err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + + return ln +} + +func listenerPort(t *testing.T, ln net.Listener) int { + t.Helper() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("解析监听地址失败: %T", ln.Addr()) + } + return tcpAddr.Port +} + +func shortLivedCommand() *exec.Cmd { + if goruntime.GOOS == "windows" { + return exec.Command("cmd", "/c", "exit", "0") + } + return exec.Command("sh", "-c", "exit 0") +} + +func longLivedCommand(duration time.Duration) *exec.Cmd { + if goruntime.GOOS == "windows" { + seconds := int(duration / time.Second) + if seconds < 1 { + seconds = 1 + } + return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1)) + } + return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds())) +} + +func stderrFailingCommand(message string) *exec.Cmd { + if goruntime.GOOS == "windows" { + return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message)) + } + return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message)) +} + +func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd { + if goruntime.GOOS == "windows" { + seconds := int(holdFor / time.Second) + if seconds < 1 { + seconds = 1 + } + // ping -n N waits roughly N-1 seconds on Windows. + return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1)) + } + return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds())) +} + +func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("等待条件成立超时") +} + +func freeLoopbackPort(t *testing.T) int { + t.Helper() + + ln := mustListenLoopback(t) + port := listenerPort(t, ln) + _ = ln.Close() + return port +} + +type devToolsTestServer struct { + port int + server *http.Server + done chan struct{} +} + +func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("启动 DevTools 测试服务失败: %v", err) + } + + srv := &http.Server{Handler: handler} + done := make(chan struct{}) + go func() { + defer close(done) + _ = srv.Serve(ln) + }() + + return &devToolsTestServer{ + port: listenerPort(t, ln), + server: srv, + done: done, + } +} + +func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer { + t.Helper() + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err) + } + + srv := &http.Server{Handler: handler} + done := make(chan struct{}) + go func() { + defer close(done) + _ = srv.Serve(ln) + }() + + return &devToolsTestServer{ + port: port, + server: srv, + done: done, + } +} + +func (s *devToolsTestServer) Close() error { + if s == nil || s.server == nil { + return nil + } + err := s.server.Close() + <-s.done + return err +} + +func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) { + t.Helper() + + content := fmt.Sprintf("%d\n/devtools/browser/test\n", port) + if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil { + t.Fatalf("写入 DevToolsActivePort 失败: %v", err) + } +} diff --git a/backend/app_instance_start_ready_test.go b/backend/app_instance_start_ready_test.go new file mode 100644 index 00000000..664885b7 --- /dev/null +++ b/backend/app_instance_start_ready_test.go @@ -0,0 +1,214 @@ +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, bridgeKey, 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 bridgeKey != "" || releaseBridge { + t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, 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, bridgeKey, 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 bridgeKey != "" || releaseBridge { + t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, 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, + []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) + } +} diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go index 1241f2a0..98be2571 100644 --- a/backend/app_instance_start_test.go +++ b/backend/app_instance_start_test.go @@ -4,14 +4,9 @@ import ( "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" "errors" - "fmt" - "net" "net/http" - "os" "os/exec" - "path/filepath" "reflect" - goruntime "runtime" "strings" "testing" "time" @@ -367,364 +362,3 @@ func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) { t.Fatal("waitBrowserProcess 未在调试端口关闭后结束") } } - -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, bridgeKey, 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 bridgeKey != "" || releaseBridge { - t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, 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, bridgeKey, 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 bridgeKey != "" || releaseBridge { - t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, 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, - []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 mustListenLoopback(t *testing.T) net.Listener { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("监听测试端口失败: %v", err) - } - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - _ = conn.Close() - } - }() - - return ln -} - -func listenerPort(t *testing.T, ln net.Listener) int { - t.Helper() - - tcpAddr, ok := ln.Addr().(*net.TCPAddr) - if !ok { - t.Fatalf("解析监听地址失败: %T", ln.Addr()) - } - return tcpAddr.Port -} - -func shortLivedCommand() *exec.Cmd { - if goruntime.GOOS == "windows" { - return exec.Command("cmd", "/c", "exit", "0") - } - return exec.Command("sh", "-c", "exit 0") -} - -func longLivedCommand(duration time.Duration) *exec.Cmd { - if goruntime.GOOS == "windows" { - seconds := int(duration / time.Second) - if seconds < 1 { - seconds = 1 - } - return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1)) - } - return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds())) -} - -func stderrFailingCommand(message string) *exec.Cmd { - if goruntime.GOOS == "windows" { - return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message)) - } - return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message)) -} - -func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd { - if goruntime.GOOS == "windows" { - seconds := int(holdFor / time.Second) - if seconds < 1 { - seconds = 1 - } - // ping -n N waits roughly N-1 seconds on Windows. - return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1)) - } - return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds())) -} - -func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { - t.Helper() - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if check() { - return - } - time.Sleep(100 * time.Millisecond) - } - t.Fatal("等待条件成立超时") -} - -func freeLoopbackPort(t *testing.T) int { - t.Helper() - - ln := mustListenLoopback(t) - port := listenerPort(t, ln) - _ = ln.Close() - return port -} - -type devToolsTestServer struct { - port int - server *http.Server - done chan struct{} -} - -func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("启动 DevTools 测试服务失败: %v", err) - } - - srv := &http.Server{Handler: handler} - done := make(chan struct{}) - go func() { - defer close(done) - _ = srv.Serve(ln) - }() - - return &devToolsTestServer{ - port: listenerPort(t, ln), - server: srv, - done: done, - } -} - -func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer { - t.Helper() - - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err) - } - - srv := &http.Server{Handler: handler} - done := make(chan struct{}) - go func() { - defer close(done) - _ = srv.Serve(ln) - }() - - return &devToolsTestServer{ - port: port, - server: srv, - done: done, - } -} - -func (s *devToolsTestServer) Close() error { - if s == nil || s.server == nil { - return nil - } - err := s.server.Close() - <-s.done - return err -} - -func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) { - t.Helper() - - content := fmt.Sprintf("%d\n/devtools/browser/test\n", port) - if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil { - t.Fatalf("写入 DevToolsActivePort 失败: %v", err) - } -} diff --git a/backend/app_proxy_check_config.go b/backend/app_proxy_check_config.go index 490c4730..fc0da590 100644 --- a/backend/app_proxy_check_config.go +++ b/backend/app_proxy_check_config.go @@ -1,9 +1,6 @@ package backend import ( - "strings" - "time" - "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/proxy" ) @@ -24,121 +21,21 @@ func (a *App) SaveProxyCheckSettings(settings ProxyCheckSettings) error { if a.config == nil { return nil } - settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, 15000) - settings.SpeedTargetID = strings.TrimSpace(settings.SpeedTargetID) - settings.IPHealthTargetID = strings.TrimSpace(settings.IPHealthTargetID) - settings.Targets = normalizeProxyCheckTargets(settings.Targets) - if len(settings.Targets) == 0 { - settings.Targets = config.DefaultConfig().ProxyCheck.Targets - } - if settings.SpeedTargetID == "" { - settings.SpeedTargetID = firstProxyCheckTargetID(settings.Targets, "speed", "") - } - if settings.IPHealthTargetID == "" { - settings.IPHealthTargetID = firstProxyCheckTargetID(settings.Targets, "ip_health", "") - } - a.config.ProxyCheck = settings + a.config.ProxyCheck = proxy.NormalizeCheckSettings(settings) return a.config.Save(a.resolveAppPath("config.yaml")) } func (a *App) proxySpeedTestConfig() *proxy.SpeedTestConfig { - cfg := proxy.DefaultSpeedTestConfig if a == nil || a.config == nil { + cfg := proxy.DefaultSpeedTestConfig return &cfg } - target := a.proxyCheckTarget(a.config.ProxyCheck.SpeedTargetID, "speed") - if strings.TrimSpace(target.URL) != "" { - cfg.URLs = []string{strings.TrimSpace(target.URL)} - } - if target.TimeoutMs > 0 { - cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond - } - return &cfg + return proxy.BuildSpeedTestConfig(a.config.ProxyCheck) } func (a *App) proxyIPHealthConfig() *proxy.IPHealthConfig { - cfg := &proxy.IPHealthConfig{Source: "ip_health"} if a == nil || a.config == nil { - return cfg + return &proxy.IPHealthConfig{Source: "ip_health"} } - target := a.proxyCheckTarget(a.config.ProxyCheck.IPHealthTargetID, "ip_health") - if strings.TrimSpace(target.URL) != "" { - cfg.URL = strings.TrimSpace(target.URL) - } - if strings.TrimSpace(target.ID) != "" { - cfg.Source = strings.TrimSpace(target.ID) - } - if strings.TrimSpace(target.Parser) != "" { - cfg.Parser = strings.TrimSpace(target.Parser) - } - if target.TimeoutMs > 0 { - cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond - } - return cfg -} - -func (a *App) proxyCheckTarget(id string, targetType string) config.ProxyCheckTarget { - if a == nil || a.config == nil { - return config.ProxyCheckTarget{} - } - normalizedID := strings.TrimSpace(id) - normalizedType := strings.TrimSpace(targetType) - for _, target := range a.config.ProxyCheck.Targets { - if normalizedID != "" && strings.EqualFold(strings.TrimSpace(target.ID), normalizedID) { - return target - } - } - for _, target := range a.config.ProxyCheck.Targets { - if normalizedType != "" && strings.EqualFold(strings.TrimSpace(target.Type), normalizedType) { - return target - } - } - return config.ProxyCheckTarget{} -} - -func normalizeProxyCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyCheckTarget { - result := make([]config.ProxyCheckTarget, 0, len(targets)) - seen := map[string]struct{}{} - for _, target := range targets { - target.ID = strings.TrimSpace(target.ID) - target.Name = strings.TrimSpace(target.Name) - target.Type = strings.TrimSpace(target.Type) - target.URL = strings.TrimSpace(target.URL) - target.Parser = strings.TrimSpace(target.Parser) - if target.ID == "" || target.URL == "" { - continue - } - key := strings.ToLower(target.ID) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if target.Name == "" { - target.Name = target.ID - } - if target.Type == "" { - target.Type = "speed" - } - if target.TimeoutMs <= 0 { - target.TimeoutMs = 10000 - } - result = append(result, target) - } - return result -} - -func firstProxyCheckTargetID(targets []config.ProxyCheckTarget, targetType string, fallback string) string { - for _, target := range targets { - if strings.EqualFold(strings.TrimSpace(target.Type), targetType) { - return strings.TrimSpace(target.ID) - } - } - return fallback -} - -func normalizePositiveInt(value int, fallback int) int { - if value > 0 { - return value - } - return fallback + return proxy.BuildIPHealthConfig(a.config.ProxyCheck) } diff --git a/backend/app_proxy_query.go b/backend/app_proxy_query.go index 2103b029..24a7c427 100644 --- a/backend/app_proxy_query.go +++ b/backend/app_proxy_query.go @@ -1,43 +1,22 @@ package backend import ( + "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/proxy" ) func (a *App) BrowserProxyList() []BrowserProxy { - if a.browserMgr.ProxyDAO != nil { - if list, err := a.browserMgr.ProxyDAO.List(); err == nil { - return list - } - } - return append([]BrowserProxy{}, a.config.Browser.Proxies...) + return browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies) } // BrowserProxyListGroups 获取所有代理分组名称 func (a *App) BrowserProxyListGroups() []string { - if a.browserMgr.ProxyDAO != nil { - if groups, err := a.browserMgr.ProxyDAO.ListGroups(); err == nil { - return groups - } - } - return nil + return browser.ListProxyGroups(a.browserMgr.ProxyDAO) } // BrowserProxyListByGroup 按分组名称查询代理 func (a *App) BrowserProxyListByGroup(groupName string) []BrowserProxy { - if a.browserMgr.ProxyDAO != nil { - if list, err := a.browserMgr.ProxyDAO.ListByGroup(groupName); err == nil { - return list - } - } - - var result []BrowserProxy - for _, item := range a.config.Browser.Proxies { - if item.GroupName == groupName { - result = append(result, item) - } - } - return result + return browser.ListProxiesByGroupWithFallback(a.browserMgr.ProxyDAO, groupName, a.config.Browser.Proxies) } // ValidateProxyConfig 验证代理配置是否支持 @@ -67,10 +46,5 @@ func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult { // getLatestProxies 获取最新的代理列表,优先从数据库读取 func (a *App) getLatestProxies() []BrowserProxy { - if a.browserMgr.ProxyDAO != nil { - if list, err := a.browserMgr.ProxyDAO.List(); err == nil && len(list) > 0 { - return list - } - } - return a.config.Browser.Proxies + return browser.LatestProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies) } diff --git a/backend/app_proxy_save.go b/backend/app_proxy_save.go index 831b5066..abd25aba 100644 --- a/backend/app_proxy_save.go +++ b/backend/app_proxy_save.go @@ -3,78 +3,12 @@ package backend import ( "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" - "strings" + "ant-chrome/backend/internal/proxy" ) func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error { log := logger.New("Browser") - normalized := make([]BrowserProxy, 0, len(proxies)) - for i, item := range proxies { - proxyName := strings.TrimSpace(item.ProxyName) - proxyConfig := strings.TrimSpace(item.ProxyConfig) - if proxyName == "" || proxyConfig == "" { - continue - } - proxyID := strings.TrimSpace(item.ProxyId) - if proxyID == "" { - proxyID = generateUUID() - } - sourceURL := strings.TrimSpace(item.SourceURL) - sourceID := strings.TrimSpace(item.SourceID) - sourceNamePrefix := strings.TrimSpace(item.SourceNamePrefix) - sourceLastRefreshAt := strings.TrimSpace(item.SourceLastRefreshAt) - sourceRefreshIntervalM := item.SourceRefreshIntervalM - if sourceRefreshIntervalM < 0 { - sourceRefreshIntervalM = 0 - } - if sourceRefreshIntervalM > 24*60 { - sourceRefreshIntervalM = 24 * 60 - } - sourceAutoRefresh := item.SourceAutoRefresh && sourceURL != "" - if sourceAutoRefresh && sourceRefreshIntervalM <= 0 { - sourceRefreshIntervalM = 60 - } - if !sourceAutoRefresh { - sourceRefreshIntervalM = 0 - } - if sourceURL == "" { - sourceID = "" - sourceNamePrefix = "" - sourceLastRefreshAt = "" - sourceAutoRefresh = false - sourceRefreshIntervalM = 0 - } - normalized = append(normalized, BrowserProxy{ - ProxyId: proxyID, - ProxyName: proxyName, - ProxyConfig: proxyConfig, - DnsServers: strings.TrimSpace(item.DnsServers), - GroupName: strings.TrimSpace(item.GroupName), - SourceID: sourceID, - SourceURL: sourceURL, - SourceNamePrefix: sourceNamePrefix, - SourceAutoRefresh: sourceAutoRefresh, - SourceRefreshIntervalM: sourceRefreshIntervalM, - SourceLastRefreshAt: sourceLastRefreshAt, - SortOrder: i, - }) - } - - builtins := []BrowserProxy{ - {ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - } - for _, builtin := range builtins { - found := false - for _, item := range normalized { - if item.ProxyId == builtin.ProxyId { - found = true - break - } - } - if !found { - normalized = append([]BrowserProxy{builtin}, normalized...) - } - } + normalized := proxy.NormalizeBrowserProxies(proxies, generateUUID) a.config.Browser.Proxies = normalized diff --git a/backend/app_snapshot_api.go b/backend/app_snapshot_api.go index 15130bec..54ea1ba6 100644 --- a/backend/app_snapshot_api.go +++ b/backend/app_snapshot_api.go @@ -1,6 +1,7 @@ package backend import ( + "ant-chrome/backend/internal/snapshot" "encoding/json" "fmt" "os" @@ -48,7 +49,7 @@ func (a *App) BrowserSnapshotCreate(profileId, name string) (SnapshotInfo, error zipPath := filepath.Join(snapDir, snapshotID+"_"+safeName+".zip") metaPath := filepath.Join(snapDir, snapshotID+"_"+safeName+".meta.json") - if err := zipDir(userDataDir, zipPath); err != nil { + if err := snapshot.ZipDir(userDataDir, zipPath); err != nil { return SnapshotInfo{}, fmt.Errorf("压缩失败: %w", err) } @@ -129,7 +130,7 @@ func (a *App) BrowserSnapshotRestore(profileId, snapshotId string) error { return err } - metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId) + metaPath, zipPath, err := snapshot.FindFiles(snapDir, snapshotId) if err != nil { return err } @@ -142,7 +143,7 @@ func (a *App) BrowserSnapshotRestore(profileId, snapshotId string) error { if err := os.MkdirAll(userDataDir, 0o755); err != nil { return err } - return unzipTo(zipPath, userDataDir) + return snapshot.UnzipTo(zipPath, userDataDir) } // BrowserSnapshotDelete 删除快照 @@ -151,7 +152,7 @@ func (a *App) BrowserSnapshotDelete(profileId, snapshotId string) error { if err != nil { return err } - metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId) + metaPath, zipPath, err := snapshot.FindFiles(snapDir, snapshotId) if err != nil { return err } diff --git a/backend/app_snapshot_paths.go b/backend/app_snapshot_paths.go index 11745837..b88c7430 100644 --- a/backend/app_snapshot_paths.go +++ b/backend/app_snapshot_paths.go @@ -1,36 +1,10 @@ package backend import ( - "fmt" - "os" - "path/filepath" - "strings" + "ant-chrome/backend/internal/snapshot" ) // snapshotDir 返回指定实例的快照目录路径(存放在 data/snapshots 下) func (a *App) snapshotDir(profileId string) (string, error) { - dir := filepath.Join(a.resolveAppPath("data"), "snapshots", profileId) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - return dir, nil -} - -// findSnapshotFiles 在快照目录中找到指定 snapshotId 的 meta 和 zip 路径 -func findSnapshotFiles(snapDir, snapshotId string) (metaPath, zipPath string, err error) { - entries, err := os.ReadDir(snapDir) - if err != nil { - return "", "", err - } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), snapshotId) && strings.HasSuffix(entry.Name(), ".meta.json") { - metaPath = filepath.Join(snapDir, entry.Name()) - zipPath = strings.TrimSuffix(metaPath, ".meta.json") + ".zip" - if _, err := os.Stat(zipPath); err != nil { - return "", "", fmt.Errorf("快照文件不存在: %s", zipPath) - } - return metaPath, zipPath, nil - } - } - return "", "", fmt.Errorf("快照不存在: %s", snapshotId) + return snapshot.EnsureDir(a.resolveAppPath("data"), profileId) } diff --git a/backend/app_snapshot_test.go b/backend/app_snapshot_test.go deleted file mode 100644 index 6c21a76c..00000000 --- a/backend/app_snapshot_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package backend - -import ( - "os" - "path/filepath" - "testing" -) - -func TestZipDirAndUnzipTo(t *testing.T) { - t.Parallel() - - root := t.TempDir() - src := filepath.Join(root, "src") - dstZip := filepath.Join(root, "archive.zip") - dstDir := filepath.Join(root, "dst") - - if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil { - t.Fatalf("mkdir src: %v", err) - } - if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil { - t.Fatalf("write source file: %v", err) - } - - if err := zipDir(src, dstZip); err != nil { - t.Fatalf("zipDir failed: %v", err) - } - if err := unzipTo(dstZip, dstDir); err != nil { - t.Fatalf("unzipTo failed: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dstDir, "nested", "file.txt")) - if err != nil { - t.Fatalf("read extracted file: %v", err) - } - if string(data) != "hello" { - t.Fatalf("extracted content = %q, want hello", string(data)) - } -} - -func TestFindSnapshotFiles(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - metaPath := filepath.Join(dir, "snap-1_demo.meta.json") - zipPath := filepath.Join(dir, "snap-1_demo.zip") - - if err := os.WriteFile(metaPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write meta: %v", err) - } - if err := os.WriteFile(zipPath, []byte("zip"), 0o644); err != nil { - t.Fatalf("write zip: %v", err) - } - - gotMeta, gotZip, err := findSnapshotFiles(dir, "snap-1") - if err != nil { - t.Fatalf("findSnapshotFiles failed: %v", err) - } - if gotMeta != metaPath { - t.Fatalf("meta path = %q, want %q", gotMeta, metaPath) - } - if gotZip != zipPath { - t.Fatalf("zip path = %q, want %q", gotZip, zipPath) - } -} diff --git a/backend/automation_script_api_helpers_test.go b/backend/automation_script_api_helpers_test.go new file mode 100644 index 00000000..da44bc35 --- /dev/null +++ b/backend/automation_script_api_helpers_test.go @@ -0,0 +1,69 @@ +package backend + +import ( + "archive/zip" + "bytes" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +func runGitForTest(t *testing.T, workdir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = workdir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) + } +} + +func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + + paths := make([]string, 0, len(files)) + for relativePath := range files { + paths = append(paths, relativePath) + } + sort.Strings(paths) + + for _, relativePath := range paths { + entry, err := writer.Create(relativePath) + if err != nil { + t.Fatalf("create zip entry failed: %v", err) + } + if _, err := entry.Write([]byte(files[relativePath])); err != nil { + t.Fatalf("write zip entry failed: %v", err) + } + } + + if err := writer.Close(); err != nil { + t.Fatalf("close zip writer failed: %v", err) + } + return buf.Bytes() +} + +func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) { + t.Helper() + + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create script library package dir failed: %v", err) + } + if strings.TrimSpace(manifest) != "" { + if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil { + t.Fatalf("write script library manifest failed: %v", err) + } + } + if strings.TrimSpace(entry) != "" { + if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil { + t.Fatalf("write script library entry failed: %v", err) + } + } +} diff --git a/backend/automation_script_api_test.go b/backend/automation_script_api_test.go index 19f32030..a05cd07b 100644 --- a/backend/automation_script_api_test.go +++ b/backend/automation_script_api_test.go @@ -1,20 +1,13 @@ package backend import ( - "archive/zip" - "bytes" - "net/http" - "net/http/httptest" "os" - "os/exec" "path/filepath" - "sort" "strings" "testing" "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" ) func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { @@ -475,552 +468,3 @@ func TestAutomationScriptRefreshFromBuiltin(t *testing.T) { t.Fatalf("expected public api config to be preserved on refresh, got %+v", refreshed.PublicAPI) } } - -func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) { - app := NewApp(t.TempDir()) - - sourceDir := filepath.Join(t.TempDir(), "local-dir-script") - if err := os.MkdirAll(filepath.Join(sourceDir, "scripts", "helpers"), 0o755); err != nil { - t.Fatalf("create local dir source failed: %v", err) - } - if err := os.WriteFile(filepath.Join(sourceDir, "automation.script.json"), []byte(`{ - "name": "本地目录脚本", - "type": "playwright-cdp", - "entryFile": "scripts/index.cjs" -}`), 0o644); err != nil { - t.Fatalf("write local dir manifest failed: %v", err) - } - if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "index.cjs"), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), 0o644); err != nil { - t.Fatalf("write local dir entry failed: %v", err) - } - if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil { - t.Fatalf("write local dir helper failed: %v", err) - } - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-local-dir", - Name: "旧本地目录脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "local-dir", - URI: sourceDir, - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed == nil { - t.Fatalf("AutomationScriptRefresh returned nil result") - } - if refreshed.ID != saved.ID { - t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) - } - if refreshed.Status != "ready" { - t.Fatalf("expected status to be preserved, got %q", refreshed.Status) - } - if refreshed.EntryFile != "scripts/index.cjs" { - t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile) - } - if !strings.Contains(refreshed.ScriptText, "helper.run()") { - t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText) - } -} - -func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) { - app := NewApp(t.TempDir()) - libraryRoot := filepath.Join(t.TempDir(), "script-library") - - firstScriptDir := filepath.Join(libraryRoot, "first-script") - writeAutomationScriptLibraryPackage(t, firstScriptDir, `{ - "name": "脚本一", - "type": "playwright-cdp", - "entryFile": "index.cjs" -}`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })") - - secondScriptDir := filepath.Join(libraryRoot, "second-script") - if err := os.MkdirAll(secondScriptDir, 0o755); err != nil { - t.Fatalf("create second script dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil { - t.Fatalf("write second script entry failed: %v", err) - } - - existing, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "existing-local-library-script", - Name: "旧脚本一", - Type: "launch-api", - Status: "disabled", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "local-dir", - URI: firstScriptDir, - }, - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Path: "library/existing-script", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - result, err := app.importAutomationLocalLibrary(libraryRoot) - if err != nil { - t.Fatalf("importAutomationLocalLibrary returned error: %v", err) - } - if result == nil { - t.Fatalf("importAutomationLocalLibrary returned nil result") - } - if result.Scanned != 2 { - t.Fatalf("expected scanned count 2, got %d", result.Scanned) - } - if len(result.Imported) != 2 { - t.Fatalf("expected two imported scripts, got %d", len(result.Imported)) - } - if len(result.Failed) != 0 { - t.Fatalf("expected no failed imports, got %+v", result.Failed) - } - - updatedFirst, err := app.AutomationScriptGet(existing.ID) - if err != nil { - t.Fatalf("AutomationScriptGet returned error: %v", err) - } - if updatedFirst.Name != "脚本一" { - t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name) - } - if updatedFirst.Status != "disabled" { - t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status) - } - if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir { - t.Fatalf("unexpected updated source: %+v", updatedFirst.Source) - } - if !strings.Contains(updatedFirst.ScriptText, "first-script") { - t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText) - } - if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled { - t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI) - } - - allScripts, err := app.automationScriptStore().List() - if err != nil { - t.Fatalf("List returned error: %v", err) - } - if len(allScripts) != 2 { - t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts)) - } -} - -func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) { - app := NewApp(t.TempDir()) - libraryRoot := filepath.Join(t.TempDir(), "script-library") - - goodDir := filepath.Join(libraryRoot, "good-script") - writeAutomationScriptLibraryPackage(t, goodDir, `{ - "name": "好脚本", - "type": "playwright-cdp", - "entryFile": "index.cjs" -}`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })") - - badDir := filepath.Join(libraryRoot, "bad-script") - writeAutomationScriptLibraryPackage(t, badDir, `{ - "name": "坏脚本", - "type": "playwright-cdp", - "entryFile": "missing.cjs" -}`, "") - - result, err := app.importAutomationLocalLibrary(libraryRoot) - if err != nil { - t.Fatalf("importAutomationLocalLibrary returned error: %v", err) - } - if result == nil { - t.Fatalf("importAutomationLocalLibrary returned nil result") - } - if result.Scanned != 2 { - t.Fatalf("expected scanned count 2, got %d", result.Scanned) - } - if len(result.Imported) != 1 { - t.Fatalf("expected one imported script, got %d", len(result.Imported)) - } - if len(result.Failed) != 1 { - t.Fatalf("expected one failed script, got %+v", result.Failed) - } - if result.Failed[0].Path != badDir { - t.Fatalf("unexpected failed path: %+v", result.Failed[0]) - } - if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") { - t.Fatalf("unexpected failed message: %+v", result.Failed[0]) - } -} - -func TestAutomationScriptRefreshFromRemote(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{ - "manifest": { - "name": "远程刷新脚本", - "description": "来自远程", - "type": "playwright-cdp", - "entryFile": "index.cjs" - }, - "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })" -}`)) - })) - defer server.Close() - - app := NewApp(t.TempDir()) - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-remote", - Name: "旧远程脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "remote-url", - URI: server.URL + "/script.json", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed == nil { - t.Fatalf("AutomationScriptRefresh returned nil result") - } - if refreshed.ID != saved.ID { - t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) - } - if refreshed.Name != "远程刷新脚本" { - t.Fatalf("expected remote manifest name, got %q", refreshed.Name) - } - if refreshed.Status != "ready" { - t.Fatalf("expected status to be preserved, got %q", refreshed.Status) - } - if !strings.Contains(refreshed.ScriptText, "source: 'remote'") { - t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText) - } - if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" { - t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) - } -} - -func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) { - app := NewApp(t.TempDir()) - - zipData := buildAutomationZipBytesForTest(t, map[string]string{ - "automation.script.json": `{ - "name": "远程 ZIP", - "type": "playwright-cdp", - "entryFile": "scripts/index.cjs" -}`, - "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })", - }) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(zipData) - })) - defer server.Close() - - bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip") - if err != nil { - t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) - } - - if bundle.Record.Name != "远程 ZIP" { - t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) - } - if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" { - t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) - } - if !strings.Contains(bundle.Record.ScriptText, "remote-zip") { - t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) - } -} - -func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.AllowTypeScriptBuild = true - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = w.Write([]byte(`export async function run() { - return { ok: true, source: 'remote-ts' } -}`)) - })) - defer server.Close() - - bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts") - if err != nil { - t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) - } - - if bundle.Record.EntryFile != "demo-script.cjs" { - t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) - } - if !strings.Contains(bundle.Record.ScriptText, "remote-ts") { - t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) - } - if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" { - t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) - } -} - -func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`export async function run() { - return { ok: true, source: 'remote-ts-refresh' } -}`)) - })) - defer server.Close() - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.AllowTypeScriptBuild = true - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-remote-ts", - Name: "旧远程 TS 脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "remote-url", - URI: server.URL + "/refresh-script.ts", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed.EntryFile != "refresh-script.cjs" { - t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile) - } - if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") { - t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText) - } - if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" { - t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) - } -} - -func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - - repoDir := filepath.Join(t.TempDir(), "automation-ts-repo") - if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil { - t.Fatalf("create repo dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ - "name": "Git TS 导入", - "type": "playwright-cdp", - "entryFile": "index.ts" -}`), 0o644); err != nil { - t.Fatalf("write git manifest failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag' - -export async function run() { - return { ok: flag, source: 'git-ts' } -}`), 0o644); err != nil { - t.Fatalf("write git entry file failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil { - t.Fatalf("write git helper file failed: %v", err) - } - - runGitForTest(t, repoDir, "init") - runGitForTest(t, repoDir, "config", "user.email", "test@example.com") - runGitForTest(t, repoDir, "config", "user.name", "Test User") - runGitForTest(t, repoDir, "add", ".") - runGitForTest(t, repoDir, "commit", "-m", "init") - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.AllowTypeScriptBuild = true - - bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo") - if err != nil { - t.Fatalf("loadAutomationGitBundle returned error: %v", err) - } - - if bundle.Record.Name != "Git TS 导入" { - t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) - } - if bundle.Record.EntryFile != "index.cjs" { - t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) - } - if !strings.Contains(bundle.Record.ScriptText, "git-ts") { - t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) - } - if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" { - t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) - } -} - -func TestAutomationScriptRefreshFromGit(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - - repoDir := filepath.Join(t.TempDir(), "automation-repo") - if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil { - t.Fatalf("create repo dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ - "name": "Git 刷新脚本", - "type": "playwright-cdp", - "entryFile": "index.cjs" -}`), 0o644); err != nil { - t.Fatalf("write git manifest failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil { - t.Fatalf("write git entry file failed: %v", err) - } - - runGitForTest(t, repoDir, "init") - runGitForTest(t, repoDir, "config", "user.email", "test@example.com") - runGitForTest(t, repoDir, "config", "user.name", "Test User") - runGitForTest(t, repoDir, "add", ".") - runGitForTest(t, repoDir, "commit", "-m", "init") - - app := NewApp(t.TempDir()) - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-git", - Name: "旧 Git 脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "git", - URI: repoDir, - Path: "scripts/demo", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed == nil { - t.Fatalf("AutomationScriptRefresh returned nil result") - } - if refreshed.ID != saved.ID { - t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) - } - if refreshed.Name != "Git 刷新脚本" { - t.Fatalf("expected git manifest name, got %q", refreshed.Name) - } - if refreshed.Status != "ready" { - t.Fatalf("expected status to be preserved, got %q", refreshed.Status) - } - if !strings.Contains(refreshed.ScriptText, "source: 'git'") { - t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText) - } - if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" { - t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) - } -} - -func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) { - app := NewApp(t.TempDir()) - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-manual", - Name: "手动脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: true })", - Source: automation.ScriptSource{ - Type: "manual", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - if _, err := app.AutomationScriptRefresh(saved.ID); err == nil { - t.Fatalf("expected unsupported source refresh to fail") - } -} - -func runGitForTest(t *testing.T, workdir string, args ...string) { - t.Helper() - - cmd := exec.Command("git", args...) - cmd.Dir = workdir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) - } -} - -func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte { - t.Helper() - - var buf bytes.Buffer - writer := zip.NewWriter(&buf) - - paths := make([]string, 0, len(files)) - for relativePath := range files { - paths = append(paths, relativePath) - } - sort.Strings(paths) - - for _, relativePath := range paths { - entry, err := writer.Create(relativePath) - if err != nil { - t.Fatalf("create zip entry failed: %v", err) - } - if _, err := entry.Write([]byte(files[relativePath])); err != nil { - t.Fatalf("write zip entry failed: %v", err) - } - } - - if err := writer.Close(); err != nil { - t.Fatalf("close zip writer failed: %v", err) - } - return buf.Bytes() -} - -func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) { - t.Helper() - - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("create script library package dir failed: %v", err) - } - if strings.TrimSpace(manifest) != "" { - if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil { - t.Fatalf("write script library manifest failed: %v", err) - } - } - if strings.TrimSpace(entry) != "" { - if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil { - t.Fatalf("write script library entry failed: %v", err) - } - } -} diff --git a/backend/automation_script_defaults.go b/backend/automation_script_defaults.go index e66665d6..20d4614f 100644 --- a/backend/automation_script_defaults.go +++ b/backend/automation_script_defaults.go @@ -8,10 +8,11 @@ import ( ) const ( - automationScriptDefaultsMarkerName = "defaults-seeded-v9" + automationScriptDefaultsMarkerName = "defaults-seeded-v10" ) var automationScriptDefaultsLegacyMarkerNames = []string{ + "defaults-seeded-v9", "defaults-seeded-v8", "defaults-seeded-v7", "defaults-seeded-v6", @@ -80,14 +81,20 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro return a.markAutomationScriptDefaultsInitialized() } - // Migration from v1: existing scripts are present, add any missing built-in baselines once. + // Migration: existing scripts are present, refresh built-in baselines and add missing ones once. if a.automationScriptDefaultsInitializedAnyLegacy() { - existingIDs := make(map[string]struct{}, len(items)) + existingByID := make(map[string]automation.ScriptRecord, len(items)) for _, item := range items { - existingIDs[item.ID] = struct{}{} + existingByID[item.ID] = item } for _, bundle := range defaults { - if _, exists := existingIDs[bundle.Record.ID]; exists { + if existing, exists := existingByID[bundle.Record.ID]; exists { + if existing.Source.Type == "builtin" { + bundle.Record = mergeBuiltinDefaultScriptForMigration(existing, bundle.Record) + if _, err := store.ImportBundle(bundle); err != nil { + return err + } + } continue } if _, err := store.ImportBundle(bundle); err != nil { @@ -97,3 +104,41 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro } return a.markAutomationScriptDefaultsInitialized() } + +func mergeBuiltinDefaultScriptForMigration(existing automation.ScriptRecord, next automation.ScriptRecord) automation.ScriptRecord { + next.ID = existing.ID + next.CreatedAt = existing.CreatedAt + next.Status = existing.Status + next.TargetConfig = existing.TargetConfig + next.PublicAPI.Enabled = existing.PublicAPI.Enabled + if existing.PublicAPI.Path != "" { + next.PublicAPI.Path = existing.PublicAPI.Path + } + if existing.PublicAPI.TimeoutMs > 0 { + next.PublicAPI.TimeoutMs = existing.PublicAPI.TimeoutMs + } + next.PublicAPI.Variables = mergeBuiltinDefaultPublicAPIVariables( + existing.PublicAPI.Variables, + next.PublicAPI.Variables, + ) + return next +} + +func mergeBuiltinDefaultPublicAPIVariables(existing []automation.ScriptPublicAPIVariable, next []automation.ScriptPublicAPIVariable) []automation.ScriptPublicAPIVariable { + existingByName := make(map[string]automation.ScriptPublicAPIVariable, len(existing)) + for _, variable := range existing { + if variable.Name != "" { + existingByName[variable.Name] = variable + } + } + + result := make([]automation.ScriptPublicAPIVariable, 0, len(next)) + for _, variable := range next { + if existingVariable, ok := existingByName[variable.Name]; ok { + variable.DefaultValue = existingVariable.DefaultValue + variable.Required = existingVariable.Required + } + result = append(result, variable) + } + return result +} diff --git a/backend/automation_script_http_e2e_helpers_test.go b/backend/automation_script_http_e2e_helpers_test.go new file mode 100644 index 00000000..ae728bd5 --- /dev/null +++ b/backend/automation_script_http_e2e_helpers_test.go @@ -0,0 +1,341 @@ +package backend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "testing" + "time" +) + +func lookupAutomationHTTPProbeNode(t *testing.T) string { + t.Helper() + + const preferred = `D:\code\plugin\nodejs\node.exe` + if _, err := os.Stat(preferred); err == nil { + return preferred + } + return lookupAutomationTestNode(t) +} + +func lookupAutomationHTTPProbeChrome(t *testing.T) string { + t.Helper() + + const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe` + if _, err := os.Stat(preferred); err == nil { + return preferred + } + t.Skip("system chrome is not installed") + return "" +} + +func automationHTTPRepoRoot(t *testing.T) string { + t.Helper() + + _, file, _, ok := goruntime.Caller(0) + if !ok { + t.Fatal("resolve repo root failed") + } + return filepath.Dir(filepath.Dir(file)) +} + +func automationHTTPFreePort(t *testing.T) int { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("allocate port failed: %v", err) + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error { + repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion)) + tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion)) + if _, err := os.Stat(repoRuntimeDir); err != nil { + return fmt.Errorf("repo runtime not found: %w", err) + } + if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil { + return err + } + if err := automationHTTPCopyFile( + filepath.Join(repoRuntimeDir, "runner.cjs"), + filepath.Join(tempRuntimeDir, "runner.cjs"), + ); err != nil { + return err + } + return automationHTTPCopyDir( + filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"), + filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"), + ) +} + +func automationHTTPCopyFile(src string, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func automationHTTPCopyDir(src string, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relativePath, err := filepath.Rel(src, path) + if err != nil { + return err + } + targetPath := filepath.Join(dst, relativePath) + if info.IsDir() { + return os.MkdirAll(targetPath, 0o755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + return os.WriteFile(targetPath, data, info.Mode()) + }) +} + +func automationHTTPRequestJSON(method string, url string, payload any, target any) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, url, body) + if err != nil { + return err + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw))) + } + if target == nil { + return nil + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw)) + } + return nil +} + +func automationHTTPHasScript(items []struct { + ID string `json:"id"` +}, scriptID string) bool { + for _, item := range items { + if strings.TrimSpace(item.ID) == scriptID { + return true + } + } + return false +} + +func automationHTTPStringValue(payload map[string]any, key string) string { + if payload == nil { + return "" + } + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} + +func automationHTTPMarshal(t *testing.T, value any) string { + t.Helper() + + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal debug payload failed: %v", err) + } + return string(data) +} + +const automationHTTPMailFixtureHTML = ` + + + + Mail Fixture + + + + +
+ +
+

Your ChatGPT verification code

+

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

+

To: target@example.com

+

Hello,

+

Your verification code is 429792.

+

Please use this code to continue signing in.

+

Best regards

+

ChatGPT

+
+
+ +` + +const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => { + const normalizeText = (value) => String(value == null ? '' : value).trim() + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(5000, Math.round(Number(params.timeoutMs))) + : 45000 + const inboxUrl = normalizeText(params.inboxUrl) + + if (!inboxUrl) { + throw new Error('inboxUrl is required') + } + + const session = await launch({ + selector, + skipDefaultStartUrls: true, + startUrls: [inboxUrl], + }) + const connection = await connect(session, { timeoutMs }) + const browser = connection.browser + if (!browser) { + throw new Error('browser connection is unavailable') + } + + const context = + connection.context || + browser.contexts()[0] || + (typeof browser.newContext === 'function' ? await browser.newContext() : null) + if (!context) { + throw new Error('browser context is unavailable') + } + + const opened = await openPage(connection, { + url: inboxUrl, + timeoutMs, + permissions: ['notifications'], + }) + const page = opened.page + await page.waitForLoadState('networkidle', { + timeout: Math.min(timeoutMs, 2500), + }).catch(() => {}) + + const result = await page.evaluate(() => { + const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim() + const article = document.querySelector('article') + const lines = Array.from(document.querySelectorAll('article p')) + .map((node) => normalizeText(node.textContent)) + .filter(Boolean) + const subject = normalizeText(document.querySelector('article h1')?.textContent) + const fromLine = lines.find((line) => line.startsWith('From:')) || '' + const toLine = lines.find((line) => line.startsWith('To:')) || '' + const articleText = normalizeText(article?.textContent) + const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*= 300 { - return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw))) - } - if target == nil { - return nil - } - if err := json.Unmarshal(raw, target); err != nil { - return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw)) - } - return nil -} - -func automationHTTPHasScript(items []struct { - ID string `json:"id"` -}, scriptID string) bool { - for _, item := range items { - if strings.TrimSpace(item.ID) == scriptID { - return true - } - } - return false -} - -func automationHTTPStringValue(payload map[string]any, key string) string { - if payload == nil { - return "" - } - value, _ := payload[key].(string) - return strings.TrimSpace(value) -} - -func automationHTTPMarshal(t *testing.T, value any) string { - t.Helper() - - data, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal debug payload failed: %v", err) - } - return string(data) -} - -const automationHTTPMailFixtureHTML = ` - - - - Mail Fixture - - - - -
- -
-

Your ChatGPT verification code

-

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

-

To: target@example.com

-

Hello,

-

Your verification code is 429792.

-

Please use this code to continue signing in.

-

Best regards

-

ChatGPT

-
-
- -` - -const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => { - const normalizeText = (value) => String(value == null ? '' : value).trim() - const timeoutMs = Number.isFinite(Number(params.timeoutMs)) - ? Math.max(5000, Math.round(Number(params.timeoutMs))) - : 45000 - const inboxUrl = normalizeText(params.inboxUrl) - - if (!inboxUrl) { - throw new Error('inboxUrl is required') - } - - const session = await launch({ - selector, - skipDefaultStartUrls: true, - startUrls: [inboxUrl], - }) - const connection = await connect(session, { timeoutMs }) - const browser = connection.browser - if (!browser) { - throw new Error('browser connection is unavailable') - } - - const context = - connection.context || - browser.contexts()[0] || - (typeof browser.newContext === 'function' ? await browser.newContext() : null) - if (!context) { - throw new Error('browser context is unavailable') - } - - const opened = await openPage(connection, { - url: inboxUrl, - timeoutMs, - permissions: ['notifications'], - }) - const page = opened.page - await page.waitForLoadState('networkidle', { - timeout: Math.min(timeoutMs, 2500), - }).catch(() => {}) - - const result = await page.evaluate(() => { - const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim() - const article = document.querySelector('article') - const lines = Array.from(document.querySelectorAll('article p')) - .map((node) => normalizeText(node.textContent)) - .filter(Boolean) - const subject = normalizeText(document.querySelector('article h1')?.textContent) - const fromLine = lines.find((line) => line.startsWith('From:')) || '' - const toLine = lines.find((line) => line.startsWith('To:')) || '' - const articleText = normalizeText(article?.textContent) - const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s* helper.run()"), 0o644); err != nil { + t.Fatalf("write local dir entry failed: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil { + t.Fatalf("write local dir helper failed: %v", err) + } + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-local-dir", + Name: "旧本地目录脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "local-dir", + URI: sourceDir, + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if refreshed.EntryFile != "scripts/index.cjs" { + t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile) + } + if !strings.Contains(refreshed.ScriptText, "helper.run()") { + t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText) + } +} + +func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) { + app := NewApp(t.TempDir()) + libraryRoot := filepath.Join(t.TempDir(), "script-library") + + firstScriptDir := filepath.Join(libraryRoot, "first-script") + writeAutomationScriptLibraryPackage(t, firstScriptDir, `{ + "name": "脚本一", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })") + + secondScriptDir := filepath.Join(libraryRoot, "second-script") + if err := os.MkdirAll(secondScriptDir, 0o755); err != nil { + t.Fatalf("create second script dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil { + t.Fatalf("write second script entry failed: %v", err) + } + + existing, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "existing-local-library-script", + Name: "旧脚本一", + Type: "launch-api", + Status: "disabled", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "local-dir", + URI: firstScriptDir, + }, + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Path: "library/existing-script", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + result, err := app.importAutomationLocalLibrary(libraryRoot) + if err != nil { + t.Fatalf("importAutomationLocalLibrary returned error: %v", err) + } + if result == nil { + t.Fatalf("importAutomationLocalLibrary returned nil result") + } + if result.Scanned != 2 { + t.Fatalf("expected scanned count 2, got %d", result.Scanned) + } + if len(result.Imported) != 2 { + t.Fatalf("expected two imported scripts, got %d", len(result.Imported)) + } + if len(result.Failed) != 0 { + t.Fatalf("expected no failed imports, got %+v", result.Failed) + } + + updatedFirst, err := app.AutomationScriptGet(existing.ID) + if err != nil { + t.Fatalf("AutomationScriptGet returned error: %v", err) + } + if updatedFirst.Name != "脚本一" { + t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name) + } + if updatedFirst.Status != "disabled" { + t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status) + } + if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir { + t.Fatalf("unexpected updated source: %+v", updatedFirst.Source) + } + if !strings.Contains(updatedFirst.ScriptText, "first-script") { + t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText) + } + if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled { + t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI) + } + + allScripts, err := app.automationScriptStore().List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(allScripts) != 2 { + t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts)) + } +} + +func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) { + app := NewApp(t.TempDir()) + libraryRoot := filepath.Join(t.TempDir(), "script-library") + + goodDir := filepath.Join(libraryRoot, "good-script") + writeAutomationScriptLibraryPackage(t, goodDir, `{ + "name": "好脚本", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })") + + badDir := filepath.Join(libraryRoot, "bad-script") + writeAutomationScriptLibraryPackage(t, badDir, `{ + "name": "坏脚本", + "type": "playwright-cdp", + "entryFile": "missing.cjs" +}`, "") + + result, err := app.importAutomationLocalLibrary(libraryRoot) + if err != nil { + t.Fatalf("importAutomationLocalLibrary returned error: %v", err) + } + if result == nil { + t.Fatalf("importAutomationLocalLibrary returned nil result") + } + if result.Scanned != 2 { + t.Fatalf("expected scanned count 2, got %d", result.Scanned) + } + if len(result.Imported) != 1 { + t.Fatalf("expected one imported script, got %d", len(result.Imported)) + } + if len(result.Failed) != 1 { + t.Fatalf("expected one failed script, got %+v", result.Failed) + } + if result.Failed[0].Path != badDir { + t.Fatalf("unexpected failed path: %+v", result.Failed[0]) + } + if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") { + t.Fatalf("unexpected failed message: %+v", result.Failed[0]) + } +} diff --git a/backend/automation_script_remote_test.go b/backend/automation_script_remote_test.go new file mode 100644 index 00000000..d199c602 --- /dev/null +++ b/backend/automation_script_remote_test.go @@ -0,0 +1,321 @@ +package backend + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/config" +) + +func TestAutomationScriptRefreshFromRemote(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "manifest": { + "name": "远程刷新脚本", + "description": "来自远程", + "type": "playwright-cdp", + "entryFile": "index.cjs" + }, + "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })" +}`)) + })) + defer server.Close() + + app := NewApp(t.TempDir()) + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-remote", + Name: "旧远程脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "remote-url", + URI: server.URL + "/script.json", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Name != "远程刷新脚本" { + t.Fatalf("expected remote manifest name, got %q", refreshed.Name) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if !strings.Contains(refreshed.ScriptText, "source: 'remote'") { + t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText) + } + if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } +} + +func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) { + app := NewApp(t.TempDir()) + + zipData := buildAutomationZipBytesForTest(t, map[string]string{ + "automation.script.json": `{ + "name": "远程 ZIP", + "type": "playwright-cdp", + "entryFile": "scripts/index.cjs" +}`, + "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipData) + })) + defer server.Close() + + bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip") + if err != nil { + t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) + } + + if bundle.Record.Name != "远程 ZIP" { + t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) + } + if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" { + t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) + } + if !strings.Contains(bundle.Record.ScriptText, "remote-zip") { + t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) + } +} + +func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) { + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.AllowTypeScriptBuild = true + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(`export async function run() { + return { ok: true, source: 'remote-ts' } +}`)) + })) + defer server.Close() + + bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts") + if err != nil { + t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) + } + + if bundle.Record.EntryFile != "demo-script.cjs" { + t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) + } + if !strings.Contains(bundle.Record.ScriptText, "remote-ts") { + t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) + } + if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" { + t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) + } +} + +func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`export async function run() { + return { ok: true, source: 'remote-ts-refresh' } +}`)) + })) + defer server.Close() + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.AllowTypeScriptBuild = true + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-remote-ts", + Name: "旧远程 TS 脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "remote-url", + URI: server.URL + "/refresh-script.ts", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed.EntryFile != "refresh-script.cjs" { + t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile) + } + if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") { + t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText) + } + if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } +} + +func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + + repoDir := filepath.Join(t.TempDir(), "automation-ts-repo") + if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil { + t.Fatalf("create repo dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ + "name": "Git TS 导入", + "type": "playwright-cdp", + "entryFile": "index.ts" +}`), 0o644); err != nil { + t.Fatalf("write git manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag' + +export async function run() { + return { ok: flag, source: 'git-ts' } +}`), 0o644); err != nil { + t.Fatalf("write git entry file failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil { + t.Fatalf("write git helper file failed: %v", err) + } + + runGitForTest(t, repoDir, "init") + runGitForTest(t, repoDir, "config", "user.email", "test@example.com") + runGitForTest(t, repoDir, "config", "user.name", "Test User") + runGitForTest(t, repoDir, "add", ".") + runGitForTest(t, repoDir, "commit", "-m", "init") + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.AllowTypeScriptBuild = true + + bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo") + if err != nil { + t.Fatalf("loadAutomationGitBundle returned error: %v", err) + } + + if bundle.Record.Name != "Git TS 导入" { + t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) + } + if bundle.Record.EntryFile != "index.cjs" { + t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) + } + if !strings.Contains(bundle.Record.ScriptText, "git-ts") { + t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) + } + if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" { + t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) + } +} + +func TestAutomationScriptRefreshFromGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + + repoDir := filepath.Join(t.TempDir(), "automation-repo") + if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil { + t.Fatalf("create repo dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ + "name": "Git 刷新脚本", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`), 0o644); err != nil { + t.Fatalf("write git manifest failed: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil { + t.Fatalf("write git entry file failed: %v", err) + } + + runGitForTest(t, repoDir, "init") + runGitForTest(t, repoDir, "config", "user.email", "test@example.com") + runGitForTest(t, repoDir, "config", "user.name", "Test User") + runGitForTest(t, repoDir, "add", ".") + runGitForTest(t, repoDir, "commit", "-m", "init") + + app := NewApp(t.TempDir()) + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-git", + Name: "旧 Git 脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "git", + URI: repoDir, + Path: "scripts/demo", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Name != "Git 刷新脚本" { + t.Fatalf("expected git manifest name, got %q", refreshed.Name) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if !strings.Contains(refreshed.ScriptText, "source: 'git'") { + t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText) + } + if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } +} + +func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) { + app := NewApp(t.TempDir()) + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "refresh-manual", + Name: "手动脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + Source: automation.ScriptSource{ + Type: "manual", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + if _, err := app.AutomationScriptRefresh(saved.ID); err == nil { + t.Fatalf("expected unsupported source refresh to fail") + } +} diff --git a/backend/automation_script_run_integration_helpers_test.go b/backend/automation_script_run_integration_helpers_test.go new file mode 100644 index 00000000..66cc55da --- /dev/null +++ b/backend/automation_script_run_integration_helpers_test.go @@ -0,0 +1,249 @@ +package backend + +import ( + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" +) + +func lookupAutomationTestNode(t *testing.T) string { + t.Helper() + + nodeExecPath, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + return nodeExecPath +} + +func prepareAutomationTestRuntime(t *testing.T, manager *automation.Manager, playwrightVersion string) { + t.Helper() + + prepareAutomationTestRuntimeWithPlaywrightModule( + t, + manager, + playwrightVersion, + "module.exports = { chromium: {} }\n", + ) +} + +func prepareAutomationTestRuntimeWithPlaywrightModule(t *testing.T, manager *automation.Manager, playwrightVersion string, playwrightModuleSource string) { + t.Helper() + + state := manager.CurrentState() + + playwrightCoreDir := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(playwrightCoreDir, 0o755); err != nil { + t.Fatalf("create playwright-core dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(playwrightCoreDir, "package.json"), []byte("{\"name\":\"playwright-core\",\"version\":\""+playwrightVersion+"\"}\n"), 0o644); err != nil { + t.Fatalf("write playwright-core package failed: %v", err) + } + if err := os.WriteFile(filepath.Join(playwrightCoreDir, "index.js"), []byte(playwrightModuleSource), 0o644); err != nil { + t.Fatalf("write playwright-core stub failed: %v", err) + } + if err := os.WriteFile(state.RunnerPath, []byte(automationTestRunnerScript), 0o755); err != nil { + t.Fatalf("write runner script failed: %v", err) + } +} + +const automationTestConnectProbePlaywrightModule = `const http = require('http') + +module.exports = { + chromium: { + connectOverCDP: async (endpoint) => { + const target = new URL('/json/version', endpoint) + await new Promise((resolve, reject) => { + const req = http.get(target, (res) => { + res.resume() + res.on('end', () => { + const status = res.statusCode || 0 + if (status >= 200 && status < 300) { + resolve() + return + } + reject(new Error('cdp connect probe failed with http ' + String(status))) + }) + }) + req.on('error', reject) + }) + + return { + contexts: () => [{ + pages: () => [], + newPage: async () => ({}) + }], + close: async () => {} + } + } + } +} +` + +const automationTestRunnerScript = `const fs = require('fs') +const path = require('path') + +async function main() { + const payloadPath = process.argv[2] + const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8')) + const script = require(payload.ScriptPath) + const startedAt = new Date().toISOString() + const result = await script.run({ + selector: payload.Selector || {}, + params: payload.Params || {}, + artifact: (name) => { + const dir = payload.ArtifactDir || path.dirname(payload.ScriptPath) + fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, name) + }, + log: () => {}, + launch: async () => ({ ok: true }), + connect: async () => ({ + browser: { contexts: () => [] }, + context: { + pages: () => [], + newPage: async () => ({}) + }, + page: null + }) + }) + + console.log(JSON.stringify({ + ok: result && result.ok !== false, + summary: result && result.summary ? String(result.summary) : '', + error: result && result.error ? String(result.error) : '', + startedAt, + finishedAt: new Date().toISOString(), + ...result + })) +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) +}) +` + +type automationConcurrentRunResult struct { + run *automation.ScriptRunRecord + err error +} + +func newAutomationPlaywrightRunTestApp(t *testing.T, playwrightModuleSource string) (*App, func()) { + t.Helper() + + nodeExecPath := lookupAutomationTestNode(t) + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.Enabled = true + app.config.Automation.NodeSource = config.AutomationNodeSourceSystem + app.config.Automation.SystemNodePath = nodeExecPath + app.config.Automation.NodeVersion = "test-node" + app.config.Automation.PlaywrightCoreVersion = "1.59.0" + app.config.Automation.RuntimeVersion = "test-runtime" + app.browserMgr = browser.NewManager(app.config, app.appRoot) + app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + app.browserMgr.CodeProvider = app.launchCodeSvc + app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) + + prepareAutomationTestRuntimeWithPlaywrightModule( + t, + app.automationMgr, + app.config.Automation.PlaywrightCoreVersion, + playwrightModuleSource, + ) + + app.launchServer = launchcode.NewLaunchServer( + app.launchCodeSvc, + app, + app.browserMgr, + 0, + ) + if err := app.launchServer.Start(); err != nil { + t.Fatalf("start launch server failed: %v", err) + } + + return app, func() { + _ = app.launchServer.Stop() + } +} + +func automationTestServerPort(t *testing.T, rawURL string) int { + t.Helper() + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse server url failed: %v", err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("parse server port failed: %v", err) + } + return port +} + +func createAutomationRunningProfileWithCode(t *testing.T, app *App, name string, code string, debugPort int) *browser.Profile { + t.Helper() + + profile, err := app.browserMgr.Create(browser.ProfileInput{ + ProfileName: name, + }) + if err != nil { + t.Fatalf("create profile failed: %v", err) + } + if profile == nil { + t.Fatal("create profile returned nil") + } + if strings.TrimSpace(code) != "" { + if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, code); err != nil { + t.Fatalf("set code failed: %v", err) + } + } + + app.browserMgr.Profiles[profile.ProfileId].Running = true + app.browserMgr.Profiles[profile.ProfileId].DebugReady = true + app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort + app.browserMgr.Profiles[profile.ProfileId].Pid = 12345 + + return profile +} + +func runAutomationScriptsConcurrently(t *testing.T, count int, runner func(index int) (*automation.ScriptRunRecord, error)) []automationConcurrentRunResult { + t.Helper() + + results := make([]automationConcurrentRunResult, count) + start := make(chan struct{}) + var wg sync.WaitGroup + + for index := 0; index < count; index++ { + index := index + wg.Add(1) + go func() { + defer wg.Done() + <-start + run, err := runner(index) + results[index] = automationConcurrentRunResult{ + run: run, + err: err, + } + }() + } + + time.Sleep(50 * time.Millisecond) + close(start) + wg.Wait() + + return results +} diff --git a/backend/automation_script_run_integration_test.go b/backend/automation_script_run_integration_test.go index 34323673..ece7cc8c 100644 --- a/backend/automation_script_run_integration_test.go +++ b/backend/automation_script_run_integration_test.go @@ -7,14 +7,10 @@ import ( "net/http/httptest" "net/url" "os" - "os/exec" - "path/filepath" "strconv" "strings" - "sync" "sync/atomic" "testing" - "time" "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" @@ -436,234 +432,3 @@ func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *te t.Fatalf("expected one success and one failure on same profile, got success=%d failed=%d results=%+v", successCount, failedCount, results) } } - -func lookupAutomationTestNode(t *testing.T) string { - t.Helper() - - nodeExecPath, err := exec.LookPath("node") - if err != nil { - t.Skip("node is not installed") - } - return nodeExecPath -} - -func prepareAutomationTestRuntime(t *testing.T, manager *automation.Manager, playwrightVersion string) { - t.Helper() - - prepareAutomationTestRuntimeWithPlaywrightModule( - t, - manager, - playwrightVersion, - "module.exports = { chromium: {} }\n", - ) -} - -func prepareAutomationTestRuntimeWithPlaywrightModule(t *testing.T, manager *automation.Manager, playwrightVersion string, playwrightModuleSource string) { - t.Helper() - - state := manager.CurrentState() - - playwrightCoreDir := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(playwrightCoreDir, 0o755); err != nil { - t.Fatalf("create playwright-core dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(playwrightCoreDir, "package.json"), []byte("{\"name\":\"playwright-core\",\"version\":\""+playwrightVersion+"\"}\n"), 0o644); err != nil { - t.Fatalf("write playwright-core package failed: %v", err) - } - if err := os.WriteFile(filepath.Join(playwrightCoreDir, "index.js"), []byte(playwrightModuleSource), 0o644); err != nil { - t.Fatalf("write playwright-core stub failed: %v", err) - } - if err := os.WriteFile(state.RunnerPath, []byte(automationTestRunnerScript), 0o755); err != nil { - t.Fatalf("write runner script failed: %v", err) - } -} - -const automationTestConnectProbePlaywrightModule = `const http = require('http') - -module.exports = { - chromium: { - connectOverCDP: async (endpoint) => { - const target = new URL('/json/version', endpoint) - await new Promise((resolve, reject) => { - const req = http.get(target, (res) => { - res.resume() - res.on('end', () => { - const status = res.statusCode || 0 - if (status >= 200 && status < 300) { - resolve() - return - } - reject(new Error('cdp connect probe failed with http ' + String(status))) - }) - }) - req.on('error', reject) - }) - - return { - contexts: () => [{ - pages: () => [], - newPage: async () => ({}) - }], - close: async () => {} - } - } - } -} -` - -const automationTestRunnerScript = `const fs = require('fs') -const path = require('path') - -async function main() { - const payloadPath = process.argv[2] - const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf8')) - const script = require(payload.ScriptPath) - const startedAt = new Date().toISOString() - const result = await script.run({ - selector: payload.Selector || {}, - params: payload.Params || {}, - artifact: (name) => { - const dir = payload.ArtifactDir || path.dirname(payload.ScriptPath) - fs.mkdirSync(dir, { recursive: true }) - return path.join(dir, name) - }, - log: () => {}, - launch: async () => ({ ok: true }), - connect: async () => ({ - browser: { contexts: () => [] }, - context: { - pages: () => [], - newPage: async () => ({}) - }, - page: null - }) - }) - - console.log(JSON.stringify({ - ok: result && result.ok !== false, - summary: result && result.summary ? String(result.summary) : '', - error: result && result.error ? String(result.error) : '', - startedAt, - finishedAt: new Date().toISOString(), - ...result - })) -} - -main().catch((error) => { - console.error(error && error.stack ? error.stack : String(error)) - process.exit(1) -}) -` - -type automationConcurrentRunResult struct { - run *automation.ScriptRunRecord - err error -} - -func newAutomationPlaywrightRunTestApp(t *testing.T, playwrightModuleSource string) (*App, func()) { - t.Helper() - - nodeExecPath := lookupAutomationTestNode(t) - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.Enabled = true - app.config.Automation.NodeSource = config.AutomationNodeSourceSystem - app.config.Automation.SystemNodePath = nodeExecPath - app.config.Automation.NodeVersion = "test-node" - app.config.Automation.PlaywrightCoreVersion = "1.59.0" - app.config.Automation.RuntimeVersion = "test-runtime" - app.browserMgr = browser.NewManager(app.config, app.appRoot) - app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - app.browserMgr.CodeProvider = app.launchCodeSvc - app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) - - prepareAutomationTestRuntimeWithPlaywrightModule( - t, - app.automationMgr, - app.config.Automation.PlaywrightCoreVersion, - playwrightModuleSource, - ) - - app.launchServer = launchcode.NewLaunchServer( - app.launchCodeSvc, - app, - app.browserMgr, - 0, - ) - if err := app.launchServer.Start(); err != nil { - t.Fatalf("start launch server failed: %v", err) - } - - return app, func() { - _ = app.launchServer.Stop() - } -} - -func automationTestServerPort(t *testing.T, rawURL string) int { - t.Helper() - - parsed, err := url.Parse(rawURL) - if err != nil { - t.Fatalf("parse server url failed: %v", err) - } - port, err := strconv.Atoi(parsed.Port()) - if err != nil { - t.Fatalf("parse server port failed: %v", err) - } - return port -} - -func createAutomationRunningProfileWithCode(t *testing.T, app *App, name string, code string, debugPort int) *browser.Profile { - t.Helper() - - profile, err := app.browserMgr.Create(browser.ProfileInput{ - ProfileName: name, - }) - if err != nil { - t.Fatalf("create profile failed: %v", err) - } - if profile == nil { - t.Fatal("create profile returned nil") - } - if strings.TrimSpace(code) != "" { - if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, code); err != nil { - t.Fatalf("set code failed: %v", err) - } - } - - app.browserMgr.Profiles[profile.ProfileId].Running = true - app.browserMgr.Profiles[profile.ProfileId].DebugReady = true - app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort - app.browserMgr.Profiles[profile.ProfileId].Pid = 12345 - - return profile -} - -func runAutomationScriptsConcurrently(t *testing.T, count int, runner func(index int) (*automation.ScriptRunRecord, error)) []automationConcurrentRunResult { - t.Helper() - - results := make([]automationConcurrentRunResult, count) - start := make(chan struct{}) - var wg sync.WaitGroup - - for index := 0; index < count; index++ { - index := index - wg.Add(1) - go func() { - defer wg.Done() - <-start - run, err := runner(index) - results[index] = automationConcurrentRunResult{ - run: run, - err: err, - } - }() - } - - time.Sleep(50 * time.Millisecond) - close(start) - wg.Wait() - - return results -} diff --git a/backend/automation_script_target_helpers.go b/backend/automation_script_target_helpers.go new file mode 100644 index 00000000..4dd5183d --- /dev/null +++ b/backend/automation_script_target_helpers.go @@ -0,0 +1,22 @@ +package backend + +import "strings" + +func appendAutomationRunSummary(summary string, targetSummary string) string { + summary = strings.TrimSpace(summary) + targetSummary = strings.TrimSpace(targetSummary) + if targetSummary == "" { + return summary + } + if summary == "" { + return targetSummary + } + return summary + " · " + targetSummary +} + +func minAutomationInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/automation_script_target_profile_helpers.go b/backend/automation_script_target_profile_helpers.go new file mode 100644 index 00000000..70447e5b --- /dev/null +++ b/backend/automation_script_target_profile_helpers.go @@ -0,0 +1,101 @@ +package backend + +import ( + "fmt" + "sort" + "strings" + + "ant-chrome/backend/internal/browser" +) + +func filterAutomationProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile { + filtered := make([]browser.Profile, 0, len(items)) + for _, item := range items { + if keep(item) { + filtered = append(filtered, item) + } + } + return filtered +} + +func automationProfileHasAllTags(profile browser.Profile, required []string) bool { + if len(required) == 0 { + return true + } + if len(profile.Tags) == 0 { + return false + } + + for _, want := range required { + found := false + for _, tag := range profile.Tags { + if strings.EqualFold(strings.TrimSpace(tag), want) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func automationProfileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool { + if len(queries) == 0 { + return true + } + if len(profile.Keywords) == 0 { + return false + } + + for _, query := range queries { + queryLower := strings.ToLower(strings.TrimSpace(query)) + found := false + for _, keyword := range profile.Keywords { + if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func sortAutomationProfilesForTarget(items []browser.Profile) { + sort.Slice(items, func(i, j int) bool { + leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName)) + rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName)) + if leftName != rightName { + return leftName < rightName + } + return items[i].ProfileId < items[j].ProfileId + }) +} + +func buildAutomationTargetAmbiguousError(items []browser.Profile) string { + const maxPreview = 5 + parts := make([]string, 0, minAutomationInt(len(items), maxPreview)) + for i := 0; i < len(items) && i < maxPreview; i++ { + parts = append(parts, automationProfileLabel(items[i])) + } + suffix := "" + if len(items) > maxPreview { + suffix = fmt.Sprintf(" 等 %d 个实例", len(items)) + } + return fmt.Sprintf("命中了多个实例:%s%s。请改用 code/profileId,或继续加分组、标签、关键字缩小范围", strings.Join(parts, ","), suffix) +} + +func automationProfileLabel(profile browser.Profile) string { + label := strings.TrimSpace(profile.ProfileName) + if label == "" { + label = strings.TrimSpace(profile.ProfileId) + } + if code := strings.TrimSpace(profile.LaunchCode); code != "" { + return fmt.Sprintf("%s[id=%s, code=%s]", label, profile.ProfileId, code) + } + return fmt.Sprintf("%s[id=%s]", label, profile.ProfileId) +} diff --git a/backend/automation_script_target_resolver.go b/backend/automation_script_target_resolver.go index b28502c9..510c456c 100644 --- a/backend/automation_script_target_resolver.go +++ b/backend/automation_script_target_resolver.go @@ -3,7 +3,6 @@ package backend import ( "encoding/json" "fmt" - "sort" "strings" "time" @@ -14,6 +13,9 @@ import ( const defaultAutomationCreateNameTemplate = "${templateName}-${timestamp}" func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord, input automation.ScriptRunRequest, required bool) (map[string]any, string, error) { + if mode := automationScriptRunTargetMode(script, input); mode != automationScriptTargetMode(script) { + script.TargetConfig.Mode = mode + } overrideSelectorText := strings.TrimSpace(input.SelectorText) if automationScriptTargetMode(script) == "manual" && !input.UseScriptSelector && overrideSelectorText != "" { selector, err := parseAutomationJSONObject(overrideSelectorText, required) @@ -52,6 +54,16 @@ func automationScriptTargetMode(script automation.ScriptRecord) string { } } +func automationScriptRunTargetMode(script automation.ScriptRecord, input automation.ScriptRunRequest) string { + mode := strings.ToLower(strings.TrimSpace(input.TargetMode)) + switch mode { + case "manual", "existing", "create", "rotate": + return mode + default: + return automationScriptTargetMode(script) + } +} + func applyAutomationRunTargetInput(script automation.ScriptRecord, value any) (automation.ScriptRecord, error) { if value == nil { return script, nil @@ -320,98 +332,6 @@ func automationTargetSelectorEmpty(selector automation.ScriptTargetSelector) boo len(selector.Tags) == 0 } -func filterAutomationProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile { - filtered := make([]browser.Profile, 0, len(items)) - for _, item := range items { - if keep(item) { - filtered = append(filtered, item) - } - } - return filtered -} - -func automationProfileHasAllTags(profile browser.Profile, required []string) bool { - if len(required) == 0 { - return true - } - if len(profile.Tags) == 0 { - return false - } - - for _, want := range required { - found := false - for _, tag := range profile.Tags { - if strings.EqualFold(strings.TrimSpace(tag), want) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func automationProfileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool { - if len(queries) == 0 { - return true - } - if len(profile.Keywords) == 0 { - return false - } - - for _, query := range queries { - queryLower := strings.ToLower(strings.TrimSpace(query)) - found := false - for _, keyword := range profile.Keywords { - if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func sortAutomationProfilesForTarget(items []browser.Profile) { - sort.Slice(items, func(i, j int) bool { - leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName)) - rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName)) - if leftName != rightName { - return leftName < rightName - } - return items[i].ProfileId < items[j].ProfileId - }) -} - -func buildAutomationTargetAmbiguousError(items []browser.Profile) string { - const maxPreview = 5 - parts := make([]string, 0, minAutomationInt(len(items), maxPreview)) - for i := 0; i < len(items) && i < maxPreview; i++ { - parts = append(parts, automationProfileLabel(items[i])) - } - suffix := "" - if len(items) > maxPreview { - suffix = fmt.Sprintf(" 等 %d 个实例", len(items)) - } - return fmt.Sprintf("命中了多个实例:%s%s。请改用 code/profileId,或继续加分组、标签、关键字缩小范围", strings.Join(parts, ","), suffix) -} - -func automationProfileLabel(profile browser.Profile) string { - label := strings.TrimSpace(profile.ProfileName) - if label == "" { - label = strings.TrimSpace(profile.ProfileId) - } - if code := strings.TrimSpace(profile.LaunchCode); code != "" { - return fmt.Sprintf("%s[id=%s, code=%s]", label, profile.ProfileId, code) - } - return fmt.Sprintf("%s[id=%s]", label, profile.ProfileId) -} - func automationProfileSelector(profileID string) map[string]any { return map[string]any{ "profileId": strings.TrimSpace(profileID), @@ -477,22 +397,3 @@ func buildAutomationCreatedProfileName(template string, script automation.Script } return fmt.Sprintf("%s-%s", templateName, now.Format("20060102-150405")) } - -func appendAutomationRunSummary(summary string, targetSummary string) string { - summary = strings.TrimSpace(summary) - targetSummary = strings.TrimSpace(targetSummary) - if targetSummary == "" { - return summary - } - if summary == "" { - return targetSummary - } - return summary + " · " + targetSummary -} - -func minAutomationInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/backend/internal/automation/assets/runner.cjs b/backend/internal/automation/assets/runner.cjs index dbc0bc4b..835843db 100644 --- a/backend/internal/automation/assets/runner.cjs +++ b/backend/internal/automation/assets/runner.cjs @@ -1,495 +1,20 @@ -const fs = require('fs'); -const http = require('http'); -const https = require('https'); +const fs = require('fs'); const path = require('path'); -const util = require('util'); -const { pathToFileURL } = require('url'); +const { + normalizeTimeout, + sleep, + writeStream, + closeBrowserConnection, + buildConnectEndpoints, + normalizePathUnderRoot, + requestJSON, + toSerializable, +} = require('./runner_shared.cjs'); +const { normalizeOrigin, normalizePermissionList, normalizePageAPIRequest, executePageAPIRequest } = require('./runner_page_api.cjs'); +const { loadScriptModule } = require('./runner_script_loader.cjs'); const ALLOWED_WAIT_UNTIL = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); -function normalizeTimeout(value, fallback) { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.round(parsed); - } - return fallback; -} - -function isPlainObject(value) { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)); -} - -function hasOwnProperty(value, key) { - return Object.prototype.hasOwnProperty.call(value, key); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function writeStream(stream, text) { - return new Promise((resolve, reject) => { - stream.write(text, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -async function closeBrowserConnection(browser) { - if (!browser || typeof browser.close !== 'function') { - return; - } - await browser.close({ reason: 'automation task finished' }).catch(() => {}); -} - -function normalizeEndpointCandidate(value) { - const normalized = String(value || '').trim(); - if (!normalized) { - return ''; - } - - try { - const parsed = new URL(normalized); - if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) { - return ''; - } - if (parsed.port === '0') { - return ''; - } - if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (!parsed.pathname || parsed.pathname === '/') && !parsed.search && !parsed.hash) { - return parsed.origin; - } - return parsed.toString(); - } catch { - return ''; - } -} - -function buildConnectEndpoints(payload, session) { - const candidates = []; - const seen = new Set(); - - const pushCandidate = (value) => { - const endpoint = normalizeEndpointCandidate(value); - if (!endpoint || seen.has(endpoint)) { - return; - } - seen.add(endpoint); - candidates.push(endpoint); - }; - - pushCandidate(session && session.cdpUrl); - - const debugPort = Number(session && session.debugPort); - if (Number.isFinite(debugPort) && debugPort > 0) { - pushCandidate(`http://127.0.0.1:${Math.round(debugPort)}`); - } - - pushCandidate(payload && payload.launchBaseUrl); - return candidates; -} - -function normalizePathUnderRoot(rootDir, targetName) { - const normalizedName = String(targetName || '').trim(); - const resolvedRoot = path.resolve(String(rootDir || '')); - if (!resolvedRoot) { - throw new Error('artifactDir is required'); - } - - const candidate = normalizedName ? path.resolve(resolvedRoot, normalizedName) : resolvedRoot; - if (candidate !== resolvedRoot && !candidate.startsWith(`${resolvedRoot}${path.sep}`)) { - throw new Error('artifact path escapes root directory'); - } - return candidate; -} - -async function requestJSON(method, requestURL, body, headers = {}) { - const target = new URL(requestURL); - const transport = target.protocol === 'https:' ? https : http; - const payload = body == null ? '' : JSON.stringify(body); - - return await new Promise((resolve, reject) => { - const req = transport.request( - { - protocol: target.protocol, - hostname: target.hostname, - port: target.port, - path: `${target.pathname}${target.search}`, - method, - headers: { - Accept: 'application/json', - ...(payload - ? { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - } - : {}), - ...headers, - }, - }, - (res) => { - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - const rawText = Buffer.concat(chunks).toString('utf8').trim(); - let responseBody = {}; - if (rawText) { - try { - responseBody = JSON.parse(rawText); - } catch { - responseBody = { rawBody: rawText }; - } - } - resolve({ - status: res.statusCode || 0, - body: responseBody, - }); - }); - } - ); - - req.on('error', reject); - if (payload) { - req.write(payload); - } - req.end(); - }); -} - -function inspectValue(value) { - return util.inspect(value, { - depth: 4, - breakLength: 120, - maxArrayLength: 20, - compact: false, - }); -} - -function toSerializable(value, seen = new WeakSet()) { - if (value == null) { - return value; - } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - if (value instanceof Error) { - return { - name: value.name, - message: value.message, - stack: value.stack, - }; - } - if (Buffer.isBuffer(value)) { - return value.toString('utf8'); - } - if (Array.isArray(value)) { - return value.map((item) => toSerializable(item, seen)); - } - if (typeof value === 'function') { - return `[Function ${value.name || 'anonymous'}]`; - } - if (typeof value !== 'object') { - return inspectValue(value); - } - if (seen.has(value)) { - return '[Circular]'; - } - seen.add(value); - - const prototype = Object.getPrototypeOf(value); - if (prototype === Object.prototype || prototype === null) { - const result = {}; - for (const [key, entry] of Object.entries(value)) { - result[key] = toSerializable(entry, seen); - } - return result; - } - - return inspectValue(value); -} - -function normalizeOrigin(value) { - const normalized = String(value || '').trim(); - if (!normalized) { - return ''; - } - - try { - const parsed = new URL(normalized); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return ''; - } - return parsed.origin; - } catch { - return ''; - } -} - -function normalizePermissionList(value) { - const source = Array.isArray(value) ? value : value == null ? [] : [value]; - const result = []; - const seen = new Set(); - - for (const item of source) { - const normalized = String(item || '').trim(); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - result.push(normalized); - } - - return result; -} - -function normalizePageAPIHeaders(value) { - const headers = {}; - if (!value) { - return headers; - } - - if (typeof value.forEach === 'function') { - value.forEach((entryValue, entryKey) => { - const key = String(entryKey || '').trim(); - if (key) { - headers[key] = String(entryValue); - } - }); - return headers; - } - - if (Array.isArray(value)) { - for (const entry of value) { - if (!Array.isArray(entry) || entry.length < 2) { - continue; - } - const key = String(entry[0] || '').trim(); - if (key) { - headers[key] = String(entry[1]); - } - } - return headers; - } - - if (isPlainObject(value)) { - for (const [key, entryValue] of Object.entries(value)) { - const normalizedKey = String(key || '').trim(); - if (normalizedKey && entryValue !== undefined && entryValue !== null) { - headers[normalizedKey] = String(entryValue); - } - } - } - return headers; -} - -function setPageAPIHeaderIfAbsent(headers, key, value) { - const normalizedKey = String(key || '').trim(); - if (!normalizedKey) { - return; - } - const lowerKey = normalizedKey.toLowerCase(); - if (Object.keys(headers).some((existingKey) => existingKey.toLowerCase() === lowerKey)) { - return; - } - headers[normalizedKey] = value; -} - -function appendPageAPIQuery(rawURL, query) { - if (!isPlainObject(query) && !Array.isArray(query)) { - return rawURL; - } - - const searchParams = new URLSearchParams(); - const appendEntry = (key, value) => { - const normalizedKey = String(key || '').trim(); - if (!normalizedKey || value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - for (const item of value) { - appendEntry(normalizedKey, item); - } - return; - } - searchParams.append(normalizedKey, String(value)); - }; - - if (Array.isArray(query)) { - for (const entry of query) { - if (Array.isArray(entry) && entry.length >= 2) { - appendEntry(entry[0], entry[1]); - } - } - } else { - for (const [key, value] of Object.entries(query)) { - appendEntry(key, value); - } - } - - const queryText = searchParams.toString(); - if (!queryText) { - return rawURL; - } - - const hashIndex = rawURL.indexOf('#'); - const baseURL = hashIndex >= 0 ? rawURL.slice(0, hashIndex) : rawURL; - const hash = hashIndex >= 0 ? rawURL.slice(hashIndex) : ''; - const separator = baseURL.includes('?') - ? baseURL.endsWith('?') || baseURL.endsWith('&') - ? '' - : '&' - : '?'; - return `${baseURL}${separator}${queryText}${hash}`; -} - -function normalizePageAPICredentials(value) { - const normalized = String(value || '').trim(); - if (['include', 'same-origin', 'omit'].includes(normalized)) { - return normalized; - } - return 'include'; -} - -function normalizePageAPIBody(source, headers) { - if (hasOwnProperty(source, 'bodyText')) { - return source.bodyText == null ? null : String(source.bodyText); - } - - if (hasOwnProperty(source, 'json')) { - setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json'); - return JSON.stringify(source.json == null ? null : source.json); - } - - if (!hasOwnProperty(source, 'body')) { - return null; - } - - const body = source.body; - if (body == null) { - return null; - } - if (typeof body === 'string') { - return body; - } - setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json'); - return JSON.stringify(body); -} - -function normalizePageAPIRequest(urlOrRequest, options = {}) { - const base = isPlainObject(urlOrRequest) ? urlOrRequest : { url: urlOrRequest }; - const source = { - ...base, - ...(isPlainObject(options) ? options : {}), - }; - const headers = normalizePageAPIHeaders(source.headers); - const bodyText = normalizePageAPIBody(source, headers); - const method = String( - source.method || (bodyText == null ? 'GET' : 'POST') - ) - .trim() - .toUpperCase(); - const url = appendPageAPIQuery(String(source.url || '').trim(), source.query || source.searchParams); - - if (!url) { - throw new Error('page api url is required'); - } - if ((method === 'GET' || method === 'HEAD') && bodyText != null) { - throw new Error(`${method} page api request cannot include a body`); - } - - return { - url, - method, - headers, - credentials: normalizePageAPICredentials(source.credentials), - bodyText, - timeoutMs: normalizeTimeout(source.timeoutMs, 30000), - parseJSON: source.parseJSON !== false, - throwOnError: source.throwOnError === true || source.throwOnHTTPError === true, - }; -} - -async function executePageAPIRequest(request) { - const headers = request && request.headers && typeof request.headers === 'object' - ? request.headers - : {}; - const init = { - method: request.method || 'GET', - headers, - credentials: request.credentials || 'include', - }; - - let timeoutID = null; - if (request.timeoutMs > 0 && typeof AbortController !== 'undefined') { - const controller = new AbortController(); - init.signal = controller.signal; - timeoutID = setTimeout(() => controller.abort(), request.timeoutMs); - } - - if (request.bodyText !== null && request.bodyText !== undefined) { - init.body = request.bodyText; - } - - try { - const response = await fetch(request.url, init); - const responseHeaders = {}; - if (response.headers && typeof response.headers.forEach === 'function') { - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - } - - const bodyText = await response.text(); - let bodyJSON = null; - let hasBodyJSON = false; - if (request.parseJSON !== false && String(bodyText || '').trim()) { - try { - bodyJSON = JSON.parse(bodyText); - hasBodyJSON = true; - } catch {} - } - - return { - ok: response.ok, - status: response.status, - statusText: response.statusText, - url: response.url, - headers: responseHeaders, - bodyText, - bodyJSON: hasBodyJSON ? bodyJSON : null, - json: hasBodyJSON ? bodyJSON : null, - error: response.ok ? '' : response.statusText || `HTTP ${response.status}`, - }; - } catch (error) { - const message = error && error.message ? error.message : String(error); - return { - ok: false, - status: 0, - statusText: '', - url: request.url, - headers: {}, - bodyText: '', - bodyJSON: null, - json: null, - error: message, - }; - } finally { - if (timeoutID) { - clearTimeout(timeoutID); - } - } -} - function buildLaunchRequestBody(defaultSelector, options) { const launchOptions = options && typeof options === 'object' ? options : {}; const body = {}; @@ -529,63 +54,6 @@ function buildLaunchRequestBody(defaultSelector, options) { return body; } -async function loadScriptModule(scriptPath) { - const resolvedPath = path.resolve(String(scriptPath || '')); - if (!resolvedPath) { - throw new Error('scriptPath is required'); - } - - let requiredModule = null; - let requireError = null; - try { - requiredModule = require(resolvedPath); - } catch (error) { - requireError = error; - } - - const imported = async () => { - const moduleURL = pathToFileURL(resolvedPath).href; - return await import(`${moduleURL}?t=${Date.now()}`); - }; - - if (requiredModule && typeof requiredModule.run === 'function') { - return requiredModule; - } - if (typeof requiredModule === 'function') { - return { run: requiredModule }; - } - if (requiredModule && requiredModule.default && typeof requiredModule.default.run === 'function') { - return requiredModule.default; - } - - try { - const importedModule = await imported(); - if (importedModule && typeof importedModule.run === 'function') { - return importedModule; - } - if (importedModule && typeof importedModule.default === 'function') { - return { run: importedModule.default }; - } - if ( - importedModule && - importedModule.default && - typeof importedModule.default.run === 'function' - ) { - return importedModule.default; - } - } catch (importError) { - if (requireError) { - throw requireError; - } - throw importError; - } - - if (requireError) { - throw requireError; - } - throw new Error('script must export run()'); -} - async function runScriptTask(payload, chromium) { const scriptModule = await loadScriptModule(payload.scriptPath); if (!scriptModule || typeof scriptModule.run !== 'function') { diff --git a/backend/internal/automation/assets/runner_page_api.cjs b/backend/internal/automation/assets/runner_page_api.cjs new file mode 100644 index 00000000..fcd207c2 --- /dev/null +++ b/backend/internal/automation/assets/runner_page_api.cjs @@ -0,0 +1,285 @@ +const { + normalizeTimeout, + isPlainObject, + hasOwnProperty, + requestJSON, +} = require('./runner_shared.cjs'); + +function normalizeOrigin(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return ''; + } + + try { + const parsed = new URL(normalized); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return ''; + } + return parsed.origin; + } catch { + return ''; + } +} + +function normalizePermissionList(value) { + const source = Array.isArray(value) ? value : value == null ? [] : [value]; + const result = []; + const seen = new Set(); + + for (const item of source) { + const normalized = String(item || '').trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + + return result; +} + +function normalizePageAPIHeaders(value) { + const headers = {}; + if (!value) { + return headers; + } + + if (typeof value.forEach === 'function') { + value.forEach((entryValue, entryKey) => { + const key = String(entryKey || '').trim(); + if (key) { + headers[key] = String(entryValue); + } + }); + return headers; + } + + if (Array.isArray(value)) { + for (const entry of value) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const key = String(entry[0] || '').trim(); + if (key) { + headers[key] = String(entry[1]); + } + } + return headers; + } + + if (isPlainObject(value)) { + for (const [key, entryValue] of Object.entries(value)) { + const normalizedKey = String(key || '').trim(); + if (normalizedKey && entryValue !== undefined && entryValue !== null) { + headers[normalizedKey] = String(entryValue); + } + } + } + return headers; +} + +function setPageAPIHeaderIfAbsent(headers, key, value) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) { + return; + } + const lowerKey = normalizedKey.toLowerCase(); + if (Object.keys(headers).some((existingKey) => existingKey.toLowerCase() === lowerKey)) { + return; + } + headers[normalizedKey] = value; +} + +function appendPageAPIQuery(rawURL, query) { + if (!isPlainObject(query) && !Array.isArray(query)) { + return rawURL; + } + + const searchParams = new URLSearchParams(); + const appendEntry = (key, value) => { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey || value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + appendEntry(normalizedKey, item); + } + return; + } + searchParams.append(normalizedKey, String(value)); + }; + + if (Array.isArray(query)) { + for (const entry of query) { + if (Array.isArray(entry) && entry.length >= 2) { + appendEntry(entry[0], entry[1]); + } + } + } else { + for (const [key, value] of Object.entries(query)) { + appendEntry(key, value); + } + } + + const queryText = searchParams.toString(); + if (!queryText) { + return rawURL; + } + + const hashIndex = rawURL.indexOf('#'); + const baseURL = hashIndex >= 0 ? rawURL.slice(0, hashIndex) : rawURL; + const hash = hashIndex >= 0 ? rawURL.slice(hashIndex) : ''; + const separator = baseURL.includes('?') + ? baseURL.endsWith('?') || baseURL.endsWith('&') + ? '' + : '&' + : '?'; + return `${baseURL}${separator}${queryText}${hash}`; +} + +function normalizePageAPICredentials(value) { + const normalized = String(value || '').trim(); + if (['include', 'same-origin', 'omit'].includes(normalized)) { + return normalized; + } + return 'include'; +} + +function normalizePageAPIBody(source, headers) { + if (hasOwnProperty(source, 'bodyText')) { + return source.bodyText == null ? null : String(source.bodyText); + } + + if (hasOwnProperty(source, 'json')) { + setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json'); + return JSON.stringify(source.json == null ? null : source.json); + } + + if (!hasOwnProperty(source, 'body')) { + return null; + } + + const body = source.body; + if (body == null) { + return null; + } + if (typeof body === 'string') { + return body; + } + setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json'); + return JSON.stringify(body); +} + +function normalizePageAPIRequest(urlOrRequest, options = {}) { + const base = isPlainObject(urlOrRequest) ? urlOrRequest : { url: urlOrRequest }; + const source = { + ...base, + ...(isPlainObject(options) ? options : {}), + }; + const headers = normalizePageAPIHeaders(source.headers); + const bodyText = normalizePageAPIBody(source, headers); + const method = String( + source.method || (bodyText == null ? 'GET' : 'POST') + ) + .trim() + .toUpperCase(); + const url = appendPageAPIQuery(String(source.url || '').trim(), source.query || source.searchParams); + + if (!url) { + throw new Error('page api url is required'); + } + if ((method === 'GET' || method === 'HEAD') && bodyText != null) { + throw new Error(`${method} page api request cannot include a body`); + } + + return { + url, + method, + headers, + credentials: normalizePageAPICredentials(source.credentials), + bodyText, + timeoutMs: normalizeTimeout(source.timeoutMs, 30000), + parseJSON: source.parseJSON !== false, + throwOnError: source.throwOnError === true || source.throwOnHTTPError === true, + }; +} + +async function executePageAPIRequest(request) { + const headers = request && request.headers && typeof request.headers === 'object' + ? request.headers + : {}; + const init = { + method: request.method || 'GET', + headers, + credentials: request.credentials || 'include', + }; + + let timeoutID = null; + if (request.timeoutMs > 0 && typeof AbortController !== 'undefined') { + const controller = new AbortController(); + init.signal = controller.signal; + timeoutID = setTimeout(() => controller.abort(), request.timeoutMs); + } + + if (request.bodyText !== null && request.bodyText !== undefined) { + init.body = request.bodyText; + } + + try { + const response = await fetch(request.url, init); + const responseHeaders = {}; + if (response.headers && typeof response.headers.forEach === 'function') { + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + } + + const bodyText = await response.text(); + let bodyJSON = null; + let hasBodyJSON = false; + if (request.parseJSON !== false && String(bodyText || '').trim()) { + try { + bodyJSON = JSON.parse(bodyText); + hasBodyJSON = true; + } catch {} + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + url: response.url, + headers: responseHeaders, + bodyText, + bodyJSON: hasBodyJSON ? bodyJSON : null, + json: hasBodyJSON ? bodyJSON : null, + error: response.ok ? '' : response.statusText || `HTTP ${response.status}`, + }; + } catch (error) { + const message = error && error.message ? error.message : String(error); + return { + ok: false, + status: 0, + statusText: '', + url: request.url, + headers: {}, + bodyText: '', + bodyJSON: null, + json: null, + error: message, + }; + } finally { + if (timeoutID) { + clearTimeout(timeoutID); + } + } +} + +module.exports = { + normalizeOrigin, + normalizePermissionList, + normalizePageAPIRequest, + executePageAPIRequest, +}; \ No newline at end of file diff --git a/backend/internal/automation/assets/runner_script_loader.cjs b/backend/internal/automation/assets/runner_script_loader.cjs new file mode 100644 index 00000000..4c7b207c --- /dev/null +++ b/backend/internal/automation/assets/runner_script_loader.cjs @@ -0,0 +1,61 @@ +const path = require('path'); +const { pathToFileURL } = require('url'); + +async function loadScriptModule(scriptPath) { + const resolvedPath = path.resolve(String(scriptPath || '')); + if (!resolvedPath) { + throw new Error('scriptPath is required'); + } + + let requiredModule = null; + let requireError = null; + try { + requiredModule = require(resolvedPath); + } catch (error) { + requireError = error; + } + + const imported = async () => { + const moduleURL = pathToFileURL(resolvedPath).href; + return await import(`${moduleURL}?t=${Date.now()}`); + }; + + if (requiredModule && typeof requiredModule.run === 'function') { + return requiredModule; + } + if (typeof requiredModule === 'function') { + return { run: requiredModule }; + } + if (requiredModule && requiredModule.default && typeof requiredModule.default.run === 'function') { + return requiredModule.default; + } + + try { + const importedModule = await imported(); + if (importedModule && typeof importedModule.run === 'function') { + return importedModule; + } + if (importedModule && typeof importedModule.default === 'function') { + return { run: importedModule.default }; + } + if ( + importedModule && + importedModule.default && + typeof importedModule.default.run === 'function' + ) { + return importedModule.default; + } + } catch (importError) { + if (requireError) { + throw requireError; + } + throw importError; + } + + if (requireError) { + throw requireError; + } + throw new Error('script must export run()'); +} + +module.exports = { loadScriptModule }; \ No newline at end of file diff --git a/backend/internal/automation/assets/runner_shared.cjs b/backend/internal/automation/assets/runner_shared.cjs new file mode 100644 index 00000000..65b48178 --- /dev/null +++ b/backend/internal/automation/assets/runner_shared.cjs @@ -0,0 +1,232 @@ +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); +const util = require('util'); + +const ALLOWED_WAIT_UNTIL = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); + +function normalizeTimeout(value, fallback) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.round(parsed); + } + return fallback; +} + +function isPlainObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function hasOwnProperty(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function writeStream(stream, text) { + return new Promise((resolve, reject) => { + stream.write(text, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function closeBrowserConnection(browser) { + if (!browser || typeof browser.close !== 'function') { + return; + } + await browser.close({ reason: 'automation task finished' }).catch(() => {}); +} + +function normalizeEndpointCandidate(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return ''; + } + + try { + const parsed = new URL(normalized); + if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) { + return ''; + } + if (parsed.port === '0') { + return ''; + } + if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (!parsed.pathname || parsed.pathname === '/') && !parsed.search && !parsed.hash) { + return parsed.origin; + } + return parsed.toString(); + } catch { + return ''; + } +} + +function buildConnectEndpoints(payload, session) { + const candidates = []; + const seen = new Set(); + + const pushCandidate = (value) => { + const endpoint = normalizeEndpointCandidate(value); + if (!endpoint || seen.has(endpoint)) { + return; + } + seen.add(endpoint); + candidates.push(endpoint); + }; + + pushCandidate(session && session.cdpUrl); + + const debugPort = Number(session && session.debugPort); + if (Number.isFinite(debugPort) && debugPort > 0) { + pushCandidate(`http://127.0.0.1:${Math.round(debugPort)}`); + } + + pushCandidate(payload && payload.launchBaseUrl); + return candidates; +} + +function normalizePathUnderRoot(rootDir, targetName) { + const normalizedName = String(targetName || '').trim(); + const resolvedRoot = path.resolve(String(rootDir || '')); + if (!resolvedRoot) { + throw new Error('artifactDir is required'); + } + + const candidate = normalizedName ? path.resolve(resolvedRoot, normalizedName) : resolvedRoot; + if (candidate !== resolvedRoot && !candidate.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error('artifact path escapes root directory'); + } + return candidate; +} + +async function requestJSON(method, requestURL, body, headers = {}) { + const target = new URL(requestURL); + const transport = target.protocol === 'https:' ? https : http; + const payload = body == null ? '' : JSON.stringify(body); + + return await new Promise((resolve, reject) => { + const req = transport.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + method, + headers: { + Accept: 'application/json', + ...(payload + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + } + : {}), + ...headers, + }, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const rawText = Buffer.concat(chunks).toString('utf8').trim(); + let responseBody = {}; + if (rawText) { + try { + responseBody = JSON.parse(rawText); + } catch { + responseBody = { rawBody: rawText }; + } + } + resolve({ + status: res.statusCode || 0, + body: responseBody, + }); + }); + } + ); + + req.on('error', reject); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +function inspectValue(value) { + return util.inspect(value, { + depth: 4, + breakLength: 120, + maxArrayLength: 20, + compact: false, + }); +} + +function toSerializable(value, seen = new WeakSet()) { + if (value == null) { + return value; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + if (Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + if (Array.isArray(value)) { + return value.map((item) => toSerializable(item, seen)); + } + if (typeof value === 'function') { + return `[Function ${value.name || 'anonymous'}]`; + } + if (typeof value !== 'object') { + return inspectValue(value); + } + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + + const prototype = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null) { + const result = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = toSerializable(entry, seen); + } + return result; + } + + return inspectValue(value); +} + + +module.exports = { + normalizeTimeout, + isPlainObject, + hasOwnProperty, + sleep, + writeStream, + closeBrowserConnection, + buildConnectEndpoints, + normalizePathUnderRoot, + requestJSON, + toSerializable, +}; \ No newline at end of file diff --git a/backend/internal/automation/builtin_script_library_test.go b/backend/internal/automation/builtin_script_library_test.go index de1265f1..686596cb 100644 --- a/backend/internal/automation/builtin_script_library_test.go +++ b/backend/internal/automation/builtin_script_library_test.go @@ -58,8 +58,9 @@ func TestDefaultScriptBundles(t *testing.T) { t.Fatalf("expected public api to be enabled for %q", bundle.Record.ID) } if bundle.Record.ID == WebImageGenerateScriptID { - if len(bundle.Record.PublicAPI.Variables) != 1 || bundle.Record.PublicAPI.Variables[0].Name != "prompt" { - t.Fatalf("expected web image script to expose prompt variable, got %+v", bundle.Record.PublicAPI.Variables) + variables := bundle.Record.PublicAPI.Variables + if len(variables) != 2 || variables[0].Name != "code" || variables[1].Name != "prompt" { + t.Fatalf("expected web image script to expose code and prompt variables, got %+v", variables) } } if len(bundle.Files) == 0 { diff --git a/backend/internal/automation/demo-library/news-query-txt/index.cjs b/backend/internal/automation/demo-library/news-query-txt/index.cjs index 96b755bc..1a9e0a8d 100644 --- a/backend/internal/automation/demo-library/news-query-txt/index.cjs +++ b/backend/internal/automation/demo-library/news-query-txt/index.cjs @@ -1,384 +1,18 @@ -const fs = require('fs') - -const DEFAULT_EXCLUDED_DOMAINS = [ - 'zhihu.com', - 'baidu.com', - 'qq.com', - '36kr.com', - 'apifox.com', - 'chatgpt-chinese.com', - 'openwebui.cn', - 'open-openai.com', - 'xiniushu.com', - 'reddit.com', - 'quora.com', - 'tieba.baidu.com', - 'weibo.com', - 'x.com', - 'twitter.com', - 'youtube.com', - 'bilibili.com', - 'douyin.com', - 'xiaohongshu.com', -] - -function normalizeInt(value, fallback, min, max) { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return fallback - } - - const rounded = Math.round(parsed) - if (rounded < min) { - return min - } - if (rounded > max) { - return max - } - return rounded -} - -function normalizeText(value) { - return String(value || '').trim() -} - -function normalizeDomainList(value) { - if (!Array.isArray(value)) { - return [] - } - - const deduped = new Set() - for (const item of value) { - const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase() - if (normalized) { - deduped.add(normalized) - } - } - return Array.from(deduped) -} - -function buildDefaultQuery(keyword) { - const normalizedKeyword = normalizeText(keyword) || 'OpenAI' - if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { - return normalizedKeyword + ' 新闻' - } - return normalizedKeyword + ' news' -} - -function buildFallbackQueries(keyword, baseQuery) { - const normalizedKeyword = normalizeText(keyword) || 'OpenAI' - const normalizedBaseQuery = normalizeText(baseQuery) - const candidates = [ - normalizedBaseQuery, - ] - - if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { - candidates.push(normalizedKeyword + ' 最新新闻') - } else { - candidates.push(normalizedKeyword + ' latest news') - } - - const deduped = new Set() - for (const item of candidates) { - const normalized = normalizeText(item) - if (normalized) { - deduped.add(normalized) - } - } - return Array.from(deduped) -} - -function buildSearchQuery(baseQuery, excludedDomains) { - const normalizedBaseQuery = normalizeText(baseQuery) - const normalizedDomains = normalizeDomainList(excludedDomains) - const parts = [normalizedBaseQuery] - - for (const domain of normalizedDomains) { - parts.push('-site:' + domain) - } - - return parts.filter(Boolean).join(' ') -} - -function mapTimeRangeToBingFilter(value) { - switch (normalizeText(value).toLowerCase()) { - case 'day': - case '24h': - case 'today': - return 'ex1:"ez1"' - case 'week': - return 'ex1:"ez2"' - case 'month': - return 'ex1:"ez3"' - default: - return '' - } -} - -function buildSearchURL(query, timeRange, firstResultIndex) { - const searchParams = new URLSearchParams({ q: query }) - const filter = mapTimeRangeToBingFilter(timeRange) - if (filter) { - searchParams.set('filters', filter) - } - if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) { - searchParams.set('first', String(firstResultIndex)) - } - return 'https://www.bing.com/search?' + searchParams.toString() -} - -function splitSnippet(snippet) { - const normalized = normalizeText(snippet) - if (!normalized) { - return { publishedAt: '', summary: '' } - } - - const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/) - if ( - match && - /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1]) - ) { - return { - publishedAt: normalizeText(match[1]), - summary: normalizeText(match[2]), - } - } - - return { - publishedAt: '', - summary: normalized, - } -} - -function parseHostname(rawUrl) { - const normalized = normalizeText(rawUrl) - if (!normalized) { - return '' - } - - try { - return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase() - } catch { - return '' - } -} - -function parsePathname(rawUrl) { - const normalized = normalizeText(rawUrl) - if (!normalized) { - return '' - } - - try { - const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase() - if (!pathname) { - return '' - } - return pathname === '/' ? pathname : pathname.replace(/\/$/, '') - } catch { - return '' - } -} - -function looksLikeQuestionTitle(title) { - const normalized = normalizeText(title) - if (!normalized) { - return false - } - - if (/[??]/.test(normalized)) { - return true - } - - return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized) -} - -function looksLikeAggregateText(text) { - const normalized = normalizeText(text).toLowerCase() - if (!normalized) { - return false - } - - return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized) -} - -function looksLikeListingPath(pathname) { - const normalized = normalizeText(pathname).toLowerCase() - if (!normalized || normalized === '/') { - return false - } - - if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) { - return true - } - - if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) { - return true - } - - return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized) -} - -function looksLikeListingText(text) { - const normalized = normalizeText(text).toLowerCase() - if (!normalized) { - return false - } - - return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized) -} - -function isBlockedHostname(hostname) { - const normalized = normalizeText(hostname).toLowerCase() - if (!normalized) { - return false - } - - const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS - const blockedKeywords = [ - 'aitrack', - 'aitoolly', - 'aiflashreport', - 'llm-stats', - 'opentools', - ] - - if (blockedSuffixes.some(function (suffix) { - return normalized === suffix || normalized.endsWith('.' + suffix) - })) { - return true - } - - return blockedKeywords.some(function (keyword) { - return normalized.includes(keyword) - }) -} - -function evaluateNewsItem(item) { - const hostname = parseHostname(item.url) - const pathname = parsePathname(item.url) - const summary = normalizeText(item.summary) - const source = normalizeText(item.source) - const reasons = [] - - if (!normalizeText(item.url)) { - reasons.push('missing-url') - } - if (!hostname) { - reasons.push('invalid-url') - } - if (hostname && isBlockedHostname(hostname)) { - reasons.push('blocked-host') - } - if (!source) { - reasons.push('missing-source') - } - if (summary.length < 20) { - reasons.push('summary-too-short') - } - if (looksLikeQuestionTitle(item.title)) { - reasons.push('question-title') - } - if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) { - reasons.push('aggregate-page') - } - if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) { - reasons.push('listing-page') - } - - return Object.assign({}, item, { - hostname: hostname, - pathname: pathname, - qualityAccepted: reasons.length === 0, - qualityReasons: reasons, - }) -} - -function formatRejectedReason(reason) { - switch (reason) { - case 'missing-url': - return '缺少链接' - case 'invalid-url': - return '链接无效' - case 'blocked-host': - return '来源站点已过滤' - case 'missing-source': - return '缺少来源' - case 'summary-too-short': - return '摘要过短' - case 'question-title': - return '标题更像问答' - case 'aggregate-page': - return '更像聚合页' - case 'listing-page': - return '更像列表页/专题页' - default: - return reason - } -} - -function formatReport(items, metadata) { - const lines = [ - '新闻抓取结果', - '查询词: ' + metadata.query, - '抓取时间: ' + metadata.generatedAt, - '搜索地址: ' + metadata.searchUrl, - '原始结果: ' + metadata.rawCount, - '通过校验: ' + items.length, - '过滤数量: ' + metadata.rejectedItems.length, - '', - ] - - for (const item of items) { - lines.push(item.rank + '. ' + item.title) - if (item.source) { - lines.push('来源: ' + item.source) - } - if (item.publishedAt) { - lines.push('时间: ' + item.publishedAt) - } - lines.push('链接: ' + item.url) - if (item.summary) { - lines.push('摘要: ' + item.summary) - } - lines.push('') - } - - if (metadata.rejectedItems.length > 0) { - lines.push('被过滤结果(最多展示 5 条)') - lines.push('') - for (const item of metadata.rejectedItems.slice(0, 5)) { - lines.push(item.rank + '. ' + item.title) - if (item.hostname) { - lines.push('站点: ' + item.hostname) - } - lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / ')) - lines.push('') - } - } - - return lines.join('\n') -} - -function pickBestAttempt(current, candidate) { - if (!current) { - return candidate - } - - if (candidate.acceptedItems.length !== current.acceptedItems.length) { - return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current - } - - if (candidate.distinctHostCount !== current.distinctHostCount) { - return candidate.distinctHostCount > current.distinctHostCount ? candidate : current - } - - if (candidate.rawItems.length !== current.rawItems.length) { - return candidate.rawItems.length > current.rawItems.length ? candidate : current - } - - return candidate -} +const fs = require('fs') +const { + DEFAULT_EXCLUDED_DOMAINS, + normalizeInt, + normalizeText, + normalizeDomainList, + buildDefaultQuery, + buildFallbackQueries, + buildSearchQuery, + buildSearchURL, + splitSnippet, + evaluateNewsItem, + formatReport, + pickBestAttempt, +} = require('./news-query-utils.cjs') module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000) diff --git a/backend/internal/automation/demo-library/news-query-txt/news-query-utils.cjs b/backend/internal/automation/demo-library/news-query-txt/news-query-utils.cjs new file mode 100644 index 00000000..2b075864 --- /dev/null +++ b/backend/internal/automation/demo-library/news-query-txt/news-query-utils.cjs @@ -0,0 +1,395 @@ +const DEFAULT_EXCLUDED_DOMAINS = [ + 'zhihu.com', + 'baidu.com', + 'qq.com', + '36kr.com', + 'apifox.com', + 'chatgpt-chinese.com', + 'openwebui.cn', + 'open-openai.com', + 'xiniushu.com', + 'reddit.com', + 'quora.com', + 'tieba.baidu.com', + 'weibo.com', + 'x.com', + 'twitter.com', + 'youtube.com', + 'bilibili.com', + 'douyin.com', + 'xiaohongshu.com', +] + +function normalizeInt(value, fallback, min, max) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + + const rounded = Math.round(parsed) + if (rounded < min) { + return min + } + if (rounded > max) { + return max + } + return rounded +} + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeDomainList(value) { + if (!Array.isArray(value)) { + return [] + } + + const deduped = new Set() + for (const item of value) { + const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase() + if (normalized) { + deduped.add(normalized) + } + } + return Array.from(deduped) +} + +function buildDefaultQuery(keyword) { + const normalizedKeyword = normalizeText(keyword) || 'OpenAI' + if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { + return normalizedKeyword + ' 新闻' + } + return normalizedKeyword + ' news' +} + +function buildFallbackQueries(keyword, baseQuery) { + const normalizedKeyword = normalizeText(keyword) || 'OpenAI' + const normalizedBaseQuery = normalizeText(baseQuery) + const candidates = [ + normalizedBaseQuery, + ] + + if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { + candidates.push(normalizedKeyword + ' 最新新闻') + } else { + candidates.push(normalizedKeyword + ' latest news') + } + + const deduped = new Set() + for (const item of candidates) { + const normalized = normalizeText(item) + if (normalized) { + deduped.add(normalized) + } + } + return Array.from(deduped) +} + +function buildSearchQuery(baseQuery, excludedDomains) { + const normalizedBaseQuery = normalizeText(baseQuery) + const normalizedDomains = normalizeDomainList(excludedDomains) + const parts = [normalizedBaseQuery] + + for (const domain of normalizedDomains) { + parts.push('-site:' + domain) + } + + return parts.filter(Boolean).join(' ') +} + +function mapTimeRangeToBingFilter(value) { + switch (normalizeText(value).toLowerCase()) { + case 'day': + case '24h': + case 'today': + return 'ex1:"ez1"' + case 'week': + return 'ex1:"ez2"' + case 'month': + return 'ex1:"ez3"' + default: + return '' + } +} + +function buildSearchURL(query, timeRange, firstResultIndex) { + const searchParams = new URLSearchParams({ q: query }) + const filter = mapTimeRangeToBingFilter(timeRange) + if (filter) { + searchParams.set('filters', filter) + } + if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) { + searchParams.set('first', String(firstResultIndex)) + } + return 'https://www.bing.com/search?' + searchParams.toString() +} + +function splitSnippet(snippet) { + const normalized = normalizeText(snippet) + if (!normalized) { + return { publishedAt: '', summary: '' } + } + + const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/) + if ( + match && + /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1]) + ) { + return { + publishedAt: normalizeText(match[1]), + summary: normalizeText(match[2]), + } + } + + return { + publishedAt: '', + summary: normalized, + } +} + +function parseHostname(rawUrl) { + const normalized = normalizeText(rawUrl) + if (!normalized) { + return '' + } + + try { + return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase() + } catch { + return '' + } +} + +function parsePathname(rawUrl) { + const normalized = normalizeText(rawUrl) + if (!normalized) { + return '' + } + + try { + const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase() + if (!pathname) { + return '' + } + return pathname === '/' ? pathname : pathname.replace(/\/$/, '') + } catch { + return '' + } +} + +function looksLikeQuestionTitle(title) { + const normalized = normalizeText(title) + if (!normalized) { + return false + } + + if (/[??]/.test(normalized)) { + return true + } + + return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized) +} + +function looksLikeAggregateText(text) { + const normalized = normalizeText(text).toLowerCase() + if (!normalized) { + return false + } + + return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized) +} + +function looksLikeListingPath(pathname) { + const normalized = normalizeText(pathname).toLowerCase() + if (!normalized || normalized === '/') { + return false + } + + if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) { + return true + } + + if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) { + return true + } + + return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized) +} + +function looksLikeListingText(text) { + const normalized = normalizeText(text).toLowerCase() + if (!normalized) { + return false + } + + return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized) +} + +function isBlockedHostname(hostname) { + const normalized = normalizeText(hostname).toLowerCase() + if (!normalized) { + return false + } + + const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS + const blockedKeywords = [ + 'aitrack', + 'aitoolly', + 'aiflashreport', + 'llm-stats', + 'opentools', + ] + + if (blockedSuffixes.some(function (suffix) { + return normalized === suffix || normalized.endsWith('.' + suffix) + })) { + return true + } + + return blockedKeywords.some(function (keyword) { + return normalized.includes(keyword) + }) +} + +function evaluateNewsItem(item) { + const hostname = parseHostname(item.url) + const pathname = parsePathname(item.url) + const summary = normalizeText(item.summary) + const source = normalizeText(item.source) + const reasons = [] + + if (!normalizeText(item.url)) { + reasons.push('missing-url') + } + if (!hostname) { + reasons.push('invalid-url') + } + if (hostname && isBlockedHostname(hostname)) { + reasons.push('blocked-host') + } + if (!source) { + reasons.push('missing-source') + } + if (summary.length < 20) { + reasons.push('summary-too-short') + } + if (looksLikeQuestionTitle(item.title)) { + reasons.push('question-title') + } + if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) { + reasons.push('aggregate-page') + } + if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) { + reasons.push('listing-page') + } + + return Object.assign({}, item, { + hostname: hostname, + pathname: pathname, + qualityAccepted: reasons.length === 0, + qualityReasons: reasons, + }) +} + +function formatRejectedReason(reason) { + switch (reason) { + case 'missing-url': + return '缺少链接' + case 'invalid-url': + return '链接无效' + case 'blocked-host': + return '来源站点已过滤' + case 'missing-source': + return '缺少来源' + case 'summary-too-short': + return '摘要过短' + case 'question-title': + return '标题更像问答' + case 'aggregate-page': + return '更像聚合页' + case 'listing-page': + return '更像列表页/专题页' + default: + return reason + } +} + +function formatReport(items, metadata) { + const lines = [ + '新闻抓取结果', + '查询词: ' + metadata.query, + '抓取时间: ' + metadata.generatedAt, + '搜索地址: ' + metadata.searchUrl, + '原始结果: ' + metadata.rawCount, + '通过校验: ' + items.length, + '过滤数量: ' + metadata.rejectedItems.length, + '', + ] + + for (const item of items) { + lines.push(item.rank + '. ' + item.title) + if (item.source) { + lines.push('来源: ' + item.source) + } + if (item.publishedAt) { + lines.push('时间: ' + item.publishedAt) + } + lines.push('链接: ' + item.url) + if (item.summary) { + lines.push('摘要: ' + item.summary) + } + lines.push('') + } + + if (metadata.rejectedItems.length > 0) { + lines.push('被过滤结果(最多展示 5 条)') + lines.push('') + for (const item of metadata.rejectedItems.slice(0, 5)) { + lines.push(item.rank + '. ' + item.title) + if (item.hostname) { + lines.push('站点: ' + item.hostname) + } + lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / ')) + lines.push('') + } + } + + return lines.join('\n') +} + +function pickBestAttempt(current, candidate) { + if (!current) { + return candidate + } + + if (candidate.acceptedItems.length !== current.acceptedItems.length) { + return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current + } + + if (candidate.distinctHostCount !== current.distinctHostCount) { + return candidate.distinctHostCount > current.distinctHostCount ? candidate : current + } + + if (candidate.rawItems.length !== current.rawItems.length) { + return candidate.rawItems.length > current.rawItems.length ? candidate : current + } + + return candidate +} + + +module.exports = { + DEFAULT_EXCLUDED_DOMAINS, + normalizeInt, + normalizeText, + normalizeDomainList, + buildDefaultQuery, + buildFallbackQueries, + buildSearchQuery, + buildSearchURL, + splitSnippet, + evaluateNewsItem, + formatReport, + pickBestAttempt, +}; \ No newline at end of file diff --git a/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json b/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json index a01ecf5e..ea81ff18 100644 --- a/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json +++ b/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json @@ -1,58 +1,50 @@ { - "format": "ant-automation-script", - "packageFormat": "ant-automation-script", - "manifestVersion": 1, - "id": "web-image-generate-download", - "name": "网页图片生成并下载", - "description": "打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。", - "type": "playwright-cdp", - "status": "draft", - "entryFile": "index.cjs", - "tags": [ - "Playwright", - "图片生成", - "下载", - "脚手架" - ], - "params": { - "pageUrl": "https://chatgpt.com/", - "prompt": "A cinematic chrome ant browser mascot, premium product lighting", - "outputFileName": "generated-image.png", - "selectors": { - "newSessionButton": "", - "promptInput": "#prompt-textarea[contenteditable=\"true\"], textarea[name=\"prompt-textarea\"]", - "sendButton": "button[data-testid=\"send-button\"], button[aria-label*=\"发送\"], button.composer-submit-button-color", - "generatedImage": "img[src*=\"/backend-api/estuary/content\"], img[alt*=\"已生成图片\"], img[src*=\"oaiusercontent\"], img[src*=\"oaidalleapiprodscus\"], img[alt*=\"生成\"], img[alt*=\"image\" i]", - "downloadButton": "" - }, - "timeoutMs": 300000, - "waitAfterLoadMs": 1200, - "settleMs": 2500, - "captureScreenshot": false - }, - "notes": "脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。", - "publicAPI": { - "enabled": true, - "method": "POST", - "path": "image/chatgpt-generate-download", - "requestMode": "standard", - "responseMode": "envelope", - "timeoutMs": 300000, - "requestBodyText": "{\n \"params\": {\n \"prompt\": \"{{prompt}}\"\n }\n}", - "responseBodyText": "{\n \"ok\": true,\n \"outputPath\": \"${artifactsDir}/generated-image.png\",\n \"downloadAddress\": \"${artifactsDir}/generated-image.png\"\n}", - "variables": [ - { - "name": "prompt", - "defaultValue": "A cinematic chrome ant browser mascot, premium product lighting", - "description": "发送到 ChatGPT 的图片生成提示词。", - "required": true - } - ] - }, - "source": { - "type": "builtin", - "uri": "repo://backend/internal/automation/demo-library/web-image-generate-download", - "ref": "HEAD", - "path": "web-image-generate-download" - } + "format": "ant-automation-script", + "packageFormat": "ant-automation-script", + "manifestVersion": 1, + "id": "web-image-generate-download", + "name": "网页图片生成并下载", + "description": "打开 ChatGPT,发送图片生成消息,等待图片生成后下载图片。", + "type": "playwright-cdp", + "status": "draft", + "entryFile": "index.cjs", + "tags": [ + "Playwright", + "图片生成", + "下载" + ], + "params": { + "prompt": "A cinematic chrome ant browser mascot, premium product lighting" + }, + "notes": "脚本默认打开 ChatGPT,输入图片生成提示词并发送;页面选择器、下载文件名等由脚本内部默认值处理,公开接口只需要传实例、提示词和超时时间。", + "publicAPI": { + "enabled": true, + "method": "POST", + "path": "image/chatgpt-generate-download", + "requestMode": "standard", + "responseMode": "envelope", + "timeoutMs": 300000, + "requestBodyText": "{\n \"instance\": {\n \"type\": \"existing\",\n \"selector\": {\n \"code\": \"{{code}}\"\n }\n },\n \"params\": {\n \"prompt\": \"{{prompt}}\"\n },\n \"timeoutMs\": 300000\n}", + "responseBodyText": "{\n \"ok\": true,\n \"status\": \"completed\",\n \"summary\": \"图片已生成并下载。\",\n \"outputPath\": \"${artifactsDir}/generated-image.png\",\n \"downloadAddress\": \"${artifactsDir}/generated-image.png\"\n}", + "variables": [ + { + "name": "code", + "defaultValue": "BUYER_001", + "description": "要使用的浏览器实例启动码。", + "required": true + }, + { + "name": "prompt", + "defaultValue": "A cinematic chrome ant browser mascot, premium product lighting", + "description": "发送到 ChatGPT 的图片生成提示词。", + "required": true + } + ] + }, + "source": { + "type": "builtin", + "uri": "repo://backend/internal/automation/demo-library/web-image-generate-download", + "ref": "HEAD", + "path": "web-image-generate-download" + } } diff --git a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs index 5f6b2eef..791ba808 100644 --- a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs +++ b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs @@ -40,6 +40,25 @@ function resolveOutputPath(outputDir, outputFileName) { return path.join(outputDir || process.cwd(), safeName) } +const DEFAULT_SELECTORS = { + newSessionButton: '', + promptInput: '#prompt-textarea[contenteditable="true"], textarea[name="prompt-textarea"]', + sendButton: 'button[data-testid="send-button"], button[aria-label*="发送"], button.composer-submit-button-color', + generatedImage: 'img[src*="/backend-api/estuary/content"], img[alt*="已生成图片"], img[src*="oaiusercontent"], img[src*="oaidalleapiprodscus"], img[alt*="生成"], img[alt*="image" i]', + downloadButton: '', +} + +function normalizeSelectors(value) { + const selectors = value && typeof value === 'object' ? value : {} + return { + newSessionButton: normalizeText(selectors.newSessionButton), + promptInput: normalizeText(selectors.promptInput) || DEFAULT_SELECTORS.promptInput, + sendButton: normalizeText(selectors.sendButton) || DEFAULT_SELECTORS.sendButton, + generatedImage: normalizeText(selectors.generatedImage) || DEFAULT_SELECTORS.generatedImage, + downloadButton: normalizeText(selectors.downloadButton), + } +} + function buildMissingSetup(selectors, pageUrl) { const missing = [] if (!pageUrl) { @@ -208,7 +227,7 @@ async function captureScreenshotIfNeeded(page, enabled, outputDir, label) { exports.run = async function run({ useBrowser, params = {}, artifact, artifactsDir, log }) { const pageUrl = normalizeText(params.pageUrl || params.url) || 'https://chatgpt.com/' const prompt = normalizeText(params.prompt) || 'A cinematic chrome ant browser mascot, premium product lighting' - const selectors = params.selectors && typeof params.selectors === 'object' ? params.selectors : {} + const selectors = normalizeSelectors(params.selectors) const timeoutMs = normalizeInt(params.timeoutMs, 300000, 5000, 900000) const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1200, 0, 30000) const settleMs = normalizeInt(params.settleMs, 2500, 0, 60000) @@ -224,7 +243,7 @@ exports.run = async function run({ useBrowser, params = {}, artifact, artifactsD return { ok: false, status: 'needs_page_info', - summary: '网页图片生成脚手架已创建,等待补充页面 URL 和选择器。', + summary: '网页图片生成缺少必要页面配置。', missing, expectedFlow: [ 'open_page', diff --git a/backend/internal/automation/runner_asset.go b/backend/internal/automation/runner_asset.go index 8499faf2..0fd9d913 100644 --- a/backend/internal/automation/runner_asset.go +++ b/backend/internal/automation/runner_asset.go @@ -6,3 +6,19 @@ const runnerScriptFileName = "runner.cjs" //go:embed assets/runner.cjs var runnerScriptContent []byte + +//go:embed assets/runner_shared.cjs +var runnerSharedScriptContent []byte + +//go:embed assets/runner_page_api.cjs +var runnerPageAPIScriptContent []byte + +//go:embed assets/runner_script_loader.cjs +var runnerScriptLoaderContent []byte + +var runnerAssetFiles = map[string][]byte{ + runnerScriptFileName: runnerScriptContent, + "runner_shared.cjs": runnerSharedScriptContent, + "runner_page_api.cjs": runnerPageAPIScriptContent, + "runner_script_loader.cjs": runnerScriptLoaderContent, +} diff --git a/backend/internal/automation/runtime_archive.go b/backend/internal/automation/runtime_archive.go index 012adac4..078b1107 100644 --- a/backend/internal/automation/runtime_archive.go +++ b/backend/internal/automation/runtime_archive.go @@ -35,21 +35,37 @@ func writeRuntimeManifest(path, nodeVersion, playwrightVersion, runtimeVersion, } func writeRunnerScript(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + runnerDir := filepath.Dir(path) + if err := os.MkdirAll(runnerDir, 0o755); err != nil { return err } - return os.WriteFile(path, runnerScriptContent, 0o755) + for name, content := range runnerAssetFiles { + mode := os.FileMode(0o644) + if name == runnerScriptFileName { + mode = 0o755 + } + if err := os.WriteFile(filepath.Join(runnerDir, name), content, mode); err != nil { + return err + } + } + return nil } func syncRunnerScript(path string) error { - current, err := os.ReadFile(path) - if err == nil && string(current) == string(runnerScriptContent) { - return nil + runnerDir := filepath.Dir(path) + for name, content := range runnerAssetFiles { + current, err := os.ReadFile(filepath.Join(runnerDir, name)) + if err != nil { + if os.IsNotExist(err) { + return writeRunnerScript(path) + } + return err + } + if string(current) != string(content) { + return writeRunnerScript(path) + } } - if err != nil && !os.IsNotExist(err) { - return err - } - return writeRunnerScript(path) + return nil } func extractArchive(archivePath, destDir, format, stripPrefix string) error { diff --git a/backend/internal/automation/runtime_manager_helpers_test.go b/backend/internal/automation/runtime_manager_helpers_test.go new file mode 100644 index 00000000..8ac77db5 --- /dev/null +++ b/backend/internal/automation/runtime_manager_helpers_test.go @@ -0,0 +1,119 @@ +package automation + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func buildTestNodeZip(t *testing.T) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + + header := &zip.FileHeader{ + Name: "node-v22.15.1-win-x64/node.exe", + Method: zip.Deflate, + } + fileWriter, err := writer.CreateHeader(header) + if err != nil { + t.Fatalf("create node zip header failed: %v", err) + } + if _, err := fileWriter.Write([]byte("fake-node-runtime")); err != nil { + t.Fatalf("write node zip failed: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close node zip failed: %v", err) + } + + hash := sha256.Sum256(buf.Bytes()) + return buf.Bytes(), hex.EncodeToString(hash[:]) +} + +func buildTestPlaywrightTGZ(t *testing.T) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + payload := []byte(`{"name":"playwright-core","version":"1.59.0"}`) + header := &tar.Header{ + Name: "package/package.json", + Mode: 0o644, + Size: int64(len(payload)), + } + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("write playwright header failed: %v", err) + } + if _, err := tarWriter.Write(payload); err != nil { + t.Fatalf("write playwright payload failed: %v", err) + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close playwright tar failed: %v", err) + } + if err := gzWriter.Close(); err != nil { + t.Fatalf("close playwright gzip failed: %v", err) + } + + hash := sha1.Sum(buf.Bytes()) + return buf.Bytes(), hex.EncodeToString(hash[:]) +} + +func buildTestPlayablePlaywrightTGZ(t *testing.T, version string) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + files := map[string][]byte{ + "package/package.json": []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`), + "package/index.js": []byte("exports.chromium = {};"), + } + + for name, payload := range files { + header := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(payload)), + } + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("write playable playwright header failed: %v", err) + } + if _, err := tarWriter.Write(payload); err != nil { + t.Fatalf("write playable playwright payload failed: %v", err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close playable playwright tar failed: %v", err) + } + if err := gzWriter.Close(); err != nil { + t.Fatalf("close playable playwright gzip failed: %v", err) + } + + hash := sha1.Sum(buf.Bytes()) + return buf.Bytes(), hex.EncodeToString(hash[:]) +} + +func writeBrokenPlaywrightModule(runtimeDir, version string) error { + moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + return err + } + + packageJSON := []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`) + if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), packageJSON, 0o644); err != nil { + return err + } + + return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte("module.exports = {};"), 0o644) +} diff --git a/backend/internal/automation/runtime_manager_test.go b/backend/internal/automation/runtime_manager_test.go index 229a1430..dffb7e76 100644 --- a/backend/internal/automation/runtime_manager_test.go +++ b/backend/internal/automation/runtime_manager_test.go @@ -1,14 +1,7 @@ package automation import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" "context" - "crypto/sha1" - "crypto/sha256" - "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -446,108 +439,3 @@ func TestEnsureInstalledRefreshesExistingRunnerScript(t *testing.T) { t.Fatalf("expected runner script to be refreshed") } } - -func buildTestNodeZip(t *testing.T) ([]byte, string) { - t.Helper() - - var buf bytes.Buffer - writer := zip.NewWriter(&buf) - - header := &zip.FileHeader{ - Name: "node-v22.15.1-win-x64/node.exe", - Method: zip.Deflate, - } - fileWriter, err := writer.CreateHeader(header) - if err != nil { - t.Fatalf("create node zip header failed: %v", err) - } - if _, err := fileWriter.Write([]byte("fake-node-runtime")); err != nil { - t.Fatalf("write node zip failed: %v", err) - } - if err := writer.Close(); err != nil { - t.Fatalf("close node zip failed: %v", err) - } - - hash := sha256.Sum256(buf.Bytes()) - return buf.Bytes(), hex.EncodeToString(hash[:]) -} - -func buildTestPlaywrightTGZ(t *testing.T) ([]byte, string) { - t.Helper() - - var buf bytes.Buffer - gzWriter := gzip.NewWriter(&buf) - tarWriter := tar.NewWriter(gzWriter) - - payload := []byte(`{"name":"playwright-core","version":"1.59.0"}`) - header := &tar.Header{ - Name: "package/package.json", - Mode: 0o644, - Size: int64(len(payload)), - } - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatalf("write playwright header failed: %v", err) - } - if _, err := tarWriter.Write(payload); err != nil { - t.Fatalf("write playwright payload failed: %v", err) - } - if err := tarWriter.Close(); err != nil { - t.Fatalf("close playwright tar failed: %v", err) - } - if err := gzWriter.Close(); err != nil { - t.Fatalf("close playwright gzip failed: %v", err) - } - - hash := sha1.Sum(buf.Bytes()) - return buf.Bytes(), hex.EncodeToString(hash[:]) -} - -func buildTestPlayablePlaywrightTGZ(t *testing.T, version string) ([]byte, string) { - t.Helper() - - var buf bytes.Buffer - gzWriter := gzip.NewWriter(&buf) - tarWriter := tar.NewWriter(gzWriter) - - files := map[string][]byte{ - "package/package.json": []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`), - "package/index.js": []byte("exports.chromium = {};"), - } - - for name, payload := range files { - header := &tar.Header{ - Name: name, - Mode: 0o644, - Size: int64(len(payload)), - } - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatalf("write playable playwright header failed: %v", err) - } - if _, err := tarWriter.Write(payload); err != nil { - t.Fatalf("write playable playwright payload failed: %v", err) - } - } - if err := tarWriter.Close(); err != nil { - t.Fatalf("close playable playwright tar failed: %v", err) - } - if err := gzWriter.Close(); err != nil { - t.Fatalf("close playable playwright gzip failed: %v", err) - } - - hash := sha1.Sum(buf.Bytes()) - return buf.Bytes(), hex.EncodeToString(hash[:]) -} - -func writeBrokenPlaywrightModule(runtimeDir, version string) error { - moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(moduleDir, 0o755); err != nil { - return err - } - - packageJSON := []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`) - if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), packageJSON, 0o644); err != nil { - return err - } - - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte("module.exports = {};"), 0o644) -} diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go index ae655fdd..f4564981 100644 --- a/backend/internal/automation/script_run_store.go +++ b/backend/internal/automation/script_run_store.go @@ -29,6 +29,7 @@ type ScriptRunRecord struct { type ScriptRunRequest struct { ScriptID string `json:"scriptId"` SelectorText string `json:"selectorText"` + TargetMode string `json:"targetMode,omitempty"` TargetInput any `json:"targetInput,omitempty"` ParamsText string `json:"paramsText"` UseScriptSelector bool `json:"useScriptSelector"` diff --git a/backend/internal/automation/task_runner_launch_test.go b/backend/internal/automation/task_runner_launch_test.go new file mode 100644 index 00000000..eff8d369 --- /dev/null +++ b/backend/internal/automation/task_runner_launch_test.go @@ -0,0 +1,383 @@ +package automation + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "ant-chrome/backend/internal/config" +) + +func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + type launchRequestPayload struct { + ProxyID string `json:"proxyId"` + ProxyConfig string `json:"proxyConfig"` + SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` + } + + receivedBody := launchRequestPayload{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/launch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&receivedBody); err != nil { + t.Fatalf("decode launch request body failed: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-launch-proxy.cjs") + scriptSource := `module.exports.run = async ({ launch }) => { + await launch({ + proxyId: 'proxy-picked', + proxyConfig: 'socks5://127.0.0.1:1080', + skipDefaultStartUrls: true, + }) + + return { + ok: true, + summary: '脚本执行成功', + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:launch-proxy", + ScriptPath: scriptPath, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + if receivedBody.ProxyID != "proxy-picked" { + t.Fatalf("expected proxyId to be forwarded, got %+v", receivedBody) + } + if receivedBody.ProxyConfig != "socks5://127.0.0.1:1080" { + t.Fatalf("expected proxyConfig to be forwarded, got %+v", receivedBody) + } + if !receivedBody.SkipDefaultStartURLs { + t.Fatalf("expected skipDefaultStartUrls to stay true, got %+v", receivedBody) + } +} + +func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/launch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 0, + "debugReady": false, + "cdpUrl": "http://127.0.0.1:0", + }) + })) + defer server.Close() + + if err := writeMockPlaywrightModuleWithExpectedEndpoint(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, server.URL); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-fallback.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { + const session = await launch({ selector }) + const connection = await connect(session) + + return { + ok: true, + summary: '脚本已通过 Launch 地址回退连接', + connectedEndpoint: connection.session.cdpUrl, + profileId: session.profileId, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:fallback", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + if result.Summary != "脚本已通过 Launch 地址回退连接" { + t.Fatalf("unexpected summary: %s", result.Summary) + } + if !strings.Contains(result.ResultText, `"connectedEndpoint":"`+server.URL+`"`) { + t.Fatalf("expected result text to contain fallback endpoint, got %s", result.ResultText) + } +} + +func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModuleWithPersistentConnection(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, ""); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script-close", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-close.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { + const session = await launch({ selector }) + const connection = await connect(session) + + return { + ok: true, + summary: '脚本执行成功', + connectedEndpoint: connection.session.cdpUrl, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + result, err := manager.RunScriptTask(ctx, ScriptTaskRequest{ + TaskKey: "script:close", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } +} + +func TestRunScriptTaskConnectHonorsPerCallTimeout(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModuleWithExpectedConnectTimeout(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, 47000); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-timeout", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-connect-timeout.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { + const session = await launch({ selector }) + const connection = await connect(session, { timeoutMs: 47000 }) + + return { + ok: true, + summary: '脚本执行成功', + connectedEndpoint: connection.session.cdpUrl, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:connect-timeout", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } +} + +func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-timeout.cjs") + scriptSource := `module.exports.run = async () => { + await new Promise(() => setInterval(() => {}, 1000)) +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + startedAt := time.Now() + _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:timeout", + ScriptPath: scriptPath, + LaunchBaseURL: "http://127.0.0.1", + Timeout: 150 * time.Millisecond, + }) + elapsed := time.Since(startedAt) + if err == nil { + t.Fatalf("expected RunScriptTask to fail on timeout") + } + if !strings.Contains(err.Error(), "超时") { + t.Fatalf("expected timeout error, got %v", err) + } + if elapsed > 3*time.Second { + t.Fatalf("expected timeout to terminate quickly, took %s", elapsed) + } + + manager.mu.Lock() + activeTaskCount := len(manager.activeTasks) + profileTaskCount := len(manager.profileTask) + manager.mu.Unlock() + if activeTaskCount != 0 || profileTaskCount != 0 { + t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount) + } +} diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go index 2bfd2ccc..87da3d79 100644 --- a/backend/internal/automation/task_runner_test.go +++ b/backend/internal/automation/task_runner_test.go @@ -7,11 +7,9 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "strings" "testing" - "time" "ant-chrome/backend/internal/config" ) @@ -474,575 +472,3 @@ func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) { t.Fatalf("expected proxy launch params to be empty, got %+v", receivedBody) } } - -func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - type launchRequestPayload struct { - ProxyID string `json:"proxyId"` - ProxyConfig string `json:"proxyConfig"` - SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` - } - - receivedBody := launchRequestPayload{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("unexpected method: %s", r.Method) - } - if r.URL.Path != "/api/launch" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&receivedBody); err != nil { - t.Fatalf("decode launch request body failed: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-script", - "debugPort": 9333, - "cdpUrl": "http://127.0.0.1:9333", - }) - })) - defer server.Close() - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-launch-proxy.cjs") - scriptSource := `module.exports.run = async ({ launch }) => { - await launch({ - proxyId: 'proxy-picked', - proxyConfig: 'socks5://127.0.0.1:1080', - skipDefaultStartUrls: true, - }) - - return { - ok: true, - summary: '脚本执行成功', - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:launch-proxy", - ScriptPath: scriptPath, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if receivedBody.ProxyID != "proxy-picked" { - t.Fatalf("expected proxyId to be forwarded, got %+v", receivedBody) - } - if receivedBody.ProxyConfig != "socks5://127.0.0.1:1080" { - t.Fatalf("expected proxyConfig to be forwarded, got %+v", receivedBody) - } - if !receivedBody.SkipDefaultStartURLs { - t.Fatalf("expected skipDefaultStartUrls to stay true, got %+v", receivedBody) - } -} - -func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("unexpected method: %s", r.Method) - } - if r.URL.Path != "/api/launch" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-script", - "debugPort": 0, - "debugReady": false, - "cdpUrl": "http://127.0.0.1:0", - }) - })) - defer server.Close() - - if err := writeMockPlaywrightModuleWithExpectedEndpoint(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, server.URL); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-fallback.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session) - - return { - ok: true, - summary: '脚本已通过 Launch 地址回退连接', - connectedEndpoint: connection.session.cdpUrl, - profileId: session.profileId, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:fallback", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if result.Summary != "脚本已通过 Launch 地址回退连接" { - t.Fatalf("unexpected summary: %s", result.Summary) - } - if !strings.Contains(result.ResultText, `"connectedEndpoint":"`+server.URL+`"`) { - t.Fatalf("expected result text to contain fallback endpoint, got %s", result.ResultText) - } -} - -func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModuleWithPersistentConnection(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, ""); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-script-close", - "debugPort": 9333, - "cdpUrl": "http://127.0.0.1:9333", - }) - })) - defer server.Close() - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-close.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session) - - return { - ok: true, - summary: '脚本执行成功', - connectedEndpoint: connection.session.cdpUrl, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - result, err := manager.RunScriptTask(ctx, ScriptTaskRequest{ - TaskKey: "script:close", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } -} - -func TestRunScriptTaskConnectHonorsPerCallTimeout(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModuleWithExpectedConnectTimeout(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, 47000); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-timeout", - "debugPort": 9333, - "cdpUrl": "http://127.0.0.1:9333", - }) - })) - defer server.Close() - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-connect-timeout.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session, { timeoutMs: 47000 }) - - return { - ok: true, - summary: '脚本执行成功', - connectedEndpoint: connection.session.cdpUrl, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:connect-timeout", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } -} - -func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-timeout.cjs") - scriptSource := `module.exports.run = async () => { - await new Promise(() => setInterval(() => {}, 1000)) -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - startedAt := time.Now() - _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:timeout", - ScriptPath: scriptPath, - LaunchBaseURL: "http://127.0.0.1", - Timeout: 150 * time.Millisecond, - }) - elapsed := time.Since(startedAt) - if err == nil { - t.Fatalf("expected RunScriptTask to fail on timeout") - } - if !strings.Contains(err.Error(), "超时") { - t.Fatalf("expected timeout error, got %v", err) - } - if elapsed > 3*time.Second { - t.Fatalf("expected timeout to terminate quickly, took %s", elapsed) - } - - manager.mu.Lock() - activeTaskCount := len(manager.activeTasks) - profileTaskCount := len(manager.profileTask) - manager.mu.Unlock() - if activeTaskCount != 0 || profileTaskCount != 0 { - t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount) - } -} - -func lookupNodeExecutable(t *testing.T) string { - t.Helper() - - nodePath, err := exec.LookPath("node") - if err != nil { - t.Skipf("node is not available: %v", err) - } - - cmd := exec.Command(nodePath, "-p", "process.execPath") - output, err := cmd.Output() - if err != nil { - return nodePath - } - - resolved := strings.TrimSpace(string(output)) - if resolved == "" { - return nodePath - } - return resolved -} - -func writeMockPlaywrightModule(runtimeDir, version string) error { - return writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, "") -} - -func writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, expectedEndpoint string) error { - return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, false) -} - -func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expectedEndpoint string) error { - return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true) -} - -func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error { - moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(moduleDir, 0o755); err != nil { - return err - } - - packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) - if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { - return err - } - - indexJS := fmt.Sprintf(`const expectedConnectTimeout = %d; - -const context = { - async grantPermissions() {}, - async newPage() { - return { - async goto() {}, - async bringToFront() {}, - async waitForLoadState() {}, - async waitForTimeout() {}, - async close() {}, - isClosed() { - return false; - }, - async title() { - return 'Mock Page Title'; - }, - url() { - return 'about:blank'; - }, - }; - }, - pages() { - return []; - }, -}; - -exports.chromium = { - async connectOverCDP(endpoint, options = {}) { - if (options.timeout !== expectedConnectTimeout) { - throw new Error('unexpected connect timeout: ' + String(options.timeout)); - } - return { - contexts() { - return [context]; - }, - async close() {}, - }; - }, -}; -`, expectedConnectTimeout) - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) -} - -func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error { - moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(moduleDir, 0o755); err != nil { - return err - } - - packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) - if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { - return err - } - - expectedEndpointJSON, err := json.Marshal(expectedEndpoint) - if err != nil { - return err - } - persistentConnectionJSON, err := json.Marshal(persistentConnection) - if err != nil { - return err - } - - indexJS := fmt.Sprintf(`const fs = require('fs'); - -const expectedEndpoint = %s; -const persistentConnection = %s; - -function createPage() { - let currentURL = 'about:blank'; - return { - async goto(url) { - currentURL = url; - }, - async bringToFront() {}, - async waitForLoadState() {}, - async waitForTimeout() {}, - async screenshot(options) { - fs.writeFileSync(options.path, 'mock-screenshot'); - }, - async evaluate(fn, arg) { - const previousFetch = global.fetch; - global.fetch = async (url, init = {}) => { - return { - ok: String(init.method || 'GET').toUpperCase() !== 'DELETE', - status: String(init.method || 'GET').toUpperCase() === 'POST' ? 201 : 200, - statusText: String(init.method || 'GET').toUpperCase() === 'DELETE' ? 'Forbidden' : 'OK', - url: String(url), - headers: { - forEach(callback) { - callback('application/json', 'content-type'); - }, - }, - async text() { - return JSON.stringify({ - ok: true, - url: String(url), - method: String(init.method || 'GET').toUpperCase(), - credentials: init.credentials || '', - headers: init.headers || {}, - body: init.body || '', - }); - }, - }; - }; - try { - return await fn(arg); - } finally { - global.fetch = previousFetch; - } - }, - async title() { - return 'Mock Page Title'; - }, - url() { - return currentURL; - }, - isClosed() { - return false; - }, - async close() {}, - }; -} - -const context = { - async grantPermissions() {}, - async newPage() { - return createPage(); - }, - pages() { - return []; - }, -}; - -exports.chromium = { - async connectOverCDP(endpoint) { - if (String(endpoint).includes(':0')) { - throw new Error('invalid cdp endpoint'); - } - if (expectedEndpoint && endpoint !== expectedEndpoint) { - throw new Error('unexpected cdp endpoint: ' + endpoint); - } - const hold = persistentConnection ? setInterval(() => {}, 1000) : null; - return { - contexts() { - return [context]; - }, - async close() { - if (hold) { - clearInterval(hold); - } - }, - }; - }, -}; -`, string(expectedEndpointJSON), string(persistentConnectionJSON)) - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) -} diff --git a/backend/internal/automation/task_runner_test_helpers_test.go b/backend/internal/automation/task_runner_test_helpers_test.go new file mode 100644 index 00000000..28cb6464 --- /dev/null +++ b/backend/internal/automation/task_runner_test_helpers_test.go @@ -0,0 +1,215 @@ +package automation + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func lookupNodeExecutable(t *testing.T) string { + t.Helper() + + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skipf("node is not available: %v", err) + } + + cmd := exec.Command(nodePath, "-p", "process.execPath") + output, err := cmd.Output() + if err != nil { + return nodePath + } + + resolved := strings.TrimSpace(string(output)) + if resolved == "" { + return nodePath + } + return resolved +} + +func writeMockPlaywrightModule(runtimeDir, version string) error { + return writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, "") +} + +func writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, expectedEndpoint string) error { + return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, false) +} + +func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expectedEndpoint string) error { + return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true) +} + +func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error { + moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + return err + } + + packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) + if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { + return err + } + + indexJS := fmt.Sprintf(`const expectedConnectTimeout = %d; + +const context = { + async grantPermissions() {}, + async newPage() { + return { + async goto() {}, + async bringToFront() {}, + async waitForLoadState() {}, + async waitForTimeout() {}, + async close() {}, + isClosed() { + return false; + }, + async title() { + return 'Mock Page Title'; + }, + url() { + return 'about:blank'; + }, + }; + }, + pages() { + return []; + }, +}; + +exports.chromium = { + async connectOverCDP(endpoint, options = {}) { + if (options.timeout !== expectedConnectTimeout) { + throw new Error('unexpected connect timeout: ' + String(options.timeout)); + } + return { + contexts() { + return [context]; + }, + async close() {}, + }; + }, +}; +`, expectedConnectTimeout) + return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) +} + +func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error { + moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + return err + } + + packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) + if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { + return err + } + + expectedEndpointJSON, err := json.Marshal(expectedEndpoint) + if err != nil { + return err + } + persistentConnectionJSON, err := json.Marshal(persistentConnection) + if err != nil { + return err + } + + indexJS := fmt.Sprintf(`const fs = require('fs'); + +const expectedEndpoint = %s; +const persistentConnection = %s; + +function createPage() { + let currentURL = 'about:blank'; + return { + async goto(url) { + currentURL = url; + }, + async bringToFront() {}, + async waitForLoadState() {}, + async waitForTimeout() {}, + async screenshot(options) { + fs.writeFileSync(options.path, 'mock-screenshot'); + }, + async evaluate(fn, arg) { + const previousFetch = global.fetch; + global.fetch = async (url, init = {}) => { + return { + ok: String(init.method || 'GET').toUpperCase() !== 'DELETE', + status: String(init.method || 'GET').toUpperCase() === 'POST' ? 201 : 200, + statusText: String(init.method || 'GET').toUpperCase() === 'DELETE' ? 'Forbidden' : 'OK', + url: String(url), + headers: { + forEach(callback) { + callback('application/json', 'content-type'); + }, + }, + async text() { + return JSON.stringify({ + ok: true, + url: String(url), + method: String(init.method || 'GET').toUpperCase(), + credentials: init.credentials || '', + headers: init.headers || {}, + body: init.body || '', + }); + }, + }; + }; + try { + return await fn(arg); + } finally { + global.fetch = previousFetch; + } + }, + async title() { + return 'Mock Page Title'; + }, + url() { + return currentURL; + }, + isClosed() { + return false; + }, + async close() {}, + }; +} + +const context = { + async grantPermissions() {}, + async newPage() { + return createPage(); + }, + pages() { + return []; + }, +}; + +exports.chromium = { + async connectOverCDP(endpoint) { + if (String(endpoint).includes(':0')) { + throw new Error('invalid cdp endpoint'); + } + if (expectedEndpoint && endpoint !== expectedEndpoint) { + throw new Error('unexpected cdp endpoint: ' + endpoint); + } + const hold = persistentConnection ? setInterval(() => {}, 1000) : null; + return { + contexts() { + return [context]; + }, + async close() { + if (hold) { + clearInterval(hold); + } + }, + }; + }, +}; +`, string(expectedEndpointJSON), string(persistentConnectionJSON)) + return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) +} diff --git a/backend/internal/browser/dashboard.go b/backend/internal/browser/dashboard.go new file mode 100644 index 00000000..6ef9b33e --- /dev/null +++ b/backend/internal/browser/dashboard.go @@ -0,0 +1,43 @@ +package browser + +import "ant-chrome/backend/internal/config" + +const DefaultMaxProfileLimit = 20 + +type DashboardStats struct { + TotalInstances int + RunningInstances int + ProxyCount int + CoreCount int + MaxProfileLimit int +} + +func BuildDashboardStats(profiles []Profile, cfg *config.Config) DashboardStats { + stats := DashboardStats{ + TotalInstances: len(profiles), + MaxProfileLimit: DefaultMaxProfileLimit, + } + for _, profile := range profiles { + if profile.Running { + stats.RunningInstances++ + } + } + if cfg != nil { + stats.ProxyCount = len(cfg.Browser.Proxies) + stats.CoreCount = len(cfg.Browser.Cores) + if cfg.App.MaxProfileLimit > 0 { + stats.MaxProfileLimit = cfg.App.MaxProfileLimit + } + } + return stats +} + +func RunningProfiles(profiles []Profile) []Profile { + result := make([]Profile, 0) + for _, profile := range profiles { + if profile.Running { + result = append(result, profile) + } + } + return result +} diff --git a/backend/internal/browser/dashboard_test.go b/backend/internal/browser/dashboard_test.go new file mode 100644 index 00000000..b0f4be9f --- /dev/null +++ b/backend/internal/browser/dashboard_test.go @@ -0,0 +1,43 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "testing" +) + +func TestBuildDashboardStats(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.Proxies = []config.BrowserProxy{{ProxyId: "p1"}, {ProxyId: "p2"}} + cfg.Browser.Cores = []config.BrowserCore{{CoreId: "c1"}} + cfg.App.MaxProfileLimit = 7 + + stats := BuildDashboardStats([]Profile{ + {ProfileId: "a", Running: true}, + {ProfileId: "b", Running: false}, + {ProfileId: "c", Running: true}, + }, cfg) + + if stats.TotalInstances != 3 || stats.RunningInstances != 2 { + t.Fatalf("instance stats = %#v", stats) + } + if stats.ProxyCount != 2 || stats.CoreCount != 1 || stats.MaxProfileLimit != 7 { + t.Fatalf("config stats = %#v", stats) + } +} + +func TestBuildDashboardStatsDefaultsWithoutConfig(t *testing.T) { + stats := BuildDashboardStats(nil, nil) + if stats.MaxProfileLimit != DefaultMaxProfileLimit { + t.Fatalf("max profile limit = %d", stats.MaxProfileLimit) + } +} + +func TestRunningProfiles(t *testing.T) { + profiles := RunningProfiles([]Profile{ + {ProfileId: "a", Running: true}, + {ProfileId: "b"}, + }) + if len(profiles) != 1 || profiles[0].ProfileId != "a" { + t.Fatalf("profiles = %#v", profiles) + } +} diff --git a/backend/internal/browser/proxy_queries.go b/backend/internal/browser/proxy_queries.go new file mode 100644 index 00000000..5073bc69 --- /dev/null +++ b/backend/internal/browser/proxy_queries.go @@ -0,0 +1,44 @@ +package browser + +func ListProxiesWithFallback(proxyDAO ProxyDAO, fallback []Proxy) []Proxy { + if proxyDAO != nil { + if list, err := proxyDAO.List(); err == nil { + return list + } + } + return append([]Proxy{}, fallback...) +} + +func ListProxyGroups(proxyDAO ProxyDAO) []string { + if proxyDAO != nil { + if groups, err := proxyDAO.ListGroups(); err == nil { + return groups + } + } + return nil +} + +func ListProxiesByGroupWithFallback(proxyDAO ProxyDAO, groupName string, fallback []Proxy) []Proxy { + if proxyDAO != nil { + if list, err := proxyDAO.ListByGroup(groupName); err == nil { + return list + } + } + + var result []Proxy + for _, item := range fallback { + if item.GroupName == groupName { + result = append(result, item) + } + } + return result +} + +func LatestProxiesWithFallback(proxyDAO ProxyDAO, fallback []Proxy) []Proxy { + if proxyDAO != nil { + if list, err := proxyDAO.List(); err == nil && len(list) > 0 { + return list + } + } + return fallback +} diff --git a/backend/internal/browser/proxy_queries_test.go b/backend/internal/browser/proxy_queries_test.go new file mode 100644 index 00000000..c09f4946 --- /dev/null +++ b/backend/internal/browser/proxy_queries_test.go @@ -0,0 +1,59 @@ +package browser + +import ( + "errors" + "testing" +) + +type proxyQueryTestDAO struct { + list []Proxy + groups []string + groupList []Proxy + listErr error + groupsErr error + groupListErr error +} + +func (d proxyQueryTestDAO) List() ([]Proxy, error) { return d.list, d.listErr } +func (d proxyQueryTestDAO) ListByGroup(string) ([]Proxy, error) { return d.groupList, d.groupListErr } +func (d proxyQueryTestDAO) ListGroups() ([]string, error) { return d.groups, d.groupsErr } +func (d proxyQueryTestDAO) Upsert(Proxy) error { return nil } +func (d proxyQueryTestDAO) Delete(string) error { return nil } +func (d proxyQueryTestDAO) DeleteAll() error { return nil } +func (d proxyQueryTestDAO) UpdateSpeedResult(string, bool, int64, string) error { return nil } +func (d proxyQueryTestDAO) UpdateIPHealthResult(string, string) error { return nil } + +func TestListProxiesWithFallbackUsesDAO(t *testing.T) { + fallback := []Proxy{{ProxyId: "fallback"}} + list := ListProxiesWithFallback(proxyQueryTestDAO{list: []Proxy{{ProxyId: "dao"}}}, fallback) + if len(list) != 1 || list[0].ProxyId != "dao" { + t.Fatalf("list = %#v", list) + } +} + +func TestListProxiesWithFallbackCopiesFallback(t *testing.T) { + fallback := []Proxy{{ProxyId: "fallback"}} + list := ListProxiesWithFallback(proxyQueryTestDAO{listErr: errors.New("failed")}, fallback) + list[0].ProxyId = "changed" + if fallback[0].ProxyId != "fallback" { + t.Fatalf("fallback was mutated") + } +} + +func TestListProxiesByGroupWithFallbackFiltersFallback(t *testing.T) { + list := ListProxiesByGroupWithFallback(nil, "group-a", []Proxy{ + {ProxyId: "a", GroupName: "group-a"}, + {ProxyId: "b", GroupName: "group-b"}, + }) + if len(list) != 1 || list[0].ProxyId != "a" { + t.Fatalf("list = %#v", list) + } +} + +func TestLatestProxiesWithFallbackKeepsFallbackForEmptyDAOList(t *testing.T) { + fallback := []Proxy{{ProxyId: "fallback"}} + list := LatestProxiesWithFallback(proxyQueryTestDAO{}, fallback) + if len(list) != 1 || list[0].ProxyId != "fallback" { + t.Fatalf("list = %#v", list) + } +} diff --git a/backend/internal/fsutil/path.go b/backend/internal/fsutil/path.go index dfb24caa..a5a7cb9e 100644 --- a/backend/internal/fsutil/path.go +++ b/backend/internal/fsutil/path.go @@ -25,6 +25,39 @@ func NormalizePathInput(p string) string { return cleaned } +func ResolveUserDataDir(appPathResolver func(string) string, userDataRoot string, userDataDir string) (string, error) { + userDataDir = strings.TrimSpace(userDataDir) + if userDataDir == "" { + return "", fmt.Errorf("用户数据目录不能为空") + } + if filepath.IsAbs(userDataDir) { + return userDataDir, nil + } + + root := strings.TrimSpace(userDataRoot) + if root == "" { + root = "data" + } + if appPathResolver != nil { + root = appPathResolver(root) + } + return filepath.Join(root, userDataDir), nil +} + +func ResolveExistingPath(appPathResolver func(string) string, inputPath string, emptyMessage string) (string, error) { + inputPath = strings.TrimSpace(inputPath) + if inputPath == "" { + return "", fmt.Errorf(emptyMessage) + } + if filepath.IsAbs(inputPath) { + return inputPath, nil + } + if appPathResolver != nil { + return appPathResolver(inputPath), nil + } + return inputPath, nil +} + // ValidateExecutable checks whether a file is runnable on the current platform. func ValidateExecutable(path string) error { info, err := os.Stat(path) diff --git a/backend/internal/fsutil/path_test.go b/backend/internal/fsutil/path_test.go index a68c9dd4..861b1e5e 100644 --- a/backend/internal/fsutil/path_test.go +++ b/backend/internal/fsutil/path_test.go @@ -17,6 +17,48 @@ func TestNormalizePathInputConvertsWindowsSeparators(t *testing.T) { } } +func TestResolveUserDataDir(t *testing.T) { + t.Parallel() + + root := t.TempDir() + got, err := ResolveUserDataDir(func(path string) string { + return filepath.Join(root, path) + }, "profiles", "profile-a") + if err != nil { + t.Fatalf("ResolveUserDataDir() 返回错误: %v", err) + } + want := filepath.Join(root, "profiles", "profile-a") + if got != want { + t.Fatalf("ResolveUserDataDir() = %q, want %q", got, want) + } +} + +func TestResolveUserDataDirUsesDefaultRoot(t *testing.T) { + t.Parallel() + + got, err := ResolveUserDataDir(func(path string) string { return filepath.Join("app", path) }, "", "profile-a") + if err != nil { + t.Fatalf("ResolveUserDataDir() 返回错误: %v", err) + } + want := filepath.Join("app", "data", "profile-a") + if got != want { + t.Fatalf("ResolveUserDataDir() = %q, want %q", got, want) + } +} + +func TestResolveExistingPathUsesResolverForRelativePath(t *testing.T) { + t.Parallel() + + got, err := ResolveExistingPath(func(path string) string { return filepath.Join("app", path) }, "chrome/core", "不能为空") + if err != nil { + t.Fatalf("ResolveExistingPath() 返回错误: %v", err) + } + want := filepath.Join("app", "chrome/core") + if got != want { + t.Fatalf("ResolveExistingPath() = %q, want %q", got, want) + } +} + func TestEnsureExecutableRepairsMissingExecBitsOnUnix(t *testing.T) { t.Parallel() diff --git a/backend/internal/launchcode/automation_public_api.go b/backend/internal/launchcode/automation_public_api.go index 4f9bde06..fffe73d4 100644 --- a/backend/internal/launchcode/automation_public_api.go +++ b/backend/internal/launchcode/automation_public_api.go @@ -1,4 +1,4 @@ -package launchcode +package launchcode import ( "bytes" @@ -8,7 +8,6 @@ import ( "net/http" "os" "path" - "strconv" "strings" "ant-chrome/backend/internal/automation" @@ -138,15 +137,9 @@ func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http if err != nil { return automation.ScriptRunRequest{}, err } - selectorText := "" - useScriptSelector := true - if strings.TrimSpace(input.Code) != "" { - encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": strings.TrimSpace(input.Code)}) - if err != nil { - return automation.ScriptRunRequest{}, badAutomationRequest("code is invalid") - } - selectorText = encodedSelectorText - useScriptSelector = false + targetMode, targetInput, selectorText, useScriptSelector, err := resolveAutomationPublicHookInstance(input) + if err != nil { + return automation.ScriptRunRequest{}, err } if err := validateAutomationTimeoutMs(input.TimeoutMs); err != nil { return automation.ScriptRunRequest{}, err @@ -160,6 +153,8 @@ func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http return automation.ScriptRunRequest{ ScriptID: record.ID, SelectorText: selectorText, + TargetMode: targetMode, + TargetInput: targetInput, ParamsText: paramsText, UseScriptSelector: useScriptSelector, UseScriptParams: false, @@ -168,9 +163,18 @@ func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http } type automationPublicHookRequestBody struct { - Code string `json:"code"` - Params map[string]interface{} `json:"params"` - TimeoutMs int `json:"timeoutMs"` + Code string `json:"code"` + Instance *automationPublicHookInstance `json:"instance"` + Params map[string]interface{} `json:"params"` + TimeoutMs int `json:"timeoutMs"` +} + +type automationPublicHookInstance struct { + Type string `json:"type"` + Selector automation.ScriptTargetSelector `json:"selector"` + TemplateSelector automation.ScriptTargetSelector `json:"templateSelector"` + CreateNameTemplate string `json:"createNameTemplate"` + ProfileName string `json:"profileName"` } func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookRequestBody, error) { @@ -191,6 +195,61 @@ func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookReq return input, nil } +func resolveAutomationPublicHookInstance(input automationPublicHookRequestBody) (string, any, string, bool, error) { + legacyCode := strings.TrimSpace(input.Code) + if input.Instance == nil { + if legacyCode == "" { + return "", nil, "", true, nil + } + encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": legacyCode}) + if err != nil { + return "", nil, "", true, badAutomationRequest("code is invalid") + } + return "", nil, encodedSelectorText, false, nil + } + + if legacyCode != "" { + return "", nil, "", true, badAutomationRequest("code and instance cannot be used together") + } + + switch strings.ToLower(strings.TrimSpace(input.Instance.Type)) { + case "script-default": + return "", nil, "", true, nil + case "existing", "rotate": + selector := input.Instance.Selector + if automationPublicHookTargetSelectorEmpty(selector) { + return "", nil, "", true, badAutomationRequest("instance.selector is required") + } + return strings.ToLower(strings.TrimSpace(input.Instance.Type)), selector, "", false, nil + case "create": + targetInput := map[string]interface{}{ + "templateSelector": input.Instance.TemplateSelector, + } + if automationPublicHookTargetSelectorEmpty(input.Instance.TemplateSelector) { + return "", nil, "", true, badAutomationRequest("instance.templateSelector is required") + } + if name := strings.TrimSpace(input.Instance.CreateNameTemplate); name != "" { + targetInput["createNameTemplate"] = name + } else if name := strings.TrimSpace(input.Instance.ProfileName); name != "" { + targetInput["profileName"] = name + } + return "create", targetInput, "", false, nil + case "": + return "", nil, "", true, badAutomationRequest("instance.type is required") + default: + return "", nil, "", true, badAutomationRequest("instance.type is unsupported") + } +} + +func automationPublicHookTargetSelectorEmpty(selector automation.ScriptTargetSelector) bool { + return strings.TrimSpace(selector.Code) == "" && + strings.TrimSpace(selector.ProfileID) == "" && + strings.TrimSpace(selector.ProfileName) == "" && + strings.TrimSpace(selector.GroupID) == "" && + len(selector.Keywords) == 0 && + len(selector.Tags) == 0 +} + func encodeAutomationPublicHookJSONObject(obj map[string]interface{}) (string, error) { encoded, err := json.Marshal(obj) if err != nil { @@ -232,7 +291,7 @@ func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body } values := input.Params - bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", input.Code) + bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", automationPublicHookInstanceCode(input)) for _, variable := range config.Variables { name := strings.TrimSpace(variable.Name) if name == "" { @@ -250,7 +309,7 @@ func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body continue } - rawValue := interface{}(variable.DefaultValue) + rawValue := automationPublicHookVariableDefaultValue(variable, input) if incomingValue, ok := values[name]; ok { rawValue = incomingValue } @@ -280,6 +339,25 @@ func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body return encoded, nil } +func automationPublicHookInstanceCode(input automationPublicHookRequestBody) string { + if code := strings.TrimSpace(input.Code); code != "" { + return code + } + if input.Instance == nil { + return "" + } + return strings.TrimSpace(input.Instance.Selector.Code) +} + +func automationPublicHookVariableDefaultValue(variable automation.ScriptPublicAPIVariable, input automationPublicHookRequestBody) interface{} { + if strings.TrimSpace(variable.Name) == "code" { + if code := automationPublicHookInstanceCode(input); code != "" { + return code + } + } + return variable.DefaultValue +} + func mergeAutomationPublicHookDefaultParams(record automation.ScriptRecord, body map[string]interface{}) map[string]interface{} { defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText) if !ok || len(defaultParams) == 0 { @@ -406,148 +484,3 @@ func decodeJSONObjectBody(body []byte, fieldName string) (map[string]interface{} return obj, true, nil } -func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int { - if requestTimeout > 0 { - return requestTimeout - } - - if r != nil { - if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" { - if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { - return parsed - } - } - } - - return fallback -} - -func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) { - _ = record - parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText) - if run.Status != "success" { - writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run)) - return - } - - response := map[string]interface{}{ - "ok": true, - "status": run.Status, - "summary": run.Summary, - "message": run.Summary, - "data": map[string]interface{}{}, - "result": map[string]interface{}{}, - } - - if hasResult { - data := compactAutomationPublicHookData(resultPayload, run) - response["data"] = data - response["result"] = data - } else if parsedPayload != nil { - data := compactAutomationPublicHookData(parsedPayload, run) - response["data"] = data - response["result"] = data - } - - writeJSON(w, http.StatusOK, response) -} - -func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} { - response := map[string]interface{}{ - "ok": false, - "status": run.Status, - "summary": run.Summary, - "message": run.Summary, - "data": map[string]interface{}{}, - "result": map[string]interface{}{}, - } - if strings.TrimSpace(run.Error) != "" { - response["error"] = run.Error - } - return response -} - -func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} { - data := compactAutomationPublicHookResult(payload, run) - delete(data, "ok") - delete(data, "summary") - return data -} - -func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} { - obj, ok := payload.(map[string]interface{}) - if !ok { - result := map[string]interface{}{"ok": true} - if strings.TrimSpace(run.Summary) != "" { - result["summary"] = run.Summary - } - if payload != nil { - result["result"] = payload - } - return result - } - - if !hasAutomationPublicHookDownloadField(obj) { - result := make(map[string]interface{}, len(obj)+1) - result["ok"] = true - for key, value := range obj { - if key != "ok" && value != nil { - result[key] = value - } - } - if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" { - result["summary"] = run.Summary - } - return result - } - - result := map[string]interface{}{"ok": true} - - for _, key := range []string{ - "downloadAddress", - "downloadPath", - "outputPath", - "sourceImageUrl", - "sourceDownloadUrl", - "screenshotPath", - "pageScreenshotPath", - "contentType", - "imageWidth", - "imageHeight", - "status", - "summary", - "error", - } { - if value, exists := obj[key]; exists && value != nil { - result[key] = value - } - } - return result -} - -func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool { - for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} { - if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { - return true - } - } - return false -} - -func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil, nil, false - } - - var payload interface{} - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, nil, false - } - - if obj, ok := payload.(map[string]interface{}); ok { - result, exists := obj["result"] - return payload, result, exists - } - return payload, nil, false -} diff --git a/backend/internal/launchcode/automation_public_api_response.go b/backend/internal/launchcode/automation_public_api_response.go new file mode 100644 index 00000000..3fa85faa --- /dev/null +++ b/backend/internal/launchcode/automation_public_api_response.go @@ -0,0 +1,125 @@ +package launchcode + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "ant-chrome/backend/internal/automation" +) + +func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) { + _ = record + parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText) + if run.Status != "success" { + writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run)) + return + } + + response := map[string]interface{}{ + "ok": true, + "status": run.Status, + "summary": run.Summary, + "message": run.Summary, + "data": map[string]interface{}{}, + "result": map[string]interface{}{}, + } + + if hasResult { + data := compactAutomationPublicHookData(resultPayload, run) + response["data"] = data + response["result"] = data + } else if parsedPayload != nil { + data := compactAutomationPublicHookData(parsedPayload, run) + response["data"] = data + response["result"] = data + } + + writeJSON(w, http.StatusOK, response) +} + +func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} { + response := map[string]interface{}{ + "ok": false, + "status": run.Status, + "summary": run.Summary, + "message": run.Summary, + "data": map[string]interface{}{}, + "result": map[string]interface{}{}, + } + if strings.TrimSpace(run.Error) != "" { + response["error"] = run.Error + } + return response +} + +func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} { + data := compactAutomationPublicHookResult(payload, run) + delete(data, "ok") + delete(data, "summary") + return data +} + +func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} { + obj, ok := payload.(map[string]interface{}) + if !ok { + result := map[string]interface{}{"ok": true} + if strings.TrimSpace(run.Summary) != "" { + result["summary"] = run.Summary + } + if payload != nil { + result["result"] = payload + } + return result + } + + if !hasAutomationPublicHookDownloadField(obj) { + result := make(map[string]interface{}, len(obj)+1) + result["ok"] = true + for key, value := range obj { + if key != "ok" && value != nil { + result[key] = value + } + } + if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" { + result["summary"] = run.Summary + } + return result + } + + result := map[string]interface{}{"ok": true} + for _, key := range []string{"downloadAddress", "downloadPath", "outputPath", "sourceImageUrl", "sourceDownloadUrl", "screenshotPath", "pageScreenshotPath", "contentType", "imageWidth", "imageHeight", "status", "summary", "error"} { + if value, exists := obj[key]; exists && value != nil { + result[key] = value + } + } + return result +} + +func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool { + for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} { + if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + return true + } + } + return false +} + +func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil, false + } + + var payload interface{} + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, nil, false + } + + if obj, ok := payload.(map[string]interface{}); ok { + result, exists := obj["result"] + return payload, result, exists + } + return payload, nil, false +} diff --git a/backend/internal/launchcode/automation_public_api_timeout.go b/backend/internal/launchcode/automation_public_api_timeout.go new file mode 100644 index 00000000..1c5347aa --- /dev/null +++ b/backend/internal/launchcode/automation_public_api_timeout.go @@ -0,0 +1,23 @@ +package launchcode + +import ( + "net/http" + "strconv" + "strings" +) + +func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int { + if requestTimeout > 0 { + return requestTimeout + } + + if r != nil { + if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + return parsed + } + } + } + + return fallback +} diff --git a/backend/internal/proxy/check_config.go b/backend/internal/proxy/check_config.go new file mode 100644 index 00000000..a18b75cf --- /dev/null +++ b/backend/internal/proxy/check_config.go @@ -0,0 +1,120 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "strings" + "time" +) + +const defaultBridgeStartTimeoutMs = 15000 +const defaultTargetTimeoutMs = 10000 + +func NormalizeCheckSettings(settings config.ProxyCheckConfig) config.ProxyCheckConfig { + settings.BridgeStartTimeoutMs = normalizePositiveInt(settings.BridgeStartTimeoutMs, defaultBridgeStartTimeoutMs) + settings.SpeedTargetID = strings.TrimSpace(settings.SpeedTargetID) + settings.IPHealthTargetID = strings.TrimSpace(settings.IPHealthTargetID) + settings.Targets = NormalizeCheckTargets(settings.Targets) + if len(settings.Targets) == 0 { + settings.Targets = config.DefaultConfig().ProxyCheck.Targets + } + if settings.SpeedTargetID == "" { + settings.SpeedTargetID = FirstCheckTargetID(settings.Targets, "speed", "") + } + if settings.IPHealthTargetID == "" { + settings.IPHealthTargetID = FirstCheckTargetID(settings.Targets, "ip_health", "") + } + return settings +} + +func BuildSpeedTestConfig(settings config.ProxyCheckConfig) *SpeedTestConfig { + cfg := DefaultSpeedTestConfig + target := FindCheckTarget(settings.Targets, settings.SpeedTargetID, "speed") + if strings.TrimSpace(target.URL) != "" { + cfg.URLs = []string{strings.TrimSpace(target.URL)} + } + if target.TimeoutMs > 0 { + cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond + } + return &cfg +} + +func BuildIPHealthConfig(settings config.ProxyCheckConfig) *IPHealthConfig { + cfg := &IPHealthConfig{Source: "ip_health"} + target := FindCheckTarget(settings.Targets, settings.IPHealthTargetID, "ip_health") + if strings.TrimSpace(target.URL) != "" { + cfg.URL = strings.TrimSpace(target.URL) + } + if strings.TrimSpace(target.ID) != "" { + cfg.Source = strings.TrimSpace(target.ID) + } + if strings.TrimSpace(target.Parser) != "" { + cfg.Parser = strings.TrimSpace(target.Parser) + } + if target.TimeoutMs > 0 { + cfg.Timeout = time.Duration(target.TimeoutMs) * time.Millisecond + } + return cfg +} + +func FindCheckTarget(targets []config.ProxyCheckTarget, id string, targetType string) config.ProxyCheckTarget { + normalizedID := strings.TrimSpace(id) + normalizedType := strings.TrimSpace(targetType) + for _, target := range targets { + if normalizedID != "" && strings.EqualFold(strings.TrimSpace(target.ID), normalizedID) { + return target + } + } + for _, target := range targets { + if normalizedType != "" && strings.EqualFold(strings.TrimSpace(target.Type), normalizedType) { + return target + } + } + return config.ProxyCheckTarget{} +} + +func NormalizeCheckTargets(targets []config.ProxyCheckTarget) []config.ProxyCheckTarget { + result := make([]config.ProxyCheckTarget, 0, len(targets)) + seen := map[string]struct{}{} + for _, target := range targets { + target.ID = strings.TrimSpace(target.ID) + target.Name = strings.TrimSpace(target.Name) + target.Type = strings.TrimSpace(target.Type) + target.URL = strings.TrimSpace(target.URL) + target.Parser = strings.TrimSpace(target.Parser) + if target.ID == "" || target.URL == "" { + continue + } + key := strings.ToLower(target.ID) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if target.Name == "" { + target.Name = target.ID + } + if target.Type == "" { + target.Type = "speed" + } + if target.TimeoutMs <= 0 { + target.TimeoutMs = defaultTargetTimeoutMs + } + result = append(result, target) + } + return result +} + +func FirstCheckTargetID(targets []config.ProxyCheckTarget, targetType string, fallback string) string { + for _, target := range targets { + if strings.EqualFold(strings.TrimSpace(target.Type), targetType) { + return strings.TrimSpace(target.ID) + } + } + return fallback +} + +func normalizePositiveInt(value int, fallback int) int { + if value > 0 { + return value + } + return fallback +} diff --git a/backend/internal/proxy/check_config_test.go b/backend/internal/proxy/check_config_test.go new file mode 100644 index 00000000..1514fc6a --- /dev/null +++ b/backend/internal/proxy/check_config_test.go @@ -0,0 +1,65 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "testing" + "time" +) + +func TestNormalizeCheckSettingsDefaultsAndSelectsTargets(t *testing.T) { + settings := NormalizeCheckSettings(config.ProxyCheckConfig{ + Targets: []config.ProxyCheckTarget{ + {ID: " speed-main ", URL: " https://speed.example.com ", Type: " speed ", TimeoutMs: 0}, + {ID: "health-main", URL: "https://health.example.com", Type: "ip_health", Parser: " ipqualityscore ", TimeoutMs: 1500}, + }, + }) + + if settings.BridgeStartTimeoutMs != defaultBridgeStartTimeoutMs { + t.Fatalf("bridge timeout = %d", settings.BridgeStartTimeoutMs) + } + if settings.SpeedTargetID != "speed-main" { + t.Fatalf("speed target id = %q", settings.SpeedTargetID) + } + if settings.IPHealthTargetID != "health-main" { + t.Fatalf("ip health target id = %q", settings.IPHealthTargetID) + } + if settings.Targets[0].TimeoutMs != defaultTargetTimeoutMs { + t.Fatalf("target timeout = %d", settings.Targets[0].TimeoutMs) + } +} + +func TestNormalizeCheckTargetsDropsInvalidAndDuplicateTargets(t *testing.T) { + targets := NormalizeCheckTargets([]config.ProxyCheckTarget{ + {ID: "main", URL: "https://example.com"}, + {ID: " MAIN ", URL: "https://duplicate.example.com"}, + {ID: "missing-url"}, + }) + + if len(targets) != 1 { + t.Fatalf("len = %d", len(targets)) + } + if targets[0].Name != "main" || targets[0].Type != "speed" { + t.Fatalf("target defaults were not applied: %#v", targets[0]) + } +} + +func TestBuildProxyCheckConfigs(t *testing.T) { + settings := config.ProxyCheckConfig{ + SpeedTargetID: "speed-main", + IPHealthTargetID: "health-main", + Targets: []config.ProxyCheckTarget{ + {ID: "speed-main", Type: "speed", URL: "https://speed.example.com", TimeoutMs: 1200}, + {ID: "health-main", Type: "ip_health", URL: "https://health.example.com", Parser: "ipqualityscore", TimeoutMs: 2300}, + }, + } + + speed := BuildSpeedTestConfig(settings) + if len(speed.URLs) != 1 || speed.URLs[0] != "https://speed.example.com" || speed.Timeout != 1200*time.Millisecond { + t.Fatalf("speed config = %#v", speed) + } + + health := BuildIPHealthConfig(settings) + if health.URL != "https://health.example.com" || health.Source != "health-main" || health.Parser != "ipqualityscore" || health.Timeout != 2300*time.Millisecond { + t.Fatalf("health config = %#v", health) + } +} diff --git a/backend/internal/proxy/normalize.go b/backend/internal/proxy/normalize.go new file mode 100644 index 00000000..4888775d --- /dev/null +++ b/backend/internal/proxy/normalize.go @@ -0,0 +1,85 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "strings" +) + +const defaultSourceRefreshIntervalM = 60 +const maxSourceRefreshIntervalM = 24 * 60 + +func NormalizeBrowserProxies(proxies []config.BrowserProxy, generateID func() string) []config.BrowserProxy { + normalized := make([]config.BrowserProxy, 0, len(proxies)+1) + for i, item := range proxies { + proxyName := strings.TrimSpace(item.ProxyName) + proxyConfig := strings.TrimSpace(item.ProxyConfig) + if proxyName == "" || proxyConfig == "" { + continue + } + + proxyID := strings.TrimSpace(item.ProxyId) + if proxyID == "" && generateID != nil { + proxyID = generateID() + } + + sourceURL := strings.TrimSpace(item.SourceURL) + sourceID := strings.TrimSpace(item.SourceID) + sourceNamePrefix := strings.TrimSpace(item.SourceNamePrefix) + sourceLastRefreshAt := strings.TrimSpace(item.SourceLastRefreshAt) + sourceRefreshIntervalM := item.SourceRefreshIntervalM + if sourceRefreshIntervalM < 0 { + sourceRefreshIntervalM = 0 + } + if sourceRefreshIntervalM > maxSourceRefreshIntervalM { + sourceRefreshIntervalM = maxSourceRefreshIntervalM + } + + sourceAutoRefresh := item.SourceAutoRefresh && sourceURL != "" + if sourceAutoRefresh && sourceRefreshIntervalM <= 0 { + sourceRefreshIntervalM = defaultSourceRefreshIntervalM + } + if !sourceAutoRefresh { + sourceRefreshIntervalM = 0 + } + if sourceURL == "" { + sourceID = "" + sourceNamePrefix = "" + sourceLastRefreshAt = "" + sourceAutoRefresh = false + sourceRefreshIntervalM = 0 + } + + normalized = append(normalized, config.BrowserProxy{ + ProxyId: proxyID, + ProxyName: proxyName, + ProxyConfig: proxyConfig, + DnsServers: strings.TrimSpace(item.DnsServers), + GroupName: strings.TrimSpace(item.GroupName), + SourceID: sourceID, + SourceURL: sourceURL, + SourceNamePrefix: sourceNamePrefix, + SourceAutoRefresh: sourceAutoRefresh, + SourceRefreshIntervalM: sourceRefreshIntervalM, + SourceLastRefreshAt: sourceLastRefreshAt, + SortOrder: i, + }) + } + + return ensureBuiltinDirectProxy(normalized) +} + +func ensureBuiltinDirectProxy(proxies []config.BrowserProxy) []config.BrowserProxy { + const directProxyID = "__direct__" + for _, item := range proxies { + if item.ProxyId == directProxyID { + return proxies + } + } + + builtin := config.BrowserProxy{ + ProxyId: directProxyID, + ProxyName: "直连(不走代理)", + ProxyConfig: "direct://", + } + return append([]config.BrowserProxy{builtin}, proxies...) +} diff --git a/backend/internal/proxy/normalize_test.go b/backend/internal/proxy/normalize_test.go new file mode 100644 index 00000000..9f0e2983 --- /dev/null +++ b/backend/internal/proxy/normalize_test.go @@ -0,0 +1,75 @@ +package proxy + +import ( + "ant-chrome/backend/internal/config" + "testing" +) + +func TestNormalizeBrowserProxiesTrimsAndAddsBuiltin(t *testing.T) { + proxies := NormalizeBrowserProxies([]config.BrowserProxy{ + {ProxyName: " main ", ProxyConfig: " http://127.0.0.1:8080 ", DnsServers: " 1.1.1.1 ", GroupName: " group-a "}, + {ProxyName: "missing config"}, + }, func() string { return "generated-id" }) + + if len(proxies) != 2 { + t.Fatalf("len = %d, want 2", len(proxies)) + } + if proxies[0].ProxyId != "__direct__" { + t.Fatalf("first proxy id = %q, want builtin direct", proxies[0].ProxyId) + } + if proxies[1].ProxyId != "generated-id" { + t.Fatalf("generated proxy id = %q", proxies[1].ProxyId) + } + if proxies[1].ProxyName != "main" || proxies[1].ProxyConfig != "http://127.0.0.1:8080" { + t.Fatalf("proxy was not trimmed: %#v", proxies[1]) + } + if proxies[1].DnsServers != "1.1.1.1" || proxies[1].GroupName != "group-a" { + t.Fatalf("metadata was not trimmed: %#v", proxies[1]) + } +} + +func TestNormalizeBrowserProxiesSourceRefreshRules(t *testing.T) { + proxies := NormalizeBrowserProxies([]config.BrowserProxy{ + { + ProxyId: "p1", + ProxyName: "source", + ProxyConfig: "http://127.0.0.1:8080", + SourceID: " source-id ", + SourceURL: " https://example.com/proxies.txt ", + SourceNamePrefix: " prefix ", + SourceAutoRefresh: true, + SourceRefreshIntervalM: -1, + SourceLastRefreshAt: " now ", + }, + { + ProxyId: "p2", + ProxyName: "without-source", + ProxyConfig: "http://127.0.0.1:8081", + SourceID: "source-id", + SourceNamePrefix: "prefix", + SourceAutoRefresh: true, + SourceRefreshIntervalM: 9999, + SourceLastRefreshAt: "now", + }, + }, nil) + + if proxies[1].SourceRefreshIntervalM != defaultSourceRefreshIntervalM { + t.Fatalf("source refresh interval = %d", proxies[1].SourceRefreshIntervalM) + } + if !proxies[1].SourceAutoRefresh { + t.Fatalf("source auto refresh should stay enabled") + } + if proxies[2].SourceID != "" || proxies[2].SourceAutoRefresh || proxies[2].SourceRefreshIntervalM != 0 { + t.Fatalf("source fields should be cleared without source url: %#v", proxies[2]) + } +} + +func TestNormalizeBrowserProxiesKeepsExistingBuiltin(t *testing.T) { + proxies := NormalizeBrowserProxies([]config.BrowserProxy{ + {ProxyId: "__direct__", ProxyName: "direct", ProxyConfig: "direct://"}, + }, nil) + + if len(proxies) != 1 { + t.Fatalf("len = %d, want 1", len(proxies)) + } +} diff --git a/backend/app_snapshot_archive.go b/backend/internal/snapshot/archive.go similarity index 88% rename from backend/app_snapshot_archive.go rename to backend/internal/snapshot/archive.go index ac31712b..1d4b3079 100644 --- a/backend/app_snapshot_archive.go +++ b/backend/internal/snapshot/archive.go @@ -1,4 +1,4 @@ -package backend +package snapshot import ( "archive/zip" @@ -10,8 +10,7 @@ import ( "strings" ) -// zipDir 递归压缩 src 目录为 dest zip 文件 -func zipDir(src, dest string) error { +func ZipDir(src, dest string) error { f, err := os.Create(dest) if err != nil { return err @@ -51,8 +50,7 @@ func zipDir(src, dest string) error { }) } -// unzipTo 解压 src zip 文件到 dest 目录 -func unzipTo(src, dest string) error { +func UnzipTo(src, dest string) error { r, err := zip.OpenReader(src) if err != nil { return err diff --git a/backend/internal/snapshot/archive_test.go b/backend/internal/snapshot/archive_test.go new file mode 100644 index 00000000..03218152 --- /dev/null +++ b/backend/internal/snapshot/archive_test.go @@ -0,0 +1,38 @@ +package snapshot + +import ( + "os" + "path/filepath" + "testing" +) + +func TestZipDirAndUnzipTo(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := filepath.Join(root, "src") + dstZip := filepath.Join(root, "archive.zip") + dstDir := filepath.Join(root, "dst") + + if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil { + t.Fatalf("mkdir src: %v", err) + } + if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("write source file: %v", err) + } + + if err := ZipDir(src, dstZip); err != nil { + t.Fatalf("ZipDir failed: %v", err) + } + if err := UnzipTo(dstZip, dstDir); err != nil { + t.Fatalf("UnzipTo failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dstDir, "nested", "file.txt")) + if err != nil { + t.Fatalf("read extracted file: %v", err) + } + if string(data) != "hello" { + t.Fatalf("extracted content = %q, want hello", string(data)) + } +} diff --git a/backend/internal/snapshot/files.go b/backend/internal/snapshot/files.go new file mode 100644 index 00000000..a65c9b5a --- /dev/null +++ b/backend/internal/snapshot/files.go @@ -0,0 +1,34 @@ +package snapshot + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func EnsureDir(appDataRoot, profileID string) (string, error) { + dir := filepath.Join(appDataRoot, "snapshots", profileID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +func FindFiles(snapDir, snapshotID string) (metaPath, zipPath string, err error) { + entries, err := os.ReadDir(snapDir) + if err != nil { + return "", "", err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), snapshotID) && strings.HasSuffix(entry.Name(), ".meta.json") { + metaPath = filepath.Join(snapDir, entry.Name()) + zipPath = strings.TrimSuffix(metaPath, ".meta.json") + ".zip" + if _, err := os.Stat(zipPath); err != nil { + return "", "", fmt.Errorf("快照文件不存在: %s", zipPath) + } + return metaPath, zipPath, nil + } + } + return "", "", fmt.Errorf("快照不存在: %s", snapshotID) +} diff --git a/backend/internal/snapshot/files_test.go b/backend/internal/snapshot/files_test.go new file mode 100644 index 00000000..30d15935 --- /dev/null +++ b/backend/internal/snapshot/files_test.go @@ -0,0 +1,50 @@ +package snapshot + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + metaPath := filepath.Join(dir, "snap-1_demo.meta.json") + zipPath := filepath.Join(dir, "snap-1_demo.zip") + + if err := os.WriteFile(metaPath, []byte("{}"), 0o644); err != nil { + t.Fatalf("write meta: %v", err) + } + if err := os.WriteFile(zipPath, []byte("zip"), 0o644); err != nil { + t.Fatalf("write zip: %v", err) + } + + gotMeta, gotZip, err := FindFiles(dir, "snap-1") + if err != nil { + t.Fatalf("FindFiles failed: %v", err) + } + if gotMeta != metaPath { + t.Fatalf("meta path = %q, want %q", gotMeta, metaPath) + } + if gotZip != zipPath { + t.Fatalf("zip path = %q, want %q", gotZip, zipPath) + } +} + +func TestEnsureDir(t *testing.T) { + t.Parallel() + + root := t.TempDir() + dir, err := EnsureDir(root, "profile-1") + if err != nil { + t.Fatalf("EnsureDir failed: %v", err) + } + expected := filepath.Join(root, "snapshots", "profile-1") + if dir != expected { + t.Fatalf("dir = %q, want %q", dir, expected) + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Fatalf("dir was not created: info=%v err=%v", info, err) + } +} diff --git a/backend/test/launchcode/server_automation_public_hook_test.go b/backend/test/launchcode/server_automation_public_hook_test.go new file mode 100644 index 00000000..7b5bf90a --- /dev/null +++ b/backend/test/launchcode/server_automation_public_hook_test.go @@ -0,0 +1,454 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/automation" +) + +func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "proton-mail-first-message", + Name: "Proton 邮件搜索并读取最新邮件", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/proton-first-message", + RequestMode: "standard", + ResponseMode: "envelope", + TimeoutMs: 120000, + }, + }, + } + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-hook-1", + ScriptID: "proton-mail-first-message", + ScriptName: "Proton 邮件搜索并读取最新邮件", + Status: "success", + Summary: "已返回最新命中邮件内容", + ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{ + "instance":{"type":"existing","selector":{"code":"BUYER_001"}}, + "params":{"recipientQuery":"target@example.com"} + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.ScriptID != "proton-mail-first-message" { + t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { + t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" { + t.Fatalf("instance 转换错误: %+v", starter.lastRunRequest) + } + if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) { + t.Fatalf("targetInput 转换错误: %s", targetInput) + } + if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` { + t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) + } + + var resp struct { + OK bool `json:"ok"` + Status string `json:"status"` + Data map[string]interface{} `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Status != "success" { + t.Fatalf("hook 响应错误: %+v", resp) + } + if resp.Data["verificationCode"] != "429792" { + t.Fatalf("expected data payload, got %+v", resp.Data) + } +} + +func TestAutomationPublicHookSupportsInstanceModes(t *testing.T) { + cases := []struct { + name string + body string + expectedMode string + expectedSelector bool + expectedInput string + useScriptTarget bool + }{ + { + name: "rotate", + body: `{ + "instance":{"type":"rotate","selector":{"groupId":"group-a","tags":["chatgpt"]}}, + "params":{"recipientQuery":"target@example.com"} + }`, + expectedMode: "rotate", + expectedInput: `"groupId":"group-a"`, + }, + { + name: "create", + body: `{ + "instance":{"type":"create","templateSelector":{"code":"TEMPLATE_001"},"createNameTemplate":"ChatGPT-Image-${timestamp}"}, + "params":{"recipientQuery":"target@example.com"} + }`, + expectedMode: "create", + expectedInput: `"createNameTemplate":"ChatGPT-Image-${timestamp}"`, + }, + { + name: "script-default", + body: `{ + "instance":{"type":"script-default"}, + "params":{"recipientQuery":"target@example.com"} + }`, + useScriptTarget: true, + }, + { + name: "legacy-code", + body: `{ + "code":"BUYER_001", + "params":{"recipientQuery":"target@example.com"} + }`, + expectedSelector: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "proton-mail-first-message", + Name: "Proton 邮件搜索并读取最新邮件", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/proton-first-message", + RequestMode: "standard", + ResponseMode: "envelope", + TimeoutMs: 120000, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(tc.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.TargetMode != tc.expectedMode { + t.Fatalf("targetMode 错误: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.UseScriptSelector != tc.useScriptTarget { + t.Fatalf("useScriptSelector 错误: %+v", starter.lastRunRequest) + } + if tc.expectedInput != "" { + targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput) + if !strings.Contains(targetInput, tc.expectedInput) { + t.Fatalf("targetInput 转换错误: %s", targetInput) + } + } + if tc.expectedSelector && starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` { + t.Fatalf("legacy selectorText 转换错误: %+v", starter.lastRunRequest) + } + }) + } +} + +func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "proton-mail-first-message", + Name: "Proton 邮件搜索并读取最新邮件", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/proton-result-only", + RequestMode: "params-only", + ResponseMode: "result-only", + TimeoutMs: 45000, + }, + }, + } + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-hook-2", + ScriptID: "proton-mail-first-message", + ScriptName: "Proton 邮件搜索并读取最新邮件", + Status: "success", + Summary: "已返回最新命中邮件内容", + ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{ + "code":"BUYER_001", + "params":{"recipientQuery":"target@example.com"} + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { + t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` { + t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) + } + if starter.lastRunRequest.TimeoutMs != 60000 { + t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + data, ok := resp["data"].(map[string]interface{}) + if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" { + t.Fatalf("expected data payload, got %+v", resp) + } +} + +func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "grok-image-generate-download", + Name: "Grok 生成图片并下载", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "image/grok-generate-download", + RequestMode: "params-only", + ResponseMode: "result-only", + TimeoutMs: 300000, + }, + }, + } + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-grok-image", + ScriptID: "grok-image-generate-download", + ScriptName: "Grok 生成图片并下载", + Status: "success", + Summary: "Grok 图片已生成并下载", + ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + data, ok := resp["data"].(map[string]interface{}) + if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" { + t.Fatalf("下载字段缺失: %+v", resp) + } + if _, exists := data["steps"]; exists { + t.Fatalf("不应返回冗余 steps: %+v", resp) + } + if _, exists := data["runId"]; exists { + t.Fatalf("不应返回 runId: %+v", resp) + } +} + +func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "image-generate", + Name: "图片生成", + ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`, + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "image/generate", + RequestMode: "standard", + ResponseMode: "envelope", + TimeoutMs: 300000, + RequestBodyText: `{"instance":{"type":"existing","selector":{"code":"{{code}}"}},"params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`, + Variables: []automation.ScriptPublicAPIVariable{ + {Name: "prompt", DefaultValue: "默认提示词", Required: true}, + {Name: "outputFileName", DefaultValue: "generated-image.png"}, + }, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{ + "code":"BUYER_001", + "params":{"prompt":"海边的机器人","outputFileName":"robot.png"} + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.UseScriptParams { + t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" { + t.Fatalf("变量模板应生成 instance target: %+v", starter.lastRunRequest) + } + if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) { + t.Fatalf("变量模板 targetInput 错误: %s", targetInput) + } + + var params map[string]interface{} + if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil { + t.Fatalf("解析 paramsText 失败: %v", err) + } + if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" { + t.Fatalf("变量未映射到 params: %+v", params) + } + selectors, ok := params["selectors"].(map[string]interface{}) + if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" { + t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params) + } + if params["timeoutMs"] != float64(300000) { + t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params) + } + if starter.lastRunRequest.TimeoutMs != 300000 { + t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest) + } +} + +func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "disabled-hook", + Name: "Disabled Hook", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: false, + Path: "mail/disabled-hook", + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String()) + } +} + +func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "strict-hook", + Name: "Strict Hook", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/strict-hook", + TimeoutMs: 120000, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + cases := []struct { + name string + body string + }{ + {name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`}, + {name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if resp.OK || resp.Error.Code != "invalid_request" { + t.Fatalf("错误 envelope 不正确: %+v", resp) + } + }) + } +} + +func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "strict-hook-timeout", + Name: "Strict Hook Timeout", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/strict-hook-timeout", + TimeoutMs: 120000, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } +} diff --git a/backend/test/launchcode/server_automation_runs_test.go b/backend/test/launchcode/server_automation_runs_test.go new file mode 100644 index 00000000..a986a5f1 --- /dev/null +++ b/backend/test/launchcode/server_automation_runs_test.go @@ -0,0 +1,108 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/automation" +) + +func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.runs = []automation.ScriptRunRecord{ + {ID: "run-1", ScriptID: "script-a", Status: "success"}, + {ID: "run-2", ScriptID: "script-b", Status: "failed"}, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunListLimit != 1 { + t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit) + } + + var resp struct { + OK bool `json:"ok"` + Data struct { + Count int `json:"count"` + Items []automation.ScriptRunRecord `json:"items"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 { + t.Fatalf("runs 响应错误: %+v", resp) + } +} + +func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) { + handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil) + req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) + } +} + +func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + handler := buildTestHandlerWithManager(svc, starter, nil) + + t.Run("invalid-json", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + }) + + t.Run("selector-must-be-object", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ + "scriptId":"news-query-txt", + "selector":"BUYER_001" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "selector must be a JSON object") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } + }) + + t.Run("timeout-must-be-in-range", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ + "scriptId":"news-query-txt", + "timeoutMs":1800001 + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } + }) +} diff --git a/backend/test/launchcode/server_automation_test.go b/backend/test/launchcode/server_automation_test.go index eef31dbf..4087b70a 100644 --- a/backend/test/launchcode/server_automation_test.go +++ b/backend/test/launchcode/server_automation_test.go @@ -83,6 +83,15 @@ func (m *mockAutomationStarter) AutomationScriptRunList(limit int) ([]automation 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() @@ -311,443 +320,3 @@ func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testi t.Fatalf("缺省时不应透传 selectorText/paramsText: %+v", starter.lastRunRequest) } } - -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(`{ - "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.SelectorText != `{"code":"BUYER_001"}` { - t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText) - } - 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 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: `{"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.SelectorText != `{"code":"BUYER_001"}` { - t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest) - } - - 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()) - } -} - -func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - starter.runs = []automation.ScriptRunRecord{ - {ID: "run-1", ScriptID: "script-a", Status: "success"}, - {ID: "run-2", ScriptID: "script-b", Status: "failed"}, - } - - handler := buildTestHandlerWithManager(svc, starter, nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastRunListLimit != 1 { - t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit) - } - - var resp struct { - OK bool `json:"ok"` - Data struct { - Count int `json:"count"` - Items []automation.ScriptRunRecord `json:"items"` - } `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 { - t.Fatalf("runs 响应错误: %+v", resp) - } -} - -func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) { - handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil) - req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String()) - } -} - -func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) { - svc := newInMemoryService() - starter := newMockAutomationStarter() - handler := buildTestHandlerWithManager(svc, starter, nil) - - t.Run("invalid-json", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}")) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - }) - - t.Run("selector-must-be-object", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ - "scriptId":"news-query-txt", - "selector":"BUYER_001" - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "selector must be a JSON object") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } - }) - - t.Run("timeout-must-be-in-range", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ - "scriptId":"news-query-txt", - "timeoutMs":1800001 - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } - }) -} diff --git a/backend/test/launchcode/server_selector_modes_test.go b/backend/test/launchcode/server_selector_modes_test.go new file mode 100644 index 00000000..e377c40b --- /dev/null +++ b/backend/test/launchcode/server_selector_modes_test.go @@ -0,0 +1,214 @@ +package launchcode_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ant-chrome/backend/internal/browser" +) + +func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Account A", + Keywords: []string{"shop", "checkout"}, + Pid: 1001, + DebugPort: 9441, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Account B", + Keywords: []string{"shop", "refund"}, + Pid: 1002, + DebugPort: 9442, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileA, profileB) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != "" { + t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile) + } + if !strings.Contains(w.Body.String(), "matchMode=first") { + t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String()) + } +} + +func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profile := &browser.Profile{ + ProfileId: "profile-get-code-only", + ProfileName: "Buyer Account 02", + Keywords: []string{"buyer-002"}, + Pid: 1004, + DebugPort: 9447, + } + starter.addProfile(profile) + manager := newSelectorTestManager(profile) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != "" { + t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile) + } +} + +func TestLaunchWithMatchModeFirst(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "B Account", + Keywords: []string{"shop"}, + Pid: 2002, + DebugPort: 9552, + } + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "A Account", + Keywords: []string{"shop"}, + Pid: 2001, + DebugPort: 9551, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileB, profileA) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastProfile != profileA.ProfileId { + t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) + } +} + +func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "A Account", + Keywords: []string{"shop"}, + Pid: 2001, + DebugPort: 9551, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "B Account", + Keywords: []string{"shop"}, + Pid: 2002, + DebugPort: 9552, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileB, profileA) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if len(starter.started) != 2 { + t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started) + } + if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId { + t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started) + } + + var resp struct { + OK bool `json:"ok"` + Count int `json:"count"` + Items []struct { + ProfileID string `json:"profileId"` + IsActive bool `json:"isActive"` + } `json:"items"` + ActiveProfileID string `json:"activeProfileId"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 { + t.Fatalf("批量启动响应错误: %+v", resp) + } + if resp.ActiveProfileID != profileB.ProfileId { + t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId) + } + if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId { + t.Fatalf("items 顺序错误: %+v", resp.Items) + } + if resp.Items[0].IsActive || !resp.Items[1].IsActive { + t.Fatalf("isActive 标记错误: %+v", resp.Items) + } +} + +func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + + profileA := &browser.Profile{ + ProfileId: "profile-a", + ProfileName: "Account A", + Keywords: []string{"shop", "checkout"}, + Pid: 1001, + DebugPort: 9441, + } + profileB := &browser.Profile{ + ProfileId: "profile-b", + ProfileName: "Account B", + Keywords: []string{"shop", "refund"}, + Pid: 1002, + DebugPort: 9442, + } + starter.addProfile(profileA) + starter.addProfile(profileB) + manager := newSelectorTestManager(profileA, profileB) + + handler := buildTestHandlerWithManager(svc, starter, manager) + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) + } + if len(starter.started) != 0 { + t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started) + } +} diff --git a/backend/test/launchcode/server_selector_test.go b/backend/test/launchcode/server_selector_test.go index 78fb3c73..0553eb6e 100644 --- a/backend/test/launchcode/server_selector_test.go +++ b/backend/test/launchcode/server_selector_test.go @@ -344,205 +344,3 @@ func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) { t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) } } - -func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Account A", - Keywords: []string{"shop", "checkout"}, - Pid: 1001, - DebugPort: 9441, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Account B", - Keywords: []string{"shop", "refund"}, - Pid: 1002, - DebugPort: 9442, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileA, profileB) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != "" { - t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile) - } - if !strings.Contains(w.Body.String(), "matchMode=first") { - t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String()) - } -} - -func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profile := &browser.Profile{ - ProfileId: "profile-get-code-only", - ProfileName: "Buyer Account 02", - Keywords: []string{"buyer-002"}, - Pid: 1004, - DebugPort: 9447, - } - starter.addProfile(profile) - manager := newSelectorTestManager(profile) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != "" { - t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile) - } -} - -func TestLaunchWithMatchModeFirst(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "B Account", - Keywords: []string{"shop"}, - Pid: 2002, - DebugPort: 9552, - } - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "A Account", - Keywords: []string{"shop"}, - Pid: 2001, - DebugPort: 9551, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileB, profileA) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if starter.lastProfile != profileA.ProfileId { - t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId) - } -} - -func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "A Account", - Keywords: []string{"shop"}, - Pid: 2001, - DebugPort: 9551, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "B Account", - Keywords: []string{"shop"}, - Pid: 2002, - DebugPort: 9552, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileB, profileA) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.started) != 2 { - t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started) - } - if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId { - t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started) - } - - var resp struct { - OK bool `json:"ok"` - Count int `json:"count"` - Items []struct { - ProfileID string `json:"profileId"` - IsActive bool `json:"isActive"` - } `json:"items"` - ActiveProfileID string `json:"activeProfileId"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 { - t.Fatalf("批量启动响应错误: %+v", resp) - } - if resp.ActiveProfileID != profileB.ProfileId { - t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId) - } - if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId { - t.Fatalf("items 顺序错误: %+v", resp.Items) - } - if resp.Items[0].IsActive || !resp.Items[1].IsActive { - t.Fatalf("isActive 标记错误: %+v", resp.Items) - } -} - -func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) { - svc := newInMemoryService() - starter := newMockStarterWithParams() - - profileA := &browser.Profile{ - ProfileId: "profile-a", - ProfileName: "Account A", - Keywords: []string{"shop", "checkout"}, - Pid: 1001, - DebugPort: 9441, - } - profileB := &browser.Profile{ - ProfileId: "profile-b", - ProfileName: "Account B", - Keywords: []string{"shop", "refund"}, - Pid: 1002, - DebugPort: 9442, - } - starter.addProfile(profileA) - starter.addProfile(profileB) - manager := newSelectorTestManager(profileA, profileB) - - handler := buildTestHandlerWithManager(svc, starter, manager) - req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String()) - } - if len(starter.started) != 0 { - t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started) - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d796082f..5dc41216 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,11 @@ -import { Suspense, lazy, useEffect, useState } from "react"; -import type { ComponentType } from "react"; -import { - BrowserRouter as Router, - Routes, - Route, - Navigate, -} from "react-router-dom"; +import { Suspense, useEffect, useState } from "react"; +import { BrowserRouter as Router } from "react-router-dom"; import { ThemeProvider } from "./shared/theme"; import { Layout } from "./shared/layout"; import { ToastContainer, Modal, Button, Loading, toast } from "./shared/components"; import { AlertCircle } from "lucide-react"; +import { AppRoutes } from "./routes/AppRoutes"; +import { lazyNamed } from "./routes/lazyNamed"; import { useNotificationStore } from "./store/notificationStore"; import { useBackupStore } from "./store/backupStore"; import { @@ -23,127 +19,6 @@ import { WindowMinimise, } from "./wailsjs/runtime/runtime"; -const CHUNK_RELOAD_COOLDOWN_MS = 10000; -const CHUNK_RELOAD_TS_KEY = "__ant_chunk_reload_ts__"; - -function isDynamicImportFetchError(error: unknown) { - const message = - error instanceof Error ? error.message : String(error ?? ""); - return /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test( - message, - ); -} - -function reloadForStaleChunkOnce() { - if (typeof window === "undefined") { - return false; - } - - const now = Date.now(); - try { - const lastAttempt = Number( - window.sessionStorage.getItem(CHUNK_RELOAD_TS_KEY) || "0", - ); - if (Number.isFinite(lastAttempt) && now - lastAttempt < CHUNK_RELOAD_COOLDOWN_MS) { - return false; - } - window.sessionStorage.setItem(CHUNK_RELOAD_TS_KEY, String(now)); - } catch { - // ignore sessionStorage failures and still try a hard reload - } - - window.location.reload(); - return true; -} - -function lazyNamed>>( - loader: () => Promise, - exportName: keyof TModule, -) { - return lazy(async () => { - let module: TModule; - try { - module = await loader(); - } catch (error) { - if (isDynamicImportFetchError(error) && reloadForStaleChunkOnce()) { - return new Promise(() => {}); - } - throw error; - } - return { - default: module[exportName] as ComponentType, - }; - }); -} - -const DashboardPage = lazyNamed( - () => import("./modules/dashboard/DashboardPage"), - "DashboardPage", -); -const SettingsPage = lazyNamed( - () => import("./modules/settings/SettingsPage"), - "SettingsPage", -); -const ProfilePage = lazyNamed( - () => import("./modules/profile/ProfilePage"), - "ProfilePage", -); -const AdminKeygenPage = lazyNamed( - () => import("./modules/profile/AdminKeygenPage"), - "AdminKeygenPage", -); -const ChartsPage = lazyNamed( - () => import("./modules/charts/ChartsPage"), - "ChartsPage", -); -const BrowserListPage = lazyNamed( - () => import("./modules/browser/pages/BrowserListPage"), - "BrowserListPage", -); -const BrowserDetailPage = lazyNamed( - () => import("./modules/browser/pages/BrowserDetailPage"), - "BrowserDetailPage", -); -const BrowserEditPage = lazyNamed( - () => import("./modules/browser/pages/BrowserEditPage"), - "BrowserEditPage", -); -const BrowserCopyPage = lazyNamed( - () => import("./modules/browser/pages/BrowserCopyPage"), - "BrowserCopyPage", -); -const BrowserLogsPage = lazyNamed( - () => import("./modules/browser/pages/BrowserLogsPage"), - "BrowserLogsPage", -); -const ProxyPoolPage = lazyNamed( - () => import("./modules/browser/pages/ProxyPoolPage"), - "ProxyPoolPage", -); -const CoreManagementPage = lazyNamed( - () => import("./modules/browser/pages/CoreManagementPage"), - "CoreManagementPage", -); -const BookmarkSettingsPage = lazyNamed( - () => import("./modules/browser/pages/BookmarkSettingsPage"), - "BookmarkSettingsPage", -); -const LaunchApiDocsPage = lazyNamed( - () => import("./modules/browser/pages/LaunchApiDocsPage"), - "LaunchApiDocsPage", -); -const TagManagementPage = lazyNamed( - () => import("./modules/browser/pages/TagManagementPage"), - "TagManagementPage", -); -const AutomationPage = lazyNamed( - () => import("./modules/browser/pages/AutomationPage"), - "AutomationPage", -); -const AutomationScriptDetailPage = lazyNamed( - () => import("./modules/browser/pages/AutomationScriptDetailPage"), - "AutomationScriptDetailPage", -); const QuickLaunchModal = lazyNamed( () => import("./modules/browser/components/QuickLaunchModal"), "QuickLaunchModal", @@ -399,49 +274,7 @@ function App() { - - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - + diff --git a/frontend/src/config/features.config.ts b/frontend/src/config/features.config.ts new file mode 100644 index 00000000..3626a8e7 --- /dev/null +++ b/frontend/src/config/features.config.ts @@ -0,0 +1,5 @@ +export const featuresConfig = { + dashboard: true, + data: true, + settings: true, +} diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index f5732c09..8540d63c 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -2,13 +2,13 @@ export { default as config } from './project.config' export { projectConfig, - navigationConfig, featuresConfig, uiConfig, } from './project.config' +export { navigationConfig } from './navigation.config' export { profilePageConfig } from './profile.config' -export type { NavItem, NavSection } from './project.config' +export type { NavItem, NavSection } from './navigation.config' export type { AuthorProfileConfig, ProfileChannelConfig, diff --git a/frontend/src/config/navigation.config.ts b/frontend/src/config/navigation.config.ts new file mode 100644 index 00000000..aab7694a --- /dev/null +++ b/frontend/src/config/navigation.config.ts @@ -0,0 +1,38 @@ +export interface NavItem { + name: string + path: string + icon: string +} + +export interface NavSection { + title: string + items: NavItem[] +} + +export const navigationConfig: NavSection[] = [ + { + title: '主菜单', + items: [ + { name: '控制台', path: '/', icon: 'LayoutDashboard' }, + ] + }, + { + title: '指纹浏览器', + items: [ + { name: '实例列表', path: '/browser/list', icon: 'Monitor' }, + { name: '自动化脚本', path: '/browser/automation', icon: 'Bot' }, + { name: '内核管理', path: '/browser/cores', icon: 'Cpu' }, + { name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' }, + { name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' }, + { name: '标签管理', path: '/browser/tags', icon: 'Tag' }, + ] + }, + { + title: '系统维护', + items: [ + { name: '系统设置', path: '/settings', icon: 'Settings' }, + { name: '文档中心', path: '/system/docs', icon: 'BookOpen' }, + { name: '日志查看', path: '/browser/logs', icon: 'FileText' }, + ] + }, +] diff --git a/frontend/src/config/profile.config.ts b/frontend/src/config/profile.config.ts index df3d16c4..e5358cb9 100644 --- a/frontend/src/config/profile.config.ts +++ b/frontend/src/config/profile.config.ts @@ -1,4 +1,4 @@ -import { projectConfig } from './project.config' +import { projectConfig } from './projectBase.config' import { PROJECT_GITHUB_URL } from './links' export type ProfileIconKey = diff --git a/frontend/src/config/project.config.ts b/frontend/src/config/project.config.ts index 49a044a1..0078900e 100644 --- a/frontend/src/config/project.config.ts +++ b/frontend/src/config/project.config.ts @@ -1,73 +1,13 @@ -/** - * 项目配置文件 - * - * 基于此脚手架创建新项目时,修改此文件即可完成定制 - */ +import { featuresConfig } from './features.config' +import { navigationConfig } from './navigation.config' +import { projectConfig } from './projectBase.config' +import { uiConfig } from './ui.config' -// 项目基础信息 -export const projectConfig = { - name: 'Ant Browser', - shortName: 'Ant', - description: '面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具', - primaryColor: 'primary', -} - -// 导航菜单配置 -export interface NavItem { - name: string - path: string - icon: string -} - -export interface NavSection { - title: string - items: NavItem[] -} - -export const navigationConfig: NavSection[] = [ - { - title: '主菜单', - items: [ - { name: '控制台', path: '/', icon: 'LayoutDashboard' }, - ] - }, - { - title: '指纹浏览器', - items: [ - { name: '实例列表', path: '/browser/list', icon: 'Monitor' }, - { name: '自动化脚本', path: '/browser/automation', icon: 'Bot' }, - { name: '内核管理', path: '/browser/cores', icon: 'Cpu' }, - { name: '代理池配置', path: '/browser/proxy-pool', icon: 'Globe' }, - { name: '默认书签', path: '/browser/bookmarks', icon: 'Bookmark' }, - { name: '标签管理', path: '/browser/tags', icon: 'Tag' }, - ] - }, - { - title: '系统维护', - items: [ - { name: '系统设置', path: '/settings', icon: 'Settings' }, - { name: '文档中心', path: '/system/docs', icon: 'BookOpen' }, - { name: '日志查看', path: '/browser/logs', icon: 'FileText' }, - ] - }, -] - -// 功能开关 -export const featuresConfig = { - dashboard: true, - data: true, - settings: true, -} - -// UI 配置 -export const uiConfig = { - pagination: { - defaultPageSize: 20, - pageSizeOptions: [10, 20, 50, 100], - }, - dateFormat: 'YYYY-MM-DD HH:mm:ss', - locale: 'zh-CN', -} +export { featuresConfig } from './features.config' +export { navigationConfig } from './navigation.config' +export { projectConfig } from './projectBase.config' +export { uiConfig } from './ui.config' +export type { NavItem, NavSection } from './navigation.config' export default { project: projectConfig, diff --git a/frontend/src/config/projectBase.config.ts b/frontend/src/config/projectBase.config.ts new file mode 100644 index 00000000..edc82d46 --- /dev/null +++ b/frontend/src/config/projectBase.config.ts @@ -0,0 +1,6 @@ +export const projectConfig = { + name: 'Ant Browser', + shortName: 'Ant', + description: '面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具', + primaryColor: 'primary', +} diff --git a/frontend/src/config/ui.config.ts b/frontend/src/config/ui.config.ts new file mode 100644 index 00000000..ae601c22 --- /dev/null +++ b/frontend/src/config/ui.config.ts @@ -0,0 +1,8 @@ +export const uiConfig = { + pagination: { + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + }, + dateFormat: 'YYYY-MM-DD HH:mm:ss', + locale: 'zh-CN', +} diff --git a/frontend/src/modules/browser/automationScriptApi.exports.ts b/frontend/src/modules/browser/automationScriptApi.exports.ts new file mode 100644 index 00000000..aa6d9a89 --- /dev/null +++ b/frontend/src/modules/browser/automationScriptApi.exports.ts @@ -0,0 +1,133 @@ +import { exportAutomationScript, type AutomationScriptRecord } from "./automationScripts"; +import { getBindings, type AutomationScriptExportResult } from "./automationScriptApi.shared"; + +function normalizeAutomationScriptExportResult( + payload: any, +): AutomationScriptExportResult { + return { + cancelled: payload?.cancelled === true, + format: String(payload?.format || ""), + message: String(payload?.message || ""), + path: String(payload?.path || ""), + fileCount: Number(payload?.fileCount) || 0, + }; +} + +function buildAutomationTemplateFallbackFilename(script: AutomationScriptRecord): string { + const normalizedName = String(script.name || "") + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/^-+|-+$/g, ""); + + return `${normalizedName || "automation-script"}-template.json`; +} + +function downloadAutomationTemplate( + filename: string, + content: string, +): AutomationScriptExportResult { + const blob = new Blob([content], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } + + return { + cancelled: false, + format: "json", + message: "模板已导出", + path: filename, + fileCount: 1, + }; +} + +export async function exportAutomationScriptTemplate( + scriptId: string, + fallbackScript?: AutomationScriptRecord, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptExport) { + return normalizeAutomationScriptExportResult( + await bindings.AutomationScriptExport(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptExport === "function") { + return normalizeAutomationScriptExportResult( + await goApp.AutomationScriptExport(normalizedScriptId), + ); + } + + if (fallbackScript && typeof document !== "undefined") { + return downloadAutomationTemplate( + buildAutomationTemplateFallbackFilename(fallbackScript), + exportAutomationScript(fallbackScript), + ); + } + + throw new Error("当前环境不支持脚本模板导出"); +} + +export async function exportAutomationScriptZip( + scriptId: string, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptExportZip) { + return normalizeAutomationScriptExportResult( + await bindings.AutomationScriptExportZip(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptExportZip === "function") { + return normalizeAutomationScriptExportResult( + await goApp.AutomationScriptExportZip(normalizedScriptId), + ); + } + + throw new Error("当前环境不支持 ZIP 脚本包导出"); +} + +export async function exportAutomationScriptDirectory( + scriptId: string, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptExportDirectory) { + return normalizeAutomationScriptExportResult( + await bindings.AutomationScriptExportDirectory(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptExportDirectory === "function") { + return normalizeAutomationScriptExportResult( + await goApp.AutomationScriptExportDirectory(normalizedScriptId), + ); + } + + throw new Error("当前环境不支持目录脚本包导出"); +} + diff --git a/frontend/src/modules/browser/automationScriptApi.imports.ts b/frontend/src/modules/browser/automationScriptApi.imports.ts new file mode 100644 index 00000000..5c14f989 --- /dev/null +++ b/frontend/src/modules/browser/automationScriptApi.imports.ts @@ -0,0 +1,190 @@ +import { importAutomationScript, type AutomationScriptRecord } from "./automationScripts"; +import { getBindings, normalizeAutomationScriptRecord, type AutomationScriptBatchImportResult } from "./automationScriptApi.shared"; + +export async function importAutomationScriptFromLocalFile(): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportLocalFile) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportLocalFile(), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportLocalFile === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportLocalFile(), + ); + } + + throw new Error("当前环境不支持本地文件导入"); +} + +export async function importAutomationScriptFromText( + text: string, +): Promise { + const normalizedText = String(text || "").trim(); + if (!normalizedText) { + throw new Error("导入内容不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportText) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportText(normalizedText), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportText === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportText(normalizedText), + ); + } + + return importAutomationScript(normalizedText); +} + +export async function importAutomationScriptFromLocalDirectory(): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportLocalDirectory) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportLocalDirectory(), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportLocalDirectory === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportLocalDirectory(), + ); + } + + throw new Error("当前环境不支持本地目录导入"); +} + +function normalizeAutomationScriptBatchImportResult( + payload: any, +): AutomationScriptBatchImportResult { + const imported = Array.isArray(payload?.imported) + ? payload.imported.map(normalizeAutomationScriptRecord) + : []; + + return { + imported, + failed: Array.isArray(payload?.failed) + ? payload.failed.map((item: any) => ({ + path: String(item?.path || ""), + message: String(item?.message || ""), + })) + : [], + scanned: + Number.isFinite(Number(payload?.scanned)) && Number(payload.scanned) > 0 + ? Math.round(Number(payload.scanned)) + : imported.length, + }; +} + +export async function importAutomationScriptFromLocalLibrary(): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportLocalLibrary) { + return normalizeAutomationScriptBatchImportResult( + await bindings.AutomationScriptImportLocalLibrary(), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportLocalLibrary === "function") { + return normalizeAutomationScriptBatchImportResult( + await goApp.AutomationScriptImportLocalLibrary(), + ); + } + + throw new Error("当前环境不支持本地脚本库导入"); +} + +export async function importAutomationScriptFromRemote(url: string): Promise { + const normalizedURL = String(url || "").trim(); + if (!normalizedURL) { + throw new Error("远程脚本地址不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportRemote) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportRemote(normalizedURL), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportRemote === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportRemote(normalizedURL), + ); + } + + throw new Error("当前环境不支持远程脚本导入"); +} + +export async function importAutomationScriptFromGit( + repoURL: string, + ref = "", + scriptPath = "", +): Promise { + const normalizedRepoURL = String(repoURL || "").trim(); + if (!normalizedRepoURL) { + throw new Error("Git 仓库地址不能为空"); + } + + const normalizedRef = String(ref || "").trim(); + const normalizedScriptPath = String(scriptPath || "").trim(); + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportGit) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptImportGit( + normalizedRepoURL, + normalizedRef, + normalizedScriptPath, + ), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportGit === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptImportGit( + normalizedRepoURL, + normalizedRef, + normalizedScriptPath, + ), + ); + } + + throw new Error("当前环境不支持 Git 脚本导入"); +} + +export async function refreshAutomationScript( + scriptId: string, +): Promise { + const normalizedScriptId = String(scriptId || "").trim(); + if (!normalizedScriptId) { + throw new Error("脚本 ID 不能为空"); + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptRefresh) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptRefresh(normalizedScriptId), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptRefresh === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptRefresh(normalizedScriptId), + ); + } + + throw new Error("当前环境不支持按来源重新导入"); +} + diff --git a/frontend/src/modules/browser/automationScriptApi.runs.ts b/frontend/src/modules/browser/automationScriptApi.runs.ts new file mode 100644 index 00000000..a90537f4 --- /dev/null +++ b/frontend/src/modules/browser/automationScriptApi.runs.ts @@ -0,0 +1,164 @@ +import { type AutomationScriptRunInput, type AutomationScriptRunRecord } from "./automationScripts"; +import { startBrowserInstanceByCode } from "./api/instances"; +import { getBindings, normalizeAutomationScriptPublicApiInvokeResult, normalizeAutomationScriptRunInput, normalizeAutomationScriptRunRecord, type AutomationScriptPublicApiInvokeInput, type AutomationScriptPublicApiInvokeResult } from "./automationScriptApi.shared"; + +export async function runAutomationScript( + input: string | AutomationScriptRunInput, +): Promise { + const request = normalizeAutomationScriptRunInput(input); + const { launchCode, startByCodeBeforeRun, ...bindingRequest } = request; + + if (startByCodeBeforeRun && launchCode) { + const startedProfile = await startBrowserInstanceByCode(launchCode); + if (!startedProfile) { + throw new Error(`通过 Launch Code 启动实例失败: ${launchCode}`); + } + } + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptRunWithOptions) { + return normalizeAutomationScriptRunRecord( + await bindings.AutomationScriptRunWithOptions(bindingRequest), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptRunWithOptions === "function") { + return normalizeAutomationScriptRunRecord( + await goApp.AutomationScriptRunWithOptions(bindingRequest), + ); + } + + if ( + bindings?.AutomationScriptRun && + bindingRequest.useScriptSelector && + bindingRequest.useScriptParams + ) { + return normalizeAutomationScriptRunRecord( + await bindings.AutomationScriptRun(bindingRequest.scriptId), + ); + } + + if ( + typeof goApp?.AutomationScriptRun === "function" && + bindingRequest.useScriptSelector && + bindingRequest.useScriptParams + ) { + return normalizeAutomationScriptRunRecord( + await goApp.AutomationScriptRun(bindingRequest.scriptId), + ); + } + + const now = new Date().toISOString(); + return { + id: `mock-run-${Date.now()}`, + scriptId: bindingRequest.scriptId, + scriptName: "", + scriptType: "", + status: "failed", + summary: "当前环境未接入自动化脚本执行", + error: "AutomationScriptRun binding is unavailable", + resultText: "", + startedAt: now, + finishedAt: now, + durationMs: 0, + }; +} + +export async function fetchAutomationScriptRuns( + limit = 20, +): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptRunList) { + const raw = (await bindings.AutomationScriptRunList(limit)) || []; + return Array.isArray(raw) + ? raw.map(normalizeAutomationScriptRunRecord) + : []; + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptRunList === "function") { + const raw = (await goApp.AutomationScriptRunList(limit)) || []; + return Array.isArray(raw) + ? raw.map(normalizeAutomationScriptRunRecord) + : []; + } + + return []; +} + +export async function invokeAutomationScriptPublicApi( + input: AutomationScriptPublicApiInvokeInput, +): Promise { + const url = String(input?.url || "").trim(); + if (!url) { + throw new Error("接口地址不能为空"); + } + + const method = String(input?.method || "POST").trim().toUpperCase() || "POST"; + const authHeader = String(input?.authHeader || "X-Ant-Api-Key").trim() || "X-Ant-Api-Key"; + const apiKey = String(input?.apiKey || "").trim(); + const bodyText = String(input?.bodyText || "").trim(); + const timeoutMs = Number.isFinite(Number(input?.timeoutMs)) + ? Math.max(1000, Math.round(Number(input?.timeoutMs))) + : 0; + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptInvokePublicAPI) { + return normalizeAutomationScriptPublicApiInvokeResult( + await bindings.AutomationScriptInvokePublicAPI({ + url, + method, + bodyText, + apiKey, + authHeader, + timeoutMs, + }), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptInvokePublicAPI === "function") { + return normalizeAutomationScriptPublicApiInvokeResult( + await goApp.AutomationScriptInvokePublicAPI({ + url, + method, + bodyText, + apiKey, + authHeader, + timeoutMs, + }), + ); + } + + const headers: Record = { + "Content-Type": "application/json", + }; + if (apiKey) { + headers[authHeader] = apiKey; + } + + const response = await fetch(url, { + method, + headers, + body: bodyText || "{}", + }); + + const rawText = await response.text(); + let bodyJson: unknown | null = null; + if (rawText.trim()) { + try { + bodyJson = JSON.parse(rawText); + } catch { + bodyJson = null; + } + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + bodyText: rawText, + bodyJson, + }; +} diff --git a/frontend/src/modules/browser/automationScriptApi.scripts.ts b/frontend/src/modules/browser/automationScriptApi.scripts.ts new file mode 100644 index 00000000..3676bfc4 --- /dev/null +++ b/frontend/src/modules/browser/automationScriptApi.scripts.ts @@ -0,0 +1,68 @@ +import { loadAutomationScripts, saveAutomationScripts, type AutomationScriptRecord } from "./automationScripts"; +import { getBindings, normalizeAutomationScriptRecord, sortScripts } from "./automationScriptApi.shared"; + +export async function fetchAutomationScripts(): Promise< + AutomationScriptRecord[] +> { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptList) { + const raw = (await bindings.AutomationScriptList()) || []; + return sortScripts( + Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [], + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptList === "function") { + const raw = (await goApp.AutomationScriptList()) || []; + return sortScripts( + Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [], + ); + } + + return loadAutomationScripts(); +} + +export async function saveAutomationScript( + script: AutomationScriptRecord, +): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptSave) { + return normalizeAutomationScriptRecord( + await bindings.AutomationScriptSave(script), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptSave === "function") { + return normalizeAutomationScriptRecord( + await goApp.AutomationScriptSave(script), + ); + } + + const current = loadAutomationScripts(); + const next = current.some((item) => item.id === script.id) + ? current.map((item) => (item.id === script.id ? script : item)) + : [script, ...current]; + saveAutomationScripts(sortScripts(next)); + return script; +} + +export async function deleteAutomationScript(scriptId: string): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptDelete) { + await bindings.AutomationScriptDelete(scriptId); + return; + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptDelete === "function") { + await goApp.AutomationScriptDelete(scriptId); + return; + } + + saveAutomationScripts( + loadAutomationScripts().filter((item) => item.id !== scriptId), + ); +} + diff --git a/frontend/src/modules/browser/automationScriptApi.shared.ts b/frontend/src/modules/browser/automationScriptApi.shared.ts new file mode 100644 index 00000000..bcf4b967 --- /dev/null +++ b/frontend/src/modules/browser/automationScriptApi.shared.ts @@ -0,0 +1,174 @@ +import { + normalizeAutomationScriptPublicAPIConfig, + normalizeAutomationScriptRecordPayload, + normalizeAutomationScriptTargetConfig, + type AutomationScriptRunInput, + type AutomationScriptRunRecord, + type AutomationScriptRecord, +} from "./automationScripts"; + +export const getBindings = async () => { + try { + return await import("../../wailsjs/go/main/App"); + } catch { + return null; + } +}; + +export function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord { + const normalized = normalizeAutomationScriptRecordPayload(payload); + if (normalized) { + return normalized; + } + + return { + packageFormat: String(payload?.packageFormat || "ant-automation-script"), + manifestVersion: Number(payload?.manifestVersion) || 1, + id: String(payload?.id || ""), + name: String(payload?.name || ""), + description: String(payload?.description || ""), + type: payload?.type === "launch-api" ? "launch-api" : "playwright-cdp", + status: + payload?.status === "ready" || payload?.status === "disabled" + ? payload.status + : "draft", + entryFile: String(payload?.entryFile || "index.cjs"), + tags: Array.isArray(payload?.tags) + ? payload.tags + .map((item: unknown) => String(item || "").trim()) + .filter(Boolean) + : [], + selectorText: String(payload?.selectorText || ""), + paramsText: String(payload?.paramsText || ""), + scriptText: String(payload?.scriptText || ""), + notes: String(payload?.notes || ""), + targetConfig: normalizeAutomationScriptTargetConfig(payload?.targetConfig), + publicAPI: normalizeAutomationScriptPublicAPIConfig(payload?.publicAPI), + source: { + type: String(payload?.source?.type || ""), + uri: String(payload?.source?.uri || ""), + ref: String(payload?.source?.ref || ""), + path: String(payload?.source?.path || ""), + importedAt: String(payload?.source?.importedAt || ""), + }, + createdAt: String(payload?.createdAt || ""), + updatedAt: String(payload?.updatedAt || ""), + }; +} + +export function sortScripts( + items: AutomationScriptRecord[], +): AutomationScriptRecord[] { + return [...items].sort( + (left, right) => + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), + ); +} + +export function normalizeAutomationScriptRunRecord( + payload: any, +): AutomationScriptRunRecord { + return { + id: String(payload?.id || ""), + scriptId: String(payload?.scriptId || ""), + scriptName: String(payload?.scriptName || ""), + scriptType: String(payload?.scriptType || ""), + status: + payload?.status === "success" || payload?.status === "running" + ? payload.status + : "failed", + summary: String(payload?.summary || ""), + error: String(payload?.error || ""), + resultText: String(payload?.resultText || ""), + startedAt: String(payload?.startedAt || ""), + finishedAt: String(payload?.finishedAt || ""), + durationMs: Number(payload?.durationMs) || 0, + }; +} + +export interface AutomationScriptExportResult { + cancelled: boolean; + format: string; + message: string; + path: string; + fileCount: number; +} + +export interface AutomationScriptImportIssue { + path: string; + message: string; +} + +export interface AutomationScriptBatchImportResult { + imported: AutomationScriptRecord[]; + failed: AutomationScriptImportIssue[]; + scanned: number; +} + +export interface AutomationScriptPublicApiInvokeInput { + url: string; + method?: string; + bodyText?: string; + apiKey?: string; + authHeader?: string; + timeoutMs?: number; +} + +export interface AutomationScriptPublicApiInvokeResult { + ok: boolean; + status: number; + statusText: string; + bodyText: string; + bodyJson: unknown | null; +} + +export function normalizeAutomationScriptPublicApiInvokeResult( + payload: any, +): AutomationScriptPublicApiInvokeResult { + return { + ok: payload?.ok === true, + status: Number(payload?.status) || 0, + statusText: String(payload?.statusText || ""), + bodyText: String(payload?.bodyText || ""), + bodyJson: payload?.bodyJson ?? null, + }; +} + +export function normalizeAutomationScriptRunInput( + input: string | AutomationScriptRunInput, +): AutomationScriptRunInput { + if (typeof input === "string") { + return { + scriptId: input, + selectorText: "", + targetInput: {}, + paramsText: "", + useScriptSelector: true, + useScriptParams: true, + timeoutMs: 0, + launchCode: "", + startByCodeBeforeRun: false, + }; + } + + return { + scriptId: String(input?.scriptId || ""), + selectorText: String(input?.selectorText || ""), + targetInput: + input?.targetInput && typeof input.targetInput === "object" + ? { ...input.targetInput } + : {}, + paramsText: String(input?.paramsText || ""), + useScriptSelector: input?.useScriptSelector !== false, + useScriptParams: input?.useScriptParams !== false, + timeoutMs: Number.isFinite(Number(input?.timeoutMs)) + ? Math.round(Number(input?.timeoutMs)) + : 0, + launchCode: String(input?.launchCode || "") + .trim() + .toUpperCase(), + startByCodeBeforeRun: input?.startByCodeBeforeRun === true, + }; +} + + diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts index d3af2d70..69822105 100644 --- a/frontend/src/modules/browser/automationScriptApi.ts +++ b/frontend/src/modules/browser/automationScriptApi.ts @@ -1,720 +1,11 @@ -import { - exportAutomationScript, - importAutomationScript, - loadAutomationScripts, - normalizeAutomationScriptPublicAPIConfig, - normalizeAutomationScriptRecordPayload, - normalizeAutomationScriptTargetConfig, - saveAutomationScripts, - type AutomationScriptRunInput, - type AutomationScriptRunRecord, - type AutomationScriptRecord, -} from "./automationScripts"; -import { startBrowserInstanceByCode } from "./api/instances"; - -const getBindings = async () => { - try { - return await import("../../wailsjs/go/main/App"); - } catch { - return null; - } -}; - -function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord { - const normalized = normalizeAutomationScriptRecordPayload(payload); - if (normalized) { - return normalized; - } - - return { - packageFormat: String(payload?.packageFormat || "ant-automation-script"), - manifestVersion: Number(payload?.manifestVersion) || 1, - id: String(payload?.id || ""), - name: String(payload?.name || ""), - description: String(payload?.description || ""), - type: payload?.type === "launch-api" ? "launch-api" : "playwright-cdp", - status: - payload?.status === "ready" || payload?.status === "disabled" - ? payload.status - : "draft", - entryFile: String(payload?.entryFile || "index.cjs"), - tags: Array.isArray(payload?.tags) - ? payload.tags - .map((item: unknown) => String(item || "").trim()) - .filter(Boolean) - : [], - selectorText: String(payload?.selectorText || ""), - paramsText: String(payload?.paramsText || ""), - scriptText: String(payload?.scriptText || ""), - notes: String(payload?.notes || ""), - targetConfig: normalizeAutomationScriptTargetConfig(payload?.targetConfig), - publicAPI: normalizeAutomationScriptPublicAPIConfig(payload?.publicAPI), - source: { - type: String(payload?.source?.type || ""), - uri: String(payload?.source?.uri || ""), - ref: String(payload?.source?.ref || ""), - path: String(payload?.source?.path || ""), - importedAt: String(payload?.source?.importedAt || ""), - }, - createdAt: String(payload?.createdAt || ""), - updatedAt: String(payload?.updatedAt || ""), - }; -} - -function sortScripts( - items: AutomationScriptRecord[], -): AutomationScriptRecord[] { - return [...items].sort( - (left, right) => - new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), - ); -} - -function normalizeAutomationScriptRunRecord( - payload: any, -): AutomationScriptRunRecord { - return { - id: String(payload?.id || ""), - scriptId: String(payload?.scriptId || ""), - scriptName: String(payload?.scriptName || ""), - scriptType: String(payload?.scriptType || ""), - status: - payload?.status === "success" || payload?.status === "running" - ? payload.status - : "failed", - summary: String(payload?.summary || ""), - error: String(payload?.error || ""), - resultText: String(payload?.resultText || ""), - startedAt: String(payload?.startedAt || ""), - finishedAt: String(payload?.finishedAt || ""), - durationMs: Number(payload?.durationMs) || 0, - }; -} - -export interface AutomationScriptExportResult { - cancelled: boolean; - format: string; - message: string; - path: string; - fileCount: number; -} - -export interface AutomationScriptImportIssue { - path: string; - message: string; -} - -export interface AutomationScriptBatchImportResult { - imported: AutomationScriptRecord[]; - failed: AutomationScriptImportIssue[]; - scanned: number; -} - -export interface AutomationScriptPublicApiInvokeInput { - url: string; - method?: string; - bodyText?: string; - apiKey?: string; - authHeader?: string; - timeoutMs?: number; -} - -export interface AutomationScriptPublicApiInvokeResult { - ok: boolean; - status: number; - statusText: string; - bodyText: string; - bodyJson: unknown | null; -} - -function normalizeAutomationScriptPublicApiInvokeResult( - payload: any, -): AutomationScriptPublicApiInvokeResult { - return { - ok: payload?.ok === true, - status: Number(payload?.status) || 0, - statusText: String(payload?.statusText || ""), - bodyText: String(payload?.bodyText || ""), - bodyJson: payload?.bodyJson ?? null, - }; -} - -function normalizeAutomationScriptRunInput( - input: string | AutomationScriptRunInput, -): AutomationScriptRunInput { - if (typeof input === "string") { - return { - scriptId: input, - selectorText: "", - targetInput: {}, - paramsText: "", - useScriptSelector: true, - useScriptParams: true, - timeoutMs: 0, - launchCode: "", - startByCodeBeforeRun: false, - }; - } - - return { - scriptId: String(input?.scriptId || ""), - selectorText: String(input?.selectorText || ""), - targetInput: - input?.targetInput && typeof input.targetInput === "object" - ? { ...input.targetInput } - : {}, - paramsText: String(input?.paramsText || ""), - useScriptSelector: input?.useScriptSelector !== false, - useScriptParams: input?.useScriptParams !== false, - timeoutMs: Number.isFinite(Number(input?.timeoutMs)) - ? Math.round(Number(input?.timeoutMs)) - : 0, - launchCode: String(input?.launchCode || "") - .trim() - .toUpperCase(), - startByCodeBeforeRun: input?.startByCodeBeforeRun === true, - }; -} - -export async function fetchAutomationScripts(): Promise< - AutomationScriptRecord[] -> { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptList) { - const raw = (await bindings.AutomationScriptList()) || []; - return sortScripts( - Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [], - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptList === "function") { - const raw = (await goApp.AutomationScriptList()) || []; - return sortScripts( - Array.isArray(raw) ? raw.map(normalizeAutomationScriptRecord) : [], - ); - } - - return loadAutomationScripts(); -} - -export async function saveAutomationScript( - script: AutomationScriptRecord, -): Promise { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptSave) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptSave(script), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptSave === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptSave(script), - ); - } - - const current = loadAutomationScripts(); - const next = current.some((item) => item.id === script.id) - ? current.map((item) => (item.id === script.id ? script : item)) - : [script, ...current]; - saveAutomationScripts(sortScripts(next)); - return script; -} - -export async function deleteAutomationScript(scriptId: string): Promise { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptDelete) { - await bindings.AutomationScriptDelete(scriptId); - return; - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptDelete === "function") { - await goApp.AutomationScriptDelete(scriptId); - return; - } - - saveAutomationScripts( - loadAutomationScripts().filter((item) => item.id !== scriptId), - ); -} - -export async function importAutomationScriptFromLocalFile(): Promise { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptImportLocalFile) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptImportLocalFile(), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptImportLocalFile === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptImportLocalFile(), - ); - } - - throw new Error("当前环境不支持本地文件导入"); -} - -export async function importAutomationScriptFromText( - text: string, -): Promise { - const normalizedText = String(text || "").trim(); - if (!normalizedText) { - throw new Error("导入内容不能为空"); - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptImportText) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptImportText(normalizedText), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptImportText === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptImportText(normalizedText), - ); - } - - return importAutomationScript(normalizedText); -} - -export async function importAutomationScriptFromLocalDirectory(): Promise { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptImportLocalDirectory) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptImportLocalDirectory(), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptImportLocalDirectory === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptImportLocalDirectory(), - ); - } - - throw new Error("当前环境不支持本地目录导入"); -} - -function normalizeAutomationScriptBatchImportResult( - payload: any, -): AutomationScriptBatchImportResult { - const imported = Array.isArray(payload?.imported) - ? payload.imported.map(normalizeAutomationScriptRecord) - : []; - - return { - imported, - failed: Array.isArray(payload?.failed) - ? payload.failed.map((item: any) => ({ - path: String(item?.path || ""), - message: String(item?.message || ""), - })) - : [], - scanned: - Number.isFinite(Number(payload?.scanned)) && Number(payload.scanned) > 0 - ? Math.round(Number(payload.scanned)) - : imported.length, - }; -} - -export async function importAutomationScriptFromLocalLibrary(): Promise { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptImportLocalLibrary) { - return normalizeAutomationScriptBatchImportResult( - await bindings.AutomationScriptImportLocalLibrary(), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptImportLocalLibrary === "function") { - return normalizeAutomationScriptBatchImportResult( - await goApp.AutomationScriptImportLocalLibrary(), - ); - } - - throw new Error("当前环境不支持本地脚本库导入"); -} - -export async function importAutomationScriptFromRemote(url: string): Promise { - const normalizedURL = String(url || "").trim(); - if (!normalizedURL) { - throw new Error("远程脚本地址不能为空"); - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptImportRemote) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptImportRemote(normalizedURL), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptImportRemote === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptImportRemote(normalizedURL), - ); - } - - throw new Error("当前环境不支持远程脚本导入"); -} - -export async function importAutomationScriptFromGit( - repoURL: string, - ref = "", - scriptPath = "", -): Promise { - const normalizedRepoURL = String(repoURL || "").trim(); - if (!normalizedRepoURL) { - throw new Error("Git 仓库地址不能为空"); - } - - const normalizedRef = String(ref || "").trim(); - const normalizedScriptPath = String(scriptPath || "").trim(); - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptImportGit) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptImportGit( - normalizedRepoURL, - normalizedRef, - normalizedScriptPath, - ), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptImportGit === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptImportGit( - normalizedRepoURL, - normalizedRef, - normalizedScriptPath, - ), - ); - } - - throw new Error("当前环境不支持 Git 脚本导入"); -} - -export async function refreshAutomationScript( - scriptId: string, -): Promise { - const normalizedScriptId = String(scriptId || "").trim(); - if (!normalizedScriptId) { - throw new Error("脚本 ID 不能为空"); - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptRefresh) { - return normalizeAutomationScriptRecord( - await bindings.AutomationScriptRefresh(normalizedScriptId), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptRefresh === "function") { - return normalizeAutomationScriptRecord( - await goApp.AutomationScriptRefresh(normalizedScriptId), - ); - } - - throw new Error("当前环境不支持按来源重新导入"); -} - -function normalizeAutomationScriptExportResult( - payload: any, -): AutomationScriptExportResult { - return { - cancelled: payload?.cancelled === true, - format: String(payload?.format || ""), - message: String(payload?.message || ""), - path: String(payload?.path || ""), - fileCount: Number(payload?.fileCount) || 0, - }; -} - -function buildAutomationTemplateFallbackFilename(script: AutomationScriptRecord): string { - const normalizedName = String(script.name || "") - .trim() - .replace(/[\\/:*?"<>|]+/g, "-") - .replace(/\s+/g, "-") - .replace(/^-+|-+$/g, ""); - - return `${normalizedName || "automation-script"}-template.json`; -} - -function downloadAutomationTemplate( - filename: string, - content: string, -): AutomationScriptExportResult { - const blob = new Blob([content], { type: "application/json;charset=utf-8" }); - const url = URL.createObjectURL(blob); - - try { - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - } finally { - URL.revokeObjectURL(url); - } - - return { - cancelled: false, - format: "json", - message: "模板已导出", - path: filename, - fileCount: 1, - }; -} - -export async function exportAutomationScriptTemplate( - scriptId: string, - fallbackScript?: AutomationScriptRecord, -): Promise { - const normalizedScriptId = String(scriptId || "").trim(); - if (!normalizedScriptId) { - throw new Error("脚本 ID 不能为空"); - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptExport) { - return normalizeAutomationScriptExportResult( - await bindings.AutomationScriptExport(normalizedScriptId), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptExport === "function") { - return normalizeAutomationScriptExportResult( - await goApp.AutomationScriptExport(normalizedScriptId), - ); - } - - if (fallbackScript && typeof document !== "undefined") { - return downloadAutomationTemplate( - buildAutomationTemplateFallbackFilename(fallbackScript), - exportAutomationScript(fallbackScript), - ); - } - - throw new Error("当前环境不支持脚本模板导出"); -} - -export async function exportAutomationScriptZip( - scriptId: string, -): Promise { - const normalizedScriptId = String(scriptId || "").trim(); - if (!normalizedScriptId) { - throw new Error("脚本 ID 不能为空"); - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptExportZip) { - return normalizeAutomationScriptExportResult( - await bindings.AutomationScriptExportZip(normalizedScriptId), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptExportZip === "function") { - return normalizeAutomationScriptExportResult( - await goApp.AutomationScriptExportZip(normalizedScriptId), - ); - } - - throw new Error("当前环境不支持 ZIP 脚本包导出"); -} - -export async function exportAutomationScriptDirectory( - scriptId: string, -): Promise { - const normalizedScriptId = String(scriptId || "").trim(); - if (!normalizedScriptId) { - throw new Error("脚本 ID 不能为空"); - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptExportDirectory) { - return normalizeAutomationScriptExportResult( - await bindings.AutomationScriptExportDirectory(normalizedScriptId), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptExportDirectory === "function") { - return normalizeAutomationScriptExportResult( - await goApp.AutomationScriptExportDirectory(normalizedScriptId), - ); - } - - throw new Error("当前环境不支持目录脚本包导出"); -} - -export async function runAutomationScript( - input: string | AutomationScriptRunInput, -): Promise { - const request = normalizeAutomationScriptRunInput(input); - const { launchCode, startByCodeBeforeRun, ...bindingRequest } = request; - - if (startByCodeBeforeRun && launchCode) { - const startedProfile = await startBrowserInstanceByCode(launchCode); - if (!startedProfile) { - throw new Error(`通过 Launch Code 启动实例失败: ${launchCode}`); - } - } - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptRunWithOptions) { - return normalizeAutomationScriptRunRecord( - await bindings.AutomationScriptRunWithOptions(bindingRequest), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptRunWithOptions === "function") { - return normalizeAutomationScriptRunRecord( - await goApp.AutomationScriptRunWithOptions(bindingRequest), - ); - } - - if ( - bindings?.AutomationScriptRun && - bindingRequest.useScriptSelector && - bindingRequest.useScriptParams - ) { - return normalizeAutomationScriptRunRecord( - await bindings.AutomationScriptRun(bindingRequest.scriptId), - ); - } - - if ( - typeof goApp?.AutomationScriptRun === "function" && - bindingRequest.useScriptSelector && - bindingRequest.useScriptParams - ) { - return normalizeAutomationScriptRunRecord( - await goApp.AutomationScriptRun(bindingRequest.scriptId), - ); - } - - const now = new Date().toISOString(); - return { - id: `mock-run-${Date.now()}`, - scriptId: bindingRequest.scriptId, - scriptName: "", - scriptType: "", - status: "failed", - summary: "当前环境未接入自动化脚本执行", - error: "AutomationScriptRun binding is unavailable", - resultText: "", - startedAt: now, - finishedAt: now, - durationMs: 0, - }; -} - -export async function fetchAutomationScriptRuns( - limit = 20, -): Promise { - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptRunList) { - const raw = (await bindings.AutomationScriptRunList(limit)) || []; - return Array.isArray(raw) - ? raw.map(normalizeAutomationScriptRunRecord) - : []; - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptRunList === "function") { - const raw = (await goApp.AutomationScriptRunList(limit)) || []; - return Array.isArray(raw) - ? raw.map(normalizeAutomationScriptRunRecord) - : []; - } - - return []; -} - -export async function invokeAutomationScriptPublicApi( - input: AutomationScriptPublicApiInvokeInput, -): Promise { - const url = String(input?.url || "").trim(); - if (!url) { - throw new Error("接口地址不能为空"); - } - - const method = String(input?.method || "POST").trim().toUpperCase() || "POST"; - const authHeader = String(input?.authHeader || "X-Ant-Api-Key").trim() || "X-Ant-Api-Key"; - const apiKey = String(input?.apiKey || "").trim(); - const bodyText = String(input?.bodyText || "").trim(); - const timeoutMs = Number.isFinite(Number(input?.timeoutMs)) - ? Math.max(1000, Math.round(Number(input?.timeoutMs))) - : 0; - - const bindings: any = await getBindings(); - if (bindings?.AutomationScriptInvokePublicAPI) { - return normalizeAutomationScriptPublicApiInvokeResult( - await bindings.AutomationScriptInvokePublicAPI({ - url, - method, - bodyText, - apiKey, - authHeader, - timeoutMs, - }), - ); - } - - const goApp = (window as any).go?.main?.App; - if (typeof goApp?.AutomationScriptInvokePublicAPI === "function") { - return normalizeAutomationScriptPublicApiInvokeResult( - await goApp.AutomationScriptInvokePublicAPI({ - url, - method, - bodyText, - apiKey, - authHeader, - timeoutMs, - }), - ); - } - - const headers: Record = { - "Content-Type": "application/json", - }; - if (apiKey) { - headers[authHeader] = apiKey; - } - - const response = await fetch(url, { - method, - headers, - body: bodyText || "{}", - }); - - const rawText = await response.text(); - let bodyJson: unknown | null = null; - if (rawText.trim()) { - try { - bodyJson = JSON.parse(rawText); - } catch { - bodyJson = null; - } - } - - return { - ok: response.ok, - status: response.status, - statusText: response.statusText, - bodyText: rawText, - bodyJson, - }; -} +export type { + AutomationScriptBatchImportResult, + AutomationScriptExportResult, + AutomationScriptImportIssue, + AutomationScriptPublicApiInvokeInput, + AutomationScriptPublicApiInvokeResult, +} from "./automationScriptApi.shared"; +export { fetchAutomationScripts, saveAutomationScript, deleteAutomationScript } from "./automationScriptApi.scripts"; +export { importAutomationScriptFromGit, importAutomationScriptFromLocalDirectory, importAutomationScriptFromLocalFile, importAutomationScriptFromLocalLibrary, importAutomationScriptFromRemote, importAutomationScriptFromText, refreshAutomationScript } from "./automationScriptApi.imports"; +export { exportAutomationScriptDirectory, exportAutomationScriptTemplate, exportAutomationScriptZip } from "./automationScriptApi.exports"; +export { fetchAutomationScriptRuns, invokeAutomationScriptPublicApi, runAutomationScript } from "./automationScriptApi.runs"; diff --git a/frontend/src/modules/browser/automationScripts.ts b/frontend/src/modules/browser/automationScripts.ts index 77076f7c..b215def4 100644 --- a/frontend/src/modules/browser/automationScripts.ts +++ b/frontend/src/modules/browser/automationScripts.ts @@ -44,6 +44,13 @@ export { resolveAutomationScriptPublicAPIConfig, suggestAutomationScriptPublicAPIPath, } from "./automationScripts/publicApi"; +export { + buildAutomationScriptPublicAPIRequestBodyWithTargetCode, + normalizeAutomationScriptPublicAPIRequestBodyForInvoke, + readAutomationScriptPublicAPIInstanceType, + readAutomationScriptPublicAPIParamObject, + readAutomationScriptPublicAPITargetCode, +} from "./automationScripts/publicApiInstances"; export { canRefreshAutomationScriptSource, getAutomationScriptRefreshLabel, diff --git a/frontend/src/modules/browser/automationScripts/builtins.ts b/frontend/src/modules/browser/automationScripts/builtins.ts index 1f837b73..88f51f63 100644 --- a/frontend/src/modules/browser/automationScripts/builtins.ts +++ b/frontend/src/modules/browser/automationScripts/builtins.ts @@ -3,416 +3,32 @@ AUTOMATION_SCRIPT_PACKAGE_FORMAT, DUAL_INSTANCE_RUNTIME_SCRIPT_ID, type AutomationScriptRecord, - type AutomationScriptType, } from "./definitions"; import { createAutomationScriptPublicAPIConfig } from "./publicApi"; import { normalizeAutomationScriptTargetConfig, - normalizeAutomationScriptTargetSelector, } from "./targets"; const BACKEND_BUILTIN_SCRIPT_PLACEHOLDER = `module.exports.run = async () => { throw new Error('内置脚本源码由后端 demo-library 提供,请在桌面应用后端环境中加载或从脚本包导入。') }`; -const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const; -const DUAL_INSTANCE_DEFAULT_START_URLS = [ - "https://finance.sina.com.cn/", - "https://map.baidu.com/", -] as const; function nowIso(): string { return new Date().toISOString(); } -export function buildSelectorTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return `{ - "code": "BUYER_001" -}`; - } - - return ""; -} - -export function buildParamsTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return `{ - "startUrls": ["https://example.com"], - "skipDefaultStartUrls": true -}`; - } - - return `{ - "url": "https://www.baidu.com", - "keyword": "OpenAI", - "timeoutMs": 30000, - "waitAfterSearchMs": 1500, - "captureScreenshot": true -}`; -} - -export function buildScriptTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return `export async function run({ baseUrl, apiKey, selector, params }) { - const response = await fetch(\`\${baseUrl}/api/launch\`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), - }, - body: JSON.stringify({ - selector, - ...(params || {}), - }), - }) - - if (!response.ok) { - throw new Error(\`launch failed: \${response.status}\`) - } - - return await response.json() -}`; - } - - return `module.exports.run = async ({ useBrowser, browserFetch, selector, params, log, artifact }) => { - const targetUrl = - typeof params.url === 'string' && params.url.trim() - ? params.url.trim() - : 'https://www.baidu.com' - const keyword = - typeof params.keyword === 'string' && params.keyword.trim() - ? params.keyword.trim() - : 'OpenAI' - const timeout = - Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0 - ? Math.round(Number(params.timeoutMs)) - : 30000 - const waitAfterSearchMs = - Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0 - ? Math.round(Number(params.waitAfterSearchMs)) - : 1500 - - const runtime = await useBrowser({ - selector, - startUrls: params.startUrls || [targetUrl], - skipDefaultStartUrls: true, - url: targetUrl, - timeoutMs: timeout, - reuseCurrentPage: true, - }) - const page = runtime.page - - const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first() - await searchInput.waitFor({ - state: 'visible', - timeout, - }) - await searchInput.fill(keyword) - await searchInput.press('Enter').catch(async () => { - const submitButton = page.locator('#su, input[type="submit"]').first() - await submitButton.click({ timeout }) - }) - await page.waitForURL(/wd=/, { timeout }).catch(() => {}) - - if (waitAfterSearchMs > 0) { - await page.waitForTimeout(waitAfterSearchMs) - } - - if (params.captureScreenshot !== false) { - await page.screenshot({ - path: artifact('baidu-search.png'), - fullPage: true, - }) - } - - const title = await page.title() - let apiResult = null - const apiUrl = typeof params.apiUrl === 'string' ? params.apiUrl.trim() : '' - if (apiUrl) { - const apiRequest = { - url: apiUrl, - method: params.apiBody === undefined ? 'GET' : 'POST', - timeoutMs: timeout, - } - if (params.apiBody !== undefined) { - apiRequest.json = params.apiBody - } - apiResult = await browserFetch(page, apiRequest) - } - log('keyword', keyword) - log('title', title) - - return { - ok: true, - summary: \`已在百度搜索 \${keyword}\`, - keyword, - url: page.url(), - title, - apiResult, - } -}`; -} - -export function buildNotesTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。"; - } - - return "默认示例使用 useBrowser 启动并接管页面;需要调用站内接口时传 apiUrl/apiBody,会通过 browserFetch 在浏览器上下文发起请求。"; -} - -function buildDualInstanceRuntimeParamsText( - codes = [...DUAL_INSTANCE_DEFAULT_CODES], -): string { - return `{ - "browsers": [ - { - "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}", - "skipDefaultStartUrls": true, - "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"] - }, - { - "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}", - "skipDefaultStartUrls": true, - "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"] - } - ], - "timeoutMs": 45000 -}`; -} - -function buildDualInstanceRuntimeScriptText(): string { - return `export async function run({ baseUrl, apiKey, params, log }) { - const normalizeCode = (value, fallback) => - String(value || fallback || "").trim().toUpperCase() - const normalizeStringArray = (value) => - Array.isArray(value) - ? value - .map((item) => String(item || "").trim()) - .filter(Boolean) - : [] - const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { - const raw = value && typeof value === "object" ? value : {} - const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) - if (!code) { - return null - } - const startUrls = normalizeStringArray(raw.startUrls) - const fallbackUrls = normalizeStringArray(fallbackStartUrls) - const launchArgs = normalizeStringArray(raw.launchArgs) - - return { - code, - skipDefaultStartUrls: - raw.skipDefaultStartUrls !== undefined - ? raw.skipDefaultStartUrls !== false - : defaultSkip, - startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, - launchArgs, - } - } - - const timeoutMs = Number.isFinite(Number(params.timeoutMs)) - ? Math.max(1000, Math.round(Number(params.timeoutMs))) - : 45000 - const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false - - let browsers = Array.isArray(params.browsers) - ? params.browsers - .map((item, index) => - normalizeBrowserInput( - item, - ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "", - ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [], - defaultSkipDefaultStartUrls, - ), - ) - .filter(Boolean) - : [] - - if (browsers.length === 0) { - browsers = [ - normalizeBrowserInput( - { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, - ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])}, - ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])}, - defaultSkipDefaultStartUrls, - ), - normalizeBrowserInput( - { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, - ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])}, - ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])}, - defaultSkipDefaultStartUrls, - ), - ].filter(Boolean) - } - - if (browsers.length === 0) { - throw new Error("params.browsers 不能为空") - } - - const headers = { - "Content-Type": "application/json", - ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}), - } - - const post = async (path, payload) => { - const response = await fetch(\`\${baseUrl}\${path}\`, { - method: "POST", - headers, - body: JSON.stringify(payload), - }) - const text = await response.text() - let body = text - try { - body = text ? JSON.parse(text) : null - } catch { - body = text - } - if (!response.ok) { - throw new Error(\`\${path} failed: \${response.status} \${text}\`) - } - return body - } - - const sessions = [] - - for (const browser of browsers) { - const sessionResult = await post("/api/runtime/session", { - selector: { code: browser.code, matchMode: "unique" }, - skipDefaultStartUrls: browser.skipDefaultStartUrls, - ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), - ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), - timeoutMs, - }) - - sessions.push(sessionResult) - } - - const browserCodes = browsers.map((item) => item.code) - log("browserCodes", browserCodes) - - return { - ok: true, - summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`, - browserCodes, - sessions, - } -}`; -} - -export function normalizeDualInstanceRuntimeParamsText(text: string): string { - const fallback = buildDualInstanceRuntimeParamsText(); - - try { - const parsed = JSON.parse(text); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return fallback; - } - - const raw = parsed as Record; - const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false; - const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : []; - const browsers = rawBrowsers - .map((item, index) => { - if (!item || typeof item !== "object") { - return null; - } - const entry = item as Record; - const code = normalizeAutomationScriptTargetSelector({ - code: - typeof entry.code === "string" - ? entry.code - : typeof entry.launchCode === "string" - ? entry.launchCode - : "", - }).code; - if (!code) { - return null; - } - - const startUrls = Array.isArray(entry.startUrls) - ? entry.startUrls - .map((value) => String(value || "").trim()) - .filter(Boolean) - : []; - const launchArgs = Array.isArray(entry.launchArgs) - ? entry.launchArgs - .map((value) => String(value || "").trim()) - .filter(Boolean) - : []; - - const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index] - ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] - : []; - - return { - code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "", - skipDefaultStartUrls: - entry.skipDefaultStartUrls !== undefined - ? entry.skipDefaultStartUrls !== false - : topLevelSkipDefaultStartUrls, - startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls, - ...(launchArgs.length > 0 ? { launchArgs } : {}), - }; - }) - .filter( - ( - item, - ): item is { - code: string; - skipDefaultStartUrls: boolean; - startUrls: string[]; - launchArgs?: string[]; - } => item !== null, - ); - - const legacyCodes = [ - normalizeAutomationScriptTargetSelector({ - code: typeof raw.primaryCode === "string" ? raw.primaryCode : "", - }).code, - normalizeAutomationScriptTargetSelector({ - code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "", - }).code, - ].filter(Boolean); - - const normalizedBrowsers = - browsers.length > 0 - ? browsers - : legacyCodes.length > 0 - ? legacyCodes.map((code, index) => ({ - code, - skipDefaultStartUrls: topLevelSkipDefaultStartUrls, - startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] - ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] - : [], - })) - : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({ - code, - skipDefaultStartUrls: true, - startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] - ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] - : [], - })); - - const timeoutMs = - Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0 - ? Math.round(Number(raw.timeoutMs)) - : 45000; - - return JSON.stringify( - { - browsers: normalizedBrowsers, - timeoutMs, - }, - null, - 2, - ); - } catch { - return fallback; - } -} +export { + buildParamsTemplate, + buildScriptTemplate, + buildSelectorTemplate, + buildNotesTemplate, + normalizeDualInstanceRuntimeParamsText, +} from "./builtinsTemplates"; +import { + buildDualInstanceRuntimeParamsText, + buildDualInstanceRuntimeScriptText, +} from "./builtinsTemplates"; export function createNewsTxtScriptDraft(): AutomationScriptRecord { const createdAt = nowIso(); @@ -496,31 +112,18 @@ export function createWebImageGenerateDownloadScriptDraft(): AutomationScriptRec id: "web-image-generate-download", name: "网页图片生成并下载", description: - "打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。", + "打开 ChatGPT,发送图片生成消息,等待图片生成后下载图片。", type: "playwright-cdp", status: "draft", entryFile: "index.cjs", - tags: ["Playwright", "图片生成", "下载", "脚手架"], + tags: ["Playwright", "图片生成", "下载"], selectorText: "", paramsText: `{ - "pageUrl": "https://chatgpt.com/", - "prompt": "A cinematic chrome ant browser mascot, premium product lighting", - "outputFileName": "generated-image.png", - "selectors": { - "newSessionButton": "", - "promptInput": "#prompt-textarea[contenteditable=\"true\"], textarea[name=\"prompt-textarea\"]", - "sendButton": "button[data-testid=\"send-button\"], button[aria-label*=\"发送\"], button.composer-submit-button-color", - "generatedImage": "img[src*=\"/backend-api/estuary/content\"], img[alt*=\"已生成图片\"], img[src*=\"oaiusercontent\"], img[src*=\"oaidalleapiprodscus\"], img[alt*=\"生成\"], img[alt*=\"image\" i]", - "downloadButton": "" - }, - "timeoutMs": 300000, - "waitAfterLoadMs": 1200, - "settleMs": 2500, - "captureScreenshot": false + "prompt": "A cinematic chrome ant browser mascot, premium product lighting" }`, scriptText: BACKEND_BUILTIN_SCRIPT_PLACEHOLDER, notes: - "脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。", + "脚本默认打开 ChatGPT,输入图片生成提示词并发送;页面选择器、下载文件名等由脚本内部默认值处理,公开接口只需要传实例、提示词和超时时间。", targetConfig: normalizeAutomationScriptTargetConfig(null), publicAPI: { ...createAutomationScriptPublicAPIConfig(), @@ -528,12 +131,21 @@ export function createWebImageGenerateDownloadScriptDraft(): AutomationScriptRec path: "image/chatgpt-generate-download", timeoutMs: 300000, requestBodyText: `{ + "instance": { + "type": "existing", + "selector": { + "code": "BUYER_001" + } + }, "params": { "prompt": "{{prompt}}" - } + }, + "timeoutMs": 300000 }`, responseBodyText: `{ "ok": true, + "status": "completed", + "summary": "图片已生成并下载。", "outputPath": "\${artifactsDir}/generated-image.png", "downloadAddress": "\${artifactsDir}/generated-image.png" }`, @@ -566,3 +178,4 @@ export function buildDefaultAutomationScripts(): AutomationScriptRecord[] { createWebImageGenerateDownloadScriptDraft(), ]; } + diff --git a/frontend/src/modules/browser/automationScripts/builtinsTemplates.ts b/frontend/src/modules/browser/automationScripts/builtinsTemplates.ts new file mode 100644 index 00000000..3589eb2c --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/builtinsTemplates.ts @@ -0,0 +1,399 @@ +import type { AutomationScriptType } from './definitions' +import { normalizeAutomationScriptTargetSelector } from './targets' + +const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const; +const DUAL_INSTANCE_DEFAULT_START_URLS = [ + "https://finance.sina.com.cn/", + "https://map.baidu.com/", +] as const; + +export function buildSelectorTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `{ + "code": "BUYER_001" +}`; + } + + return ""; +} + +export function buildParamsTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `{ + "startUrls": ["https://example.com"], + "skipDefaultStartUrls": true +}`; + } + + return `{ + "url": "https://www.baidu.com", + "keyword": "OpenAI", + "timeoutMs": 30000, + "waitAfterSearchMs": 1500, + "captureScreenshot": true +}`; +} + +export function buildScriptTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `export async function run({ baseUrl, apiKey, selector, params }) { + const response = await fetch(\`\${baseUrl}/api/launch\`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), + }, + body: JSON.stringify({ + selector, + ...(params || {}), + }), + }) + + if (!response.ok) { + throw new Error(\`launch failed: \${response.status}\`) + } + + return await response.json() +}`; + } + + return `module.exports.run = async ({ useBrowser, browserFetch, selector, params, log, artifact }) => { + const targetUrl = + typeof params.url === 'string' && params.url.trim() + ? params.url.trim() + : 'https://www.baidu.com' + const keyword = + typeof params.keyword === 'string' && params.keyword.trim() + ? params.keyword.trim() + : 'OpenAI' + const timeout = + Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0 + ? Math.round(Number(params.timeoutMs)) + : 30000 + const waitAfterSearchMs = + Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0 + ? Math.round(Number(params.waitAfterSearchMs)) + : 1500 + + const runtime = await useBrowser({ + selector, + startUrls: params.startUrls || [targetUrl], + skipDefaultStartUrls: true, + url: targetUrl, + timeoutMs: timeout, + reuseCurrentPage: true, + }) + const page = runtime.page + + const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first() + await searchInput.waitFor({ + state: 'visible', + timeout, + }) + await searchInput.fill(keyword) + await searchInput.press('Enter').catch(async () => { + const submitButton = page.locator('#su, input[type="submit"]').first() + await submitButton.click({ timeout }) + }) + await page.waitForURL(/wd=/, { timeout }).catch(() => {}) + + if (waitAfterSearchMs > 0) { + await page.waitForTimeout(waitAfterSearchMs) + } + + if (params.captureScreenshot !== false) { + await page.screenshot({ + path: artifact('baidu-search.png'), + fullPage: true, + }) + } + + const title = await page.title() + let apiResult = null + const apiUrl = typeof params.apiUrl === 'string' ? params.apiUrl.trim() : '' + if (apiUrl) { + const apiRequest = { + url: apiUrl, + method: params.apiBody === undefined ? 'GET' : 'POST', + timeoutMs: timeout, + } + if (params.apiBody !== undefined) { + apiRequest.json = params.apiBody + } + apiResult = await browserFetch(page, apiRequest) + } + log('keyword', keyword) + log('title', title) + + return { + ok: true, + summary: \`已在百度搜索 \${keyword}\`, + keyword, + url: page.url(), + title, + apiResult, + } +}`; +} + +export function buildNotesTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。"; + } + + return "默认示例使用 useBrowser 启动并接管页面;需要调用站内接口时传 apiUrl/apiBody,会通过 browserFetch 在浏览器上下文发起请求。"; +} + +export function buildDualInstanceRuntimeParamsText( + codes = [...DUAL_INSTANCE_DEFAULT_CODES], +): string { + return `{ + "browsers": [ + { + "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}", + "skipDefaultStartUrls": true, + "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"] + }, + { + "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}", + "skipDefaultStartUrls": true, + "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"] + } + ], + "timeoutMs": 45000 +}`; +} + +export function buildDualInstanceRuntimeScriptText(): string { + return `export async function run({ baseUrl, apiKey, params, log }) { + const normalizeCode = (value, fallback) => + String(value || fallback || "").trim().toUpperCase() + const normalizeStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => String(item || "").trim()) + .filter(Boolean) + : [] + const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { + const raw = value && typeof value === "object" ? value : {} + const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) + if (!code) { + return null + } + const startUrls = normalizeStringArray(raw.startUrls) + const fallbackUrls = normalizeStringArray(fallbackStartUrls) + const launchArgs = normalizeStringArray(raw.launchArgs) + + return { + code, + skipDefaultStartUrls: + raw.skipDefaultStartUrls !== undefined + ? raw.skipDefaultStartUrls !== false + : defaultSkip, + startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, + launchArgs, + } + } + + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(1000, Math.round(Number(params.timeoutMs))) + : 45000 + const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false + + let browsers = Array.isArray(params.browsers) + ? params.browsers + .map((item, index) => + normalizeBrowserInput( + item, + ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "", + ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [], + defaultSkipDefaultStartUrls, + ), + ) + .filter(Boolean) + : [] + + if (browsers.length === 0) { + browsers = [ + normalizeBrowserInput( + { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])}, + ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])}, + defaultSkipDefaultStartUrls, + ), + normalizeBrowserInput( + { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])}, + ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])}, + defaultSkipDefaultStartUrls, + ), + ].filter(Boolean) + } + + if (browsers.length === 0) { + throw new Error("params.browsers 不能为空") + } + + const headers = { + "Content-Type": "application/json", + ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}), + } + + const post = async (path, payload) => { + const response = await fetch(\`\${baseUrl}\${path}\`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + const text = await response.text() + let body = text + try { + body = text ? JSON.parse(text) : null + } catch { + body = text + } + if (!response.ok) { + throw new Error(\`\${path} failed: \${response.status} \${text}\`) + } + return body + } + + const sessions = [] + + for (const browser of browsers) { + const sessionResult = await post("/api/runtime/session", { + selector: { code: browser.code, matchMode: "unique" }, + skipDefaultStartUrls: browser.skipDefaultStartUrls, + ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), + ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), + timeoutMs, + }) + + sessions.push(sessionResult) + } + + const browserCodes = browsers.map((item) => item.code) + log("browserCodes", browserCodes) + + return { + ok: true, + summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`, + browserCodes, + sessions, + } +}`; +} + +export function normalizeDualInstanceRuntimeParamsText(text: string): string { + const fallback = buildDualInstanceRuntimeParamsText(); + + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return fallback; + } + + const raw = parsed as Record; + const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false; + const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : []; + const browsers = rawBrowsers + .map((item, index) => { + if (!item || typeof item !== "object") { + return null; + } + const entry = item as Record; + const code = normalizeAutomationScriptTargetSelector({ + code: + typeof entry.code === "string" + ? entry.code + : typeof entry.launchCode === "string" + ? entry.launchCode + : "", + }).code; + if (!code) { + return null; + } + + const startUrls = Array.isArray(entry.startUrls) + ? entry.startUrls + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + const launchArgs = Array.isArray(entry.launchArgs) + ? entry.launchArgs + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + + const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : []; + + return { + code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "", + skipDefaultStartUrls: + entry.skipDefaultStartUrls !== undefined + ? entry.skipDefaultStartUrls !== false + : topLevelSkipDefaultStartUrls, + startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls, + ...(launchArgs.length > 0 ? { launchArgs } : {}), + }; + }) + .filter( + ( + item, + ): item is { + code: string; + skipDefaultStartUrls: boolean; + startUrls: string[]; + launchArgs?: string[]; + } => item !== null, + ); + + const legacyCodes = [ + normalizeAutomationScriptTargetSelector({ + code: typeof raw.primaryCode === "string" ? raw.primaryCode : "", + }).code, + normalizeAutomationScriptTargetSelector({ + code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "", + }).code, + ].filter(Boolean); + + const normalizedBrowsers = + browsers.length > 0 + ? browsers + : legacyCodes.length > 0 + ? legacyCodes.map((code, index) => ({ + code, + skipDefaultStartUrls: topLevelSkipDefaultStartUrls, + startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : [], + })) + : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({ + code, + skipDefaultStartUrls: true, + startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : [], + })); + + const timeoutMs = + Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0 + ? Math.round(Number(raw.timeoutMs)) + : 45000; + + return JSON.stringify( + { + browsers: normalizedBrowsers, + timeoutMs, + }, + null, + 2, + ); + } catch { + return fallback; + } +} + + diff --git a/frontend/src/modules/browser/automationScripts/publicApi.ts b/frontend/src/modules/browser/automationScripts/publicApi.ts index 399f5260..f79b29a1 100644 --- a/frontend/src/modules/browser/automationScripts/publicApi.ts +++ b/frontend/src/modules/browser/automationScripts/publicApi.ts @@ -164,48 +164,46 @@ function isPlainAutomationJSONObject( return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function hasSameAutomationJSONShape( - left: Record, - right: Record, -): boolean { - const leftKeys = Object.keys(left).sort(); - const rightKeys = Object.keys(right).sort(); - if (leftKeys.length !== rightKeys.length) { - return false; - } +const AUTOMATION_SCRIPT_PUBLIC_API_INTERNAL_PARAM_KEYS = new Set([ + "pageUrl", + "url", + "selectors", + "outputFileName", + "captureScreenshot", +]); - for (let index = 0; index < leftKeys.length; index += 1) { - if (leftKeys[index] !== rightKeys[index]) { - return false; +function buildAutomationScriptPublicAPIExampleParams( + script: Pick, +): Record { + const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {}; + const result: Record = {}; + Object.entries(params).forEach(([key, value]) => { + if (!AUTOMATION_SCRIPT_PUBLIC_API_INTERNAL_PARAM_KEYS.has(key)) { + result[key] = value; } - } - - return leftKeys.every((key) => { - const leftValue = left[key]; - const rightValue = right[key]; - - if (Array.isArray(leftValue) || Array.isArray(rightValue)) { - return Array.isArray(leftValue) && Array.isArray(rightValue); - } - if ( - isPlainAutomationJSONObject(leftValue) && - isPlainAutomationJSONObject(rightValue) - ) { - return hasSameAutomationJSONShape(leftValue, rightValue); - } - return true; }); + return result; +} + +function hasAutomationScriptPublicAPIExampleParams( + script: Pick, + params: Record, +): boolean { + const expectedParams = buildAutomationScriptPublicAPIExampleParams(script); + return Object.keys(expectedParams).every((key) => key in params); } function buildAutomationScriptPublicAPIDefaultRequestExample( - script: Pick, + script: Pick, config: AutomationScriptPublicAPIConfig, ): string { - const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {}; + const params = buildAutomationScriptPublicAPIExampleParams(script); return JSON.stringify( { - code: "", + instance: { + type: "script-default", + }, params, timeoutMs: config.timeoutMs, }, @@ -231,7 +229,7 @@ function buildAutomationScriptPublicAPIDefaultResponseExample(): string { function isLegacyAutomationScriptPublicAPIRequestExample( - script: Pick, + script: Pick, parsedBody: Record, ): boolean { const allowedLegacyKeys = new Set(["code", "launchCode", "param", "params", "timeoutMs"]); @@ -249,11 +247,9 @@ function isLegacyAutomationScriptPublicAPIRequestExample( return false; } - const expectedParams = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText); if ( - expectedParams && paramsValue !== undefined && - !hasSameAutomationJSONShape(paramsValue, expectedParams) + !hasAutomationScriptPublicAPIExampleParams(script, paramsValue) ) { return false; } @@ -262,7 +258,7 @@ function isLegacyAutomationScriptPublicAPIRequestExample( } function shouldUseDerivedAutomationScriptPublicAPIRequestBody( - script: Pick, + script: Pick, config: AutomationScriptPublicAPIConfig, ): boolean { const sourceText = config.requestBodyText.trim(); @@ -289,12 +285,12 @@ function shouldUseDerivedAutomationScriptPublicAPIRequestBody( return true; } - const allowedKeys = new Set(["code", "params", "timeoutMs"]); + const allowedKeys = new Set(["code", "instance", "params", "timeoutMs"]); if (Object.keys(parsedBody).some((key) => !allowedKeys.has(key))) { return false; } - if (!("code" in parsedBody)) { + if (!("code" in parsedBody) && !("instance" in parsedBody)) { return false; } @@ -303,18 +299,24 @@ function shouldUseDerivedAutomationScriptPublicAPIRequestBody( return false; } + const instanceValue = parsedBody.instance; + if (instanceValue !== undefined) { + if (!isPlainAutomationJSONObject(instanceValue)) { + return false; + } + if (String(instanceValue.type || "").trim() !== "script-default") { + return false; + } + } + const paramsValue = parsedBody.params; if (paramsValue !== undefined && !isPlainAutomationJSONObject(paramsValue)) { return false; } - const expectedParams = safeParseAutomationScriptPublicAPIJSONObject( - script.paramsText, - ); if ( - expectedParams && paramsValue !== undefined && - !hasSameAutomationJSONShape(paramsValue, expectedParams) + !hasAutomationScriptPublicAPIExampleParams(script, paramsValue) ) { return false; } @@ -344,7 +346,7 @@ function shouldUseDerivedAutomationScriptPublicAPIResponseBody( } export function buildAutomationScriptPublicAPIRequestExample( - script: Pick, + script: Pick, config: AutomationScriptPublicAPIConfig, ): string { if (!shouldUseDerivedAutomationScriptPublicAPIRequestBody(script, config)) { diff --git a/frontend/src/modules/browser/automationScripts/publicApiInstances.ts b/frontend/src/modules/browser/automationScripts/publicApiInstances.ts new file mode 100644 index 00000000..ba8f277e --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/publicApiInstances.ts @@ -0,0 +1,120 @@ +import { safeParseAutomationScriptPublicAPIJSONObject } from "./publicApiUtils"; + +function normalizeLaunchCode(value: unknown): string { + return String(value || "").trim().toUpperCase(); +} + +function isPlainJSONObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function readAutomationScriptPublicAPIParamObject( + body: Record, +): Record { + if (isPlainJSONObject(body.param)) { + return body.param; + } + if (isPlainJSONObject(body.params)) { + return body.params; + } + return {}; +} + +export function readAutomationScriptPublicAPITargetCode(bodyText: string): string { + const body = safeParseAutomationScriptPublicAPIJSONObject(bodyText); + if (!body) return ""; + const instance = isPlainJSONObject(body.instance) ? body.instance : null; + const selector = isPlainJSONObject(instance?.selector) ? instance.selector : null; + if (selector?.code) { + return normalizeLaunchCode(selector.code); + } + return normalizeLaunchCode(body.code || body.launchCode); +} + +export function readAutomationScriptPublicAPIInstanceType(bodyText: string): string { + const body = safeParseAutomationScriptPublicAPIJSONObject(bodyText); + if (!body) return ""; + const instance = isPlainJSONObject(body.instance) ? body.instance : null; + return String(instance?.type || "").trim(); +} + +export function normalizeAutomationScriptPublicAPIRequestBodyForInvoke( + bodyText: string, +): string { + const body = safeParseAutomationScriptPublicAPIJSONObject(bodyText); + if (!body) { + return bodyText; + } + + const params = readAutomationScriptPublicAPIParamObject(body); + const topLevelParams: Record = {}; + for (const [key, value] of Object.entries(body)) { + if ( + [ + "code", + "launchCode", + "instance", + "params", + "param", + "timeoutMs", + "selector", + ].includes(key) + ) { + continue; + } + topLevelParams[key] = value; + } + + const nextBody: Record = { + params: { + ...params, + ...topLevelParams, + }, + }; + + if (isPlainJSONObject(body.instance)) { + nextBody.instance = body.instance; + } else { + const code = normalizeLaunchCode(body.code || body.launchCode); + if (code) { + nextBody.instance = { + type: "existing", + selector: { code }, + }; + } + } + + if (Number.isFinite(Number(body.timeoutMs))) { + nextBody.timeoutMs = Math.round(Number(body.timeoutMs)); + } + + return JSON.stringify(nextBody, null, 2); +} + +export function buildAutomationScriptPublicAPIRequestBodyWithTargetCode( + currentBodyText: string, + fallbackBodyText: string, + code: string, +): string { + const sourceBody = + safeParseAutomationScriptPublicAPIJSONObject(currentBodyText) || + safeParseAutomationScriptPublicAPIJSONObject(fallbackBodyText) || + {}; + const sourceParam = readAutomationScriptPublicAPIParamObject(sourceBody); + const nextBody: Record = { + ...sourceBody, + instance: { + type: "existing", + selector: { + code: normalizeLaunchCode(code), + }, + }, + params: sourceParam, + }; + delete nextBody.code; + delete nextBody.launchCode; + delete nextBody.selector; + delete nextBody.param; + + return JSON.stringify(nextBody, null, 2); +} diff --git a/frontend/src/modules/browser/components/AutomationScriptPublicApiBodyExamples.tsx b/frontend/src/modules/browser/components/AutomationScriptPublicApiBodyExamples.tsx new file mode 100644 index 00000000..c3133381 --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationScriptPublicApiBodyExamples.tsx @@ -0,0 +1,129 @@ +import { Copy } from "lucide-react"; +import { Button, Textarea } from "../../../shared/components"; +import { copyText } from "./AutomationScriptPublicApiModal.helpers"; +import type { AutomationScriptPublicAPIConfig } from "../automationScripts"; + +interface AutomationScriptPublicApiBodyExamplesProps { + busy: boolean; + resolvedConfig: AutomationScriptPublicAPIConfig; + requestExampleFallback: string; + responseExampleFallback: string; + requestBodyError: string; + responseBodyError: string; + updateConfig: (patch: Partial) => void; +} + +export function AutomationScriptPublicApiBodyExamples({ + busy, + resolvedConfig, + requestExampleFallback, + responseExampleFallback, + requestBodyError, + responseBodyError, + updateConfig, +}: AutomationScriptPublicApiBodyExamplesProps) { + return ( +
+
+
+
+ 请求 Body +
+
+ + +
+
+