feat: migrate automation public api updates

This commit is contained in:
ant-black
2026-06-06 22:01:27 +08:00
parent adfbd25ca5
commit 4c9361f1d9
152 changed files with 17117 additions and 14236 deletions
+2 -1
View File
@@ -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)
}
+9 -33
View File
@@ -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())
}
+10 -24
View File
@@ -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) {
+171
View File
@@ -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)
}
}
+214
View File
@@ -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)
}
}
-366
View File
@@ -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)
}
}
+5 -108
View File
@@ -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)
}
+5 -31
View File
@@ -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)
}
+2 -68
View File
@@ -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
+5 -4
View File
@@ -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
}
+2 -28
View File
@@ -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)
}
-64
View File
@@ -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)
}
}
@@ -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)
}
}
}
-556
View File
@@ -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)
}
}
}
+50 -5
View File
@@ -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
}
@@ -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 = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mail Fixture</title>
<script>
window.__notificationProbe = {
supported: typeof Notification !== 'undefined',
requested: false,
result: '',
error: '',
};
document.addEventListener('DOMContentLoaded', () => {
if (typeof Notification === 'undefined' || typeof Notification.requestPermission !== 'function') {
document.documentElement.setAttribute('data-notification-probe', 'unsupported');
return;
}
window.__notificationProbe.requested = true;
Notification.requestPermission()
.then((result) => {
window.__notificationProbe.result = String(result || '');
document.documentElement.setAttribute('data-notification-probe', window.__notificationProbe.result || 'empty');
})
.catch((error) => {
window.__notificationProbe.error = String(error && error.message ? error.message : error);
document.documentElement.setAttribute('data-notification-probe', 'error');
});
});
</script>
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f6f7fb; }
main { display: flex; gap: 20px; padding: 24px; min-height: 100vh; box-sizing: border-box; }
.sidebar { width: 32%; min-width: 320px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 20px; box-sizing: border-box; }
.viewer { width: 60%; min-height: 420px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 24px; box-sizing: border-box; }
input { width: 100%; height: 42px; padding: 0 12px; font-size: 16px; box-sizing: border-box; }
[role="row"] { margin-top: 16px; min-height: 56px; border: 1px solid #c8cfdd; border-radius: 10px; padding: 16px; cursor: pointer; background: #fafbff; }
p { margin: 0 0 12px; line-height: 1.55; }
h1 { margin: 0 0 16px; font-size: 28px; }
</style>
</head>
<body>
<main>
<section class="sidebar">
<div role="dialog" tabindex="-1" data-focus-root="1" class="overlay no-outline" data-testid="overlay-button" id="advanced-search-overlay-14">
<input
type="search"
readonly
title="关键词"
placeholder="搜索邮件"
value=""
aria-label="Search messages"
data-testid="search-keyword"
class="input-element w-full cursor-text"
/>
</div>
<div role="row">target@example.com ChatGPT verification code 429792</div>
</section>
<article role="article" class="viewer">
<h1>Your ChatGPT verification code</h1>
<p>From: ChatGPT &lt;noreply@tm.openai.com&gt;</p>
<p>To: target@example.com</p>
<p>Hello,</p>
<p>Your verification code is 429792.</p>
<p>Please use this code to continue signing in.</p>
<p>Best regards</p>
<p>ChatGPT</p>
</article>
</main>
</body>
</html>`
const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
const normalizeText = (value) => String(value == null ? '' : value).trim()
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
? Math.max(5000, Math.round(Number(params.timeoutMs)))
: 45000
const inboxUrl = normalizeText(params.inboxUrl)
if (!inboxUrl) {
throw new Error('inboxUrl is required')
}
const session = await launch({
selector,
skipDefaultStartUrls: true,
startUrls: [inboxUrl],
})
const connection = await connect(session, { timeoutMs })
const browser = connection.browser
if (!browser) {
throw new Error('browser connection is unavailable')
}
const context =
connection.context ||
browser.contexts()[0] ||
(typeof browser.newContext === 'function' ? await browser.newContext() : null)
if (!context) {
throw new Error('browser context is unavailable')
}
const opened = await openPage(connection, {
url: inboxUrl,
timeoutMs,
permissions: ['notifications'],
})
const page = opened.page
await page.waitForLoadState('networkidle', {
timeout: Math.min(timeoutMs, 2500),
}).catch(() => {})
const result = await page.evaluate(() => {
const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
const article = document.querySelector('article')
const lines = Array.from(document.querySelectorAll('article p'))
.map((node) => normalizeText(node.textContent))
.filter(Boolean)
const subject = normalizeText(document.querySelector('article h1')?.textContent)
const fromLine = lines.find((line) => line.startsWith('From:')) || ''
const toLine = lines.find((line) => line.startsWith('To:')) || ''
const articleText = normalizeText(article?.textContent)
const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*</)
const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
const signature = lines.slice(-2).join('\n')
return {
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
subject,
verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
signature,
}
})
return {
ok: true,
permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
summary: '已提取测试邮件内容',
...result,
}
}`
var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
automationHTTPMailProbeScriptTextRaw,
"summary: 'mail probe extracted message',",
)
-330
View File
@@ -1,20 +1,14 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
goruntime "runtime"
"strings"
"testing"
"time"
"ant-chrome/backend/internal/automation"
"ant-chrome/backend/internal/config"
@@ -262,327 +256,3 @@ func TestAutomationScriptRunHTTPReturnsSavedMailProbeScript(t *testing.T) {
"subject": automationHTTPStringValue(parsed, "subject"),
}))
}
func lookupAutomationHTTPProbeNode(t *testing.T) string {
t.Helper()
const preferred = `D:\code\plugin\nodejs\node.exe`
if _, err := os.Stat(preferred); err == nil {
return preferred
}
return lookupAutomationTestNode(t)
}
func lookupAutomationHTTPProbeChrome(t *testing.T) string {
t.Helper()
const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe`
if _, err := os.Stat(preferred); err == nil {
return preferred
}
t.Skip("system chrome is not installed")
return ""
}
func automationHTTPRepoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := goruntime.Caller(0)
if !ok {
t.Fatal("resolve repo root failed")
}
return filepath.Dir(filepath.Dir(file))
}
func automationHTTPFreePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("allocate port failed: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error {
repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
if _, err := os.Stat(repoRuntimeDir); err != nil {
return fmt.Errorf("repo runtime not found: %w", err)
}
if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil {
return err
}
if err := automationHTTPCopyFile(
filepath.Join(repoRuntimeDir, "runner.cjs"),
filepath.Join(tempRuntimeDir, "runner.cjs"),
); err != nil {
return err
}
return automationHTTPCopyDir(
filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"),
filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"),
)
}
func automationHTTPCopyFile(src string, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func automationHTTPCopyDir(src string, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
relativePath, err := filepath.Rel(src, path)
if err != nil {
return err
}
targetPath := filepath.Join(dst, relativePath)
if info.IsDir() {
return os.MkdirAll(targetPath, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
return os.WriteFile(targetPath, data, info.Mode())
})
}
func automationHTTPRequestJSON(method string, url string, payload any, target any) error {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw)))
}
if target == nil {
return nil
}
if err := json.Unmarshal(raw, target); err != nil {
return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw))
}
return nil
}
func automationHTTPHasScript(items []struct {
ID string `json:"id"`
}, scriptID string) bool {
for _, item := range items {
if strings.TrimSpace(item.ID) == scriptID {
return true
}
}
return false
}
func automationHTTPStringValue(payload map[string]any, key string) string {
if payload == nil {
return ""
}
value, _ := payload[key].(string)
return strings.TrimSpace(value)
}
func automationHTTPMarshal(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal debug payload failed: %v", err)
}
return string(data)
}
const automationHTTPMailFixtureHTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mail Fixture</title>
<script>
window.__notificationProbe = {
supported: typeof Notification !== 'undefined',
requested: false,
result: '',
error: '',
};
document.addEventListener('DOMContentLoaded', () => {
if (typeof Notification === 'undefined' || typeof Notification.requestPermission !== 'function') {
document.documentElement.setAttribute('data-notification-probe', 'unsupported');
return;
}
window.__notificationProbe.requested = true;
Notification.requestPermission()
.then((result) => {
window.__notificationProbe.result = String(result || '');
document.documentElement.setAttribute('data-notification-probe', window.__notificationProbe.result || 'empty');
})
.catch((error) => {
window.__notificationProbe.error = String(error && error.message ? error.message : error);
document.documentElement.setAttribute('data-notification-probe', 'error');
});
});
</script>
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f6f7fb; }
main { display: flex; gap: 20px; padding: 24px; min-height: 100vh; box-sizing: border-box; }
.sidebar { width: 32%; min-width: 320px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 20px; box-sizing: border-box; }
.viewer { width: 60%; min-height: 420px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 24px; box-sizing: border-box; }
input { width: 100%; height: 42px; padding: 0 12px; font-size: 16px; box-sizing: border-box; }
[role="row"] { margin-top: 16px; min-height: 56px; border: 1px solid #c8cfdd; border-radius: 10px; padding: 16px; cursor: pointer; background: #fafbff; }
p { margin: 0 0 12px; line-height: 1.55; }
h1 { margin: 0 0 16px; font-size: 28px; }
</style>
</head>
<body>
<main>
<section class="sidebar">
<div role="dialog" tabindex="-1" data-focus-root="1" class="overlay no-outline" data-testid="overlay-button" id="advanced-search-overlay-14">
<input
type="search"
readonly
title="关键词"
placeholder="搜索邮件"
value=""
aria-label="Search messages"
data-testid="search-keyword"
class="input-element w-full cursor-text"
/>
</div>
<div role="row">target@example.com ChatGPT verification code 429792</div>
</section>
<article role="article" class="viewer">
<h1>Your ChatGPT verification code</h1>
<p>From: ChatGPT &lt;noreply@tm.openai.com&gt;</p>
<p>To: target@example.com</p>
<p>Hello,</p>
<p>Your verification code is 429792.</p>
<p>Please use this code to continue signing in.</p>
<p>Best regards</p>
<p>ChatGPT</p>
</article>
</main>
</body>
</html>`
const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
const normalizeText = (value) => String(value == null ? '' : value).trim()
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
? Math.max(5000, Math.round(Number(params.timeoutMs)))
: 45000
const inboxUrl = normalizeText(params.inboxUrl)
if (!inboxUrl) {
throw new Error('inboxUrl is required')
}
const session = await launch({
selector,
skipDefaultStartUrls: true,
startUrls: [inboxUrl],
})
const connection = await connect(session, { timeoutMs })
const browser = connection.browser
if (!browser) {
throw new Error('browser connection is unavailable')
}
const context =
connection.context ||
browser.contexts()[0] ||
(typeof browser.newContext === 'function' ? await browser.newContext() : null)
if (!context) {
throw new Error('browser context is unavailable')
}
const opened = await openPage(connection, {
url: inboxUrl,
timeoutMs,
permissions: ['notifications'],
})
const page = opened.page
await page.waitForLoadState('networkidle', {
timeout: Math.min(timeoutMs, 2500),
}).catch(() => {})
const result = await page.evaluate(() => {
const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
const article = document.querySelector('article')
const lines = Array.from(document.querySelectorAll('article p'))
.map((node) => normalizeText(node.textContent))
.filter(Boolean)
const subject = normalizeText(document.querySelector('article h1')?.textContent)
const fromLine = lines.find((line) => line.startsWith('From:')) || ''
const toLine = lines.find((line) => line.startsWith('To:')) || ''
const articleText = normalizeText(article?.textContent)
const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*</)
const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
const signature = lines.slice(-2).join('\n')
return {
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
subject,
verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
signature,
}
})
return {
ok: true,
permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
summary: '已提取测试邮件内容',
...result,
}
}`
var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
automationHTTPMailProbeScriptTextRaw,
"summary: 'mail probe extracted message',",
)
+195
View File
@@ -0,0 +1,195 @@
package backend
import (
"os"
"path/filepath"
"strings"
"testing"
"ant-chrome/backend/internal/automation"
)
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])
}
}
+321
View File
@@ -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")
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
+13 -112
View File
@@ -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
}
+13 -545
View File
@@ -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') {
@@ -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,
};
@@ -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 };
@@ -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,
};
@@ -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 {
@@ -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)
@@ -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,
};
@@ -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"
}
}
@@ -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',
@@ -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,
}
+25 -9
View File
@@ -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 {
@@ -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)
}
@@ -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)
}
@@ -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"`
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
+43
View File
@@ -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
}
@@ -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)
}
}
+44
View File
@@ -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
}
@@ -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)
}
}
+33
View File
@@ -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)
+42
View File
@@ -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()
@@ -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
}
@@ -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
}
@@ -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
}
+120
View File
@@ -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
}
@@ -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)
}
}
+85
View File
@@ -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...)
}
+75
View File
@@ -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))
}
}
@@ -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
+38
View File
@@ -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))
}
}
+34
View File
@@ -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)
}
+50
View File
@@ -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)
}
}
@@ -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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
t.Fatalf("instance 转换错误: %+v", starter.lastRunRequest)
}
if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
t.Fatalf("targetInput 转换错误: %s", targetInput)
}
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
}
var resp struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Status != "success" {
t.Fatalf("hook 响应错误: %+v", resp)
}
if resp.Data["verificationCode"] != "429792" {
t.Fatalf("expected data payload, got %+v", resp.Data)
}
}
func TestAutomationPublicHookSupportsInstanceModes(t *testing.T) {
cases := []struct {
name string
body string
expectedMode string
expectedSelector bool
expectedInput string
useScriptTarget bool
}{
{
name: "rotate",
body: `{
"instance":{"type":"rotate","selector":{"groupId":"group-a","tags":["chatgpt"]}},
"params":{"recipientQuery":"target@example.com"}
}`,
expectedMode: "rotate",
expectedInput: `"groupId":"group-a"`,
},
{
name: "create",
body: `{
"instance":{"type":"create","templateSelector":{"code":"TEMPLATE_001"},"createNameTemplate":"ChatGPT-Image-${timestamp}"},
"params":{"recipientQuery":"target@example.com"}
}`,
expectedMode: "create",
expectedInput: `"createNameTemplate":"ChatGPT-Image-${timestamp}"`,
},
{
name: "script-default",
body: `{
"instance":{"type":"script-default"},
"params":{"recipientQuery":"target@example.com"}
}`,
useScriptTarget: true,
},
{
name: "legacy-code",
body: `{
"code":"BUYER_001",
"params":{"recipientQuery":"target@example.com"}
}`,
expectedSelector: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "proton-mail-first-message",
Name: "Proton 邮件搜索并读取最新邮件",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/proton-first-message",
RequestMode: "standard",
ResponseMode: "envelope",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.TargetMode != tc.expectedMode {
t.Fatalf("targetMode 错误: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.UseScriptSelector != tc.useScriptTarget {
t.Fatalf("useScriptSelector 错误: %+v", starter.lastRunRequest)
}
if tc.expectedInput != "" {
targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput)
if !strings.Contains(targetInput, tc.expectedInput) {
t.Fatalf("targetInput 转换错误: %s", targetInput)
}
}
if tc.expectedSelector && starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
t.Fatalf("legacy selectorText 转换错误: %+v", starter.lastRunRequest)
}
})
}
}
func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "proton-mail-first-message",
Name: "Proton 邮件搜索并读取最新邮件",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/proton-result-only",
RequestMode: "params-only",
ResponseMode: "result-only",
TimeoutMs: 45000,
},
},
}
starter.runResult = &automation.ScriptRunRecord{
ID: "run-hook-2",
ScriptID: "proton-mail-first-message",
ScriptName: "Proton 邮件搜索并读取最新邮件",
Status: "success",
Summary: "已返回最新命中邮件内容",
ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
"code":"BUYER_001",
"params":{"recipientQuery":"target@example.com"}
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
}
if starter.lastRunRequest.TimeoutMs != 60000 {
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
t.Fatalf("expected data payload, got %+v", resp)
}
}
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "grok-image-generate-download",
Name: "Grok 生成图片并下载",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "image/grok-generate-download",
RequestMode: "params-only",
ResponseMode: "result-only",
TimeoutMs: 300000,
},
},
}
starter.runResult = &automation.ScriptRunRecord{
ID: "run-grok-image",
ScriptID: "grok-image-generate-download",
ScriptName: "Grok 生成图片并下载",
Status: "success",
Summary: "Grok 图片已生成并下载",
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
t.Fatalf("下载字段缺失: %+v", resp)
}
if _, exists := data["steps"]; exists {
t.Fatalf("不应返回冗余 steps: %+v", resp)
}
if _, exists := data["runId"]; exists {
t.Fatalf("不应返回 runId: %+v", resp)
}
}
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "image-generate",
Name: "图片生成",
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "image/generate",
RequestMode: "standard",
ResponseMode: "envelope",
TimeoutMs: 300000,
RequestBodyText: `{"instance":{"type":"existing","selector":{"code":"{{code}}"}},"params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
Variables: []automation.ScriptPublicAPIVariable{
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
{Name: "outputFileName", DefaultValue: "generated-image.png"},
},
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
"code":"BUYER_001",
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.UseScriptParams {
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
t.Fatalf("变量模板应生成 instance target: %+v", starter.lastRunRequest)
}
if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
t.Fatalf("变量模板 targetInput 错误: %s", targetInput)
}
var params map[string]interface{}
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), &params); err != nil {
t.Fatalf("解析 paramsText 失败: %v", err)
}
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
t.Fatalf("变量未映射到 params: %+v", params)
}
selectors, ok := params["selectors"].(map[string]interface{})
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
}
if params["timeoutMs"] != float64(300000) {
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
}
if starter.lastRunRequest.TimeoutMs != 300000 {
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
}
}
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "disabled-hook",
Name: "Disabled Hook",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: false,
Path: "mail/disabled-hook",
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("期望 404,实际 %dbody=%s", w.Code, w.Body.String())
}
}
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "strict-hook",
Name: "Strict Hook",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/strict-hook",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
cases := []struct {
name string
body string
}{
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if resp.OK || resp.Error.Code != "invalid_request" {
t.Fatalf("错误 envelope 不正确: %+v", resp)
}
})
}
}
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "strict-hook-timeout",
Name: "Strict Hook Timeout",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/strict-hook-timeout",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
}
@@ -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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunListLimit != 1 {
t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Count int `json:"count"`
Items []automation.ScriptRunRecord `json:"items"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
t.Fatalf("runs 响应错误: %+v", resp)
}
}
func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("期望 503,实际 %dbody=%s", w.Code, w.Body.String())
}
}
func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
handler := buildTestHandlerWithManager(svc, starter, nil)
t.Run("invalid-json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
})
t.Run("selector-must-be-object", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
"scriptId":"news-query-txt",
"selector":"BUYER_001"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
})
t.Run("timeout-must-be-in-range", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
"scriptId":"news-query-txt",
"timeoutMs":1800001
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
})
}
@@ -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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
}
if starter.lastRunRequest.TimeoutMs != 60000 {
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
t.Fatalf("expected data payload, got %+v", resp)
}
}
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "grok-image-generate-download",
Name: "Grok 生成图片并下载",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "image/grok-generate-download",
RequestMode: "params-only",
ResponseMode: "result-only",
TimeoutMs: 300000,
},
},
}
starter.runResult = &automation.ScriptRunRecord{
ID: "run-grok-image",
ScriptID: "grok-image-generate-download",
ScriptName: "Grok 生成图片并下载",
Status: "success",
Summary: "Grok 图片已生成并下载",
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
t.Fatalf("下载字段缺失: %+v", resp)
}
if _, exists := data["steps"]; exists {
t.Fatalf("不应返回冗余 steps: %+v", resp)
}
if _, exists := data["runId"]; exists {
t.Fatalf("不应返回 runId: %+v", resp)
}
}
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "image-generate",
Name: "图片生成",
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "image/generate",
RequestMode: "standard",
ResponseMode: "envelope",
TimeoutMs: 300000,
RequestBodyText: `{"code":"{{code}}","params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
Variables: []automation.ScriptPublicAPIVariable{
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
{Name: "outputFileName", DefaultValue: "generated-image.png"},
},
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
"code":"BUYER_001",
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.UseScriptParams {
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest)
}
var params map[string]interface{}
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), &params); err != nil {
t.Fatalf("解析 paramsText 失败: %v", err)
}
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
t.Fatalf("变量未映射到 params: %+v", params)
}
selectors, ok := params["selectors"].(map[string]interface{})
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
}
if params["timeoutMs"] != float64(300000) {
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
}
if starter.lastRunRequest.TimeoutMs != 300000 {
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
}
}
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "disabled-hook",
Name: "Disabled Hook",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: false,
Path: "mail/disabled-hook",
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("期望 404,实际 %dbody=%s", w.Code, w.Body.String())
}
}
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "strict-hook",
Name: "Strict Hook",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/strict-hook",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
cases := []struct {
name string
body string
}{
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if resp.OK || resp.Error.Code != "invalid_request" {
t.Fatalf("错误 envelope 不正确: %+v", resp)
}
})
}
}
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "strict-hook-timeout",
Name: "Strict Hook Timeout",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/strict-hook-timeout",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
}
func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.runs = []automation.ScriptRunRecord{
{ID: "run-1", ScriptID: "script-a", Status: "success"},
{ID: "run-2", ScriptID: "script-b", Status: "failed"},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunListLimit != 1 {
t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Count int `json:"count"`
Items []automation.ScriptRunRecord `json:"items"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
t.Fatalf("runs 响应错误: %+v", resp)
}
}
func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("期望 503,实际 %dbody=%s", w.Code, w.Body.String())
}
}
func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
handler := buildTestHandlerWithManager(svc, starter, nil)
t.Run("invalid-json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
})
t.Run("selector-must-be-object", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
"scriptId":"news-query-txt",
"selector":"BUYER_001"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
})
t.Run("timeout-must-be-in-range", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
"scriptId":"news-query-txt",
"timeoutMs":1800001
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
})
}
@@ -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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != "" {
t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
}
if !strings.Contains(w.Body.String(), "matchMode=first") {
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
}
}
func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profile := &browser.Profile{
ProfileId: "profile-get-code-only",
ProfileName: "Buyer Account 02",
Keywords: []string{"buyer-002"},
Pid: 1004,
DebugPort: 9447,
}
starter.addProfile(profile)
manager := newSelectorTestManager(profile)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != "" {
t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
}
}
func TestLaunchWithMatchModeFirst(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profileB := &browser.Profile{
ProfileId: "profile-b",
ProfileName: "B Account",
Keywords: []string{"shop"},
Pid: 2002,
DebugPort: 9552,
}
profileA := &browser.Profile{
ProfileId: "profile-a",
ProfileName: "A Account",
Keywords: []string{"shop"},
Pid: 2001,
DebugPort: 9551,
}
starter.addProfile(profileA)
starter.addProfile(profileB)
manager := newSelectorTestManager(profileB, profileA)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != profileA.ProfileId {
t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
}
}
func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profileA := &browser.Profile{
ProfileId: "profile-a",
ProfileName: "A Account",
Keywords: []string{"shop"},
Pid: 2001,
DebugPort: 9551,
}
profileB := &browser.Profile{
ProfileId: "profile-b",
ProfileName: "B Account",
Keywords: []string{"shop"},
Pid: 2002,
DebugPort: 9552,
}
starter.addProfile(profileA)
starter.addProfile(profileB)
manager := newSelectorTestManager(profileB, profileA)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if len(starter.started) != 2 {
t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
}
if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
}
var resp struct {
OK bool `json:"ok"`
Count int `json:"count"`
Items []struct {
ProfileID string `json:"profileId"`
IsActive bool `json:"isActive"`
} `json:"items"`
ActiveProfileID string `json:"activeProfileId"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
t.Fatalf("批量启动响应错误: %+v", resp)
}
if resp.ActiveProfileID != profileB.ProfileId {
t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
}
if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
t.Fatalf("items 顺序错误: %+v", resp.Items)
}
if resp.Items[0].IsActive || !resp.Items[1].IsActive {
t.Fatalf("isActive 标记错误: %+v", resp.Items)
}
}
func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profileA := &browser.Profile{
ProfileId: "profile-a",
ProfileName: "Account A",
Keywords: []string{"shop", "checkout"},
Pid: 1001,
DebugPort: 9441,
}
profileB := &browser.Profile{
ProfileId: "profile-b",
ProfileName: "Account B",
Keywords: []string{"shop", "refund"},
Pid: 1002,
DebugPort: 9442,
}
starter.addProfile(profileA)
starter.addProfile(profileB)
manager := newSelectorTestManager(profileA, profileB)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("期望 409,实际 %dbody=%s", w.Code, w.Body.String())
}
if len(starter.started) != 0 {
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
}
}
@@ -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,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != "" {
t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
}
if !strings.Contains(w.Body.String(), "matchMode=first") {
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
}
}
func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profile := &browser.Profile{
ProfileId: "profile-get-code-only",
ProfileName: "Buyer Account 02",
Keywords: []string{"buyer-002"},
Pid: 1004,
DebugPort: 9447,
}
starter.addProfile(profile)
manager := newSelectorTestManager(profile)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != "" {
t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
}
}
func TestLaunchWithMatchModeFirst(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profileB := &browser.Profile{
ProfileId: "profile-b",
ProfileName: "B Account",
Keywords: []string{"shop"},
Pid: 2002,
DebugPort: 9552,
}
profileA := &browser.Profile{
ProfileId: "profile-a",
ProfileName: "A Account",
Keywords: []string{"shop"},
Pid: 2001,
DebugPort: 9551,
}
starter.addProfile(profileA)
starter.addProfile(profileB)
manager := newSelectorTestManager(profileB, profileA)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastProfile != profileA.ProfileId {
t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
}
}
func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profileA := &browser.Profile{
ProfileId: "profile-a",
ProfileName: "A Account",
Keywords: []string{"shop"},
Pid: 2001,
DebugPort: 9551,
}
profileB := &browser.Profile{
ProfileId: "profile-b",
ProfileName: "B Account",
Keywords: []string{"shop"},
Pid: 2002,
DebugPort: 9552,
}
starter.addProfile(profileA)
starter.addProfile(profileB)
manager := newSelectorTestManager(profileB, profileA)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if len(starter.started) != 2 {
t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
}
if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
}
var resp struct {
OK bool `json:"ok"`
Count int `json:"count"`
Items []struct {
ProfileID string `json:"profileId"`
IsActive bool `json:"isActive"`
} `json:"items"`
ActiveProfileID string `json:"activeProfileId"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
t.Fatalf("批量启动响应错误: %+v", resp)
}
if resp.ActiveProfileID != profileB.ProfileId {
t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
}
if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
t.Fatalf("items 顺序错误: %+v", resp.Items)
}
if resp.Items[0].IsActive || !resp.Items[1].IsActive {
t.Fatalf("isActive 标记错误: %+v", resp.Items)
}
}
func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
profileA := &browser.Profile{
ProfileId: "profile-a",
ProfileName: "Account A",
Keywords: []string{"shop", "checkout"},
Pid: 1001,
DebugPort: 9441,
}
profileB := &browser.Profile{
ProfileId: "profile-b",
ProfileName: "Account B",
Keywords: []string{"shop", "refund"},
Pid: 1002,
DebugPort: 9442,
}
starter.addProfile(profileA)
starter.addProfile(profileB)
manager := newSelectorTestManager(profileA, profileB)
handler := buildTestHandlerWithManager(svc, starter, manager)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("期望 409,实际 %dbody=%s", w.Code, w.Body.String())
}
if len(starter.started) != 0 {
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
}
}
+5 -172
View File
@@ -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<TModule extends Record<string, ComponentType<any>>>(
loader: () => Promise<TModule>,
exportName: keyof TModule,
) {
return lazy(async () => {
let module: TModule;
try {
module = await loader();
} catch (error) {
if (isDynamicImportFetchError(error) && reloadForStaleChunkOnce()) {
return new Promise<never>(() => {});
}
throw error;
}
return {
default: module[exportName] as ComponentType<any>,
};
});
}
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() {
<Router>
<Layout>
<Suspense fallback={routeFallback}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/charts" element={<ChartsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/keygen" element={<AdminKeygenPage />} />
<Route path="/browser/list" element={<BrowserListPage />} />
<Route
path="/browser/detail/:id"
element={<BrowserDetailPage />}
/>
<Route path="/browser/edit/:id" element={<BrowserEditPage />} />
<Route path="/browser/copy/:id" element={<BrowserCopyPage />} />
<Route
path="/browser/monitor"
element={<Navigate to="/browser/list" replace />}
/>
<Route path="/browser/logs" element={<BrowserLogsPage />} />
<Route path="/browser/proxy-pool" element={<ProxyPoolPage />} />
<Route path="/browser/cores" element={<CoreManagementPage />} />
<Route
path="/browser/bookmarks"
element={<BookmarkSettingsPage />}
/>
<Route path="/browser/automation" element={<AutomationPage />} />
<Route
path="/browser/automation/:scriptId"
element={<AutomationScriptDetailPage />}
/>
<Route
path="/system/docs"
element={<LaunchApiDocsPage />}
/>
<Route
path="/browser/launch-api"
element={<Navigate to="/system/docs" replace />}
/>
<Route path="/browser/tags" element={<TagManagementPage />} />
<Route
path="/system/tutorial"
element={<Navigate to="/system/docs" replace />}
/>
</Routes>
<AppRoutes />
</Suspense>
</Layout>
<ToastContainer />
+5
View File
@@ -0,0 +1,5 @@
export const featuresConfig = {
dashboard: true,
data: true,
settings: true,
}
+2 -2
View File
@@ -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,
+38
View File
@@ -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' },
]
},
]
+1 -1
View File
@@ -1,4 +1,4 @@
import { projectConfig } from './project.config'
import { projectConfig } from './projectBase.config'
import { PROJECT_GITHUB_URL } from './links'
export type ProfileIconKey =
+9 -69
View File
@@ -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,
@@ -0,0 +1,6 @@
export const projectConfig = {
name: 'Ant Browser',
shortName: 'Ant',
description: '面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具',
primaryColor: 'primary',
}
+8
View File
@@ -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',
}
@@ -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<AutomationScriptExportResult> {
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<AutomationScriptExportResult> {
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<AutomationScriptExportResult> {
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("当前环境不支持目录脚本包导出");
}
@@ -0,0 +1,190 @@
import { importAutomationScript, type AutomationScriptRecord } from "./automationScripts";
import { getBindings, normalizeAutomationScriptRecord, type AutomationScriptBatchImportResult } from "./automationScriptApi.shared";
export async function importAutomationScriptFromLocalFile(): Promise<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptBatchImportResult> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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("当前环境不支持按来源重新导入");
}
@@ -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<AutomationScriptRunRecord> {
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<AutomationScriptRunRecord[]> {
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<AutomationScriptPublicApiInvokeResult> {
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<string, string> = {
"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,
};
}
@@ -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<AutomationScriptRecord> {
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<void> {
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),
);
}
@@ -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,
};
}
@@ -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<AutomationScriptRecord> {
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<void> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptBatchImportResult> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptRecord> {
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<AutomationScriptExportResult> {
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<AutomationScriptExportResult> {
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<AutomationScriptExportResult> {
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<AutomationScriptRunRecord> {
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<AutomationScriptRunRecord[]> {
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<AutomationScriptPublicApiInvokeResult> {
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<string, string> = {
"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";
@@ -44,6 +44,13 @@ export {
resolveAutomationScriptPublicAPIConfig,
suggestAutomationScriptPublicAPIPath,
} from "./automationScripts/publicApi";
export {
buildAutomationScriptPublicAPIRequestBodyWithTargetCode,
normalizeAutomationScriptPublicAPIRequestBodyForInvoke,
readAutomationScriptPublicAPIInstanceType,
readAutomationScriptPublicAPIParamObject,
readAutomationScriptPublicAPITargetCode,
} from "./automationScripts/publicApiInstances";
export {
canRefreshAutomationScriptSource,
getAutomationScriptRefreshLabel,
@@ -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<string, unknown>;
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<string, unknown>;
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(),
];
}
@@ -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<string, unknown>;
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<string, unknown>;
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;
}
}
@@ -164,48 +164,46 @@ function isPlainAutomationJSONObject(
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasSameAutomationJSONShape(
left: Record<string, unknown>,
right: Record<string, unknown>,
): 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<AutomationScriptRecord, "paramsText">,
): Record<string, unknown> {
const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {};
const result: Record<string, unknown> = {};
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<AutomationScriptRecord, "paramsText">,
params: Record<string, unknown>,
): boolean {
const expectedParams = buildAutomationScriptPublicAPIExampleParams(script);
return Object.keys(expectedParams).every((key) => key in params);
}
function buildAutomationScriptPublicAPIDefaultRequestExample(
script: Pick<AutomationScriptRecord, "paramsText" | "selectorText">,
script: Pick<AutomationScriptRecord, "id" | "name" | "paramsText" | "selectorText">,
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<AutomationScriptRecord, "paramsText" | "selectorText">,
script: Pick<AutomationScriptRecord, "id" | "name" | "paramsText" | "selectorText">,
parsedBody: Record<string, unknown>,
): 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<AutomationScriptRecord, "paramsText" | "selectorText">,
script: Pick<AutomationScriptRecord, "id" | "name" | "paramsText" | "selectorText">,
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<AutomationScriptRecord, "paramsText" | "selectorText">,
script: Pick<AutomationScriptRecord, "id" | "name" | "paramsText" | "selectorText">,
config: AutomationScriptPublicAPIConfig,
): string {
if (!shouldUseDerivedAutomationScriptPublicAPIRequestBody(script, config)) {
@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function readAutomationScriptPublicAPIParamObject(
body: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown> = {
...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);
}
@@ -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<AutomationScriptPublicAPIConfig>) => void;
}
export function AutomationScriptPublicApiBodyExamples({
busy,
resolvedConfig,
requestExampleFallback,
responseExampleFallback,
requestBodyError,
responseBodyError,
updateConfig,
}: AutomationScriptPublicApiBodyExamplesProps) {
return (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
Body
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
void copyText(
resolvedConfig.requestBodyText || requestExampleFallback,
"Body 已复制",
)
}
>
<Copy className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => updateConfig({ requestBodyText: "" })}
disabled={busy}
>
</Button>
</div>
</div>
<Textarea
rows={10}
value={resolvedConfig.requestBodyText}
onChange={(event) =>
updateConfig({ requestBodyText: event.target.value })
}
className="mt-3 font-mono"
placeholder={requestExampleFallback}
disabled={busy}
/>
{requestBodyError ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{requestBodyError}
</p>
) : (
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
</p>
)}
</div>
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
void copyText(
resolvedConfig.responseBodyText || responseExampleFallback,
"Response 已复制",
)
}
>
<Copy className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => updateConfig({ responseBodyText: "" })}
disabled={busy}
>
</Button>
</div>
</div>
<Textarea
rows={13}
value={resolvedConfig.responseBodyText}
onChange={(event) =>
updateConfig({ responseBodyText: event.target.value })
}
className="mt-3 font-mono"
placeholder={responseExampleFallback}
disabled={busy}
/>
{responseBodyError ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{responseBodyError}
</p>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,231 @@
import { toast } from "../../../shared/components";
import type { AutomationScriptPublicApiInvokeResult } from "../automationScriptApi";
import {
applyAutomationScriptPublicAPIVariables,
buildAutomationScriptPublicAPIPath,
buildAutomationScriptPublicAPIRequestExample,
collectAutomationScriptPublicAPIVariableValues,
readAutomationScriptPublicAPIParamObject,
type AutomationScriptPublicAPIConfig,
type AutomationScriptRecord,
} from "../automationScripts";
const INSTANCE_VARIABLE_NAMES = new Set([
"code",
"launchCode",
"primaryCode",
"secondaryCode",
]);
export function isInstanceVariableName(name: string): boolean {
return INSTANCE_VARIABLE_NAMES.has(name.trim());
}
export function parseJSONText(
text: string,
): { ok: boolean; value: unknown | null; error: string } {
const sourceText = String(text || "").trim();
if (!sourceText) {
return { ok: true, value: null, error: "" };
}
try {
return {
ok: true,
value: JSON.parse(sourceText),
error: "",
};
} catch (error: unknown) {
return {
ok: false,
value: null,
error: error instanceof Error ? error.message : "JSON 解析失败",
};
}
}
export function safeParseJSONObject(text: string): Record<string, unknown> | null {
const parsed = parseJSONText(text);
if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
return null;
}
if (Array.isArray(parsed.value)) {
return null;
}
return parsed.value as Record<string, unknown>;
}
function normalizeLaunchCode(value: unknown): string {
return String(value || "").trim().toUpperCase();
}
export function readPublicApiDualTargetCode(bodyText: string, index: number): string {
const body = safeParseJSONObject(bodyText);
if (!body) return "";
const param = readAutomationScriptPublicAPIParamObject(body);
const browsers = Array.isArray(param.browsers)
? param.browsers
: Array.isArray(body.browsers)
? body.browsers
: [];
const browser = browsers[index];
if (!browser || typeof browser !== "object" || Array.isArray(browser)) {
return "";
}
return normalizeLaunchCode(
(browser as Record<string, unknown>).code ||
(browser as Record<string, unknown>).launchCode,
);
}
export function buildRequestBodyWithDualTargetCode(
currentBodyText: string,
fallbackBodyText: string,
index: number,
code: string,
): string {
const sourceBody =
safeParseJSONObject(currentBodyText) || safeParseJSONObject(fallbackBodyText) || {};
const sourceParam = readAutomationScriptPublicAPIParamObject(sourceBody);
const sourceBrowsers = Array.isArray(sourceParam.browsers)
? sourceParam.browsers
: [];
const nextBrowsers = [...sourceBrowsers];
const currentBrowser = nextBrowsers[index];
const nextBrowser =
currentBrowser && typeof currentBrowser === "object" && !Array.isArray(currentBrowser)
? { ...(currentBrowser as Record<string, unknown>) }
: {};
nextBrowser.code = normalizeLaunchCode(code);
delete nextBrowser.launchCode;
nextBrowsers[index] = nextBrowser;
const nextBody: Record<string, unknown> = {
...sourceBody,
params: {
...sourceParam,
browsers: nextBrowsers,
},
};
delete nextBody.param;
delete nextBody.browsers;
return JSON.stringify(nextBody, null, 2);
}
export function buildCurlPreview(
script: AutomationScriptRecord,
config: AutomationScriptPublicAPIConfig,
launchBaseUrl: string,
apiAuthEnabled: boolean,
apiAuthHeader: string,
): string {
const lines = [
`curl -X ${config.method} ${launchBaseUrl}${buildAutomationScriptPublicAPIPath(config.path)} \\`,
` -H "Content-Type: application/json" \\`,
];
if (apiAuthEnabled && apiAuthHeader.trim()) {
lines.push(` -H "${apiAuthHeader}: <YOUR_API_KEY>" \\`);
}
const requestBody = applyAutomationScriptPublicAPIVariables(
buildAutomationScriptPublicAPIRequestExample(script, config),
config.variables,
collectAutomationScriptPublicAPIVariableValues(config),
).bodyText
.split("\n")
.map((line, index, all) =>
index === all.length - 1 ? ` -d '${line}'` : ` -d '${line}`,
)
.join("\n");
lines.push(requestBody);
return lines.join("\n");
}
export function formatInvokeResult(result: AutomationScriptPublicApiInvokeResult): string {
if (result.bodyJson !== null) {
try {
return JSON.stringify(result.bodyJson, null, 2);
} catch {
// noop
}
}
return result.bodyText.trim() || "(empty)";
}
export interface PublicApiOutputEntry {
key: string;
label: string;
path: string;
}
export function parsePublicApiOutputEntries(result: AutomationScriptPublicApiInvokeResult | null): PublicApiOutputEntry[] {
if (!result?.bodyJson || typeof result.bodyJson !== "object" || Array.isArray(result.bodyJson)) {
return [];
}
const seen = new Set<string>();
const outputs: PublicApiOutputEntry[] = [];
const addOutput = (key: string, value: string) => {
const path = value.trim();
if (!path || seen.has(path)) {
return;
}
seen.add(path);
outputs.push({ key, label: formatPublicApiOutputLabel(key), path });
};
const collect = (value: unknown, keyHint = "") => {
if (!value) {
return;
}
if (typeof value === "string") {
if (/path$/i.test(keyHint) || keyHint === "downloadAddress") {
addOutput(keyHint, value);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item) => collect(item, keyHint));
return;
}
if (typeof value !== "object") {
return;
}
Object.entries(value as Record<string, unknown>).forEach(([key, nestedValue]) =>
collect(nestedValue, key),
);
};
collect(result.bodyJson);
return outputs;
}
function formatPublicApiOutputLabel(key: string): string {
switch (key) {
case "outputPath":
case "downloadPath":
case "downloadAddress":
return "导出文件";
case "screenshotPath":
return "截图文件";
case "artifacts":
return "导出文件";
default:
return key;
}
}
export function formatPublicApiOutputName(path: string): string {
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
export async function copyText(text: string, successMessage: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch {
toast.error("复制失败");
}
}
@@ -1,39 +1,40 @@
import { useEffect, useRef, useState } from "react";
import { Copy, Play, Plus, Sparkles, Trash2 } from "lucide-react";
import {
Button,
FormItem,
Input,
Modal,
Select,
Switch,
Textarea,
toast,
} from "../../../shared/components";
import { useEffect, useRef, useState } from "react";
import { toast } from "../../../shared/components";
import {
invokeAutomationScriptPublicApi,
type AutomationScriptPublicApiInvokeResult,
} from "../automationScriptApi";
import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
import { openCorePath } from "../api";
import type { BrowserProfile } from "../types";
import {
AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS,
applyAutomationScriptPublicAPIVariables,
buildAutomationScriptPublicAPIPath,
buildAutomationScriptPublicAPIRequestExample,
buildAutomationScriptPublicAPIRequestBodyWithTargetCode,
buildAutomationScriptPublicAPIResponseExample,
collectAutomationScriptPublicAPIVariableValues,
DUAL_INSTANCE_RUNTIME_SCRIPT_ID,
isAutomationScriptPublicAPIVariableName,
normalizeAutomationScriptPublicAPIRequestBodyForInvoke,
normalizeAutomationScriptPublicAPIConfig,
prepareAutomationScriptPublicAPIConfigForSave,
readAutomationScriptPublicAPIInstanceType,
readAutomationScriptPublicAPITargetCode,
resolveAutomationScriptPublicAPIConfig,
suggestAutomationScriptPublicAPIPath,
type AutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIVariable,
type AutomationScriptRecord,
} from "../automationScripts";
import {
buildRequestBodyWithDualTargetCode,
isInstanceVariableName,
parseJSONText,
parsePublicApiOutputEntries,
readPublicApiDualTargetCode,
safeParseJSONObject,
} from "./AutomationScriptPublicApiModal.helpers";
import { AutomationScriptPublicApiModalView } from "./AutomationScriptPublicApiModalView";
interface AutomationScriptPublicApiModalProps {
open: boolean;
script: AutomationScriptRecord;
@@ -50,192 +51,6 @@ interface AutomationScriptPublicApiModalProps {
) => Promise<boolean> | boolean;
}
function parseJSONText(
text: string,
): { ok: boolean; value: unknown | null; error: string } {
const sourceText = String(text || "").trim();
if (!sourceText) {
return { ok: true, value: null, error: "" };
}
try {
return {
ok: true,
value: JSON.parse(sourceText),
error: "",
};
} catch (error: unknown) {
return {
ok: false,
value: null,
error: error instanceof Error ? error.message : "JSON 解析失败",
};
}
}
function safeParseJSONObject(text: string): Record<string, unknown> | null {
const parsed = parseJSONText(text);
if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
return null;
}
if (Array.isArray(parsed.value)) {
return null;
}
return parsed.value as Record<string, unknown>;
}
function normalizeLaunchCode(value: unknown): string {
return String(value || "").trim().toUpperCase();
}
function readPublicApiTargetCode(bodyText: string): string {
const body = safeParseJSONObject(bodyText);
if (!body) return "";
return normalizeLaunchCode(body.code || body.launchCode);
}
function readPublicApiParamObject(
body: Record<string, unknown>,
): Record<string, unknown> {
if (body.param && typeof body.param === "object" && !Array.isArray(body.param)) {
return body.param as Record<string, unknown>;
}
if (body.params && typeof body.params === "object" && !Array.isArray(body.params)) {
return body.params as Record<string, unknown>;
}
return {};
}
function readPublicApiDualTargetCode(bodyText: string, index: number): string {
const body = safeParseJSONObject(bodyText);
if (!body) return "";
const param = readPublicApiParamObject(body);
const browsers = Array.isArray(param.browsers)
? param.browsers
: Array.isArray(body.browsers)
? body.browsers
: [];
const browser = browsers[index];
if (!browser || typeof browser !== "object" || Array.isArray(browser)) {
return "";
}
return normalizeLaunchCode(
(browser as Record<string, unknown>).code ||
(browser as Record<string, unknown>).launchCode,
);
}
function buildRequestBodyWithTargetCode(
currentBodyText: string,
fallbackBodyText: string,
code: string,
): string {
const sourceBody =
safeParseJSONObject(currentBodyText) || safeParseJSONObject(fallbackBodyText) || {};
const sourceParam =
sourceBody.param && typeof sourceBody.param === "object" && !Array.isArray(sourceBody.param)
? sourceBody.param
: sourceBody.params && typeof sourceBody.params === "object" && !Array.isArray(sourceBody.params)
? sourceBody.params
: {};
const nextBody: Record<string, unknown> = {
...sourceBody,
code: normalizeLaunchCode(code),
param: sourceParam,
};
delete nextBody.launchCode;
delete nextBody.selector;
delete nextBody.params;
return JSON.stringify(nextBody, null, 2);
}
function buildRequestBodyWithDualTargetCode(
currentBodyText: string,
fallbackBodyText: string,
index: number,
code: string,
): string {
const sourceBody =
safeParseJSONObject(currentBodyText) || safeParseJSONObject(fallbackBodyText) || {};
const sourceParam = readPublicApiParamObject(sourceBody);
const sourceBrowsers = Array.isArray(sourceParam.browsers)
? sourceParam.browsers
: [];
const nextBrowsers = [...sourceBrowsers];
const currentBrowser = nextBrowsers[index];
const nextBrowser =
currentBrowser && typeof currentBrowser === "object" && !Array.isArray(currentBrowser)
? { ...(currentBrowser as Record<string, unknown>) }
: {};
nextBrowser.code = normalizeLaunchCode(code);
delete nextBrowser.launchCode;
nextBrowsers[index] = nextBrowser;
const nextBody: Record<string, unknown> = {
...sourceBody,
param: {
...sourceParam,
browsers: nextBrowsers,
},
};
delete nextBody.params;
delete nextBody.browsers;
return JSON.stringify(nextBody, null, 2);
}
function buildCurlPreview(
script: AutomationScriptRecord,
config: AutomationScriptPublicAPIConfig,
launchBaseUrl: string,
apiAuthEnabled: boolean,
apiAuthHeader: string,
): string {
const lines = [
`curl -X ${config.method} ${launchBaseUrl}${buildAutomationScriptPublicAPIPath(config.path)} \\`,
` -H "Content-Type: application/json" \\`,
];
if (apiAuthEnabled && apiAuthHeader.trim()) {
lines.push(` -H "${apiAuthHeader}: <YOUR_API_KEY>" \\`);
}
const requestBody = applyAutomationScriptPublicAPIVariables(
buildAutomationScriptPublicAPIRequestExample(script, config),
config.variables,
collectAutomationScriptPublicAPIVariableValues(config),
).bodyText
.split("\n")
.map((line, index, all) =>
index === all.length - 1 ? ` -d '${line}'` : ` -d '${line}`,
)
.join("\n");
lines.push(requestBody);
return lines.join("\n");
}
function formatInvokeResult(result: AutomationScriptPublicApiInvokeResult): string {
if (result.bodyJson !== null) {
try {
return JSON.stringify(result.bodyJson, null, 2);
} catch {
// noop
}
}
return result.bodyText.trim() || "(empty)";
}
async function copyText(text: string, successMessage: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch {
toast.error("复制失败");
}
}
export function AutomationScriptPublicApiModal({
open,
script,
@@ -260,6 +75,9 @@ export function AutomationScriptPublicApiModal({
requestBodyText: "",
},
);
const requestBodySource = resolvedConfig.requestBodyText.trim()
? resolvedConfig.requestBodyText
: requestExampleFallback;
const responseExampleFallback = buildAutomationScriptPublicAPIResponseExample(
script,
{
@@ -273,13 +91,23 @@ export function AutomationScriptPublicApiModal({
collectAutomationScriptPublicAPIVariableValues(resolvedConfig),
);
const resolvedRequestBodyText = resolvedRequestBody.bodyText;
const invalidVariableNames = resolvedConfig.variables
const visibleVariables = resolvedConfig.variables
.map((variable, index) => ({ variable, index }))
.filter(({ variable }) => !isInstanceVariableName(variable.name));
const visibleVariableNames = new Set(
visibleVariables.map(({ variable }) => variable.name),
);
const invalidVariableNames = visibleVariables
.map(({ variable }) => variable)
.filter((variable) => !isAutomationScriptPublicAPIVariableName(variable.name))
.map((variable) => variable.name);
const missingVisibleVariables = resolvedRequestBody.missingRequired.filter(
(name) => visibleVariableNames.has(name),
);
const variableError = invalidVariableNames.length
? `变量名只能使用字母、数字、下划线,且不能以数字开头:${invalidVariableNames.join(", ")}`
: resolvedRequestBody.missingRequired.length
? `必填变量缺少默认值:${resolvedRequestBody.missingRequired.join(", ")}`
: missingVisibleVariables.length
? `必填变量缺少默认值:${missingVisibleVariables.join(", ")}`
: "";
const responseBodyValidation = parseJSONText(resolvedConfig.responseBodyText);
const requestBodyError =
@@ -291,7 +119,12 @@ export function AutomationScriptPublicApiModal({
? `响应示例不是合法 JSON${responseBodyValidation.error}`
: "";
const isDualInstanceRuntimeScript = script.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID;
const selectedTargetCode = readPublicApiTargetCode(resolvedRequestBodyText);
const selectedTargetCode = readAutomationScriptPublicAPITargetCode(
resolvedRequestBodyText,
);
const selectedInstanceType = readAutomationScriptPublicAPIInstanceType(
resolvedRequestBodyText,
);
const selectedPrimaryTargetCode = readPublicApiDualTargetCode(
resolvedRequestBodyText,
0,
@@ -307,14 +140,24 @@ export function AutomationScriptPublicApiModal({
: "两个实例 Code 必填"
: selectedTargetCode
? ""
: "实例 Code 必填";
: selectedInstanceType === "script-default"
? ""
: "实例 Code 必填";
const invokeDisabled =
busy ||
!resolvedConfig.enabled ||
!!variableError ||
!!requestBodyError ||
!!responseBodyError ||
!!targetCodeError;
const [apiKey, setApiKey] = useState("");
const [invoking, setInvoking] = useState(false);
const [invokeResult, setInvokeResult] =
useState<AutomationScriptPublicApiInvokeResult | null>(null);
const [invokeError, setInvokeError] = useState("");
const testSectionRef = useRef<HTMLDivElement | null>(null);
const testSectionRef = useRef<HTMLDivElement>(null);
const outputEntries = parsePublicApiOutputEntries(invokeResult);
useEffect(() => {
if (!open) {
@@ -355,8 +198,8 @@ export function AutomationScriptPublicApiModal({
const handleTargetCodeChange = (code: string) => {
updateConfig({
requestBodyText: buildRequestBodyWithTargetCode(
resolvedConfig.requestBodyText,
requestBodyText: buildAutomationScriptPublicAPIRequestBodyWithTargetCode(
requestBodySource,
requestExampleFallback,
code,
),
@@ -366,7 +209,7 @@ export function AutomationScriptPublicApiModal({
const handleDualTargetCodeChange = (index: number, code: string) => {
updateConfig({
requestBodyText: buildRequestBodyWithDualTargetCode(
resolvedConfig.requestBodyText,
requestBodySource,
requestExampleFallback,
index,
code,
@@ -399,7 +242,7 @@ export function AutomationScriptPublicApiModal({
variables: [
...resolvedConfig.variables,
{
name: baseName || `variable${resolvedConfig.variables.length + 1}`,
name: baseName || `variable${visibleVariables.length + 1}`,
defaultValue: "",
description: "",
required: false,
@@ -453,7 +296,9 @@ export function AutomationScriptPublicApiModal({
const result = await invokeAutomationScriptPublicApi({
url: fullURL,
method: resolvedConfig.method,
bodyText: resolvedRequestBodyText,
bodyText: normalizeAutomationScriptPublicAPIRequestBodyForInvoke(
resolvedRequestBodyText,
),
apiKey,
authHeader: apiAuthHeader,
timeoutMs: resolvedConfig.timeoutMs + 10000,
@@ -473,403 +318,56 @@ export function AutomationScriptPublicApiModal({
}
};
const handleOpenOutputPath = async (path: string) => {
try {
await openCorePath(path);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "打开目录失败";
toast.error(message);
}
};
return (
<Modal
<AutomationScriptPublicApiModalView
open={open}
onClose={onClose}
title="对外接口管理"
width="1100px"
footer={
<Button variant="secondary" onClick={onClose}>
</Button>
}
>
<div className="space-y-4">
<div
ref={testSectionRef}
className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
<Button
type="button"
size="sm"
onClick={() => void handleInvoke()}
loading={invoking}
disabled={
busy ||
!resolvedConfig.enabled ||
!!variableError ||
!!requestBodyError ||
!!responseBodyError ||
!!targetCodeError
}
>
<Play className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 grid grid-cols-1 gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-3">
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
</div>
<div className="mt-2 break-all text-sm text-[var(--color-text-primary)]">
{fullURL}
</div>
</div>
{apiAuthEnabled ? (
<FormItem label={`API Key (${apiAuthHeader})`}>
<Input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="留空则使用当前应用里的 Launch API Key"
/>
</FormItem>
) : (
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-3 text-sm text-[var(--color-text-secondary)]">
Launch API
</div>
)}
</div>
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
{invokeResult ? (
<div className="text-xs text-[var(--color-text-muted)]">
HTTP {invokeResult.status} {invokeResult.statusText}
</div>
) : null}
</div>
{invokeError ? (
<div className="mt-3 rounded-lg border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-3 text-sm text-[var(--color-text-secondary)]">
{invokeError}
</div>
) : null}
{!invokeError && !invokeResult ? (
<div className="mt-3 rounded-lg border border-dashed border-[var(--color-border-muted)] px-3 py-8 text-center text-sm text-[var(--color-text-muted)]">
</div>
) : null}
{invokeResult ? (
<pre className="mt-3 overflow-x-auto rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-3 text-xs leading-6 text-[var(--color-text-secondary)]">
<code>{formatInvokeResult(invokeResult)}</code>
</pre>
) : null}
</div>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<Select
value={resolvedConfig.method}
options={AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
onChange={(event) =>
updateConfig({ method: event.target.value as "POST" })
}
className="w-[96px] shrink-0 font-semibold"
disabled
/>
<Input
value={fullURL}
readOnly
className="min-w-0 flex-1 font-mono sm:min-w-[280px]"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void copyText(fullURL, "接口地址已复制")}
disabled={busy}
>
<Copy className="h-4 w-4" />
URL
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() =>
void copyText(
buildCurlPreview(
script,
resolvedConfig,
launchBaseUrl,
apiAuthEnabled,
apiAuthHeader,
),
"curl 已复制",
)
}
disabled={busy}
>
<Copy className="h-4 w-4" />
curl
</Button>
<div className="ml-auto flex h-9 items-center gap-2 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 text-sm text-[var(--color-text-secondary)]">
<span>{resolvedConfig.enabled ? "已启用" : "未启用"}</span>
<Switch
checked={resolvedConfig.enabled}
onChange={(checked) => updateConfig({ enabled: checked })}
disabled={busy}
/>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_180px]">
<FormItem label="Path">
<div className="flex gap-2">
<Input
value={resolvedConfig.path}
onChange={(event) => updateConfig({ path: event.target.value })}
placeholder="mail/proton-first-message"
className="font-mono"
disabled={busy}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="!h-9 !min-w-[88px] shrink-0 whitespace-nowrap"
onClick={handleApplySuggestedPath}
disabled={busy}
>
<Sparkles className="h-4 w-4" />
</Button>
</div>
<div className="mt-1 break-all text-xs text-[var(--color-text-muted)]">
{fullPath}
</div>
</FormItem>
<FormItem label="Timeout">
<Input
type="number"
min={1000}
max={1800000}
value={String(resolvedConfig.timeoutMs)}
onChange={(event) =>
updateConfig({ timeoutMs: Number(event.target.value) || 0 })
}
disabled={busy}
/>
</FormItem>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleAddVariable}
disabled={busy}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{resolvedConfig.variables.length > 0 ? (
<div className="mt-3 space-y-2">
{resolvedConfig.variables.map((variable, index) => (
<div
key={`${index}-${variable.name}`}
className="grid grid-cols-1 gap-2 rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-2 py-2 lg:grid-cols-[180px_minmax(0,1fr)_minmax(0,1fr)_86px_36px]"
>
<Input
value={variable.name}
onChange={(event) =>
updateVariable(index, { name: event.target.value })
}
placeholder="searchQuery"
className="font-mono"
disabled={busy}
/>
<Input
value={variable.defaultValue}
onChange={(event) =>
updateVariable(index, { defaultValue: event.target.value })
}
placeholder="默认值"
disabled={busy}
/>
<Input
value={variable.description}
onChange={(event) =>
updateVariable(index, { description: event.target.value })
}
placeholder="说明"
disabled={busy}
/>
<label className="flex h-9 items-center justify-center gap-2 rounded-lg border border-[var(--color-border-muted)] text-sm text-[var(--color-text-secondary)]">
<input
type="checkbox"
checked={variable.required}
onChange={(event) =>
updateVariable(index, { required: event.target.checked })
}
disabled={busy}
/>
</label>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => handleRemoveVariable(index)}
disabled={busy}
aria-label="删除变量"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-[var(--color-border-muted)] px-3 py-3 text-sm text-[var(--color-text-muted)]">
</div>
)}
{variableError ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{variableError}
</p>
) : (
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
Body 使 <code>{"${name}"}</code> curl
</p>
)}
</div>
{isDualInstanceRuntimeScript ? (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<AutomationInstanceSelector
title="传入实例 1"
mode="manual"
modes={["manual"]}
profiles={profiles}
selectedCode={selectedPrimaryTargetCode}
disabled={busy}
codePlaceholder="例如 BUYER_001"
onCodeChange={(code) => handleDualTargetCodeChange(0, code)}
/>
<AutomationInstanceSelector
title="传入实例 2"
mode="manual"
modes={["manual"]}
profiles={profiles}
selectedCode={selectedSecondaryTargetCode}
disabled={busy}
codePlaceholder="例如 BUYER_002"
onCodeChange={(code) => handleDualTargetCodeChange(1, code)}
/>
</div>
) : (
<AutomationInstanceSelector
title="传入实例"
mode="manual"
modes={["manual"]}
profiles={profiles}
selectedCode={selectedTargetCode}
disabled={busy}
codePlaceholder="例如 BUYER_001"
onCodeChange={handleTargetCodeChange}
/>
)}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
Body
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => updateConfig({ requestBodyText: "" })}
disabled={busy}
>
</Button>
</div>
</div>
<Textarea
rows={13}
value={resolvedConfig.requestBodyText}
onChange={(event) =>
updateConfig({ requestBodyText: event.target.value })
}
className="mt-3 font-mono"
placeholder={requestExampleFallback}
disabled={busy}
/>
{requestBodyError ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{requestBodyError}
</p>
) : null}
</div>
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
Response
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => updateConfig({ responseBodyText: "" })}
disabled={busy}
>
</Button>
</div>
</div>
<Textarea
rows={13}
value={resolvedConfig.responseBodyText}
onChange={(event) =>
updateConfig({ responseBodyText: event.target.value })
}
className="mt-3 font-mono"
placeholder={responseExampleFallback}
disabled={busy}
/>
{responseBodyError ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{responseBodyError}
</p>
) : null}
</div>
</div>
</div>
</Modal>
busy={busy}
script={script}
launchBaseUrl={launchBaseUrl}
apiAuthEnabled={apiAuthEnabled}
apiAuthHeader={apiAuthHeader}
profiles={profiles}
fullURL={fullURL}
fullPath={fullPath}
resolvedConfig={resolvedConfig}
requestExampleFallback={requestExampleFallback}
responseExampleFallback={responseExampleFallback}
visibleVariables={visibleVariables}
variableError={variableError}
requestBodyError={requestBodyError}
responseBodyError={responseBodyError}
isDualInstanceRuntimeScript={isDualInstanceRuntimeScript}
selectedTargetCode={selectedTargetCode}
selectedPrimaryTargetCode={selectedPrimaryTargetCode}
selectedSecondaryTargetCode={selectedSecondaryTargetCode}
invokeDisabled={invokeDisabled}
apiKey={apiKey}
setApiKey={setApiKey}
invoking={invoking}
invokeResult={invokeResult}
invokeError={invokeError}
outputEntries={outputEntries}
testSectionRef={testSectionRef}
updateConfig={updateConfig}
updateVariable={updateVariable}
handleApplySuggestedPath={handleApplySuggestedPath}
handleAddVariable={handleAddVariable}
handleRemoveVariable={handleRemoveVariable}
handleTargetCodeChange={handleTargetCodeChange}
handleDualTargetCodeChange={handleDualTargetCodeChange}
handleInvoke={handleInvoke}
handleOpenOutputPath={handleOpenOutputPath}
/>
);
}
@@ -0,0 +1,474 @@
import type { RefObject } from "react";
import { Copy, FolderOpen, Play, Plus, Sparkles, Trash2 } from "lucide-react";
import { Button, FormItem, Input, Modal, Select, Switch } from "../../../shared/components";
import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
import {
buildCurlPreview,
copyText,
formatInvokeResult,
formatPublicApiOutputName,
type PublicApiOutputEntry,
} from "./AutomationScriptPublicApiModal.helpers";
import {
AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS,
type AutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIVariable,
type AutomationScriptRecord,
} from "../automationScripts";
import type { BrowserProfile } from "../types";
import type { AutomationScriptPublicApiInvokeResult } from "../automationScriptApi";
import { AutomationScriptPublicApiBodyExamples } from "./AutomationScriptPublicApiBodyExamples";
interface VisiblePublicApiVariable {
variable: AutomationScriptPublicAPIVariable;
index: number;
}
interface AutomationScriptPublicApiModalViewProps {
open: boolean;
onClose: () => void;
busy: boolean;
script: AutomationScriptRecord;
launchBaseUrl: string;
apiAuthEnabled: boolean;
apiAuthHeader: string;
profiles: BrowserProfile[];
fullURL: string;
fullPath: string;
resolvedConfig: AutomationScriptPublicAPIConfig;
requestExampleFallback: string;
responseExampleFallback: string;
visibleVariables: VisiblePublicApiVariable[];
variableError: string;
requestBodyError: string;
responseBodyError: string;
isDualInstanceRuntimeScript: boolean;
selectedTargetCode: string;
selectedPrimaryTargetCode: string;
selectedSecondaryTargetCode: string;
invokeDisabled: boolean;
apiKey: string;
setApiKey: (value: string) => void;
invoking: boolean;
invokeResult: AutomationScriptPublicApiInvokeResult | null;
invokeError: string;
outputEntries: PublicApiOutputEntry[];
testSectionRef: RefObject<HTMLDivElement>;
updateConfig: (patch: Partial<AutomationScriptPublicAPIConfig>) => void;
updateVariable: (index: number, patch: Partial<AutomationScriptPublicAPIVariable>) => void;
handleApplySuggestedPath: () => void;
handleAddVariable: () => void;
handleRemoveVariable: (index: number) => void;
handleTargetCodeChange: (code: string) => void;
handleDualTargetCodeChange: (index: number, code: string) => void;
handleInvoke: () => Promise<void>;
handleOpenOutputPath: (path: string) => Promise<void>;
}
export function AutomationScriptPublicApiModalView({
open,
onClose,
busy,
script,
launchBaseUrl,
apiAuthEnabled,
apiAuthHeader,
profiles,
fullURL,
fullPath,
resolvedConfig,
requestExampleFallback,
responseExampleFallback,
visibleVariables,
variableError,
requestBodyError,
responseBodyError,
isDualInstanceRuntimeScript,
selectedTargetCode,
selectedPrimaryTargetCode,
selectedSecondaryTargetCode,
invokeDisabled,
apiKey,
setApiKey,
invoking,
invokeResult,
invokeError,
outputEntries,
testSectionRef,
updateConfig,
updateVariable,
handleApplySuggestedPath,
handleAddVariable,
handleRemoveVariable,
handleTargetCodeChange,
handleDualTargetCodeChange,
handleInvoke,
handleOpenOutputPath,
}: AutomationScriptPublicApiModalViewProps) {
return (
<Modal
open={open}
onClose={onClose}
title="对外接口管理"
width="1100px"
footer={
<div className="flex w-full items-center justify-end gap-2">
<Button variant="secondary" onClick={onClose} disabled={invoking}>
</Button>
<Button
type="button"
onClick={() => void handleInvoke()}
loading={invoking}
disabled={invokeDisabled}
>
<Play className="h-4 w-4" />
</Button>
</div>
}
>
<div className="space-y-4">
<div
ref={testSectionRef}
className="rounded-xl border border-slate-200/70 bg-slate-100 px-3 py-3 shadow-inner"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-slate-800">
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-3">
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
</div>
<div className="mt-2 break-all text-sm text-[var(--color-text-primary)]">
{fullURL}
</div>
</div>
{apiAuthEnabled ? (
<FormItem label={`API Key (${apiAuthHeader})`}>
<Input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="留空则使用当前应用里的 Launch API Key"
/>
</FormItem>
) : (
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-3 text-sm text-[var(--color-text-secondary)]">
Launch API
</div>
)}
</div>
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
{invokeResult ? (
<div className="text-xs text-[var(--color-text-muted)]">
HTTP {invokeResult.status} {invokeResult.statusText}
</div>
) : null}
</div>
{invokeError ? (
<div className="mt-3 rounded-lg border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-3 text-sm text-[var(--color-text-secondary)]">
{invokeError}
</div>
) : null}
{!invokeError && !invokeResult ? (
<div className="mt-3 rounded-lg border border-dashed border-[var(--color-border-muted)] px-3 py-8 text-center text-sm text-[var(--color-text-muted)]">
</div>
) : null}
{invokeResult ? (
<div className="mt-3 space-y-3">
<pre className="overflow-x-auto rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] p-3 text-xs leading-6 text-[var(--color-text-secondary)]">
<code>{formatInvokeResult(invokeResult)}</code>
</pre>
{outputEntries.length > 0 ? (
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="space-y-2">
{outputEntries.map((output) => (
<div
key={`${output.key}-${output.path}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="text-sm text-[var(--color-text-primary)]">
{output.label} · {formatPublicApiOutputName(output.path)}
</div>
<div className="mt-1 break-all text-xs text-[var(--color-text-muted)]">
{output.path}
</div>
</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void handleOpenOutputPath(output.path)}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<Select
value={resolvedConfig.method}
options={AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
onChange={(event) =>
updateConfig({ method: event.target.value as "POST" })
}
className="w-[96px] shrink-0 font-semibold"
disabled
/>
<Input
value={fullURL}
readOnly
className="min-w-0 flex-1 font-mono sm:min-w-[280px]"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void copyText(fullURL, "接口地址已复制")}
disabled={busy}
>
<Copy className="h-4 w-4" />
URL
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() =>
void copyText(
buildCurlPreview(
script,
resolvedConfig,
launchBaseUrl,
apiAuthEnabled,
apiAuthHeader,
),
"curl 已复制",
)
}
disabled={busy}
>
<Copy className="h-4 w-4" />
curl
</Button>
<div className="ml-auto flex h-9 items-center gap-2 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 text-sm text-[var(--color-text-secondary)]">
<span>{resolvedConfig.enabled ? "已启用" : "未启用"}</span>
<Switch
checked={resolvedConfig.enabled}
onChange={(checked) => updateConfig({ enabled: checked })}
disabled={busy}
/>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_180px]">
<FormItem label="Path">
<div className="flex gap-2">
<Input
value={resolvedConfig.path}
onChange={(event) => updateConfig({ path: event.target.value })}
placeholder="mail/proton-first-message"
className="font-mono"
disabled={busy}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="!h-9 !min-w-[88px] shrink-0 whitespace-nowrap"
onClick={handleApplySuggestedPath}
disabled={busy}
>
<Sparkles className="h-4 w-4" />
</Button>
</div>
<div className="mt-1 break-all text-xs text-[var(--color-text-muted)]">
{fullPath}
</div>
</FormItem>
<FormItem label="Timeout">
<Input
type="number"
min={1000}
max={1800000}
value={String(resolvedConfig.timeoutMs)}
onChange={(event) =>
updateConfig({ timeoutMs: Number(event.target.value) || 0 })
}
disabled={busy}
/>
</FormItem>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleAddVariable}
disabled={busy}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{visibleVariables.length > 0 ? (
<div className="mt-3 space-y-2">
{visibleVariables.map(({ variable, index }) => (
<div
key={`${index}-${variable.name}`}
className="grid grid-cols-1 gap-2 rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-2 py-2 lg:grid-cols-[180px_minmax(0,1fr)_minmax(0,1fr)_86px_36px]"
>
<Input
value={variable.name}
onChange={(event) =>
updateVariable(index, { name: event.target.value })
}
placeholder="searchQuery"
className="font-mono"
disabled={busy}
/>
<Input
value={variable.defaultValue}
onChange={(event) =>
updateVariable(index, { defaultValue: event.target.value })
}
placeholder="默认值"
disabled={busy}
/>
<Input
value={variable.description}
onChange={(event) =>
updateVariable(index, { description: event.target.value })
}
placeholder="说明"
disabled={busy}
/>
<label className="flex h-9 items-center justify-center gap-2 rounded-lg border border-[var(--color-border-muted)] text-sm text-[var(--color-text-secondary)]">
<input
type="checkbox"
checked={variable.required}
onChange={(event) =>
updateVariable(index, { required: event.target.checked })
}
disabled={busy}
/>
</label>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => handleRemoveVariable(index)}
disabled={busy}
aria-label="删除变量"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-[var(--color-border-muted)] px-3 py-3 text-sm text-[var(--color-text-muted)]">
</div>
)}
{variableError ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{variableError}
</p>
) : (
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
<code>{"{{name}}"}</code> Code
</p>
)}
</div>
{isDualInstanceRuntimeScript ? (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<AutomationInstanceSelector
title="传入实例 1"
mode="manual"
modes={["manual"]}
profiles={profiles}
selectedCode={selectedPrimaryTargetCode}
disabled={busy}
codePlaceholder="例如 BUYER_001"
onCodeChange={(code) => handleDualTargetCodeChange(0, code)}
/>
<AutomationInstanceSelector
title="传入实例 2"
mode="manual"
modes={["manual"]}
profiles={profiles}
selectedCode={selectedSecondaryTargetCode}
disabled={busy}
codePlaceholder="例如 BUYER_002"
onCodeChange={(code) => handleDualTargetCodeChange(1, code)}
/>
</div>
) : (
<AutomationInstanceSelector
title="传入实例"
mode="manual"
modes={["manual"]}
profiles={profiles}
selectedCode={selectedTargetCode}
disabled={busy}
codePlaceholder="例如 BUYER_001"
onCodeChange={handleTargetCodeChange}
/>
)}
<AutomationScriptPublicApiBodyExamples
busy={busy}
resolvedConfig={resolvedConfig}
requestExampleFallback={requestExampleFallback}
responseExampleFallback={responseExampleFallback}
requestBodyError={requestBodyError}
responseBodyError={responseBodyError}
updateConfig={updateConfig}
/>
</div>
</Modal>
);
}
@@ -0,0 +1,470 @@
import { toast } from "../../../shared/components";
import {
applyAutomationScriptPublicAPIVariables,
collectAutomationScriptPublicAPIVariableValues,
type AutomationScriptPublicAPIConfig,
type AutomationScriptRecord,
} from "../automationScripts";
import type { AutomationDemoSession } from "../demoSession";
import type { BrowserProfile } from "../types";
import type { ResultOutputEntry, RunVariableInputs, SelectableProfile } from "./AutomationScriptRunModal.types";
export function validateJsonObjectText(
text: string,
label: string,
required: boolean,
): string {
const normalized = text.trim();
if (!normalized) {
return required ? `${label}不能为空` : "";
}
try {
const parsed = JSON.parse(normalized);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return `${label}必须是 JSON 对象`;
}
return "";
} catch {
return `${label}不是合法 JSON`;
}
}
export function formatDateTime(value?: string): string {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString("zh-CN", { hour12: false });
}
export function formatDuration(durationMs?: number): string {
if (!durationMs || durationMs <= 0) {
return "-";
}
if (durationMs < 1000) {
return `${durationMs} ms`;
}
return `${(durationMs / 1000).toFixed(2)} s`;
}
export function parseRunResultOutputs(resultText?: string): ResultOutputEntry[] {
const normalized = String(resultText || "").trim();
if (!normalized) {
return [];
}
try {
const parsed = JSON.parse(normalized);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return [];
}
const seen = new Set<string>();
const outputs: ResultOutputEntry[] = [];
const addOutput = (key: string, value: string) => {
const path = value.trim();
if (!path || seen.has(path)) {
return;
}
seen.add(path);
outputs.push({
key,
label: formatRunResultOutputLabel(key),
path,
});
};
const collectOutputs = (value: unknown, keyHint = "") => {
if (!value) {
return;
}
if (typeof value === "string") {
if (/path$/i.test(keyHint)) {
addOutput(keyHint, value);
}
return;
}
if (Array.isArray(value)) {
if (keyHint === "artifacts") {
value.forEach((item) => {
if (typeof item === "string") {
addOutput(keyHint, item);
}
});
return;
}
value.forEach((item) => collectOutputs(item, keyHint));
return;
}
if (typeof value !== "object") {
return;
}
for (const [nestedKey, nestedValue] of Object.entries(
value as Record<string, unknown>,
)) {
collectOutputs(nestedValue, nestedKey);
}
};
collectOutputs(parsed);
return outputs;
} catch {
return [];
}
}
function formatRunResultOutputLabel(key: string): string {
switch (key) {
case "outputPath":
return "输出文件";
case "screenshotPath":
return "截图文件";
case "artifacts":
return "导出文件";
default:
return key;
}
}
export function formatRunResultOutputName(path: string): string {
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
export function formatRunResultText(resultText?: string): string {
const normalized = String(resultText || "").trim();
if (!normalized) {
return "";
}
try {
return JSON.stringify(JSON.parse(normalized), null, 2);
} catch {
return resultText || "";
}
}
export async function copyToClipboard(text: string, successMessage: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch {
toast.error("复制失败");
}
}
export function buildDemoSelectorText(launchCode: string) {
return JSON.stringify(
{
code: launchCode,
},
null,
2,
);
}
export function normalizeLaunchCode(value?: string): string {
return String(value || "")
.trim()
.toUpperCase();
}
export function isPlaceholderSelectorText(text: string): boolean {
const normalized = text.trim();
if (!normalized) {
return true;
}
try {
const parsed = JSON.parse(normalized);
const code =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? String((parsed as Record<string, unknown>).code || "")
.trim()
.toUpperCase()
: "";
return !code || code === "BUYER_001" || code === "DEMO_ABC123";
} catch {
return false;
}
}
function parseJsonObjectText(text: string): Record<string, unknown> {
const normalized = text.trim();
if (!normalized) {
return {};
}
try {
const parsed = JSON.parse(normalized);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, unknown>;
} catch {
return {};
}
}
function mergeJsonObjectValues(
base: Record<string, unknown>,
patch: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...base };
Object.entries(patch).forEach(([key, value]) => {
const baseValue = merged[key];
if (
baseValue &&
typeof baseValue === "object" &&
!Array.isArray(baseValue) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
merged[key] = mergeJsonObjectValues(
baseValue as Record<string, unknown>,
value as Record<string, unknown>,
);
return;
}
merged[key] = value;
});
return merged;
}
export function buildPublicAPIVariableInputs(
config: AutomationScriptPublicAPIConfig,
): RunVariableInputs {
return collectAutomationScriptPublicAPIVariableValues(config);
}
export function buildParamsTextFromPublicAPIRequest(
config: AutomationScriptPublicAPIConfig,
values: RunVariableInputs,
fallbackParamsText: string,
): { paramsText: string; missingRequired: string[]; usedVariables: string[] } {
const resolvedBody = applyAutomationScriptPublicAPIVariables(
config.requestBodyText,
config.variables,
values,
);
const body = parseJsonObjectText(resolvedBody.bodyText);
const fallbackParams = parseJsonObjectText(fallbackParamsText);
const requestParams =
config.requestMode === "params-only"
? body
: body.params && typeof body.params === "object" && !Array.isArray(body.params)
? (body.params as Record<string, unknown>)
: {};
const params =
Object.keys(requestParams).length > 0
? mergeJsonObjectValues(fallbackParams, requestParams)
: fallbackParams;
return {
paramsText: JSON.stringify(params, null, 2),
missingRequired: resolvedBody.missingRequired,
usedVariables: resolvedBody.usedVariables,
};
}
export function isCodeOnlySelectorForLaunchCode(
text: string,
launchCode: string,
): boolean {
const normalizedCode = normalizeLaunchCode(launchCode);
const normalizedText = text.trim();
if (!normalizedCode || !normalizedText) {
return false;
}
try {
const parsed = JSON.parse(normalizedText);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return false;
}
const entries = Object.entries(parsed as Record<string, unknown>).filter(
([, value]) => {
if (value == null) {
return false;
}
if (typeof value === "string") {
return value.trim() !== "";
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
},
);
if (entries.length !== 1 || entries[0]?.[0] !== "code") {
return false;
}
return normalizeLaunchCode(String(entries[0][1] || "")) === normalizedCode;
} catch {
return false;
}
}
export function resolveInitialSelectorText(
script: AutomationScriptRecord,
demoSession: AutomationDemoSession,
): string {
if (
script.targetConfig.mode !== "manual" &&
script.targetConfig.mode !== "existing"
) {
return "";
}
if (script.targetConfig.mode === "existing") {
const selectorCode = normalizeLaunchCode(script.targetConfig.selector.code);
if (selectorCode) {
return buildDemoSelectorText(selectorCode);
}
}
const currentSelectorText = String(script.selectorText || "");
if (
script.type === "playwright-cdp" &&
isPlaceholderSelectorText(currentSelectorText) &&
demoSession.launchCode
) {
return buildDemoSelectorText(demoSession.launchCode);
}
return currentSelectorText;
}
export function resolveRunnableSelectorText(
script: AutomationScriptRecord,
currentSelectorText: string,
demoSession: AutomationDemoSession,
): string {
if (
script.targetConfig.mode !== "manual" &&
script.targetConfig.mode !== "existing"
) {
return currentSelectorText;
}
if (
script.type === "playwright-cdp" &&
isPlaceholderSelectorText(currentSelectorText) &&
demoSession.launchCode
) {
return buildDemoSelectorText(demoSession.launchCode);
}
return currentSelectorText;
}
export function resolveSelectorLaunchCode(text: string): string {
const normalized = text.trim();
if (!normalized) {
return "";
}
try {
const parsed = JSON.parse(normalized);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "";
}
return String((parsed as Record<string, unknown>).code || "")
.trim()
.toUpperCase();
} catch {
return "";
}
}
export function filterSelectableProfiles(profiles: BrowserProfile[]): SelectableProfile[] {
return profiles
.flatMap((profile) => {
const launchCode = normalizeLaunchCode(profile.launchCode);
if (!launchCode) {
return [];
}
return [
{
...profile,
launchCode,
},
];
})
.sort((left, right) => {
if (left.running !== right.running) {
return left.running ? -1 : 1;
}
return left.profileName.localeCompare(right.profileName, "zh-CN");
});
}
export function resolvePreferredProfileId(
profiles: SelectableProfile[],
preferredProfileId: string,
preferredLaunchCode: string,
): string {
const normalizedProfileId = String(preferredProfileId || "").trim();
const normalizedCode = normalizeLaunchCode(preferredLaunchCode);
if (!normalizedProfileId && !normalizedCode) {
return "";
}
if (normalizedProfileId) {
const matchedByID = profiles.find(
(profile) => profile.profileId === normalizedProfileId,
);
if (matchedByID) {
return matchedByID.profileId;
}
}
const matchedByCode = profiles.find(
(profile) => normalizeLaunchCode(profile.launchCode) === normalizedCode,
);
if (matchedByCode) {
return matchedByCode.profileId;
}
return "";
}
export function buildSelectableProfileOptions(profiles: SelectableProfile[]) {
return profiles.map((profile) => ({
value: profile.profileId,
label: `${profile.launchCode} · ${profile.profileName} · ${formatSelectableProfileStatus(profile)}`,
}));
}
function formatSelectableProfileStatus(profile: SelectableProfile): string {
if (profile.running && profile.debugReady && profile.debugPort > 0) {
return "可连接";
}
if (profile.running) {
return "启动中";
}
return "未启动,执行时自动启动";
}
export function sortTemplateProfiles(profiles: BrowserProfile[]) {
return [...profiles].sort((left, right) =>
left.profileName.localeCompare(right.profileName, "zh-CN"),
);
}
export function buildTemplateProfileOptions(profiles: BrowserProfile[]) {
return profiles.map((profile) => ({
value: profile.profileId,
label: [profile.launchCode || "", profile.profileName || profile.profileId]
.filter(Boolean)
.join(" · "),
}));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import type { AutomationScriptRecord } from "../automationScripts";
import type { BrowserProfile } from "../types";
export type DemoPreparationMode = "select" | "create";
export type SelectableProfile = BrowserProfile & {
launchCode: string;
};
export interface DemoCreateDraft {
profileName: string;
templateProfileId: string;
}
export interface ResultOutputEntry {
key: string;
label: string;
path: string;
}
export interface AutomationScriptRunModalProps {
open: boolean;
script: AutomationScriptRecord | null;
dirty?: boolean;
onClose: () => void;
}
export type RunVariableInputs = Record<string, string>;
export const DEFAULT_DEMO_CREATE_DRAFT: DemoCreateDraft = {
profileName: "",
templateProfileId: "",
};
@@ -0,0 +1,404 @@
import type { Dispatch, SetStateAction } from "react";
import { FileText, Play } from "lucide-react";
import {
Badge,
Button,
FormItem,
Input,
Modal,
Textarea,
} from "../../../shared/components";
import {
describeAutomationScriptTargetConfig,
getAutomationScriptTypeLabel,
type AutomationScriptRecord,
type AutomationScriptRunRecord,
type AutomationScriptTargetSelector,
} from "../automationScripts";
import { TargetSelectorEditor } from "../pages/automationScriptDetail/shared";
import type { SelectorSuggestion } from "../pages/automationScriptDetail/helpers";
import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
import { AutomationScriptRunResultPanel } from "./AutomationScriptRunResultPanel";
import type { DemoCreateDraft, DemoPreparationMode, RunVariableInputs, SelectableProfile } from "./AutomationScriptRunModal.types";
import { formatDateTime } from "./AutomationScriptRunModal.helpers";
type Option = { value: string; label: string };
interface AutomationScriptRunModalViewProps {
open: boolean;
dirty: boolean;
script: AutomationScriptRecord;
running: boolean;
demoBusy: boolean;
launchApiExecutable: boolean;
showDemoProfilePicker: boolean;
isManualTargetMode: boolean;
usesStoredTargetConfig: boolean;
isDualInstanceRuntimeScript: boolean;
selectorDetachedFromSelectedProfile: boolean;
showsSelectorInput: boolean;
hasPublicAPIVariables: boolean;
hasUnusedPublicAPIVariables: boolean;
profilesLoading: boolean;
selectedProfileId: string;
selectedProfile: SelectableProfile | null;
selectedLaunchCode: string;
selectorText: string;
paramsText: string;
paramsFieldLabel: string;
paramsPlaceholder: string;
demoMode: DemoPreparationMode;
createDraft: DemoCreateDraft;
rotateSelector: AutomationScriptTargetSelector;
variableInputs: RunVariableInputs;
publicAPIVariables: Array<{ name: string; description?: string; defaultValue?: string }>;
selectableProfileOptions: Option[];
templateProfileOptions: Option[];
codeSuggestions: SelectorSuggestion[];
profileIdSuggestions: SelectorSuggestion[];
profileNameSuggestions: SelectorSuggestion[];
groupOptions: Option[];
lastRun: AutomationScriptRunRecord | null;
handleClose: () => void;
handleOpenScriptDetail: () => void;
handlePrimaryAction: () => Promise<void>;
handleSelectedProfileChange: (profileId: string) => void;
handleLaunchCodeChange: (code: string) => void;
handleRestoreSelectedProfileSelector: () => void;
handleSelectorTextChange: (value: string) => void;
handleOpenOutputPath: (path: string) => Promise<void>;
setCreateDraft: Dispatch<SetStateAction<DemoCreateDraft>>;
updateVariableInput: (name: string, value: string) => void;
updateParamsText: (value: string) => void;
updateRotateSelector: (patch: Partial<AutomationScriptTargetSelector>) => void;
}
export function AutomationScriptRunModalView({
open,
dirty,
script,
running,
demoBusy,
launchApiExecutable,
showDemoProfilePicker,
isManualTargetMode,
usesStoredTargetConfig,
isDualInstanceRuntimeScript,
selectorDetachedFromSelectedProfile,
showsSelectorInput,
hasPublicAPIVariables,
hasUnusedPublicAPIVariables,
profilesLoading,
selectedProfileId,
selectedProfile,
selectedLaunchCode,
selectorText,
paramsText,
paramsFieldLabel,
paramsPlaceholder,
demoMode,
createDraft,
rotateSelector,
variableInputs,
publicAPIVariables,
selectableProfileOptions,
templateProfileOptions,
codeSuggestions,
profileIdSuggestions,
profileNameSuggestions,
groupOptions,
lastRun,
handleClose,
handleOpenScriptDetail,
handlePrimaryAction,
handleSelectedProfileChange,
handleLaunchCodeChange,
handleRestoreSelectedProfileSelector,
handleSelectorTextChange,
handleOpenOutputPath,
setCreateDraft,
updateVariableInput,
updateParamsText,
updateRotateSelector,
}: AutomationScriptRunModalViewProps) {
return (
<Modal
open={open}
onClose={handleClose}
title="执行脚本"
width="880px"
footer={
<>
<Button
variant="secondary"
onClick={handleClose}
disabled={running || demoBusy}
>
</Button>
<Button
onClick={() => void handlePrimaryAction()}
loading={running}
disabled={!launchApiExecutable || demoBusy}
>
<Play className="h-4 w-4" />
</Button>
</>
}
>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--color-border-muted)] pb-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="max-w-[26rem] truncate text-sm font-semibold text-[var(--color-text-primary)]">
{script.name}
</div>
<span className="text-xs text-[var(--color-text-muted)]">
{formatDateTime(script.updatedAt)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge
variant={script.type === "launch-api" ? "info" : "default"}
size="sm"
>
{getAutomationScriptTypeLabel(script.type)}
</Badge>
<Badge
variant={
script.status === "ready"
? "success"
: script.status === "disabled"
? "default"
: "warning"
}
size="sm"
dot
>
{script.status === "ready"
? "可用"
: script.status === "disabled"
? "停用"
: "草稿"}
</Badge>
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleOpenScriptDetail}
disabled={running || demoBusy}
>
<FileText className="h-4 w-4" />
</Button>
</div>
{dirty && (
<div className="rounded-xl border border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10 px-4 py-3 text-sm text-[var(--color-text-secondary)]">
{isDualInstanceRuntimeScript
? "当前详情页还有未保存修改。本次执行只使用弹窗里的启动配置,不会自动保存页面内容。"
: "当前详情页还有未保存修改。本次执行只使用弹窗里的 selector / params,不会自动保存页面内容。"}
</div>
)}
{usesStoredTargetConfig && (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-4 py-3 text-sm text-[var(--color-text-secondary)]">
<div>
{describeAutomationScriptTargetConfig(script.targetConfig)}
</div>
<div className="mt-2 text-xs text-[var(--color-text-muted)]">
沿
</div>
</div>
)}
{showDemoProfilePicker && isManualTargetMode ? (
<AutomationInstanceSelector
title="传入实例"
mode="manual"
modes={["manual"]}
loading={profilesLoading}
disabled={running || demoBusy}
selectedCode={selectedLaunchCode}
selectedProfileId={selectedProfileId}
profileOptions={selectableProfileOptions}
selectPlaceholder="暂无可选实例"
codePlaceholder="例如 BUYER_001"
onCodeChange={handleLaunchCodeChange}
onSelectProfile={handleSelectedProfileChange}
extra={
selectorDetachedFromSelectedProfile ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
<span> selector JSON </span>
<Button
size="sm"
variant="secondary"
onClick={handleRestoreSelectedProfileSelector}
disabled={running || demoBusy || !selectedProfile}
>
</Button>
</div>
) : null
}
/>
) : null}
{script.targetConfig.mode === "create" ? (
<AutomationInstanceSelector
title="模板创建"
mode="create"
modes={["create"]}
loading={profilesLoading}
disabled={running || demoBusy}
createName={createDraft.profileName}
templateProfileId={createDraft.templateProfileId}
templateOptions={templateProfileOptions}
templatePlaceholder="暂无模板"
onCreateNameChange={(profileName) =>
setCreateDraft((current) => ({
...current,
profileName,
}))
}
onTemplateChange={(templateProfileId) =>
setCreateDraft((current) => ({
...current,
templateProfileId,
}))
}
/>
) : null}
{script.targetConfig.mode === "rotate" ? (
<AutomationInstanceSelector
title="条件轮询"
mode="rotate"
modes={["rotate"]}
disabled={running || demoBusy}
extra={
<TargetSelectorEditor
selector={rotateSelector}
onChange={updateRotateSelector}
codeSuggestions={codeSuggestions}
profileIdSuggestions={profileIdSuggestions}
profileNameSuggestions={profileNameSuggestions}
groupOptions={groupOptions}
disabled={running || demoBusy}
/>
}
/>
) : null}
{showDemoProfilePicker && !isManualTargetMode && demoMode === "select" ? (
<AutomationInstanceSelector
title="实例选择"
mode="select"
modes={["select"]}
loading={profilesLoading}
disabled={running || demoBusy}
selectedProfileId={selectedProfileId}
profileOptions={selectableProfileOptions}
selectPlaceholder="暂无可选实例"
hint="也可在下方手动填 selector。"
onSelectProfile={handleSelectedProfileChange}
extra={
selectorDetachedFromSelectedProfile ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
<span> selector JSON </span>
<Button
size="sm"
variant="secondary"
onClick={handleRestoreSelectedProfileSelector}
disabled={running || demoBusy}
>
</Button>
</div>
) : null
}
/>
) : null}
{script.status === "disabled" ? (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-4 py-4 text-sm text-[var(--color-text-secondary)]">
</div>
) : (
<div className="space-y-3">
{hasPublicAPIVariables ? (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
</div>
{hasUnusedPublicAPIVariables ? (
<div className="text-xs text-[var(--color-text-muted)]">
</div>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{publicAPIVariables.map((variable) => (
<FormItem key={variable.name} label={variable.name}>
<Input
value={variableInputs[variable.name] || ""}
onChange={(event) =>
updateVariableInput(variable.name, event.target.value)
}
placeholder={variable.description || variable.defaultValue}
className="h-10 rounded-lg"
disabled={running || demoBusy}
/>
</FormItem>
))}
</div>
</div>
) : null}
<div
className={
showsSelectorInput
? "grid grid-cols-1 gap-3 xl:grid-cols-2"
: "grid grid-cols-1 gap-3"
}
>
{showsSelectorInput && (
<FormItem label="目标选择器 JSON">
<Textarea
rows={hasPublicAPIVariables ? 6 : 9}
value={selectorText}
onChange={(event) => handleSelectorTextChange(event.target.value)}
className="font-mono text-xs"
placeholder='{"code":"DEMO_ABC123"}'
disabled={running || demoBusy}
/>
</FormItem>
)}
<FormItem label={paramsFieldLabel}>
<Textarea
rows={hasPublicAPIVariables ? 6 : 9}
value={paramsText}
onChange={(event) => updateParamsText(event.target.value)}
className="font-mono text-xs"
placeholder={paramsPlaceholder}
disabled={running || demoBusy}
/>
</FormItem>
</div>
</div>
)}
{lastRun && (
<AutomationScriptRunResultPanel
lastRun={lastRun}
handleOpenOutputPath={handleOpenOutputPath}
/>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,98 @@
import { Copy, FolderOpen } from "lucide-react";
import { Badge, Button, Textarea } from "../../../shared/components";
import type { AutomationScriptRunRecord } from "../automationScripts";
import {
copyToClipboard,
formatDateTime,
formatDuration,
formatRunResultOutputName,
formatRunResultText,
parseRunResultOutputs,
} from "./AutomationScriptRunModal.helpers";
interface AutomationScriptRunResultPanelProps {
lastRun: AutomationScriptRunRecord;
handleOpenOutputPath: (path: string) => Promise<void>;
}
export function AutomationScriptRunResultPanel({
lastRun,
handleOpenOutputPath,
}: AutomationScriptRunResultPanelProps) {
const resultOutputs = parseRunResultOutputs(lastRun.resultText);
const formattedResultText = formatRunResultText(lastRun.resultText);
return (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={lastRun.status === "success" ? "success" : "error"}
size="sm"
dot
>
{lastRun.status === "success" ? "执行成功" : "执行失败"}
</Badge>
<span className="text-sm text-[var(--color-text-primary)]">
{lastRun.summary || "执行已完成"}
</span>
</div>
<div className="text-xs text-[var(--color-text-muted)]">
{formatDateTime(lastRun.startedAt)} · {formatDuration(lastRun.durationMs)}
</div>
</div>
{lastRun.error && (
<div className="mt-3 break-all text-sm text-[var(--color-error)]">
{lastRun.error}
</div>
)}
{lastRun.resultText && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-[var(--color-text-muted)]"></div>
<Button
size="sm"
variant="secondary"
onClick={() => void copyToClipboard(formattedResultText, "执行结果已复制")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<Textarea rows={10} value={formattedResultText} readOnly className="font-mono" />
{resultOutputs.length > 0 && (
<div className="rounded-lg border border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)] px-3 py-3">
<div className="space-y-2">
{resultOutputs.map((output) => (
<div
key={`${output.key}-${output.path}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border-muted)] bg-[var(--color-bg-surface)] px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="text-sm text-[var(--color-text-primary)]">
{output.label} · {formatRunResultOutputName(output.path)}
</div>
<div className="mt-1 break-all text-xs text-[var(--color-text-muted)]">
{output.path}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void handleOpenOutputPath(output.path)}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,441 @@
import yaml from 'js-yaml'
import type { BrowserProxy } from '../types'
import { CHAIN_SOCKS5_PREFIX, type ChainImportForm, type ChainHopForm, type ChainSocks5Config, type ChainSocks5HopConfig, type ClashProxy, type DirectImportForm, type ImportCandidate, type ProxyDisplayInfo } from './ProxyImportModal.types'
function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return null
}
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) {
return null
}
const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
const portVal = Number(hop.port || 0)
if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
const username = String(hop.username || '').trim()
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
if (password && !username) return null
return {
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
password: password || undefined,
}
}
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as Record<string, unknown>
const first = normalizeHop(parsed.first)
const second = normalizeHop(parsed.second)
if (!first || !second) return null
const localPortRaw = parsed.localPort
const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
? 0
: Number(localPortRaw)
if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
return {
first,
second,
localPort: localPortNum > 0 ? localPortNum : undefined,
}
} catch {
return null
}
}
export function parseProxyInfo(proxyConfig: string): { type: string; server: string; port: number } {
const cfg = proxyConfig.trim()
if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 }
const chain = parseChainSocks5Config(cfg)
if (chain) {
return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 }
}
const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//)
if (urlMatch) {
const scheme = urlMatch[1].toLowerCase()
try {
const u = new URL(cfg)
return { type: scheme, server: u.hostname, port: parseInt(u.port) || 0 }
} catch {
return { type: scheme, server: '-', port: 0 }
}
}
try {
const parsed = yaml.load(cfg) as ClashProxy[] | ClashProxy
const proxy = Array.isArray(parsed) ? parsed[0] : parsed
return { type: proxy?.type || '-', server: proxy?.server || '-', port: proxy?.port || 0 }
} catch {
return { type: '-', server: '-', port: 0 }
}
}
function proxyToYaml(proxy: ClashProxy): string {
return yaml.dump([proxy], { flowLevel: -1, lineWidth: -1 }).trim()
}
function quoteYamlScalar(value: string): string {
const v = value.trim()
if (!v) return "''"
return `'${v.replace(/'/g, "''")}'`
}
function normalizeImportedProxyArray(payload: unknown): ClashProxy[] | null {
const asArray = (input: unknown): ClashProxy[] => {
if (!Array.isArray(input)) return []
return input.filter((item): item is ClashProxy => !!item && typeof item === 'object')
}
if (Array.isArray(payload)) {
return asArray(payload)
}
if (!payload || typeof payload !== 'object') {
return null
}
const record = payload as Record<string, unknown>
if (Array.isArray(record.proxies)) {
return asArray(record.proxies)
}
if (Array.isArray(record.proxy)) {
return asArray(record.proxy)
}
if (Array.isArray(record.Proxy)) {
return asArray(record.Proxy)
}
return null
}
function normalizeLooseClashImportText(raw: string): string {
const normalizedNewline = raw.replace(//g, '').replace(/\r\n/g, '\n').trim()
if (!normalizedNewline) return normalizedNewline
const lines = normalizedNewline.split('\n')
const fixedLines = lines.map(line => {
const m = line.match(/^(\s*)-\s*([^,{][^,]*?)\s*,\s*(type\s*:.*)$/i)
if (!m) return line
const indent = m[1] || ''
const name = m[2] || ''
const tail = m[3] || ''
return `${indent}- { name: ${quoteYamlScalar(name)}, ${tail.trim()} }`
})
const hasProxiesRoot = fixedLines.some(line => /^\s*proxies\s*:/.test(line))
if (hasProxiesRoot) {
return fixedLines.join('\n')
}
const looksLikeProxyList = fixedLines.some(line => /^\s*-\s*/.test(line))
if (!looksLikeProxyList) {
return fixedLines.join('\n')
}
const indented = fixedLines.map(line => {
if (!line.trim()) return line
return ` ${line}`
})
return `proxies:\n${indented.join('\n')}`
}
export function parseClashImportText(raw: string): ClashProxy[] {
const input = raw.trim()
if (!input) {
throw new Error('请输入 YAML 内容')
}
const attempts = [input]
const normalized = normalizeLooseClashImportText(input)
if (normalized && normalized !== input) {
attempts.push(normalized)
}
let lastError: unknown = null
for (const text of attempts) {
try {
const parsed = yaml.load(text)
const proxies = normalizeImportedProxyArray(parsed)
if (proxies) {
return proxies
}
} catch (error) {
lastError = error
}
}
if (lastError && typeof lastError === 'object' && lastError !== null && 'message' in lastError) {
throw new Error(String((lastError as { message?: string }).message || '解析失败'))
}
throw new Error('无效的 YAML 格式,需要包含 proxies 数组')
}
function normalizeDirectProxyConfig(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
if (/^socket:\/\//i.test(trimmed)) {
return trimmed.replace(/^socket:\/\//i, 'socks5://')
}
if (/^socks:\/\//i.test(trimmed)) {
return trimmed.replace(/^socks:\/\//i, 'socks5://')
}
return trimmed
}
function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string {
const name = rawName.trim()
const fallbackName = server
? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}`
: `导入代理 ${index + 1}`
const finalName = name || fallbackName
return prefix ? `${prefix}-${finalName}` : finalName
}
function formatDirectProxyHost(raw: string): string {
const host = raw.trim()
if (!host) return ''
if (host.startsWith('[') && host.endsWith(']')) {
return host
}
return host.includes(':') ? `[${host}]` : host
}
export function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
const serverInput = form.server.trim()
if (!serverInput) {
throw new Error('请输入代理地址')
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) {
throw new Error('代理地址只需要填写主机名或 IP,不需要协议头')
}
const portInput = form.port.trim()
if (!portInput) {
throw new Error('请输入代理端口')
}
if (!/^\d+$/.test(portInput)) {
throw new Error('代理端口必须为数字')
}
const port = Number(portInput)
if (port < 1 || port > 65535) {
throw new Error('代理端口必须在 1-65535 之间')
}
const username = form.username.trim()
const password = form.password
if (password && !username) {
throw new Error('填写密码时请同时填写账号')
}
const auth = username
? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: ''
const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}`
let parsedURL: URL
try {
parsedURL = new URL(rawConfig)
} catch {
throw new Error('请输入有效的代理地址')
}
if (!parsedURL.hostname) {
throw new Error('请输入有效的代理地址')
}
const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '')
const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1')
return {
proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''),
proxyConfig: normalizedConfig,
}
}
export function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
}
const portInput = hop.port.trim()
if (!portInput) {
throw new Error(`请输入${label}代理端口`)
}
if (!/^\d+$/.test(portInput)) {
throw new Error(`${label}代理端口必须为数字`)
}
const port = Number(portInput)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label}代理端口必须在 1-65535 之间`)
}
const username = hop.username.trim()
const password = hop.password
if (password && !username) {
throw new Error(`${label}填写密码时请同时填写账号`)
}
return {
protocol,
server,
port,
username: username || undefined,
password: password || undefined,
}
}
const localPortInput = form.localPort.trim()
if (localPortInput && !/^\d+$/.test(localPortInput)) {
throw new Error('本地监听端口必须为数字')
}
const localPort = localPortInput ? Number(localPortInput) : 0
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
throw new Error('本地监听端口必须在 1-65535 之间')
}
const payload: ChainSocks5Config = {
first: parseHop('第一层', form.first),
second: parseHop('第二层', form.second),
localPort: localPort > 0 ? localPort : undefined,
}
const encodedPayload = encodeURIComponent(JSON.stringify(payload))
const proxyConfig = `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
return {
proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
proxyConfig,
}
}
function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: string): string {
const rawName = (proxy.name || '').trim() || `导入代理 ${index + 1}`
return prefix ? `${prefix}-${rawName}` : rawName
}
export function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
return parsedProxies.map((proxy, index) => ({
proxyName: resolveImportedProxyName(proxy, index, prefix),
proxyConfig: proxyToYaml(proxy),
}))
}
export function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] {
return candidates.map((candidate, index) => {
const info = parseProxyInfo(candidate.proxyConfig)
return {
proxyId: `preview-${index}`,
proxyName: candidate.proxyName,
proxyConfig: candidate.proxyConfig,
groupName: candidate.groupName || groupName,
type: info.type || '-',
server: info.server || '-',
port: info.port || 0,
}
})
}
export function normalizeRefreshIntervalM(value: number): number {
if (!Number.isFinite(value)) return 0
if (value <= 0) return 0
if (value < 5) return 5
if (value > 24 * 60) return 24 * 60
return Math.round(value)
}
function normalizeSourceURL(sourceURL: string): string {
const raw = (sourceURL || '').trim()
if (!raw) return ''
try {
const parsed = new URL(raw)
parsed.hash = ''
return parsed.toString()
} catch {
return raw
}
}
function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string {
const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}`
let hash = 5381
for (let i = 0; i < key.length; i += 1) {
hash = ((hash << 5) + hash) ^ key.charCodeAt(i)
}
const unsigned = hash >>> 0
return `src-${unsigned.toString(36)}`
}
export function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string {
const normalizedURL = normalizeSourceURL(sourceURL)
const normalizedPrefix = sourceNamePrefix.trim()
const existing = list.find(item =>
normalizeSourceURL(item.sourceUrl || '') === normalizedURL &&
(item.sourceNamePrefix || '').trim() === normalizedPrefix &&
(item.sourceId || '').trim() !== ''
)
if (existing?.sourceId?.trim()) {
return existing.sourceId.trim()
}
return buildStableSourceID(sourceURL, sourceNamePrefix)
}
export function nextProxyID(): string {
return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
const exactMap = new Map<string, BrowserProxy[]>()
const nameMap = new Map<string, BrowserProxy[]>()
oldSourceProxies.forEach(item => {
const exactKey = `${item.proxyName}|||${item.proxyConfig}`
const exactList = exactMap.get(exactKey) || []
exactList.push(item)
exactMap.set(exactKey, exactList)
const nameKey = item.proxyName
const nameList = nameMap.get(nameKey) || []
nameList.push(item)
nameMap.set(nameKey, nameList)
})
return (name: string, configText: string): string | null => {
const exactKey = `${name}|||${configText}`
const exactList = exactMap.get(exactKey)
if (exactList && exactList.length > 0) {
const item = exactList.shift()
if (item?.proxyId) return item.proxyId
}
const nameList = nameMap.get(name)
if (nameList && nameList.length > 0) {
const item = nameList.shift()
if (item?.proxyId) return item.proxyId
}
return null
}
}
@@ -1,557 +1,31 @@
import { useEffect, useMemo, useState } from 'react'
import yaml from 'js-yaml'
import { Button, FormItem, Input, Modal, Select, Table, Textarea, toast } from '../../../shared/components'
import { useEffect, useMemo, useState } from 'react'
import { Button, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProxy } from '../types'
import { fetchClashImportFromURL, saveBrowserProxies } from '../api'
import { DIRECT_QUICK_IMPORT_TEMPLATE, buildDirectImportCandidatesFromText, parseDirectImportText } from '../pages/proxyPool/helpers'
interface ProxyImportModalProps {
open: boolean
onClose: () => void
existingProxies: BrowserProxy[]
groups: string[]
globalAutoRefreshEnabled?: boolean
globalRefreshIntervalM?: number
onImported?: (newProxies: BrowserProxy[]) => void | Promise<void>
}
interface ClashProxy {
name: string
type: string
server: string
port: number
[key: string]: any
}
type ProxyImportMode = 'clash' | 'direct' | 'chain'
interface DirectImportForm {
proxyName: string
protocol: 'http' | 'https' | 'socks5'
server: string
port: string
username: string
password: string
}
interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
password: string
}
interface ChainImportForm {
proxyName: string
localPort: string
first: ChainHopForm
second: ChainHopForm
}
const DIRECT_PROXY_PROTOCOL_OPTIONS = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
proxyName: '',
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
}
const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
proxyName: '',
localPort: '',
first: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
second: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
}
interface ImportCandidate {
proxyName: string
proxyConfig: string
groupName?: string
}
interface ProxyDisplayInfo {
proxyId: string
proxyName: string
proxyConfig: string
groupName: string
type: string
server: string
port: number
}
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
interface ChainSocks5HopConfig {
protocol: 'http' | 'socks5'
server: string
port: number
username?: string
password?: string
}
interface ChainSocks5Config {
localPort?: number
first: ChainSocks5HopConfig
second: ChainSocks5HopConfig
}
function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return null
}
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) {
return null
}
const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
const portVal = Number(hop.port || 0)
if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
const username = String(hop.username || '').trim()
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
if (password && !username) return null
return {
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
password: password || undefined,
}
}
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as Record<string, unknown>
const first = normalizeHop(parsed.first)
const second = normalizeHop(parsed.second)
if (!first || !second) return null
const localPortRaw = parsed.localPort
const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
? 0
: Number(localPortRaw)
if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
return {
first,
second,
localPort: localPortNum > 0 ? localPortNum : undefined,
}
} catch {
return null
}
}
function parseProxyInfo(proxyConfig: string): { type: string; server: string; port: number } {
const cfg = proxyConfig.trim()
if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 }
const chain = parseChainSocks5Config(cfg)
if (chain) {
return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 }
}
const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//)
if (urlMatch) {
const scheme = urlMatch[1].toLowerCase()
try {
const u = new URL(cfg)
return { type: scheme, server: u.hostname, port: parseInt(u.port) || 0 }
} catch {
return { type: scheme, server: '-', port: 0 }
}
}
try {
const parsed = yaml.load(cfg) as ClashProxy[] | ClashProxy
const proxy = Array.isArray(parsed) ? parsed[0] : parsed
return { type: proxy?.type || '-', server: proxy?.server || '-', port: proxy?.port || 0 }
} catch {
return { type: '-', server: '-', port: 0 }
}
}
function proxyToYaml(proxy: ClashProxy): string {
return yaml.dump([proxy], { flowLevel: -1, lineWidth: -1 }).trim()
}
function quoteYamlScalar(value: string): string {
const v = value.trim()
if (!v) return "''"
return `'${v.replace(/'/g, "''")}'`
}
function normalizeImportedProxyArray(payload: unknown): ClashProxy[] | null {
const asArray = (input: unknown): ClashProxy[] => {
if (!Array.isArray(input)) return []
return input.filter((item): item is ClashProxy => !!item && typeof item === 'object')
}
if (Array.isArray(payload)) {
return asArray(payload)
}
if (!payload || typeof payload !== 'object') {
return null
}
const record = payload as Record<string, unknown>
if (Array.isArray(record.proxies)) {
return asArray(record.proxies)
}
if (Array.isArray(record.proxy)) {
return asArray(record.proxy)
}
if (Array.isArray(record.Proxy)) {
return asArray(record.Proxy)
}
return null
}
function normalizeLooseClashImportText(raw: string): string {
const normalizedNewline = raw.replace(//g, '').replace(/\r\n/g, '\n').trim()
if (!normalizedNewline) return normalizedNewline
const lines = normalizedNewline.split('\n')
const fixedLines = lines.map(line => {
const m = line.match(/^(\s*)-\s*([^,{][^,]*?)\s*,\s*(type\s*:.*)$/i)
if (!m) return line
const indent = m[1] || ''
const name = m[2] || ''
const tail = m[3] || ''
return `${indent}- { name: ${quoteYamlScalar(name)}, ${tail.trim()} }`
})
const hasProxiesRoot = fixedLines.some(line => /^\s*proxies\s*:/.test(line))
if (hasProxiesRoot) {
return fixedLines.join('\n')
}
const looksLikeProxyList = fixedLines.some(line => /^\s*-\s*/.test(line))
if (!looksLikeProxyList) {
return fixedLines.join('\n')
}
const indented = fixedLines.map(line => {
if (!line.trim()) return line
return ` ${line}`
})
return `proxies:\n${indented.join('\n')}`
}
function parseClashImportText(raw: string): ClashProxy[] {
const input = raw.trim()
if (!input) {
throw new Error('请输入 YAML 内容')
}
const attempts = [input]
const normalized = normalizeLooseClashImportText(input)
if (normalized && normalized !== input) {
attempts.push(normalized)
}
let lastError: unknown = null
for (const text of attempts) {
try {
const parsed = yaml.load(text)
const proxies = normalizeImportedProxyArray(parsed)
if (proxies) {
return proxies
}
} catch (error) {
lastError = error
}
}
if (lastError && typeof lastError === 'object' && lastError !== null && 'message' in lastError) {
throw new Error(String((lastError as { message?: string }).message || '解析失败'))
}
throw new Error('无效的 YAML 格式,需要包含 proxies 数组')
}
function normalizeDirectProxyConfig(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
if (/^socket:\/\//i.test(trimmed)) {
return trimmed.replace(/^socket:\/\//i, 'socks5://')
}
if (/^socks:\/\//i.test(trimmed)) {
return trimmed.replace(/^socks:\/\//i, 'socks5://')
}
return trimmed
}
function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string {
const name = rawName.trim()
const fallbackName = server
? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}`
: `导入代理 ${index + 1}`
const finalName = name || fallbackName
return prefix ? `${prefix}-${finalName}` : finalName
}
function formatDirectProxyHost(raw: string): string {
const host = raw.trim()
if (!host) return ''
if (host.startsWith('[') && host.endsWith(']')) {
return host
}
return host.includes(':') ? `[${host}]` : host
}
function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
const serverInput = form.server.trim()
if (!serverInput) {
throw new Error('请输入代理地址')
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) {
throw new Error('代理地址只需要填写主机名或 IP,不需要协议头')
}
const portInput = form.port.trim()
if (!portInput) {
throw new Error('请输入代理端口')
}
if (!/^\d+$/.test(portInput)) {
throw new Error('代理端口必须为数字')
}
const port = Number(portInput)
if (port < 1 || port > 65535) {
throw new Error('代理端口必须在 1-65535 之间')
}
const username = form.username.trim()
const password = form.password
if (password && !username) {
throw new Error('填写密码时请同时填写账号')
}
const auth = username
? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: ''
const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}`
let parsedURL: URL
try {
parsedURL = new URL(rawConfig)
} catch {
throw new Error('请输入有效的代理地址')
}
if (!parsedURL.hostname) {
throw new Error('请输入有效的代理地址')
}
const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '')
const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1')
return {
proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''),
proxyConfig: normalizedConfig,
}
}
function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
}
const portInput = hop.port.trim()
if (!portInput) {
throw new Error(`请输入${label}代理端口`)
}
if (!/^\d+$/.test(portInput)) {
throw new Error(`${label}代理端口必须为数字`)
}
const port = Number(portInput)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label}代理端口必须在 1-65535 之间`)
}
const username = hop.username.trim()
const password = hop.password
if (password && !username) {
throw new Error(`${label}填写密码时请同时填写账号`)
}
return {
protocol,
server,
port,
username: username || undefined,
password: password || undefined,
}
}
const localPortInput = form.localPort.trim()
if (localPortInput && !/^\d+$/.test(localPortInput)) {
throw new Error('本地监听端口必须为数字')
}
const localPort = localPortInput ? Number(localPortInput) : 0
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
throw new Error('本地监听端口必须在 1-65535 之间')
}
const payload: ChainSocks5Config = {
first: parseHop('第一层', form.first),
second: parseHop('第二层', form.second),
localPort: localPort > 0 ? localPort : undefined,
}
const encodedPayload = encodeURIComponent(JSON.stringify(payload))
const proxyConfig = `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
return {
proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
proxyConfig,
}
}
function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: string): string {
const rawName = (proxy.name || '').trim() || `导入代理 ${index + 1}`
return prefix ? `${prefix}-${rawName}` : rawName
}
function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
return parsedProxies.map((proxy, index) => ({
proxyName: resolveImportedProxyName(proxy, index, prefix),
proxyConfig: proxyToYaml(proxy),
}))
}
function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] {
return candidates.map((candidate, index) => {
const info = parseProxyInfo(candidate.proxyConfig)
return {
proxyId: `preview-${index}`,
proxyName: candidate.proxyName,
proxyConfig: candidate.proxyConfig,
groupName: candidate.groupName || groupName,
type: info.type || '-',
server: info.server || '-',
port: info.port || 0,
}
})
}
function normalizeRefreshIntervalM(value: number): number {
if (!Number.isFinite(value)) return 0
if (value <= 0) return 0
if (value < 5) return 5
if (value > 24 * 60) return 24 * 60
return Math.round(value)
}
function normalizeSourceURL(sourceURL: string): string {
const raw = (sourceURL || '').trim()
if (!raw) return ''
try {
const parsed = new URL(raw)
parsed.hash = ''
return parsed.toString()
} catch {
return raw
}
}
function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string {
const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}`
let hash = 5381
for (let i = 0; i < key.length; i += 1) {
hash = ((hash << 5) + hash) ^ key.charCodeAt(i)
}
const unsigned = hash >>> 0
return `src-${unsigned.toString(36)}`
}
function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string {
const normalizedURL = normalizeSourceURL(sourceURL)
const normalizedPrefix = sourceNamePrefix.trim()
const existing = list.find(item =>
normalizeSourceURL(item.sourceUrl || '') === normalizedURL &&
(item.sourceNamePrefix || '').trim() === normalizedPrefix &&
(item.sourceId || '').trim() !== ''
)
if (existing?.sourceId?.trim()) {
return existing.sourceId.trim()
}
return buildStableSourceID(sourceURL, sourceNamePrefix)
}
function nextProxyID(): string {
return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
const exactMap = new Map<string, BrowserProxy[]>()
const nameMap = new Map<string, BrowserProxy[]>()
oldSourceProxies.forEach(item => {
const exactKey = `${item.proxyName}|||${item.proxyConfig}`
const exactList = exactMap.get(exactKey) || []
exactList.push(item)
exactMap.set(exactKey, exactList)
const nameKey = item.proxyName
const nameList = nameMap.get(nameKey) || []
nameList.push(item)
nameMap.set(nameKey, nameList)
})
return (name: string, configText: string): string | null => {
const exactKey = `${name}|||${configText}`
const exactList = exactMap.get(exactKey)
if (exactList && exactList.length > 0) {
const item = exactList.shift()
if (item?.proxyId) return item.proxyId
}
const nameList = nameMap.get(name)
if (nameList && nameList.length > 0) {
const item = nameList.shift()
if (item?.proxyId) return item.proxyId
}
return null
}
}
import {
INITIAL_CHAIN_IMPORT_FORM,
INITIAL_DIRECT_IMPORT_FORM,
type ChainHopForm,
type ChainImportForm,
type DirectImportForm,
type ProxyDisplayInfo,
type ProxyImportModalProps,
type ProxyImportMode,
} from './ProxyImportModal.types'
import {
buildChainImportCandidate,
buildDirectImportCandidate,
buildImportCandidatesFromClash,
buildImportPreview,
createExistingProxyIDPicker,
parseClashImportText,
nextProxyID,
normalizeRefreshIntervalM,
resolveImportSourceID,
} from './ProxyImportModal.helpers'
import { ProxyImportModalView } from './ProxyImportModalView'
export function ProxyImportModal({
open,
@@ -797,336 +271,44 @@ export function ProxyImportModal({
], [])
return (
<>
<Modal
open={open}
onClose={onClose}
title="导入代理配置"
width="600px"
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={fetchingImportUrl}></Button>
<Button onClick={handleParseImport} disabled={fetchingImportUrl || !canParseImport}></Button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<Button
variant={importMode === 'clash' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('clash')}
>
Clash / YAML
</Button>
<Button
variant={importMode === 'direct' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('direct')}
>
HTTP / SOCKS5
</Button>
<Button
variant={importMode === 'chain' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('chain')}
>
</Button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
<>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<Input
value={importUrl}
onChange={e => {
const next = e.target.value
setImportUrl(next)
if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
setImportResolvedUrl('')
}
}}
placeholder="订阅 URL"
className="flex-1"
/>
<Button
variant="secondary"
onClick={handleFetchImportURL}
loading={fetchingImportUrl}
disabled={!importUrl.trim()}
>
URL
</Button>
</div>
{importResolvedUrl.trim() && (
<p className="text-xs text-[var(--color-success)] mt-1 break-all">
{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"> YAML DNS </p>
</FormItem>
<Textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={12}
placeholder={`proxies:\n - name: vless-v6\n type: vless\n server: example.com\n port: 443\n uuid: your-uuid\n ...`}
/>
</>
)}
{importMode === 'direct' && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
<FormItem label="文本辅助(可选)" hint="支持单个 JSON、JSON 数组,或多行 http:// / https:// / socks5://,每行一个">
<Textarea
value={directImportText}
onChange={e => setDirectImportText(e.target.value)}
rows={8}
placeholder={DIRECT_QUICK_IMPORT_TEMPLATE}
/>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={handleFillDirectTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={() => void handleCopyDirectTemplate()}>
</Button>
<Button size="sm" variant="secondary" onClick={handleApplyDirectText} disabled={!directImportText.trim()}>
</Button>
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
</p>
</FormItem>
</div>
)}
{importMode === 'chain' && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理名称(可选)">
<Input
value={chainImportForm.proxyName}
onChange={e => setChainImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="链路名称"
/>
</FormItem>
<FormItem label="本地监听端口(可选)">
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.localPort}
onChange={e => setChainImportForm(prev => ({ ...prev, localPort: e.target.value }))}
placeholder="留空自动分配"
/>
</FormItem>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.first.protocol}
onChange={e => updateChainHop('first', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.first.server}
onChange={e => updateChainHop('first', 'server', e.target.value)}
placeholder="例如:s1.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.first.port}
onChange={e => updateChainHop('first', 'port', e.target.value)}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainImportForm.first.username}
onChange={e => updateChainHop('first', 'username', e.target.value)}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainImportForm.first.password}
onChange={e => updateChainHop('first', 'password', e.target.value)}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.second.protocol}
onChange={e => updateChainHop('second', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.second.server}
onChange={e => updateChainHop('second', 'server', e.target.value)}
placeholder="例如:s2.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.second.port}
onChange={e => updateChainHop('second', 'port', e.target.value)}
placeholder="例如:1081"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainImportForm.second.username}
onChange={e => updateChainHop('second', 'username', e.target.value)}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainImportForm.second.password}
onChange={e => updateChainHop('second', 'password', e.target.value)}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
</div>
</div>
)}
<FormItem label="分组名称(可选)">
<Input
value={importGroupName}
onChange={e => setImportGroupName(e.target.value)}
placeholder="分组名称"
list="proxy-groups-datalist"
/>
{groups.length > 0 && (
<datalist id="proxy-groups-datalist">
{groups.map(g => <option key={g} value={g} />)}
</datalist>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"></p>
</FormItem>
{importMode === 'clash' && (
<FormItem label="名称前缀(可选)">
<Input
value={importNamePrefix}
onChange={e => setImportNamePrefix(e.target.value)}
placeholder="例如:HK、US、机场A"
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
<code className="px-1 bg-[var(--color-bg-secondary)] rounded">-</code>
</p>
</FormItem>
)}
{importMode === 'clash' && (
<FormItem label="批量 DNS 配置(可选)">
<Textarea value={importDnsServers} onChange={e => setImportDnsServers(e.target.value)} rows={5}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
<p className="text-xs text-[var(--color-text-muted)] mt-1"> DNS</p>
</FormItem>
)}
</div>
</Modal>
<Modal
open={previewModalOpen}
onClose={() => setPreviewModalOpen(false)}
title="确认导入以下代理"
width="700px"
footer={
<>
<Button variant="secondary" onClick={() => setPreviewModalOpen(false)}></Button>
<Button onClick={handleConfirmImport} loading={importing} disabled={previewList.length === 0}></Button>
</>
}
>
<div className="space-y-3">
{importMode === 'clash' && importDnsServers.trim() && (
<p className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-secondary)] px-3 py-2 rounded"> DNS</p>
)}
<Table columns={previewColumns} data={previewList} rowKey="proxyId" maxHeight="380px" emptyText="无代理数据" />
</div>
</Modal>
</>
<ProxyImportModalView
open={open}
onClose={onClose}
fetchingImportUrl={fetchingImportUrl}
canParseImport={canParseImport}
importMode={importMode}
importUrl={importUrl}
importResolvedUrl={importResolvedUrl}
importText={importText}
importDnsServers={importDnsServers}
importNamePrefix={importNamePrefix}
importGroupName={importGroupName}
directImportText={directImportText}
directImportForm={directImportForm}
chainImportForm={chainImportForm}
groups={groups}
previewModalOpen={previewModalOpen}
previewList={previewList}
importing={importing}
previewColumns={previewColumns}
onParseImport={handleParseImport}
onImportModeChange={handleImportModeChange}
onImportUrlChange={setImportUrl}
onImportResolvedUrlChange={setImportResolvedUrl}
onFetchImportURL={handleFetchImportURL}
onImportTextChange={setImportText}
onImportDnsServersChange={setImportDnsServers}
onImportNamePrefixChange={setImportNamePrefix}
onImportGroupNameChange={setImportGroupName}
onDirectImportTextChange={setDirectImportText}
onDirectImportFormChange={setDirectImportForm}
onChainImportFormChange={setChainImportForm}
onUpdateChainHop={updateChainHop}
onFillDirectTemplate={handleFillDirectTemplate}
onCopyDirectTemplate={handleCopyDirectTemplate}
onApplyDirectText={handleApplyDirectText}
onPreviewModalOpenChange={setPreviewModalOpen}
onConfirmImport={handleConfirmImport}
/>
)
}
@@ -0,0 +1,112 @@
import type { BrowserProxy } from '../types'
export interface ProxyImportModalProps {
open: boolean
onClose: () => void
existingProxies: BrowserProxy[]
groups: string[]
globalAutoRefreshEnabled?: boolean
globalRefreshIntervalM?: number
onImported?: (newProxies: BrowserProxy[]) => void | Promise<void>
}
export interface ClashProxy {
name: string
type: string
server: string
port: number
[key: string]: any
}
export type ProxyImportMode = 'clash' | 'direct' | 'chain'
export interface DirectImportForm {
proxyName: string
protocol: 'http' | 'https' | 'socks5'
server: string
port: string
username: string
password: string
}
export interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
password: string
}
export interface ChainImportForm {
proxyName: string
localPort: string
first: ChainHopForm
second: ChainHopForm
}
export const DIRECT_PROXY_PROTOCOL_OPTIONS = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
export const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
proxyName: '',
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
}
export const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
proxyName: '',
localPort: '',
first: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
second: {
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
},
}
export interface ImportCandidate {
proxyName: string
proxyConfig: string
groupName?: string
}
export interface ProxyDisplayInfo {
proxyId: string
proxyName: string
proxyConfig: string
groupName: string
type: string
server: string
port: number
}
export const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
export interface ChainSocks5HopConfig {
protocol: 'http' | 'socks5'
server: string
port: number
username?: string
password?: string
}
export interface ChainSocks5Config {
localPort?: number
first: ChainSocks5HopConfig
second: ChainSocks5HopConfig
}
@@ -0,0 +1,425 @@
import { Button, FormItem, Input, Modal, Select, Table, Textarea } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import { DIRECT_QUICK_IMPORT_TEMPLATE } from '../pages/proxyPool/helpers'
import {
DIRECT_PROXY_PROTOCOL_OPTIONS,
type ChainHopForm,
type ChainImportForm,
type DirectImportForm,
type ProxyDisplayInfo,
type ProxyImportMode,
} from './ProxyImportModal.types'
interface ProxyImportModalViewProps {
open: boolean
onClose: () => void
fetchingImportUrl: boolean
canParseImport: boolean
importMode: ProxyImportMode
importUrl: string
importResolvedUrl: string
importText: string
importDnsServers: string
importNamePrefix: string
importGroupName: string
directImportText: string
directImportForm: DirectImportForm
chainImportForm: ChainImportForm
groups: string[]
previewModalOpen: boolean
previewList: ProxyDisplayInfo[]
importing: boolean
previewColumns: TableColumn<ProxyDisplayInfo>[]
onParseImport: () => void
onImportModeChange: (mode: ProxyImportMode) => void
onImportUrlChange: (value: string) => void
onImportResolvedUrlChange: (value: string) => void
onFetchImportURL: () => Promise<void>
onImportTextChange: (value: string) => void
onImportDnsServersChange: (value: string) => void
onImportNamePrefixChange: (value: string) => void
onImportGroupNameChange: (value: string) => void
onDirectImportTextChange: (value: string) => void
onDirectImportFormChange: import("react").Dispatch<import("react").SetStateAction<DirectImportForm>>
onChainImportFormChange: import("react").Dispatch<import("react").SetStateAction<ChainImportForm>>
onUpdateChainHop: (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => void
onFillDirectTemplate: () => void
onCopyDirectTemplate: () => Promise<void>
onApplyDirectText: () => void
onPreviewModalOpenChange: (open: boolean) => void
onConfirmImport: () => Promise<void>
}
export function ProxyImportModalView({
open,
onClose,
fetchingImportUrl,
canParseImport,
importMode,
importUrl,
importResolvedUrl,
importText,
importDnsServers,
importNamePrefix,
importGroupName,
directImportText,
directImportForm,
chainImportForm,
groups,
previewModalOpen,
previewList,
importing,
previewColumns,
onParseImport,
onImportModeChange,
onImportUrlChange,
onImportResolvedUrlChange,
onFetchImportURL,
onImportTextChange,
onImportDnsServersChange,
onImportNamePrefixChange,
onImportGroupNameChange,
onDirectImportTextChange,
onDirectImportFormChange,
onChainImportFormChange,
onUpdateChainHop,
onFillDirectTemplate,
onCopyDirectTemplate,
onApplyDirectText,
onPreviewModalOpenChange,
onConfirmImport,
}: ProxyImportModalViewProps) {
return (
<>
<Modal
open={open}
onClose={onClose}
title="导入代理配置"
width="600px"
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={fetchingImportUrl}></Button>
<Button onClick={onParseImport} disabled={fetchingImportUrl || !canParseImport}></Button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<Button
variant={importMode === 'clash' ? undefined : 'secondary'}
onClick={() => onImportModeChange('clash')}
>
Clash / YAML
</Button>
<Button
variant={importMode === 'direct' ? undefined : 'secondary'}
onClick={() => onImportModeChange('direct')}
>
HTTP / SOCKS5
</Button>
<Button
variant={importMode === 'chain' ? undefined : 'secondary'}
onClick={() => onImportModeChange('chain')}
>
</Button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
{importMode === 'clash'
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups'
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,也支持 JSON 或多行标准代理文本批量导入,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
<>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<Input
value={importUrl}
onChange={e => {
const next = e.target.value
onImportUrlChange(next)
if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
onImportResolvedUrlChange('')
}
}}
placeholder="订阅 URL"
className="flex-1"
/>
<Button
variant="secondary"
onClick={onFetchImportURL}
loading={fetchingImportUrl}
disabled={!importUrl.trim()}
>
URL
</Button>
</div>
{importResolvedUrl.trim() && (
<p className="text-xs text-[var(--color-success)] mt-1 break-all">
{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"> YAML DNS </p>
</FormItem>
<Textarea
value={importText}
onChange={e => onImportTextChange(e.target.value)}
rows={12}
placeholder={`proxies:\n - name: vless-v6\n type: vless\n server: example.com\n port: 443\n uuid: your-uuid\n ...`}
/>
</>
)}
{importMode === 'direct' && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={e => onDirectImportFormChange(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={e => onDirectImportFormChange(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={e => onDirectImportFormChange(prev => ({ ...prev, server: e.target.value }))}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={e => onDirectImportFormChange(prev => ({ ...prev, port: e.target.value }))}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={e => onDirectImportFormChange(prev => ({ ...prev, username: e.target.value }))}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={e => onDirectImportFormChange(prev => ({ ...prev, password: e.target.value }))}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
<FormItem label="文本辅助(可选)" hint="支持单个 JSON、JSON 数组,或多行 http:// / https:// / socks5://,每行一个">
<Textarea
value={directImportText}
onChange={e => onDirectImportTextChange(e.target.value)}
rows={8}
placeholder={DIRECT_QUICK_IMPORT_TEMPLATE}
/>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={onFillDirectTemplate}>
</Button>
<Button size="sm" variant="secondary" onClick={() => void onCopyDirectTemplate()}>
</Button>
<Button size="sm" variant="secondary" onClick={onApplyDirectText} disabled={!directImportText.trim()}>
</Button>
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
</p>
</FormItem>
</div>
)}
{importMode === 'chain' && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理名称(可选)">
<Input
value={chainImportForm.proxyName}
onChange={e => onChainImportFormChange(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="链路名称"
/>
</FormItem>
<FormItem label="本地监听端口(可选)">
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.localPort}
onChange={e => onChainImportFormChange(prev => ({ ...prev, localPort: e.target.value }))}
placeholder="留空自动分配"
/>
</FormItem>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.first.protocol}
onChange={e => onUpdateChainHop('first', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.first.server}
onChange={e => onUpdateChainHop('first', 'server', e.target.value)}
placeholder="例如:s1.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.first.port}
onChange={e => onUpdateChainHop('first', 'port', e.target.value)}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainImportForm.first.username}
onChange={e => onUpdateChainHop('first', 'username', e.target.value)}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainImportForm.first.password}
onChange={e => onUpdateChainHop('first', 'password', e.target.value)}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainImportForm.second.protocol}
onChange={e => onUpdateChainHop('second', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={chainImportForm.second.server}
onChange={e => onUpdateChainHop('second', 'server', e.target.value)}
placeholder="例如:s2.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={chainImportForm.second.port}
onChange={e => onUpdateChainHop('second', 'port', e.target.value)}
placeholder="例如:1081"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={chainImportForm.second.username}
onChange={e => onUpdateChainHop('second', 'username', e.target.value)}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={chainImportForm.second.password}
onChange={e => onUpdateChainHop('second', 'password', e.target.value)}
placeholder="留空则不使用密码"
/>
</FormItem>
</div>
</div>
</div>
)}
<FormItem label="分组名称(可选)">
<Input
value={importGroupName}
onChange={e => onImportGroupNameChange(e.target.value)}
placeholder="分组名称"
list="proxy-groups-datalist"
/>
{groups.length > 0 && (
<datalist id="proxy-groups-datalist">
{groups.map(g => <option key={g} value={g} />)}
</datalist>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1"></p>
</FormItem>
{importMode === 'clash' && (
<FormItem label="名称前缀(可选)">
<Input
value={importNamePrefix}
onChange={e => onImportNamePrefixChange(e.target.value)}
placeholder="例如:HK、US、机场A"
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
<code className="px-1 bg-[var(--color-bg-secondary)] rounded">-</code>
</p>
</FormItem>
)}
{importMode === 'clash' && (
<FormItem label="批量 DNS 配置(可选)">
<Textarea value={importDnsServers} onChange={e => onImportDnsServersChange(e.target.value)} rows={5}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
<p className="text-xs text-[var(--color-text-muted)] mt-1"> DNS</p>
</FormItem>
)}
</div>
</Modal>
<Modal
open={previewModalOpen}
onClose={() => onPreviewModalOpenChange(false)}
title="确认导入以下代理"
width="700px"
footer={
<>
<Button variant="secondary" onClick={() => onPreviewModalOpenChange(false)}></Button>
<Button onClick={onConfirmImport} loading={importing} disabled={previewList.length === 0}></Button>
</>
}
>
<div className="space-y-3">
{importMode === 'clash' && importDnsServers.trim() && (
<p className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-secondary)] px-3 py-2 rounded"> DNS</p>
)}
<Table columns={previewColumns} data={previewList} rowKey="proxyId" maxHeight="380px" emptyText="无代理数据" />
</div>
</Modal>
</>
)
}
@@ -0,0 +1,155 @@
import type { Dispatch, SetStateAction } from 'react'
import { Button, FormItem, Input, Modal, Select, Textarea } from '../../../shared/components'
import type { ChainEditForm, ChainHopForm } from './ProxyPickerModal.helpers'
interface ProxyEditModalProps {
open: boolean
chainEditMode: boolean
editName: string
editConfig: string
editGroup: string
editDnsServers: string
chainEditForm: ChainEditForm
saving: boolean
setEditName: Dispatch<SetStateAction<string>>
setEditConfig: Dispatch<SetStateAction<string>>
setEditGroup: Dispatch<SetStateAction<string>>
setEditDnsServers: Dispatch<SetStateAction<string>>
setChainEditForm: Dispatch<SetStateAction<ChainEditForm>>
updateChainHop: (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => void
onClose: () => void
onSave: () => void
}
export function ProxyEditModal({
open,
chainEditMode,
editName,
editConfig,
editGroup,
editDnsServers,
chainEditForm,
saving,
setEditName,
setEditConfig,
setEditGroup,
setEditDnsServers,
setChainEditForm,
updateChainHop,
onClose,
onSave,
}: ProxyEditModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title="编辑代理"
width="520px"
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}></Button>
<Button onClick={onSave} loading={saving}></Button>
</>
}
>
<div className="space-y-3">
<FormItem label="代理名称" required>
<Input
value={chainEditMode ? chainEditForm.proxyName : editName}
onChange={e => {
if (chainEditMode) {
setChainEditForm(prev => ({ ...prev, proxyName: e.target.value }))
} else {
setEditName(e.target.value)
}
}}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="分组名称(可选)">
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="分组名称" />
</FormItem>
{chainEditMode ? (
<div className="space-y-3 rounded-md border border-[var(--color-border)] p-3">
<FormItem label="本地监听端口(可选)">
<Input
type="number"
min={1}
max={65535}
value={chainEditForm.localPort}
onChange={e => setChainEditForm(prev => ({ ...prev, localPort: e.target.value }))}
placeholder="留空自动分配"
/>
</FormItem>
<ChainHopSection title="第一层代理" hop="first" form={chainEditForm} updateChainHop={updateChainHop} />
<ChainHopSection title="第二层代理" hop="second" form={chainEditForm} updateChainHop={updateChainHop} />
</div>
) : (
<FormItem label="代理配置" required>
<Textarea
value={editConfig}
onChange={e => setEditConfig(e.target.value)}
rows={6}
placeholder="支持 http://、https://、socks5://、chain+socks5://"
/>
</FormItem>
)}
<FormItem label="DNS 服务器(可选)">
<Textarea
value={editDnsServers}
onChange={e => setEditDnsServers(e.target.value)}
rows={4}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`}
/>
</FormItem>
</div>
</Modal>
)
}
function ChainHopSection({
title,
hop,
form,
updateChainHop,
}: {
title: string
hop: 'first' | 'second'
form: ChainEditForm
updateChainHop: (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => void
}) {
const hopForm = form[hop]
return (
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">{title}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={hopForm.protocol}
onChange={e => updateChainHop(hop, 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input value={hopForm.server} onChange={e => updateChainHop(hop, 'server', e.target.value)} />
</FormItem>
<FormItem label="代理端口" required>
<Input type="number" min={1} max={65535} value={hopForm.port} onChange={e => updateChainHop(hop, 'port', e.target.value)} />
</FormItem>
<FormItem label="账号(可选)">
<Input value={hopForm.username} onChange={e => updateChainHop(hop, 'username', e.target.value)} />
</FormItem>
<FormItem label="密码(可选)">
<Input type="password" value={hopForm.password} onChange={e => updateChainHop(hop, 'password', e.target.value)} />
</FormItem>
</div>
</div>
)
}
@@ -0,0 +1,201 @@
export type SpeedResult = { ok: boolean; latencyMs: number; error: string }
export type ChainSocksHop = {
protocol?: 'http' | 'socks5'
server?: string
port?: number
username?: string
password?: string
}
export type ChainSocksConfig = {
localPort?: number
first?: ChainSocksHop
second?: ChainSocksHop
}
export interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
password: string
}
export interface ChainEditForm {
proxyName: string
localPort: string
first: ChainHopForm
second: ChainHopForm
}
export const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
proxyName: '',
localPort: '',
first: { protocol: 'http', server: '', port: '', username: '', password: '' },
second: { protocol: 'http', server: '', port: '', username: '', password: '' },
}
export const ALL_GROUP = '__all__'
export const DIRECT_PROXY_ID = '__direct__'
export const SPEED_RESULT_EVENT = 'proxy:speed:result'
export const BATCH_TEST_CONCURRENCY = 20
export const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
export function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return null
}
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) {
return null
}
const normalizeHop = (raw: unknown): ChainSocksHop | null => {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
const portVal = Number(hop.port || 0)
if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
const username = String(hop.username || '').trim()
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
if (password && !username) return null
return {
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
password: password || undefined,
}
}
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as Record<string, unknown>
const first = normalizeHop(parsed.first)
const second = normalizeHop(parsed.second)
if (!first || !second) return null
const localPortRaw = parsed.localPort
const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
? 0
: Number(localPortRaw)
if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
return {
first,
second,
localPort: localPortNum > 0 ? localPortNum : undefined,
}
} catch {
return null
}
}
export function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditForm {
return {
proxyName,
localPort: cfg.localPort ? String(cfg.localPort) : '',
first: {
protocol: cfg.first?.protocol || 'socks5',
server: cfg.first?.server || '',
port: cfg.first?.port ? String(cfg.first.port) : '',
username: cfg.first?.username || '',
password: cfg.first?.password || '',
},
second: {
protocol: cfg.second?.protocol || 'socks5',
server: cfg.second?.server || '',
port: cfg.second?.port ? String(cfg.second.port) : '',
username: cfg.second?.username || '',
password: cfg.second?.password || '',
},
}
}
export function buildChainProxyConfig(form: ChainEditForm): string {
const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
}
const portInput = hop.port.trim()
if (!portInput) {
throw new Error(`请输入${label}代理端口`)
}
if (!/^\d+$/.test(portInput)) {
throw new Error(`${label}代理端口必须为数字`)
}
const port = Number(portInput)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label}代理端口必须在 1-65535 之间`)
}
const username = hop.username.trim()
const password = hop.password
if (password && !username) {
throw new Error(`${label}填写密码时请同时填写账号`)
}
return {
protocol,
server,
port,
username: username || undefined,
password: password || undefined,
}
}
const localPortInput = form.localPort.trim()
if (localPortInput && !/^\d+$/.test(localPortInput)) {
throw new Error('本地监听端口必须为数字')
}
const localPort = localPortInput ? Number(localPortInput) : 0
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
throw new Error('本地监听端口必须在 1-65535 之间')
}
const payload: ChainSocksConfig = {
first: parseHop('第一层', form.first),
second: parseHop('第二层', form.second),
localPort: localPort > 0 ? localPort : undefined,
}
const encodedPayload = encodeURIComponent(JSON.stringify(payload))
return `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
}
export function formatProxyConfigForDisplay(proxyConfig: string): string {
const raw = (proxyConfig || '').trim()
if (!raw || !raw.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return raw
}
const encoded = raw.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) return raw
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as ChainSocksConfig
const firstServer = (parsed.first?.server || '').trim()
const secondServer = (parsed.second?.server || '').trim()
if (!firstServer || !secondServer) return raw
return `${firstServer} -> ${secondServer}`
} catch {
return raw
}
}
@@ -0,0 +1,89 @@
import { Check, Loader2, Pencil, Trash2, Wifi } from 'lucide-react'
import type { BrowserProxy } from '../types'
import { DIRECT_PROXY_ID, type SpeedResult } from './ProxyPickerModal.helpers'
export function GroupItem({ label, active, count, onClick }: { label: string; active: boolean; count: number; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${
active
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
}`}
>
<span className="truncate">{label}</span>
<span className="text-xs opacity-60 shrink-0">{count}</span>
</button>
)
}
interface ProxyRowProps {
proxy: BrowserProxy
selected: boolean
testing: boolean
speedResult?: SpeedResult
displayConfig: string
onSelect: () => void
onTest: (e: React.MouseEvent) => void
onEdit: (e: React.MouseEvent) => void
onDelete: (e: React.MouseEvent) => void
}
function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
if (testing) return <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--color-text-muted)] shrink-0" />
if (!result) return null
if (!result.ok) return <span className="text-xs text-red-500 shrink-0"></span>
const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium shrink-0 ${color}`}>{result.latencyMs}ms</span>
}
export function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
const isDirect = proxy.proxyId === DIRECT_PROXY_ID
const disableDelete = isDirect
return (
<div
onClick={onSelect}
className={`w-full px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors border-b border-[var(--color-border)]/40 last:border-0 overflow-hidden ${
selected ? 'bg-[var(--color-primary)]/10' : 'hover:bg-[var(--color-bg-hover)]'
}`}
>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{proxy.proxyName || proxy.proxyId}
{proxy.groupName && <span className="ml-2 text-xs text-[var(--color-primary)]/70 font-normal">[{proxy.groupName}]</span>}
</div>
<div className="text-xs text-[var(--color-text-muted)] truncate mt-0.5 w-0 min-w-full">
{displayConfig}
</div>
</div>
<SpeedBadge testing={testing} result={speedResult} />
<button
onClick={onTest}
disabled={testing}
title="测速"
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-40 transition-colors"
>
<Wifi className="w-3.5 h-3.5" />
</button>
<button
onClick={onEdit}
disabled={isDirect}
title={isDirect ? '直连不可编辑' : '编辑代理'}
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
disabled={disableDelete}
title={isDirect ? '直连不可删除' : '删除代理'}
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{selected && <Check className="w-4 h-4 text-[var(--color-primary)] shrink-0" />}
</div>
)
}
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, Loader2, Pencil, Plus, Search, Trash2, Wifi, X } from 'lucide-react'
import { Button, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components'
import { Plus, Search, Wifi, X } from 'lucide-react'
import { ConfirmModal, toast } from '../../../shared/components'
import type { BrowserProxy } from '../types'
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies } from '../api'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
import { ProxyImportModal } from './ProxyImportModal'
import { ProxyEditModal } from './ProxyPickerModal.edit'
import { GroupItem, ProxyRow } from './ProxyPickerModal.rows'
import { ALL_GROUP, BATCH_TEST_CONCURRENCY, DIRECT_PROXY_ID, INITIAL_CHAIN_EDIT_FORM, SPEED_RESULT_EVENT, buildChainProxyConfig, formatProxyConfigForDisplay, parseChainSocks5Config, toChainEditForm, type ChainEditForm, type ChainHopForm, type SpeedResult } from './ProxyPickerModal.helpers'
interface ProxyPickerModalProps {
open: boolean
@@ -16,208 +19,6 @@ interface ProxyPickerModalProps {
onProxyDeleted?: (deletedProxyId: string, nextProxies: BrowserProxy[]) => void
}
type SpeedResult = { ok: boolean; latencyMs: number; error: string }
type ChainSocksHop = {
protocol?: 'http' | 'socks5'
server?: string
port?: number
username?: string
password?: string
}
type ChainSocksConfig = {
localPort?: number
first?: ChainSocksHop
second?: ChainSocksHop
}
interface ChainHopForm {
protocol: 'http' | 'socks5'
server: string
port: string
username: string
password: string
}
interface ChainEditForm {
proxyName: string
localPort: string
first: ChainHopForm
second: ChainHopForm
}
const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
proxyName: '',
localPort: '',
first: { protocol: 'http', server: '', port: '', username: '', password: '' },
second: { protocol: 'http', server: '', port: '', username: '', password: '' },
}
function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return null
}
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) {
return null
}
const normalizeHop = (raw: unknown): ChainSocksHop | null => {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<string, unknown>
const protocol = String(hop.protocol || '').trim().toLowerCase()
if (protocol && protocol !== 'socks5' && protocol !== 'http') return null
const server = String(hop.server || '').trim()
if (!server) return null
const portVal = Number(hop.port || 0)
if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null
const username = String(hop.username || '').trim()
const password = hop.password === undefined || hop.password === null ? '' : String(hop.password)
if (password && !username) return null
return {
protocol: protocol === 'http' ? 'http' : 'socks5',
server,
port: portVal,
username: username || undefined,
password: password || undefined,
}
}
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as Record<string, unknown>
const first = normalizeHop(parsed.first)
const second = normalizeHop(parsed.second)
if (!first || !second) return null
const localPortRaw = parsed.localPort
const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === ''
? 0
: Number(localPortRaw)
if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null
return {
first,
second,
localPort: localPortNum > 0 ? localPortNum : undefined,
}
} catch {
return null
}
}
function toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditForm {
return {
proxyName,
localPort: cfg.localPort ? String(cfg.localPort) : '',
first: {
protocol: cfg.first?.protocol || 'socks5',
server: cfg.first?.server || '',
port: cfg.first?.port ? String(cfg.first.port) : '',
username: cfg.first?.username || '',
password: cfg.first?.password || '',
},
second: {
protocol: cfg.second?.protocol || 'socks5',
server: cfg.second?.server || '',
port: cfg.second?.port ? String(cfg.second.port) : '',
username: cfg.second?.username || '',
password: cfg.second?.password || '',
},
}
}
function buildChainProxyConfig(form: ChainEditForm): string {
const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
const protocol = hop.protocol === 'socks5' ? 'socks5' : 'http'
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
}
const portInput = hop.port.trim()
if (!portInput) {
throw new Error(`请输入${label}代理端口`)
}
if (!/^\d+$/.test(portInput)) {
throw new Error(`${label}代理端口必须为数字`)
}
const port = Number(portInput)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label}代理端口必须在 1-65535 之间`)
}
const username = hop.username.trim()
const password = hop.password
if (password && !username) {
throw new Error(`${label}填写密码时请同时填写账号`)
}
return {
protocol,
server,
port,
username: username || undefined,
password: password || undefined,
}
}
const localPortInput = form.localPort.trim()
if (localPortInput && !/^\d+$/.test(localPortInput)) {
throw new Error('本地监听端口必须为数字')
}
const localPort = localPortInput ? Number(localPortInput) : 0
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
throw new Error('本地监听端口必须在 1-65535 之间')
}
const payload: ChainSocksConfig = {
first: parseHop('第一层', form.first),
second: parseHop('第二层', form.second),
localPort: localPort > 0 ? localPort : undefined,
}
const encodedPayload = encodeURIComponent(JSON.stringify(payload))
return `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
}
const ALL_GROUP = '__all__'
const DIRECT_PROXY_ID = '__direct__'
const SPEED_RESULT_EVENT = 'proxy:speed:result'
const BATCH_TEST_CONCURRENCY = 20
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
function formatProxyConfigForDisplay(proxyConfig: string): string {
const raw = (proxyConfig || '').trim()
if (!raw || !raw.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return raw
}
const encoded = raw.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) return raw
try {
const decoded = decodeURIComponent(encoded)
const parsed = JSON.parse(decoded) as ChainSocksConfig
const firstServer = (parsed.first?.server || '').trim()
const secondServer = (parsed.second?.server || '').trim()
if (!firstServer || !secondServer) return raw
return `${firstServer} -> ${secondServer}`
} catch {
return raw
}
}
export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onProxyListUpdated, onProxyDeleted }: ProxyPickerModalProps) {
const [groups, setGroups] = useState<string[]>([])
const [allProxies, setAllProxies] = useState<BrowserProxy[]>([])
@@ -618,128 +419,24 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
onImported={handleImported}
/>
<Modal
<ProxyEditModal
open={!!editingProxy}
chainEditMode={chainEditMode}
editName={editName}
editConfig={editConfig}
editGroup={editGroup}
editDnsServers={editDnsServers}
chainEditForm={chainEditForm}
saving={savingEdit}
setEditName={setEditName}
setEditConfig={setEditConfig}
setEditGroup={setEditGroup}
setEditDnsServers={setEditDnsServers}
setChainEditForm={setChainEditForm}
updateChainHop={updateChainHop}
onClose={closeEditModal}
title="编辑代理"
width="520px"
footer={
<>
<Button variant="secondary" onClick={closeEditModal} disabled={savingEdit}></Button>
<Button onClick={handleSaveEdit} loading={savingEdit}></Button>
</>
}
>
<div className="space-y-3">
<FormItem label="代理名称" required>
<Input
value={chainEditMode ? chainEditForm.proxyName : editName}
onChange={e => {
if (chainEditMode) {
setChainEditForm(prev => ({ ...prev, proxyName: e.target.value }))
} else {
setEditName(e.target.value)
}
}}
placeholder="节点名称"
/>
</FormItem>
<FormItem label="分组名称(可选)">
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="分组名称" />
</FormItem>
{chainEditMode ? (
<div className="space-y-3 rounded-md border border-[var(--color-border)] p-3">
<FormItem label="本地监听端口(可选)">
<Input
type="number"
min={1}
max={65535}
value={chainEditForm.localPort}
onChange={e => setChainEditForm(prev => ({ ...prev, localPort: e.target.value }))}
placeholder="留空自动分配"
/>
</FormItem>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainEditForm.first.protocol}
onChange={e => updateChainHop('first', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input value={chainEditForm.first.server} onChange={e => updateChainHop('first', 'server', e.target.value)} />
</FormItem>
<FormItem label="代理端口" required>
<Input type="number" min={1} max={65535} value={chainEditForm.first.port} onChange={e => updateChainHop('first', 'port', e.target.value)} />
</FormItem>
<FormItem label="账号(可选)">
<Input value={chainEditForm.first.username} onChange={e => updateChainHop('first', 'username', e.target.value)} />
</FormItem>
<FormItem label="密码(可选)">
<Input type="password" value={chainEditForm.first.password} onChange={e => updateChainHop('first', 'password', e.target.value)} />
</FormItem>
</div>
</div>
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
<h4 className="text-sm font-medium text-[var(--color-text-primary)]"></h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="协议">
<Select
value={chainEditForm.second.protocol}
onChange={e => updateChainHop('second', 'protocol', e.target.value)}
options={[
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
]}
/>
</FormItem>
<FormItem label="代理地址" required>
<Input value={chainEditForm.second.server} onChange={e => updateChainHop('second', 'server', e.target.value)} />
</FormItem>
<FormItem label="代理端口" required>
<Input type="number" min={1} max={65535} value={chainEditForm.second.port} onChange={e => updateChainHop('second', 'port', e.target.value)} />
</FormItem>
<FormItem label="账号(可选)">
<Input value={chainEditForm.second.username} onChange={e => updateChainHop('second', 'username', e.target.value)} />
</FormItem>
<FormItem label="密码(可选)">
<Input type="password" value={chainEditForm.second.password} onChange={e => updateChainHop('second', 'password', e.target.value)} />
</FormItem>
</div>
</div>
</div>
) : (
<FormItem label="代理配置" required>
<Textarea
value={editConfig}
onChange={e => setEditConfig(e.target.value)}
rows={6}
placeholder="支持 http://、https://、socks5://、chain+socks5://"
/>
</FormItem>
)}
<FormItem label="DNS 服务器(可选)">
<Textarea
value={editDnsServers}
onChange={e => setEditDnsServers(e.target.value)}
rows={4}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`}
/>
</FormItem>
</div>
</Modal>
onSave={handleSaveEdit}
/>
<ConfirmModal
open={!!deleteCandidate}
onClose={() => setDeleteCandidate(null)}
@@ -755,88 +452,3 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onPr
)
}
function GroupItem({ label, active, count, onClick }: { label: string; active: boolean; count: number; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${
active
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
}`}
>
<span className="truncate">{label}</span>
<span className="text-xs opacity-60 shrink-0">{count}</span>
</button>
)
}
interface ProxyRowProps {
proxy: BrowserProxy
selected: boolean
testing: boolean
speedResult?: SpeedResult
displayConfig: string
onSelect: () => void
onTest: (e: React.MouseEvent) => void
onEdit: (e: React.MouseEvent) => void
onDelete: (e: React.MouseEvent) => void
}
function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
if (testing) return <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--color-text-muted)] shrink-0" />
if (!result) return null
if (!result.ok) return <span className="text-xs text-red-500 shrink-0"></span>
const color = result.latencyMs < 200 ? 'text-green-500' : result.latencyMs < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium shrink-0 ${color}`}>{result.latencyMs}ms</span>
}
function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
const isDirect = proxy.proxyId === DIRECT_PROXY_ID
const disableDelete = isDirect
return (
<div
onClick={onSelect}
className={`w-full px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors border-b border-[var(--color-border)]/40 last:border-0 overflow-hidden ${
selected ? 'bg-[var(--color-primary)]/10' : 'hover:bg-[var(--color-bg-hover)]'
}`}
>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{proxy.proxyName || proxy.proxyId}
{proxy.groupName && <span className="ml-2 text-xs text-[var(--color-primary)]/70 font-normal">[{proxy.groupName}]</span>}
</div>
<div className="text-xs text-[var(--color-text-muted)] truncate mt-0.5 w-0 min-w-full">
{displayConfig}
</div>
</div>
<SpeedBadge testing={testing} result={speedResult} />
<button
onClick={onTest}
disabled={testing}
title="测速"
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-40 transition-colors"
>
<Wifi className="w-3.5 h-3.5" />
</button>
<button
onClick={onEdit}
disabled={isDirect}
title={isDirect ? '直连不可编辑' : '编辑代理'}
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
disabled={disableDelete}
title={isDirect ? '直连不可删除' : '删除代理'}
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{selected && <Check className="w-4 h-4 text-[var(--color-primary)] shrink-0" />}
</div>
)
}
@@ -0,0 +1,47 @@
import type { BrowserProfile } from '../types'
export interface ProfileTagSection {
tag: string
items: BrowserProfile[]
}
export interface GroupFilterOption {
id: string
name: string
count: number
}
export const UNTAGGED_LABEL = '未打标签'
export const GROUP_ALL = '__all__'
export const GROUP_UNGROUPED = '__ungrouped__'
export function normalizeText(v?: string): string {
return (v || '').trim().toLowerCase()
}
export function normalizeCode(v?: string): string {
return normalizeText(v).toUpperCase()
}
export function buildSearchText(profile: BrowserProfile): string {
return [
profile.profileName,
profile.launchCode || '',
...(profile.tags || []),
...(profile.keywords || []),
]
.join(' ')
.toLowerCase()
}
export function sortProfiles(a: BrowserProfile, b: BrowserProfile): number {
if (a.running !== b.running) {
return a.running ? -1 : 1
}
return a.profileName.localeCompare(b.profileName, 'zh-CN')
}
export function pickPrimaryTag(profile: BrowserProfile): string {
const tags = (profile.tags || []).map(t => t.trim()).filter(Boolean)
return tags.length > 0 ? tags[0] : UNTAGGED_LABEL
}

Some files were not shown because too many files have changed in this diff Show More