mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: migrate automation public api updates
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <noreply@tm.openai.com></p>
|
||||
<p>To: target@example.com</p>
|
||||
<p>Hello,</p>
|
||||
<p>Your verification code is 429792.</p>
|
||||
<p>Please use this code to continue signing in.</p>
|
||||
<p>Best regards</p>
|
||||
<p>ChatGPT</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
|
||||
const normalizeText = (value) => String(value == null ? '' : value).trim()
|
||||
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
|
||||
? Math.max(5000, Math.round(Number(params.timeoutMs)))
|
||||
: 45000
|
||||
const inboxUrl = normalizeText(params.inboxUrl)
|
||||
|
||||
if (!inboxUrl) {
|
||||
throw new Error('inboxUrl is required')
|
||||
}
|
||||
|
||||
const session = await launch({
|
||||
selector,
|
||||
skipDefaultStartUrls: true,
|
||||
startUrls: [inboxUrl],
|
||||
})
|
||||
const connection = await connect(session, { timeoutMs })
|
||||
const browser = connection.browser
|
||||
if (!browser) {
|
||||
throw new Error('browser connection is unavailable')
|
||||
}
|
||||
|
||||
const context =
|
||||
connection.context ||
|
||||
browser.contexts()[0] ||
|
||||
(typeof browser.newContext === 'function' ? await browser.newContext() : null)
|
||||
if (!context) {
|
||||
throw new Error('browser context is unavailable')
|
||||
}
|
||||
|
||||
const opened = await openPage(connection, {
|
||||
url: inboxUrl,
|
||||
timeoutMs,
|
||||
permissions: ['notifications'],
|
||||
})
|
||||
const page = opened.page
|
||||
await page.waitForLoadState('networkidle', {
|
||||
timeout: Math.min(timeoutMs, 2500),
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
||||
const article = document.querySelector('article')
|
||||
const lines = Array.from(document.querySelectorAll('article p'))
|
||||
.map((node) => normalizeText(node.textContent))
|
||||
.filter(Boolean)
|
||||
const subject = normalizeText(document.querySelector('article h1')?.textContent)
|
||||
const fromLine = lines.find((line) => line.startsWith('From:')) || ''
|
||||
const toLine = lines.find((line) => line.startsWith('To:')) || ''
|
||||
const articleText = normalizeText(article?.textContent)
|
||||
const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*</)
|
||||
const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
|
||||
const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
|
||||
const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
|
||||
const signature = lines.slice(-2).join('\n')
|
||||
|
||||
return {
|
||||
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
|
||||
notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
|
||||
mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
|
||||
senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
|
||||
recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
|
||||
subject,
|
||||
verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
|
||||
signature,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
|
||||
permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
|
||||
summary: '已提取测试邮件内容',
|
||||
...result,
|
||||
}
|
||||
}`
|
||||
|
||||
var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
|
||||
|
||||
var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
|
||||
automationHTTPMailProbeScriptTextRaw,
|
||||
"summary: 'mail probe extracted message',",
|
||||
)
|
||||
@@ -1,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 <noreply@tm.openai.com></p>
|
||||
<p>To: target@example.com</p>
|
||||
<p>Hello,</p>
|
||||
<p>Your verification code is 429792.</p>
|
||||
<p>Please use this code to continue signing in.</p>
|
||||
<p>Best regards</p>
|
||||
<p>ChatGPT</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
|
||||
const normalizeText = (value) => String(value == null ? '' : value).trim()
|
||||
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
|
||||
? Math.max(5000, Math.round(Number(params.timeoutMs)))
|
||||
: 45000
|
||||
const inboxUrl = normalizeText(params.inboxUrl)
|
||||
|
||||
if (!inboxUrl) {
|
||||
throw new Error('inboxUrl is required')
|
||||
}
|
||||
|
||||
const session = await launch({
|
||||
selector,
|
||||
skipDefaultStartUrls: true,
|
||||
startUrls: [inboxUrl],
|
||||
})
|
||||
const connection = await connect(session, { timeoutMs })
|
||||
const browser = connection.browser
|
||||
if (!browser) {
|
||||
throw new Error('browser connection is unavailable')
|
||||
}
|
||||
|
||||
const context =
|
||||
connection.context ||
|
||||
browser.contexts()[0] ||
|
||||
(typeof browser.newContext === 'function' ? await browser.newContext() : null)
|
||||
if (!context) {
|
||||
throw new Error('browser context is unavailable')
|
||||
}
|
||||
|
||||
const opened = await openPage(connection, {
|
||||
url: inboxUrl,
|
||||
timeoutMs,
|
||||
permissions: ['notifications'],
|
||||
})
|
||||
const page = opened.page
|
||||
await page.waitForLoadState('networkidle', {
|
||||
timeout: Math.min(timeoutMs, 2500),
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
||||
const article = document.querySelector('article')
|
||||
const lines = Array.from(document.querySelectorAll('article p'))
|
||||
.map((node) => normalizeText(node.textContent))
|
||||
.filter(Boolean)
|
||||
const subject = normalizeText(document.querySelector('article h1')?.textContent)
|
||||
const fromLine = lines.find((line) => line.startsWith('From:')) || ''
|
||||
const toLine = lines.find((line) => line.startsWith('To:')) || ''
|
||||
const articleText = normalizeText(article?.textContent)
|
||||
const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*</)
|
||||
const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
|
||||
const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
|
||||
const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
|
||||
const signature = lines.slice(-2).join('\n')
|
||||
|
||||
return {
|
||||
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
|
||||
notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
|
||||
mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
|
||||
senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
|
||||
recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
|
||||
subject,
|
||||
verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
|
||||
signature,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
|
||||
permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
|
||||
summary: '已提取测试邮件内容',
|
||||
...result,
|
||||
}
|
||||
}`
|
||||
|
||||
var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
|
||||
|
||||
var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
|
||||
automationHTTPMailProbeScriptTextRaw,
|
||||
"summary: 'mail probe extracted message',",
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+48
-56
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
|
||||
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
|
||||
t.Fatalf("instance 转换错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
|
||||
t.Fatalf("targetInput 转换错误: %s", targetInput)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Status != "success" {
|
||||
t.Fatalf("hook 响应错误: %+v", resp)
|
||||
}
|
||||
if resp.Data["verificationCode"] != "429792" {
|
||||
t.Fatalf("expected data payload, got %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookSupportsInstanceModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
expectedMode string
|
||||
expectedSelector bool
|
||||
expectedInput string
|
||||
useScriptTarget bool
|
||||
}{
|
||||
{
|
||||
name: "rotate",
|
||||
body: `{
|
||||
"instance":{"type":"rotate","selector":{"groupId":"group-a","tags":["chatgpt"]}},
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
expectedMode: "rotate",
|
||||
expectedInput: `"groupId":"group-a"`,
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
body: `{
|
||||
"instance":{"type":"create","templateSelector":{"code":"TEMPLATE_001"},"createNameTemplate":"ChatGPT-Image-${timestamp}"},
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
expectedMode: "create",
|
||||
expectedInput: `"createNameTemplate":"ChatGPT-Image-${timestamp}"`,
|
||||
},
|
||||
{
|
||||
name: "script-default",
|
||||
body: `{
|
||||
"instance":{"type":"script-default"},
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
useScriptTarget: true,
|
||||
},
|
||||
{
|
||||
name: "legacy-code",
|
||||
body: `{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`,
|
||||
expectedSelector: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-first-message",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.TargetMode != tc.expectedMode {
|
||||
t.Fatalf("targetMode 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector != tc.useScriptTarget {
|
||||
t.Fatalf("useScriptSelector 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if tc.expectedInput != "" {
|
||||
targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput)
|
||||
if !strings.Contains(targetInput, tc.expectedInput) {
|
||||
t.Fatalf("targetInput 转换错误: %s", targetInput)
|
||||
}
|
||||
}
|
||||
if tc.expectedSelector && starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("legacy selectorText 转换错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-result-only",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 45000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-hook-2",
|
||||
ScriptID: "proton-mail-first-message",
|
||||
ScriptName: "Proton 邮件搜索并读取最新邮件",
|
||||
Status: "success",
|
||||
Summary: "已返回最新命中邮件内容",
|
||||
ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 60000 {
|
||||
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
|
||||
t.Fatalf("expected data payload, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "grok-image-generate-download",
|
||||
Name: "Grok 生成图片并下载",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/grok-generate-download",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 300000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-grok-image",
|
||||
ScriptID: "grok-image-generate-download",
|
||||
ScriptName: "Grok 生成图片并下载",
|
||||
Status: "success",
|
||||
Summary: "Grok 图片已生成并下载",
|
||||
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
|
||||
t.Fatalf("下载字段缺失: %+v", resp)
|
||||
}
|
||||
if _, exists := data["steps"]; exists {
|
||||
t.Fatalf("不应返回冗余 steps: %+v", resp)
|
||||
}
|
||||
if _, exists := data["runId"]; exists {
|
||||
t.Fatalf("不应返回 runId: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "image-generate",
|
||||
Name: "图片生成",
|
||||
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/generate",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 300000,
|
||||
RequestBodyText: `{"instance":{"type":"existing","selector":{"code":"{{code}}"}},"params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
|
||||
Variables: []automation.ScriptPublicAPIVariable{
|
||||
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
|
||||
{Name: "outputFileName", DefaultValue: "generated-image.png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.TargetMode != "existing" || starter.lastRunRequest.SelectorText != "" {
|
||||
t.Fatalf("变量模板应生成 instance target: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if targetInput := automationRunTargetInputJSON(t, starter.lastRunRequest.TargetInput); !strings.Contains(targetInput, `"code":"BUYER_001"`) {
|
||||
t.Fatalf("变量模板 targetInput 错误: %s", targetInput)
|
||||
}
|
||||
|
||||
var params map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil {
|
||||
t.Fatalf("解析 paramsText 失败: %v", err)
|
||||
}
|
||||
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
|
||||
t.Fatalf("变量未映射到 params: %+v", params)
|
||||
}
|
||||
selectors, ok := params["selectors"].(map[string]interface{})
|
||||
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
|
||||
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if params["timeoutMs"] != float64(300000) {
|
||||
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 300000 {
|
||||
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "disabled-hook",
|
||||
Name: "Disabled Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: false,
|
||||
Path: "mail/disabled-hook",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook",
|
||||
Name: "Strict Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
|
||||
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp.OK || resp.Error.Code != "invalid_request" {
|
||||
t.Fatalf("错误 envelope 不正确: %+v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook-timeout",
|
||||
Name: "Strict Hook Timeout",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook-timeout",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/automation"
|
||||
)
|
||||
|
||||
func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.runs = []automation.ScriptRunRecord{
|
||||
{ID: "run-1", ScriptID: "script-a", Status: "success"},
|
||||
{ID: "run-2", ScriptID: "script-b", Status: "failed"},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunListLimit != 1 {
|
||||
t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Count int `json:"count"`
|
||||
Items []automation.ScriptRunRecord `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
|
||||
t.Fatalf("runs 响应错误: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
|
||||
t.Run("invalid-json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selector-must-be-object", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"selector":"BUYER_001"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout-must-be-in-range", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"timeoutMs":1800001
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -83,6 +83,15 @@ func (m *mockAutomationStarter) AutomationScriptRunList(limit int) ([]automation
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func automationRunTargetInputJSON(t *testing.T, value interface{}) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal targetInput failed: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
@@ -311,443 +320,3 @@ func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testi
|
||||
t.Fatalf("缺省时不应透传 selectorText/paramsText: %+v", starter.lastRunRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-first-message",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-hook-1",
|
||||
ScriptID: "proton-mail-first-message",
|
||||
ScriptName: "Proton 邮件搜索并读取最新邮件",
|
||||
Status: "success",
|
||||
Summary: "已返回最新命中邮件内容",
|
||||
ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
|
||||
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Status != "success" {
|
||||
t.Fatalf("hook 响应错误: %+v", resp)
|
||||
}
|
||||
if resp.Data["verificationCode"] != "429792" {
|
||||
t.Fatalf("expected data payload, got %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-result-only",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 45000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-hook-2",
|
||||
ScriptID: "proton-mail-first-message",
|
||||
ScriptName: "Proton 邮件搜索并读取最新邮件",
|
||||
Status: "success",
|
||||
Summary: "已返回最新命中邮件内容",
|
||||
ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 60000 {
|
||||
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
|
||||
t.Fatalf("expected data payload, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "grok-image-generate-download",
|
||||
Name: "Grok 生成图片并下载",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/grok-generate-download",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 300000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-grok-image",
|
||||
ScriptID: "grok-image-generate-download",
|
||||
ScriptName: "Grok 生成图片并下载",
|
||||
Status: "success",
|
||||
Summary: "Grok 图片已生成并下载",
|
||||
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
|
||||
t.Fatalf("下载字段缺失: %+v", resp)
|
||||
}
|
||||
if _, exists := data["steps"]; exists {
|
||||
t.Fatalf("不应返回冗余 steps: %+v", resp)
|
||||
}
|
||||
if _, exists := data["runId"]; exists {
|
||||
t.Fatalf("不应返回 runId: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "image-generate",
|
||||
Name: "图片生成",
|
||||
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/generate",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 300000,
|
||||
RequestBodyText: `{"code":"{{code}}","params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
|
||||
Variables: []automation.ScriptPublicAPIVariable{
|
||||
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
|
||||
{Name: "outputFileName", DefaultValue: "generated-image.png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest)
|
||||
}
|
||||
|
||||
var params map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil {
|
||||
t.Fatalf("解析 paramsText 失败: %v", err)
|
||||
}
|
||||
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
|
||||
t.Fatalf("变量未映射到 params: %+v", params)
|
||||
}
|
||||
selectors, ok := params["selectors"].(map[string]interface{})
|
||||
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
|
||||
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if params["timeoutMs"] != float64(300000) {
|
||||
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 300000 {
|
||||
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "disabled-hook",
|
||||
Name: "Disabled Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: false,
|
||||
Path: "mail/disabled-hook",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook",
|
||||
Name: "Strict Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
|
||||
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp.OK || resp.Error.Code != "invalid_request" {
|
||||
t.Fatalf("错误 envelope 不正确: %+v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook-timeout",
|
||||
Name: "Strict Hook Timeout",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook-timeout",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.runs = []automation.ScriptRunRecord{
|
||||
{ID: "run-1", ScriptID: "script-a", Status: "success"},
|
||||
{ID: "run-2", ScriptID: "script-b", Status: "failed"},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts/runs?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunListLimit != 1 {
|
||||
t.Fatalf("limit 透传错误: %d", starter.lastRunListLimit)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Count int `json:"count"`
|
||||
Items []automation.ScriptRunRecord `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
|
||||
t.Fatalf("runs 响应错误: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptAPIUnavailableReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := buildTestHandlerWithManager(newInMemoryService(), newMockStarterWithParams(), nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/automation/scripts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
|
||||
t.Run("invalid-json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString("{bad json}"))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selector-must-be-object", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"selector":"BUYER_001"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "selector must be a JSON object") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout-must-be-in-range", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
|
||||
"scriptId":"news-query-txt",
|
||||
"timeoutMs":1800001
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
)
|
||||
|
||||
func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "matchMode=first") {
|
||||
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-get-code-only",
|
||||
ProfileName: "Buyer Account 02",
|
||||
Keywords: []string{"buyer-002"},
|
||||
Pid: 1004,
|
||||
DebugPort: 9447,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeFirst(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 2 {
|
||||
t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
|
||||
}
|
||||
if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
|
||||
t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
IsActive bool `json:"isActive"`
|
||||
} `json:"items"`
|
||||
ActiveProfileID string `json:"activeProfileId"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("批量启动响应错误: %+v", resp)
|
||||
}
|
||||
if resp.ActiveProfileID != profileB.ProfileId {
|
||||
t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
|
||||
}
|
||||
if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
|
||||
t.Fatalf("items 顺序错误: %+v", resp.Items)
|
||||
}
|
||||
if resp.Items[0].IsActive || !resp.Items[1].IsActive {
|
||||
t.Fatalf("isActive 标记错误: %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 0 {
|
||||
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
|
||||
}
|
||||
}
|
||||
@@ -344,205 +344,3 @@ func TestLaunchWithTopLevelCodeFallbackReturnsFirstByDefault(t *testing.T) {
|
||||
t.Fatalf("code 关键字兜底多命中时应默认取排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithAmbiguousKeywordSelectorAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"unique"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("歧义场景不应启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "matchMode=first") {
|
||||
t.Fatalf("错误信息未提示 matchMode=first: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchByCodeDoesNotFallbackToKeyword(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: "profile-get-code-only",
|
||||
ProfileName: "Buyer Account 02",
|
||||
Keywords: []string{"buyer-002"},
|
||||
Pid: 1004,
|
||||
DebugPort: 9447,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
manager := newSelectorTestManager(profile)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/buyer-002", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/launch/{code} 应保持纯 code 语义,期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "" {
|
||||
t.Fatalf("GET /api/launch/{code} 不应按关键字兜底启动实例: %s", starter.lastProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeFirst(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"first"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != profileA.ProfileId {
|
||||
t.Fatalf("matchMode=first 应命中排序后的第一个实例: got=%s want=%s", starter.lastProfile, profileA.ProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithMatchModeAllStartsAllMatchedProfiles(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "A Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2001,
|
||||
DebugPort: 9551,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "B Account",
|
||||
Keywords: []string{"shop"},
|
||||
Pid: 2002,
|
||||
DebugPort: 9552,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileB, profileA)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"selector":{"keyword":"shop","matchMode":"all"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 2 {
|
||||
t.Fatalf("matchMode=all 应启动 2 个实例: %+v", starter.started)
|
||||
}
|
||||
if starter.started[0] != profileA.ProfileId || starter.started[1] != profileB.ProfileId {
|
||||
t.Fatalf("matchMode=all 应按稳定排序依次启动: got=%+v", starter.started)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
IsActive bool `json:"isActive"`
|
||||
} `json:"items"`
|
||||
ActiveProfileID string `json:"activeProfileId"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("批量启动响应错误: %+v", resp)
|
||||
}
|
||||
if resp.ActiveProfileID != profileB.ProfileId {
|
||||
t.Fatalf("activeProfileId 错误: got=%s want=%s", resp.ActiveProfileID, profileB.ProfileId)
|
||||
}
|
||||
if resp.Items[0].ProfileID != profileA.ProfileId || resp.Items[1].ProfileID != profileB.ProfileId {
|
||||
t.Fatalf("items 顺序错误: %+v", resp.Items)
|
||||
}
|
||||
if resp.Items[0].IsActive || !resp.Items[1].IsActive {
|
||||
t.Fatalf("isActive 标记错误: %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithTopLevelCodeFallbackAndExplicitUniqueReturnsConflict(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
|
||||
profileA := &browser.Profile{
|
||||
ProfileId: "profile-a",
|
||||
ProfileName: "Account A",
|
||||
Keywords: []string{"shop", "checkout"},
|
||||
Pid: 1001,
|
||||
DebugPort: 9441,
|
||||
}
|
||||
profileB := &browser.Profile{
|
||||
ProfileId: "profile-b",
|
||||
ProfileName: "Account B",
|
||||
Keywords: []string{"shop", "refund"},
|
||||
Pid: 1002,
|
||||
DebugPort: 9442,
|
||||
}
|
||||
starter.addProfile(profileA)
|
||||
starter.addProfile(profileB)
|
||||
manager := newSelectorTestManager(profileA, profileB)
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, manager)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"code":"shop","matchMode":"unique"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("期望 409,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(starter.started) != 0 {
|
||||
t.Fatalf("显式 unique 不应启动任何实例: %+v", starter.started)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-172
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const featuresConfig = {
|
||||
dashboard: true,
|
||||
data: true,
|
||||
settings: true,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { projectConfig } from './project.config'
|
||||
import { projectConfig } from './projectBase.config'
|
||||
import { PROJECT_GITHUB_URL } from './links'
|
||||
|
||||
export type ProfileIconKey =
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user