feat: merge automation workflow updates

This commit is contained in:
ant-black
2026-06-05 23:24:09 +08:00
parent 8dabaa43c3
commit adfbd25ca5
137 changed files with 15490 additions and 3882 deletions
+29
View File
@@ -96,3 +96,32 @@ tmp-debug-port*/
.env
.env.*
!.env.example
# Local sensitive configs / accounts
*.ovpn
clash.yaml
mihomo.yaml
singbox.json
xray.json
proxy-subscription.yaml
proxy-subscriptions.yaml
subscription.yaml
subscriptions.yaml
account.json
accounts.json
accounts.yaml
accounts.yml
*credential*.json
*credentials*.json
*secret*.json
*secrets*.json
*token*.json
*tokens*.json
*cookie*.json
*cookies*.json
*.local.yaml
*.local.yml
*.local.json
# Local backups
.local-backup/
+36
View File
@@ -0,0 +1,36 @@
# Project Agent Instructions
<!-- ant-ready-start-skills:start -->
## Shared Local Skills
Use the shared local skills below when the task matches their scope:
- `page-style-linear-flow`: `D:\code\open_source\ant-ready-start\skills\page-style-linear-flow\SKILL.md`
- `ui-ux-pro-max`: `D:\code\open_source\ant-ready-start\skills\ui-ux-pro-max\SKILL.md`
- `frontend-skill`: `D:\code\open_source\ant-ready-start\skills\frontend-skill\SKILL.md`
- `create-plan`: `D:\code\open_source\ant-ready-start\skills\create-plan\SKILL.md`
- `create-plan-doc`: `D:\code\open_source\ant-ready-start\skills\create-plan-doc\SKILL.md`
Apply it for frontend page design or refactors involving pages, admin panels, forms, tables, dashboards, detail views, wizards, modal/drawer placement, or multi-step flows.
Apply `ui-ux-pro-max` for broader UI/UX design work involving visual direction, design-system shaping, palette and typography selection, component styling, landing pages, dashboards, and cross-stack interface generation when linear-flow rules alone are not enough.
Apply `frontend-skill` when the task needs stronger frontend art direction, visual hierarchy, landing-page composition, sparse premium layouts, image-led sections, or restrained motion design.
Apply `create-plan` when the user explicitly asks for a plan, task breakdown, implementation roadmap, rollout outline, or a step-by-step execution plan before coding.
Apply `create-plan-doc` when the user explicitly asks for a plan that should also be saved into the repository as a markdown document under `docs/plan`.
Core expectations:
- Keep each page focused on one primary responsibility.
- Do not mix operational tables and submit forms on the same screen.
- Use modal/drawer for short low-risk forms; use a dedicated page or wizard for complex flows.
- Remove filler copy, repeated headings, decorative cards, and meaningless whitespace.
- Keep the next action obvious and preserve predictable back/cancel/save behavior.
- For detailed UI checks, selectively read `D:\code\open_source\ant-ready-start\skills\page-style-linear-flow\references\checklist.md`.
These shared skill instructions supplement project-specific rules in this `AGENTS.md`; keep more specific project rules authoritative for this repository.
<!-- ant-ready-start-skills:end -->
+45 -5
View File
@@ -1,4 +1,4 @@
# Ant Browser
# Ant Browser
> 面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具(Windows / Linux / macOS unsigned)。
@@ -152,18 +152,58 @@ Ant Browser 适合以下场景:
1. 开发默认使用 `master` 分支;该分支不带测试用户数据,适合作为日常开发基线。
2. 如需带测试库的演示环境,请切换到 `user_data` 分支。
3. Windows 统一执行 `bat\dev.bat`;默认是稳定模式,如需前端 HMR 联调使用 `bat\dev.bat live`,如需受限内存复现使用 `bat\dev.bat limited`
3. Windows 统一执行 `bat\dev.bat`;默认是 `live` 热更新模式,如需静态资源排查使用 `bat\dev.bat stable`,如需受限内存复现使用 `bat\dev.bat limited`
4. Windows 运行时使用 `bin/xray.exe``bin/sing-box.exe`Linux 运行时使用 `bin/linux-<arch>/xray``bin/linux-<arch>/sing-box`macOS 运行时使用 `bin/darwin-<arch>/xray``bin/darwin-<arch>/sing-box`
5. 运行时文件采用“仓库固定 + 哈希校验”,校验清单在 `publish/runtime-manifest.json`,固定来源清单在 `publish/runtime-sources.json`
6. 如需刷新 Linux / macOS 运行时,执行 `python3 tools/runtime/sync-runtime.py --target <target>`(会按固定来源下载、校验归档并更新 manifest)。
开发模式说明:
- `bat\dev.bat`:默认稳定模式,先构建 `frontend/dist`,再以静态资源模式启动 Wails,不依赖外部 Vite dev server
- `bat\dev.bat live`:显式启动 Vite watcher,并通过 `-frontenddevserverurl` 接入桌面壳
- `bat\dev.bat`:默认 `live` 模式启动 Vite watcher,并通过 `-frontenddevserverurl` 接入桌面壳
- `bat\dev.bat stable`:先构建 `frontend/dist`,再以静态资源模式启动 Wails,不依赖外部 Vite dev server
- `bat\dev.bat live`:显式指定 `live` 模式,效果与默认一致
- `bat\dev.bat limited`:在 `live` 基础上为 watcher 与其子进程附加 Windows Job Object 内存限制
- 如需为依赖下载配置代理,可在启动前设置 `DEV_PROXY_URL``DEV_NO_PROXY``DEV_GOPROXY`
### 自动化脚本包
自动化脚本现在分成两层:
- 仓库里的可提交 demo 脚本库:`backend/internal/automation/demo-library/`
- 本地运行时 / 用户自定义脚本:`data/automation/scripts/`
规则是:
- 只有 demo 脚本库里的脚本会提交到 git
- `data/automation/scripts/` 下的运行时脚本统一忽略,不提交 git
- 默认只同步三个 demo`dual-instance-runtime-switch``news-query-txt``web-image-generate-download`
脚本包采用“一脚本一目录”的可搬运结构:
```text
<script-id>/
├── automation.script.json
├── index.cjs
└── 其他辅助文件
```
其中:
- `automation.script.json`:脚本元数据和默认参数
- `index.cjs`:入口脚本,`entryFile` 也可以改成相对路径,例如 `scripts/index.cjs`
- 其他辅助文件:脚本依赖的本地模块、模板、静态资源
运行时落盘结构和分发结构不同。应用内部会把脚本写到:
```text
data/automation/scripts/<script-id>/
├── config
├── index.cjs
└── 其他辅助文件
```
这里的 `config` 是应用内部持久化格式;对外复制、导入、脚本库管理一律使用 `automation.script.json` 包结构。
### Linux 发布打包(源码)
Linux 发布脚本位于 `publish/linux/`
@@ -261,7 +301,7 @@ chrome/
- Releases<https://github.com/black-ant/Ant-Browser/releases>
- Issues<https://github.com/black-ant/Ant-Browser/issues>
- 友链<https://linux.do/>
- 感谢以下社区的支持<https://linux.do/>
## License
+3
View File
@@ -42,6 +42,8 @@ type App struct {
maintenanceMu sync.Mutex
bridgeMu sync.Mutex
xrayBridgeRefs map[string]string
deferredStartTargetsMu sync.Mutex
deferredStartTargets map[string][]string
automationTargetMu sync.Mutex
automationTargetCursor map[string]string
stopServicesOnce sync.Once
@@ -58,6 +60,7 @@ func NewApp(appRoot string, appVersion ...string) *App {
appRoot: strings.TrimSpace(appRoot),
version: version,
xrayBridgeRefs: make(map[string]string),
deferredStartTargets: make(map[string][]string),
automationTargetCursor: make(map[string]string),
}
}
+3
View File
@@ -13,6 +13,7 @@ func (a *App) GetBrowserSettings() BrowserSettings {
DefaultFingerprintArgs: append([]string{}, a.config.Browser.DefaultFingerprintArgs...),
DefaultLaunchArgs: append([]string{}, a.config.Browser.DefaultLaunchArgs...),
DefaultStartURLs: append([]string{}, a.config.Browser.DefaultStartURLs...),
LightStartEnabled: browserLightStartEnabled(a.config),
RestoreLastSession: a.config.Browser.RestoreLastSession,
StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config),
StartStableWindowMs: browserStartStableWindowMillis(a.config),
@@ -29,6 +30,8 @@ func (a *App) SaveBrowserSettings(settings BrowserSettings) error {
} else if a.config.Browser.DefaultStartURLs == nil {
a.config.Browser.DefaultStartURLs = config.DefaultBrowserStartURLs()
}
lightStartEnabled := settings.LightStartEnabled
a.config.Browser.LightStartEnabled = &lightStartEnabled
a.config.Browser.RestoreLastSession = settings.RestoreLastSession
if settings.StartReadyTimeoutMs > 0 {
a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs
+11
View File
@@ -17,6 +17,7 @@ type BrowserCore = browser.Core
type BrowserCoreInput = browser.CoreInput
type BrowserCoreValidateResult = browser.CoreValidateResult
type BrowserCoreExtendedInfo = browser.CoreExtendedInfo
type BrowserProfileCopyOptions = browser.ProfileCopyOptions
// BrowserProfileList 获取所有实例列表
func (a *App) BrowserProfileList() []BrowserProfile { return a.browserMgr.List() }
@@ -51,6 +52,16 @@ func (a *App) BrowserProfileCopy(profileId string, newName string) (*BrowserProf
return a.browserMgr.Copy(profileId, newName)
}
// BrowserProfileCopyWithMode 按模式复制实例配置。
func (a *App) BrowserProfileCopyWithMode(profileId string, newName string, mode string) (*BrowserProfile, error) {
return a.browserMgr.CopyWithMode(profileId, newName, mode)
}
// BrowserProfileCopyWithOptions 按结构化选项复制实例配置。
func (a *App) BrowserProfileCopyWithOptions(profileId string, newName string, options BrowserProfileCopyOptions) (*BrowserProfile, error) {
return a.browserMgr.CopyWithOptions(profileId, newName, options)
}
// migrateToSQLite 一次性迁移:若 SQLite 表为空则从旧文件导入数据,或初始化默认数据
// 迁移顺序:cores → proxies → profiles → bookmarks
func (a *App) migrateToSQLite() {
+8
View File
@@ -31,6 +31,7 @@ func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T
UserDataRoot: app.config.Browser.UserDataRoot,
DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...),
DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...),
LightStartEnabled: browserLightStartEnabled(app.config),
}); err != nil {
t.Fatalf("SaveBrowserSettings returned error: %v", err)
}
@@ -44,6 +45,9 @@ func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T
if len(app.config.Browser.DefaultStartURLs) != len(config.DefaultBrowserStartURLs()) {
t.Fatalf("expected default start urls to be preserved, got %v", app.config.Browser.DefaultStartURLs)
}
if app.config.Browser.LightStartEnabled == nil || !*app.config.Browser.LightStartEnabled {
t.Fatal("expected light start setting to be preserved")
}
}
func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) {
@@ -55,6 +59,7 @@ func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) {
DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...),
DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...),
DefaultStartURLs: []string{},
LightStartEnabled: false,
RestoreLastSession: true,
StartReadyTimeoutMs: 18000,
StartStableWindowMs: 3000,
@@ -71,6 +76,9 @@ func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) {
if len(app.config.Browser.DefaultStartURLs) != 0 {
t.Fatalf("expected default start urls to be cleared, got %v", app.config.Browser.DefaultStartURLs)
}
if app.config.Browser.LightStartEnabled == nil || *app.config.Browser.LightStartEnabled {
t.Fatal("expected light start to be disabled")
}
if !app.config.Browser.RestoreLastSession {
t.Fatal("expected restore last session to be enabled")
}
+40 -11
View File
@@ -11,6 +11,12 @@ import (
"github.com/gorilla/websocket"
)
const (
cdpHTTPTimeout = 3 * time.Second
cdpWebSocketHandshakeTimeout = 3 * time.Second
cdpWebSocketReadTimeout = 5 * time.Second
)
// ============================================================================
// Cookie 管理 API(通过 CDP
// ============================================================================
@@ -53,15 +59,41 @@ type cdpResponse struct {
} `json:"error,omitempty"`
}
func cdpGetEndpointBody(debugPort int, endpoint string) ([]byte, error) {
client := &http.Client{Timeout: cdpHTTPTimeout}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", debugPort, endpoint))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
return body, nil
}
func cdpDialWebSocket(wsURL string) (*websocket.Conn, error) {
dialer := *websocket.DefaultDialer
dialer.HandshakeTimeout = cdpWebSocketHandshakeTimeout
conn, _, err := dialer.Dial(wsURL, nil)
if err != nil {
return nil, err
}
return conn, nil
}
// cdpCall 向指定 debugPort 发送单次 CDP 命令并返回 result 字段
func cdpCall(debugPort int, method string, params map[string]any) (map[string]any, error) {
// 1. 获取 WebSocket 调试地址
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json", debugPort))
body, err := cdpGetEndpointBody(debugPort, "/json")
if err != nil {
return nil, fmt.Errorf("CDP /json 请求失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var targets []cdpTarget
if err := json.Unmarshal(body, &targets); err != nil || len(targets) == 0 {
@@ -83,12 +115,12 @@ func cdpCall(debugPort int, method string, params map[string]any) (map[string]an
}
// 2. 建立 WebSocket 连接
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, err := cdpDialWebSocket(wsURL)
if err != nil {
return nil, fmt.Errorf("WebSocket 连接失败: %w", err)
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout))
// 3. 发送 CDP 命令
msg := cdpMessage{Id: 1, Method: method, Params: params}
@@ -108,13 +140,10 @@ func cdpCall(debugPort int, method string, params map[string]any) (map[string]an
}
func cdpBrowserCall(debugPort int, method string, params map[string]any) error {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", debugPort))
body, err := cdpGetEndpointBody(debugPort, "/json/version")
if err != nil {
return fmt.Errorf("CDP /json/version 请求失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var version cdpBrowserVersion
if err := json.Unmarshal(body, &version); err != nil {
return fmt.Errorf("CDP browser target 解析失败: %w", err)
@@ -124,12 +153,12 @@ func cdpBrowserCall(debugPort int, method string, params map[string]any) error {
return fmt.Errorf("未找到浏览器级 WebSocket 调试地址")
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, err := cdpDialWebSocket(wsURL)
if err != nil {
return fmt.Errorf("浏览器级 WebSocket 连接失败: %w", err)
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout))
msg := cdpMessage{Id: 1, Method: method, Params: params}
if err := conn.WriteJSON(msg); err != nil {
+16 -3
View File
@@ -7,7 +7,10 @@ import (
)
func (a *App) GetDashboardStats() map[string]interface{} {
profiles := a.browserMgr.List()
profiles := []BrowserProfile{}
if a.browserMgr != nil {
profiles = a.browserMgr.List()
}
totalInstances := len(profiles)
runningInstances := 0
for _, profile := range profiles {
@@ -15,8 +18,17 @@ func (a *App) GetDashboardStats() map[string]interface{} {
runningInstances++
}
}
proxyCount := len(a.config.Browser.Proxies)
coreCount := len(a.config.Browser.Cores)
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
}
}
var mem goruntime.MemStats
goruntime.ReadMemStats(&mem)
@@ -28,6 +40,7 @@ func (a *App) GetDashboardStats() map[string]interface{} {
"proxyCount": proxyCount,
"coreCount": coreCount,
"memUsedMB": int(memUsedMB),
"maxProfileLimit": maxProfileLimit,
"appVersion": a.appVersion(),
}
}
+19 -23
View File
@@ -12,6 +12,15 @@ import (
"time"
)
var startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) {
cmd := exec.Command(chromeBinaryPath, args...)
cmd.Dir = filepath.Dir(chromeBinaryPath)
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd, nil
}
func tryCloseBrowserViaCDP(debugPort int, timeout time.Duration) bool {
if debugPort <= 0 || !canConnectDebugPort(debugPort, 250*time.Millisecond) {
return false
@@ -99,23 +108,8 @@ func browserRestoreLastSession(cfg *config.Config) bool {
}
func appendLaunchTargets(args []string, startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool) []string {
normalizedStartURLs := normalizeNonEmptyStrings(startURLs)
if len(normalizedStartURLs) > 0 {
return browser.BuildLaunchArgs(args, normalizedStartURLs)
}
if !skipDefaultStartURLs {
normalizedDefaultStartURLs := normalizeNonEmptyStrings(defaultStartURLs)
if len(normalizedDefaultStartURLs) > 0 {
return browser.BuildLaunchArgs(args, normalizedDefaultStartURLs)
}
}
if !restoreLastSession {
return browser.BuildLaunchArgs(args, []string{"about:blank"})
}
return args
launchTargets, _ := buildBrowserLaunchTargets(startURLs, defaultStartURLs, skipDefaultStartURLs, restoreLastSession, false)
return browser.BuildLaunchArgs(args, launchTargets)
}
func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile) {
@@ -129,6 +123,7 @@ func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile
profile.RuntimeWarning = ""
profile.LastStopAt = time.Now().Format(time.RFC3339)
delete(a.browserMgr.BrowserProcesses, profileId)
a.clearDeferredStartTargets(profileId)
a.releaseProfileXrayBridge(profileId)
if a.launchServer != nil {
a.launchServer.ClearActiveProfile(profileId)
@@ -158,14 +153,15 @@ func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraL
args = append(args, "about:blank")
}
cmd := exec.Command(chromeBinaryPath, args...)
cmd.Dir = filepath.Dir(chromeBinaryPath)
if err := cmd.Start(); err != nil {
cmd, err := startBrowserWindowProcess(chromeBinaryPath, args)
if err != nil {
return fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err))
}
go func() {
_ = cmd.Wait()
}()
if cmd != nil {
go func() {
_ = cmd.Wait()
}()
}
return nil
}
+4
View File
@@ -27,6 +27,10 @@ func (a *App) waitBrowserProcess(profileId string, monitor *browserProcessMonito
if wasRunning && debugPort > 0 {
snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, browserLauncherDetachGraceWindow)
if warningSnapshot, warningChanged := a.finalizeDeferredStartTargets(profileId, debugPort); warningSnapshot != nil {
snapshot = warningSnapshot
changed = changed || warningChanged
}
if snapshot != nil && changed {
log.Info("浏览器启动器进程退出后,调试接口延迟就绪",
logger.F("profile_id", profileId),
+265
View File
@@ -0,0 +1,265 @@
package backend
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
goruntime "runtime"
"strings"
"testing"
"time"
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
)
func TestBrowserInstanceOpenURLUsesCDPCreateTarget(t *testing.T) {
t.Parallel()
server := newRecordedCDPServer(t)
defer server.Close()
app := newBrowserOpenURLTestApp(t)
app.browserMgr.Profiles = map[string]*BrowserProfile{
"profile-ready": {
ProfileId: "profile-ready",
ProfileName: "Ready Browser",
Running: true,
DebugReady: true,
DebugPort: server.Port(),
Pid: 12345,
},
}
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
ok, err := app.BrowserInstanceOpenUrl("profile-ready", "https://open.example/")
if err != nil {
t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err)
}
if !ok {
t.Fatal("expected BrowserInstanceOpenUrl to succeed")
}
want := []recordedCDPCommand{
{Scope: "browser", Method: "Target.createTarget", URL: "https://open.example/"},
}
if got := server.Commands(); !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected CDP command sequence:\n got=%v\nwant=%v", got, want)
}
}
func TestBrowserInstanceOpenURLFallsBackToWindowWhenDebugPending(t *testing.T) {
app, exePath := newBrowserOpenURLTestAppWithCore(t)
cmd := longLivedCommand(2 * time.Second)
if err := cmd.Start(); err != nil {
t.Fatalf("启动长生命周期测试进程失败: %v", err)
}
defer func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}()
profile := &BrowserProfile{
ProfileId: "profile-pending",
ProfileName: "Pending Browser",
UserDataDir: "profile-pending",
Running: true,
DebugReady: false,
DebugPort: 0,
Pid: cmd.Process.Pid,
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd}
expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile)
var gotPath string
var gotArgs []string
originalStart := startBrowserWindowProcess
startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) {
gotPath = chromeBinaryPath
gotArgs = append([]string{}, args...)
return nil, nil
}
defer func() {
startBrowserWindowProcess = originalStart
}()
ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://pending.example/")
if err != nil {
t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err)
}
if !ok {
t.Fatal("expected BrowserInstanceOpenUrl to succeed")
}
if gotPath != exePath {
t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath)
}
wantArgs := []string{
fmt.Sprintf("--user-data-dir=%s", expectedUserDataDir),
"https://pending.example/",
}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs)
}
}
func TestBrowserInstanceOpenURLFallsBackToWindowWhenCDPOpenFails(t *testing.T) {
app, exePath := newBrowserOpenURLTestAppWithCore(t)
cmd := longLivedCommand(2 * time.Second)
if err := cmd.Start(); err != nil {
t.Fatalf("启动长生命周期测试进程失败: %v", err)
}
defer func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}()
profile := &BrowserProfile{
ProfileId: "profile-ready-fallback",
ProfileName: "Fallback Browser",
UserDataDir: "profile-ready-fallback",
Running: true,
DebugReady: true,
DebugPort: freeLoopbackPort(t),
Pid: cmd.Process.Pid,
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd}
expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile)
var gotPath string
var gotArgs []string
originalStart := startBrowserWindowProcess
startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) {
gotPath = chromeBinaryPath
gotArgs = append([]string{}, args...)
return nil, nil
}
defer func() {
startBrowserWindowProcess = originalStart
}()
ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://fallback.example/")
if err != nil {
t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err)
}
if !ok {
t.Fatal("expected BrowserInstanceOpenUrl to succeed")
}
if gotPath != exePath {
t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath)
}
wantArgs := []string{
fmt.Sprintf("--user-data-dir=%s", expectedUserDataDir),
"https://fallback.example/",
}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs)
}
}
func TestBrowserInstanceOpenURLMarksStaleProfileStopped(t *testing.T) {
t.Parallel()
app := newBrowserOpenURLTestApp(t)
profile := &BrowserProfile{
ProfileId: "profile-stale",
ProfileName: "Stale Browser",
Running: true,
DebugReady: false,
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://stale.example/")
if err == nil {
t.Fatal("expected BrowserInstanceOpenUrl to fail for stale runtime state")
}
if ok {
t.Fatal("expected BrowserInstanceOpenUrl to return false for stale runtime state")
}
if !strings.Contains(err.Error(), "运行状态已失效") {
t.Fatalf("unexpected error: %v", err)
}
if profile.Running {
t.Fatal("expected stale profile to be marked stopped")
}
if profile.DebugReady {
t.Fatal("expected stale profile debug state to be cleared")
}
if profile.DebugPort != 0 || profile.Pid != 0 {
t.Fatalf("expected runtime identifiers to be cleared, got debugPort=%d pid=%d", profile.DebugPort, profile.Pid)
}
}
func newBrowserOpenURLTestApp(t *testing.T) *App {
t.Helper()
cfg := config.DefaultConfig()
app := NewApp("")
app.config = cfg
app.browserMgr = browser.NewManager(cfg, t.TempDir())
app.browserMgr.Profiles = make(map[string]*BrowserProfile)
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
return app
}
func newBrowserOpenURLTestAppWithCore(t *testing.T) (*App, string) {
t.Helper()
cfg := config.DefaultConfig()
exePath := createFakeBrowserExecutable(t)
cfg.Browser.Cores = []config.BrowserCore{
{
CoreId: "core-open-url-test",
CoreName: "Open URL Test Core",
CorePath: exePath,
IsDefault: true,
},
}
app := NewApp("")
app.config = cfg
app.browserMgr = browser.NewManager(cfg, t.TempDir())
app.browserMgr.Profiles = make(map[string]*BrowserProfile)
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
return app, exePath
}
func createFakeBrowserExecutable(t *testing.T) string {
t.Helper()
candidates := browser.CoreExecutableCandidates()
if len(candidates) == 0 {
t.Fatal("no core executable candidates available")
}
baseDir := t.TempDir()
exePath := filepath.Join(baseDir, filepath.FromSlash(candidates[0]))
if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil {
t.Fatalf("创建测试内核目录失败: %v", err)
}
mode := os.FileMode(0o644)
content := []byte("test-browser")
if goruntime.GOOS != "windows" {
mode = 0o755
content = []byte("#!/bin/sh\nexit 0\n")
}
if err := os.WriteFile(exePath, content, mode); err != nil {
t.Fatalf("写入测试内核可执行文件失败: %v", err)
}
return exePath
}
+4
View File
@@ -30,6 +30,10 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s
}
plan, err := a.prepareBrowserStartPlan(input, profile)
if err == errBrowserStartHandledByRecoveredRuntime {
a.emitBrowserInstanceStarted(profile, true)
return profile, nil
}
if err != nil {
return profile, err
}
+20
View File
@@ -11,6 +11,7 @@ import (
func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browserStartPlan) (*BrowserProfile, error) {
log := logger.New("Browser")
profile := plan.profile
a.clearDeferredStartTargets(input.ProfileID)
cmd := exec.Command(plan.chromeBinaryPath, plan.args...)
cmd.Dir = filepath.Dir(plan.chromeBinaryPath)
@@ -49,6 +50,20 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser
a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey)
plan.releaseXrayBridge = false
}
if len(plan.deferredStartTargets) > 0 {
if err := openBrowserStartTargets(stableDebugPort, plan.deferredStartTargets); err != nil {
warning := deferredStartTargetsWarning(plan.deferredStartTargets, err)
profile.RuntimeWarning = warning
profile.LastError = ""
log.Warn("浏览器已就绪,但启动页延后打开失败",
logger.F("profile_id", input.ProfileID),
logger.F("debug_port", stableDebugPort),
logger.F("target_count", len(plan.deferredStartTargets)),
logger.F("error", err.Error()),
logger.F("warning", warning),
)
}
}
log.Info("实例启动",
logger.F("profile_id", input.ProfileID),
@@ -97,6 +112,9 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser
runtimeWarning := browserDebugPendingWarning(plan.totalReadyTimeout)
pendingStartNotice = browserDebugPendingStartNotice(plan.totalReadyTimeout)
a.markProfileRunningLocked(input.ProfileID, profile, cmd, cmd.Process.Pid, plan.assignedDebugPort, false, runtimeWarning)
if len(plan.deferredStartTargets) > 0 {
a.storeDeferredStartTargets(input.ProfileID, plan.deferredStartTargets)
}
if plan.acquiredXrayBridgeKey != "" {
a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey)
plan.releaseXrayBridge = false
@@ -120,10 +138,12 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser
}
if lastStartErr != nil {
a.clearDeferredStartTargets(input.ProfileID)
profile.LastError = lastStartErr.Error()
return profile, lastStartErr
}
a.clearDeferredStartTargets(input.ProfileID)
startErr := fmt.Errorf("实例启动失败:浏览器在等待窗口内仍未就绪")
profile.LastError = startErr.Error()
return profile, startErr
+58 -8
View File
@@ -26,6 +26,7 @@ type browserStartPlan struct {
chromeBinaryPath string
userDataDir string
args []string
deferredStartTargets []string
effectiveProxy string
acquiredXrayBridgeKey string
releaseXrayBridge bool
@@ -36,6 +37,8 @@ type browserStartPlan struct {
totalReadyTimeout time.Duration
}
var clearBrowserSessionRestoreData = browser.ClearSessionRestoreData
func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyID string, proxyConfig string) browserStartInput {
normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs)
if preferVisibleWindow {
@@ -76,6 +79,7 @@ func (a *App) resolveBrowserStartProfile(input browserStartInput) (*BrowserProfi
log.Error("实例不存在", logger.F("profile_id", input.ProfileID), logger.F("reason", err.Error()))
return nil, false, err
}
a.ensureProfileLaunchCode(profile)
if !profile.Running {
return profile, false, nil
@@ -113,7 +117,8 @@ func (a *App) resolveBrowserStartProfile(input browserStartInput) (*BrowserProfi
}
func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserProfile) (*browserStartPlan, error) {
sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, err := a.prepareBrowserLaunchContext(input, profile)
bookmarks := a.BookmarkList()
sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, err := a.prepareBrowserLaunchContext(input, profile, bookmarks)
if err != nil {
return nil, err
}
@@ -126,6 +131,15 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
startReadyTimeout, startStableWindow := a.browserStartTimingSettings()
maxStartAttempts := browserStartAttemptCount()
totalReadyTimeout := time.Duration(maxStartAttempts) * startReadyTimeout
restoreLastSession := browserRestoreLastSession(a.config)
defaultStartURLs := mergeStartURLs(browserDefaultStartURLs(a.config), bookmarkStartURLs(bookmarks))
launchTargets, deferredStartTargets := buildBrowserLaunchTargets(
input.StartURLs,
defaultStartURLs,
input.SkipDefaultStartURLs,
restoreLastSession,
browserLightStartEnabled(a.config),
)
assignedDebugPort, err := nextAvailablePort()
if err != nil {
@@ -143,7 +157,8 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
profile: profile,
chromeBinaryPath: chromeBinaryPath,
userDataDir: userDataDir,
args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, a.browserDefaultStartURLs(), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)),
args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, launchTargets),
deferredStartTargets: deferredStartTargets,
effectiveProxy: effectiveProxy,
acquiredXrayBridgeKey: acquiredXrayBridgeKey,
releaseXrayBridge: releaseXrayBridge,
@@ -155,7 +170,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP
}, nil
}
func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *BrowserProfile) ([]string, []string, string, string, error) {
func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *BrowserProfile, bookmarks []BrowserBookmark) ([]string, []string, string, string, error) {
log := logger.New("Browser")
sanitizedProfileLaunchArgs, managedProfileArgs := sanitizeManagedLaunchArgs(profile.LaunchArgs)
@@ -193,12 +208,47 @@ func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *Brow
return nil, nil, "", "", startErr
}
if err := browser.EnsureDefaultBookmarks(userDataDir, a.BookmarkList()); err != nil {
if err := browser.EnsureDefaultBookmarks(userDataDir, bookmarks); err != nil {
log.Error("默认书签写入失败", logger.F("error", err.Error()))
}
if detection, ok := detectBrowserRuntimeByActivePort(userDataDir); ok && detection.DebugReady {
a.markProfileRunningLocked(input.ProfileID, profile, nil, detection.PID, detection.DebugPort, true, "")
log.Warn("检测到同一用户数据目录已有浏览器运行,已接管为当前实例状态",
logger.F("profile_id", input.ProfileID),
logger.F("user_data_dir", userDataDir),
logger.F("pid", detection.PID),
logger.F("debug_port", detection.DebugPort),
)
if input.PreferVisibleWindow {
if err := a.openBrowserWindowForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil {
startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err)
profile.LastError = startErr.Error()
return nil, nil, "", "", startErr
}
}
return nil, nil, "", "", errBrowserStartHandledByRecoveredRuntime
}
if !browserRestoreLastSession(a.config) {
if err := browser.ClearSessionRestoreData(userDataDir); err != nil {
if err := clearBrowserSessionRestoreData(userDataDir); err != nil {
if terminated, terminateErr := terminateBrowserProcessesByUserDataDir(userDataDir, 5*time.Second); terminateErr == nil && terminated {
log.Warn("会话缓存被旧浏览器进程占用,已结束占用进程并重试清理",
logger.F("profile_id", input.ProfileID),
logger.F("user_data_dir", userDataDir),
)
if retryErr := clearBrowserSessionRestoreData(userDataDir); retryErr == nil {
return sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, nil
} else {
err = retryErr
}
} else if terminateErr != nil {
log.Warn("会话缓存清理失败后尝试结束占用进程失败",
logger.F("profile_id", input.ProfileID),
logger.F("user_data_dir", userDataDir),
logger.F("error", terminateErr.Error()),
)
}
sessionDir := filepath.Join(userDataDir, "Default", "Sessions")
startErr := fmt.Errorf("实例启动失败:无法清理上次会话缓存 %s。原因:%w。请关闭占用该目录的浏览器进程后重试。", sessionDir, err)
log.Error("会话恢复缓存清理失败",
@@ -215,7 +265,7 @@ func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *Brow
return sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, nil
}
func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool) []string {
func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, launchTargets []string) []string {
args := []string{
fmt.Sprintf("--user-data-dir=%s", userDataDir),
fmt.Sprintf("--remote-debugging-port=%d", debugPort),
@@ -241,7 +291,7 @@ func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPo
}
if effectiveProxy == "direct://" {
args = append(args, "--proxy-server=direct://")
args = append(args, "--no-proxy-server")
} else if effectiveProxy != "" {
args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy))
}
@@ -249,5 +299,5 @@ func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPo
args = append(args, profile.FingerprintArgs...)
args = append(args, sanitizedProfileLaunchArgs...)
args = append(args, sanitizedExtraLaunchArgs...)
return appendLaunchTargets(args, startURLs, defaultStartURLs, skipDefaultStartURLs, restoreLastSession)
return browser.BuildLaunchArgs(args, launchTargets)
}
+31
View File
@@ -540,6 +540,37 @@ func TestAppendLaunchTargetsPreservesSessionRestoreWhenEnabled(t *testing.T) {
}
}
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()
+108 -6
View File
@@ -1,6 +1,13 @@
package backend
import "fmt"
import (
"fmt"
"strings"
"ant-chrome/backend/internal/logger"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) {
a.browserMgr.Mutex.Lock()
@@ -9,17 +16,112 @@ func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) {
if !exists {
return nil, fmt.Errorf("profile not found")
}
a.ensureProfileLaunchCode(profile)
if !profile.Running {
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
if detection, ok := detectBrowserRuntimeByUserDataDir(userDataDir); ok && detection.DebugReady {
a.markProfileRunningLocked(profileId, profile, nil, detection.PID, detection.DebugPort, true, "")
logger.New("Browser").Warn("状态查询发现同一用户数据目录浏览器已运行,已同步实例状态",
logger.F("profile_id", profileId),
logger.F("user_data_dir", userDataDir),
logger.F("pid", detection.PID),
logger.F("debug_port", detection.DebugPort),
)
}
}
return profile, nil
}
func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) bool {
func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) (bool, error) {
normalizedTargetURL := strings.TrimSpace(targetUrl)
if normalizedTargetURL == "" {
return false, fmt.Errorf("打开地址失败:目标地址不能为空")
}
log := logger.New("Browser")
a.browserMgr.Mutex.Lock()
profile, exists := a.browserMgr.Profiles[profileId]
a.browserMgr.Mutex.Unlock()
if !exists || !profile.Running {
return false
if !exists {
a.browserMgr.Mutex.Unlock()
return false, fmt.Errorf("打开地址失败:未找到实例配置(ID=%s)。请刷新列表后重试。", profileId)
}
return true
a.ensureProfileLaunchCode(profile)
trackedCmd := a.browserMgr.BrowserProcesses[profileId]
if !profile.Running {
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
if detection, ok := detectBrowserRuntimeByUserDataDir(userDataDir); ok && detection.DebugReady {
a.markProfileRunningLocked(profileId, profile, nil, detection.PID, detection.DebugPort, true, "")
log.Warn("打开地址前发现同一用户数据目录浏览器已运行,已同步实例状态",
logger.F("profile_id", profileId),
logger.F("user_data_dir", userDataDir),
logger.F("pid", detection.PID),
logger.F("debug_port", detection.DebugPort),
)
} else {
a.browserMgr.Mutex.Unlock()
return false, fmt.Errorf("打开地址失败:实例当前未运行,请先启动实例后再试。")
}
}
if !isBrowserProfileLive(profile, trackedCmd) {
staleDebugPort := profile.DebugPort
stalePID := profile.Pid
a.markProfileStoppedLocked(profileId, profile)
profile.LastError = "打开地址失败:检测到实例运行状态已失效,请先重新启动实例。"
a.browserMgr.Mutex.Unlock()
log.Warn("检测到实例运行状态已失效,取消复用打开地址",
logger.F("profile_id", profileId),
logger.F("debug_port", staleDebugPort),
logger.F("pid", stalePID),
)
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId)
}
return false, fmt.Errorf("%s", profile.LastError)
}
snapshot := copyBrowserProfileSnapshot(profile)
a.browserMgr.Mutex.Unlock()
if snapshot.DebugReady && snapshot.DebugPort > 0 {
if err := createBrowserStartTarget(snapshot.DebugPort, normalizedTargetURL); err == nil {
log.Info("复用运行中实例通过 CDP 打开地址",
logger.F("profile_id", profileId),
logger.F("debug_port", snapshot.DebugPort),
logger.F("target_url", normalizedTargetURL),
)
return true, nil
} else {
log.Warn("运行中实例通过 CDP 打开地址失败,回退到浏览器进程唤起",
logger.F("profile_id", profileId),
logger.F("debug_port", snapshot.DebugPort),
logger.F("target_url", normalizedTargetURL),
logger.F("error", err.Error()),
)
}
}
if err := a.openBrowserWindowForRunningProfile(snapshot, nil, []string{normalizedTargetURL}); err != nil {
openErr := fmt.Errorf("打开地址失败:实例运行中,但复用现有会话打开页面失败:%w", err)
log.Error("运行中实例打开地址失败",
logger.F("profile_id", profileId),
logger.F("debug_ready", snapshot.DebugReady),
logger.F("debug_port", snapshot.DebugPort),
logger.F("target_url", normalizedTargetURL),
logger.F("error", err.Error()),
logger.F("reason", openErr.Error()),
)
return false, openErr
}
log.Info("复用运行中实例通过浏览器进程打开地址",
logger.F("profile_id", profileId),
logger.F("debug_ready", snapshot.DebugReady),
logger.F("debug_port", snapshot.DebugPort),
logger.F("target_url", normalizedTargetURL),
)
return true, nil
}
func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab {
+15 -3
View File
@@ -55,7 +55,11 @@ func (a *App) BrowserProfileGetCode(profileId string) (string, error) {
if a.launchCodeSvc == nil {
return "", nil
}
return a.launchCodeSvc.EnsureCode(profileId)
code, err := a.launchCodeSvc.EnsureCode(profileId)
if err == nil {
a.setManagedProfileLaunchCode(profileId, code)
}
return code, err
}
// BrowserProfileRegenerateCode 重新生成实例的 LaunchCodeWails 绑定)
@@ -63,7 +67,11 @@ func (a *App) BrowserProfileRegenerateCode(profileId string) (string, error) {
if a.launchCodeSvc == nil {
return "", nil
}
return a.launchCodeSvc.RegenerateCode(profileId)
code, err := a.launchCodeSvc.RegenerateCode(profileId)
if err == nil {
a.setManagedProfileLaunchCode(profileId, code)
}
return code, err
}
// BrowserProfileSetCode 自定义设置实例 LaunchCodeWails 绑定)
@@ -71,7 +79,11 @@ func (a *App) BrowserProfileSetCode(profileId string, code string) (string, erro
if a.launchCodeSvc == nil {
return "", nil
}
return a.launchCodeSvc.SetCode(profileId, code)
updated, err := a.launchCodeSvc.SetCode(profileId, code)
if err == nil {
a.setManagedProfileLaunchCode(profileId, updated)
}
return updated, err
}
// BrowserInstanceStartByCode 通过 LaunchCode 启动实例(Wails 绑定)
+14 -6
View File
@@ -119,18 +119,26 @@ func (a *App) automationDemoRequest(method string, apiPath string, body any) (in
return a.automationDemoRequestWithContext(context.Background(), method, apiPath, body)
}
func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) {
func automationDemoRequestContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
if _, hasDeadline := ctx.Deadline(); hasDeadline {
return ctx, func() {}
}
return context.WithTimeout(ctx, automationDemoTimeout)
}
func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) {
reqCtx, cancel := automationDemoRequestContext(ctx)
defer cancel()
baseURL, authHeader, authValue, err := a.automationDemoEndpoint()
if err != nil {
return 0, nil, err
}
requestURL := strings.TrimRight(baseURL, "/") + apiPath
ctx, cancel := context.WithTimeout(ctx, automationDemoTimeout)
defer cancel()
var reader io.Reader
if body != nil {
@@ -141,7 +149,7 @@ func (a *App) automationDemoRequestWithContext(ctx context.Context, method strin
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, requestURL, reader)
req, err := http.NewRequestWithContext(reqCtx, method, requestURL, reader)
if err != nil {
return 0, nil, fmt.Errorf("create demo request failed: %w", err)
}
@@ -152,9 +160,9 @@ func (a *App) automationDemoRequestWithContext(ctx context.Context, method strin
req.Header.Set(authHeader, authValue)
}
resp, err := (&http.Client{Timeout: automationDemoTimeout}).Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
if ctxErr := reqCtx.Err(); ctxErr != nil {
return 0, nil, fmt.Errorf("call launch api failed: %w", ctxErr)
}
return 0, nil, fmt.Errorf("call launch api failed: %w", err)
+37
View File
@@ -2,9 +2,11 @@ package backend
import (
"bytes"
"context"
"net/http"
"regexp"
"testing"
"time"
)
func TestAutomationDemoLaunchCodeFormat(t *testing.T) {
@@ -85,3 +87,38 @@ func TestDecodeAutomationDemoBodyFallsBackToRawText(t *testing.T) {
t.Fatalf("expected rawBody fallback, got %#v", payload["rawBody"])
}
}
func TestAutomationDemoRequestContextAppliesDefaultTimeoutWithoutDeadline(t *testing.T) {
startedAt := time.Now()
ctx, cancel := automationDemoRequestContext(context.Background())
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected request context to contain a deadline")
}
timeout := deadline.Sub(startedAt)
if timeout < 9*time.Second || timeout > 11*time.Second {
t.Fatalf("expected default timeout near 10s, got %s", timeout)
}
}
func TestAutomationDemoRequestContextKeepsExistingDeadline(t *testing.T) {
wantDeadline := time.Now().Add(45 * time.Second)
parentCtx, parentCancel := context.WithDeadline(context.Background(), wantDeadline)
defer parentCancel()
ctx, cancel := automationDemoRequestContext(parentCtx)
defer cancel()
gotDeadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected existing deadline to be preserved")
}
diff := gotDeadline.Sub(wantDeadline)
if diff < -100*time.Millisecond || diff > 100*time.Millisecond {
t.Fatalf("expected preserved deadline %s, got %s", wantDeadline, gotDeadline)
}
}
+293 -2
View File
@@ -24,8 +24,8 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) {
if err != nil {
t.Fatalf("AutomationScriptList returned error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected two default scripts, got %d", len(items))
if len(items) != 3 {
t.Fatalf("expected three default scripts, got %d", len(items))
}
byID := make(map[string]automation.ScriptRecord, len(items))
@@ -36,6 +36,7 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) {
expectedNames := map[string]string{
"dual-instance-runtime-switch": "双实例启动与 Runtime 切换",
"news-query-txt": "查询新闻并写 TXT",
"web-image-generate-download": "网页图片生成并下载",
}
for scriptID, expectedName := range expectedNames {
@@ -49,6 +50,9 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) {
if script.EntryFile != "index.cjs" {
t.Fatalf("unexpected default entry file for %q: %q", scriptID, script.EntryFile)
}
if script.Source.Type != "builtin" {
t.Fatalf("expected builtin source for %q, got %+v", scriptID, script.Source)
}
scriptDir := filepath.Join(app.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "scripts"))), script.ID)
if _, err := os.Stat(filepath.Join(scriptDir, "config")); err != nil {
@@ -82,6 +86,59 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) {
}
}
func TestAutomationScriptListAddsMissingBuiltinWhenLegacyMarkerExists(t *testing.T) {
app := NewApp(t.TempDir())
if _, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: "custom-script",
Name: "自定义脚本",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "index.cjs",
ScriptText: "module.exports.run = async () => ({ ok: true })",
}); err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
}
legacyMarkerPath := app.automationScriptDefaultsMarkerPath("defaults-seeded-v7")
if err := os.MkdirAll(filepath.Dir(legacyMarkerPath), 0o755); err != nil {
t.Fatalf("create legacy marker dir failed: %v", err)
}
if err := os.WriteFile(legacyMarkerPath, []byte("ok\n"), 0o644); err != nil {
t.Fatalf("write legacy marker failed: %v", err)
}
items, err := app.AutomationScriptList()
if err != nil {
t.Fatalf("AutomationScriptList returned error: %v", err)
}
if len(items) != 4 {
t.Fatalf("expected custom script plus three defaults, got %d items", len(items))
}
expectedDefaultIDs := []string{
automation.DualInstanceRuntimeScriptID,
automation.NewsQueryTXTScriptID,
automation.WebImageGenerateScriptID,
}
for _, scriptID := range expectedDefaultIDs {
found := false
for _, item := range items {
if item.ID == scriptID {
found = true
break
}
}
if !found {
t.Fatalf("expected migrated default script %q to exist", scriptID)
}
}
if !app.automationScriptDefaultsInitialized() {
t.Fatalf("expected new defaults marker to be written")
}
}
func TestAutomationScriptSaveListAndDelete(t *testing.T) {
app := NewApp(t.TempDir())
@@ -276,6 +333,37 @@ func TestAutomationScriptRunWithOptionsAllowsEmptySelectorForDualInstanceRuntime
}
}
func TestAutomationScriptRunWithOptionsSeedsDefaultScriptsOnFreshApp(t *testing.T) {
app := NewApp(t.TempDir())
run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{
ScriptID: automation.DualInstanceRuntimeScriptID,
UseScriptSelector: true,
UseScriptParams: true,
})
if err != nil {
t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err)
}
if run == nil {
t.Fatalf("AutomationScriptRunWithOptions returned nil result")
}
if run.ScriptID != automation.DualInstanceRuntimeScriptID {
t.Fatalf("unexpected script id: %+v", run)
}
if run.ScriptName != "双实例启动与 Runtime 切换" {
t.Fatalf("expected default script metadata to be hydrated, got %+v", run)
}
if run.ScriptType != "launch-api" {
t.Fatalf("expected default script type launch-api, got %+v", run)
}
if run.Summary == "脚本读取失败" {
t.Fatalf("expected direct run to seed defaults before execution, got %+v", run)
}
if strings.Contains(strings.ToLower(run.Error), "script not found") {
t.Fatalf("expected seeded default script, got %+v", run)
}
}
func TestAutomationScriptRefreshFromLocalFile(t *testing.T) {
app := NewApp(t.TempDir())
@@ -330,6 +418,64 @@ func TestAutomationScriptRefreshFromLocalFile(t *testing.T) {
}
}
func TestAutomationScriptRefreshFromBuiltin(t *testing.T) {
app := NewApp(t.TempDir())
savedImportedAt := "2026-01-01T00:00:00Z"
saved, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: automation.NewsQueryTXTScriptID,
Name: "旧新闻脚本",
Type: "launch-api",
Status: "ready",
EntryFile: "index.cjs",
ScriptText: "module.exports.run = async () => ({ ok: false })",
Source: automation.ScriptSource{
Type: "builtin",
URI: "repo://backend/internal/automation/demo-library/news-query-txt",
Ref: "HEAD",
Path: automation.NewsQueryTXTScriptID,
ImportedAt: savedImportedAt,
},
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Path: "demo/news-refresh",
RequestMode: "params-only",
},
})
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.Name != "查询新闻并写 TXT" {
t.Fatalf("expected builtin script name to be restored, got %q", refreshed.Name)
}
if !strings.Contains(refreshed.ScriptText, "acceptedItems") {
t.Fatalf("expected refreshed builtin script text to contain news filtering logic, got %q", refreshed.ScriptText)
}
if refreshed.Source.Type != "builtin" || refreshed.Source.Path != automation.NewsQueryTXTScriptID {
t.Fatalf("unexpected refreshed source: %+v", refreshed.Source)
}
if refreshed.Source.ImportedAt == "" || refreshed.Source.ImportedAt == savedImportedAt {
t.Fatalf("expected builtin refresh to update importedAt, got %+v", refreshed.Source)
}
if refreshed.PublicAPI.Path != "demo/news-refresh" || !refreshed.PublicAPI.Enabled {
t.Fatalf("expected public api config to be preserved on refresh, got %+v", refreshed.PublicAPI)
}
}
func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) {
app := NewApp(t.TempDir())
@@ -388,6 +534,133 @@ func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) {
}
}
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(`{
@@ -733,3 +1006,21 @@ func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byt
}
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)
}
}
}
+29 -12
View File
@@ -8,10 +8,19 @@ import (
)
const (
automationScriptDefaultsMarkerName = "defaults-seeded-v2"
automationScriptDefaultsLegacyMarkerName = "defaults-seeded-v1"
automationScriptDefaultsMarkerName = "defaults-seeded-v9"
)
var automationScriptDefaultsLegacyMarkerNames = []string{
"defaults-seeded-v8",
"defaults-seeded-v7",
"defaults-seeded-v6",
"defaults-seeded-v5",
"defaults-seeded-v4",
"defaults-seeded-v3",
"defaults-seeded-v2",
}
func (a *App) automationScriptDefaultsMarkerPath(name string) string {
return a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", name)))
}
@@ -25,8 +34,13 @@ func (a *App) automationScriptDefaultsInitialized() bool {
return a.automationScriptDefaultsInitializedByName(automationScriptDefaultsMarkerName)
}
func (a *App) automationScriptDefaultsInitializedLegacy() bool {
return a.automationScriptDefaultsInitializedByName(automationScriptDefaultsLegacyMarkerName)
func (a *App) automationScriptDefaultsInitializedAnyLegacy() bool {
for _, name := range automationScriptDefaultsLegacyMarkerNames {
if a.automationScriptDefaultsInitializedByName(name) {
return true
}
}
return false
}
func (a *App) markAutomationScriptDefaultsInitialized() error {
@@ -38,7 +52,10 @@ func (a *App) markAutomationScriptDefaultsInitialized() error {
}
func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) error {
defaults := automation.DefaultScripts()
defaults, err := automation.DefaultScriptBundles()
if err != nil {
return err
}
items, err := store.List()
if err != nil {
return err
@@ -51,12 +68,12 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro
if len(items) == 0 {
// Keep legacy behavior for users that had deleted all defaults under v1.
if a.automationScriptDefaultsInitializedLegacy() {
if a.automationScriptDefaultsInitializedAnyLegacy() {
return a.markAutomationScriptDefaultsInitialized()
}
for _, record := range defaults {
if _, err := store.Save(record); err != nil {
for _, bundle := range defaults {
if _, err := store.ImportBundle(bundle); err != nil {
return err
}
}
@@ -64,16 +81,16 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro
}
// Migration from v1: existing scripts are present, add any missing built-in baselines once.
if a.automationScriptDefaultsInitializedLegacy() {
if a.automationScriptDefaultsInitializedAnyLegacy() {
existingIDs := make(map[string]struct{}, len(items))
for _, item := range items {
existingIDs[item.ID] = struct{}{}
}
for _, record := range defaults {
if _, exists := existingIDs[record.ID]; exists {
for _, bundle := range defaults {
if _, exists := existingIDs[bundle.Record.ID]; exists {
continue
}
if _, err := store.Save(record); err != nil {
if _, err := store.ImportBundle(bundle); err != nil {
return err
}
}
+588
View File
@@ -0,0 +1,588 @@
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"
)
type automationHTTPProfileCreateResponse struct {
OK bool `json:"ok"`
Created bool `json:"created"`
Launched bool `json:"launched"`
ProfileID string `json:"profileId"`
LaunchCode string `json:"launchCode"`
}
type automationHTTPScriptsResponse struct {
OK bool `json:"ok"`
Data struct {
Count int `json:"count"`
Items []struct {
ID string `json:"id"`
} `json:"items"`
} `json:"data"`
}
type automationHTTPRunResponse struct {
OK bool `json:"ok"`
Data struct {
Run struct {
Status string `json:"status"`
Summary string `json:"summary"`
Error string `json:"error"`
ResultText string `json:"resultText"`
} `json:"run"`
} `json:"data"`
}
type automationHTTPHookEnvelopeResponse struct {
OK bool `json:"ok"`
Status string `json:"status"`
Summary string `json:"summary"`
Result map[string]interface{} `json:"result"`
}
type automationHTTPLaunchLogsResponse struct {
OK bool `json:"ok"`
Items []json.RawMessage `json:"items"`
}
func TestAutomationScriptRunHTTPReturnsSavedMailProbeScript(t *testing.T) {
nodePath := lookupAutomationHTTPProbeNode(t)
chromePath := lookupAutomationHTTPProbeChrome(t)
repoRoot := automationHTTPRepoRoot(t)
tempRoot := t.TempDir()
cfg := config.DefaultConfig()
cfg.Logging.FileEnabled = false
cfg.LaunchServer.Port = automationHTTPFreePort(t)
cfg.Automation.Enabled = true
cfg.Automation.NodeSource = config.AutomationNodeSourceSystem
cfg.Automation.SystemNodePath = nodePath
cfg.Automation.HeadlessDefault = true
if err := cfg.Save(filepath.Join(tempRoot, "config.yaml")); err != nil {
t.Fatalf("save config failed: %v", err)
}
if err := prepareAutomationHTTPRuntime(tempRoot, repoRoot, cfg.Automation.RuntimeVersion); err != nil {
t.Fatalf("prepare runtime failed: %v", err)
}
fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, automationHTTPMailFixtureHTML)
}))
defer fixtureServer.Close()
app := NewApp(tempRoot)
Start(app, nil)
defer Stop(app, nil)
if err := app.BrowserCoreSave(BrowserCoreInput{
CoreId: "system-chrome",
CoreName: "System Chrome",
CorePath: chromePath,
IsDefault: true,
}); err != nil {
t.Fatalf("save core failed: %v", err)
}
if err := app.BrowserCoreSetDefault("system-chrome"); err != nil {
t.Fatalf("set default core failed: %v", err)
}
baseURL, ok := app.GetLaunchServerInfo()["baseUrl"].(string)
if !ok || strings.TrimSpace(baseURL) == "" {
t.Fatalf("launch server baseUrl missing: %+v", app.GetLaunchServerInfo())
}
var createResp automationHTTPProfileCreateResponse
if err := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/profiles", map[string]any{
"profile": map[string]any{
"profileName": "mail-probe",
"launchArgs": []string{
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--window-size=1440,1024",
},
},
"launchCode": "MAIL01",
}, &createResp); err != nil {
t.Fatalf("create profile via http failed: %v", err)
}
if !createResp.OK || !createResp.Created || createResp.LaunchCode != "MAIL01" {
t.Fatalf("unexpected create response: %+v", createResp)
}
savedScript, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: "mail-probe-script",
Name: "测试邮件探针",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "index.cjs",
ScriptText: automationHTTPMailProbeScriptText,
})
if err != nil {
t.Fatalf("save mail probe script failed: %v", err)
}
if savedScript == nil {
t.Fatalf("expected mail probe script to be saved")
}
savedScript.PublicAPI = automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/probe-message",
RequestMode: "params-only",
ResponseMode: "envelope",
TimeoutMs: 120000,
}
savedScript.SelectorText = fmt.Sprintf("{\n \"code\": %q\n}", createResp.LaunchCode)
if _, err := app.AutomationScriptSave(*savedScript); err != nil {
t.Fatalf("save mail probe public api config failed: %v", err)
}
var scriptsResp automationHTTPScriptsResponse
if err := automationHTTPRequestJSON(http.MethodGet, baseURL+"/api/automation/scripts", nil, &scriptsResp); err != nil {
t.Fatalf("list scripts via http failed: %v", err)
}
if !scriptsResp.OK || scriptsResp.Data.Count == 0 {
t.Fatalf("unexpected scripts response: %+v", scriptsResp)
}
if !automationHTTPHasScript(scriptsResp.Data.Items, "mail-probe-script") {
t.Fatalf("saved mail probe script missing: %+v", scriptsResp)
}
var runResp automationHTTPRunResponse
runErr := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/automation/scripts/run", map[string]any{
"scriptId": "mail-probe-script",
"selector": map[string]any{
"code": createResp.LaunchCode,
},
"params": map[string]any{
"inboxUrl": fixtureServer.URL,
"timeoutMs": 45000,
},
"timeoutMs": 120000,
}, &runResp)
if runErr != nil {
t.Fatalf("run script via http failed: %v", runErr)
}
parsed := make(map[string]any)
if text := strings.TrimSpace(runResp.Data.Run.ResultText); text != "" {
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
t.Fatalf("parse run result failed: %v; result=%s", err, text)
}
if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 0 {
parsed = nested
}
}
if runResp.Data.Run.Status != "success" {
var logsResp automationHTTPLaunchLogsResponse
_ = automationHTTPRequestJSON(http.MethodGet, baseURL+"/api/launch/logs?limit=10", nil, &logsResp)
t.Fatalf("unexpected run response: status=%s summary=%s error=%s logs=%s",
runResp.Data.Run.Status,
runResp.Data.Run.Summary,
runResp.Data.Run.Error,
automationHTTPMarshal(t, logsResp),
)
}
if got := automationHTTPStringValue(parsed, "mailboxName"); got != "ChatGPT" {
t.Fatalf("unexpected mailboxName: %q parsed=%s", got, automationHTTPMarshal(t, parsed))
}
if got := automationHTTPStringValue(parsed, "senderEmail"); got != "noreply@tm.openai.com" {
t.Fatalf("unexpected senderEmail: %q parsed=%s", got, automationHTTPMarshal(t, parsed))
}
if got := automationHTTPStringValue(parsed, "recipientEmail"); got != "target@example.com" {
t.Fatalf("unexpected recipientEmail: %q parsed=%s", got, automationHTTPMarshal(t, parsed))
}
if got := automationHTTPStringValue(parsed, "verificationCode"); got != "429792" {
t.Fatalf("unexpected verificationCode: %q parsed=%s", got, automationHTTPMarshal(t, parsed))
}
if got := parsed["permissionApplied"]; got != true {
t.Fatalf("expected permissionApplied=true, got %#v parsed=%s", got, automationHTTPMarshal(t, parsed))
}
if got := automationHTTPStringValue(parsed, "permissionOrigin"); got != fixtureServer.URL {
t.Fatalf("unexpected permissionOrigin: %q parsed=%s", got, automationHTTPMarshal(t, parsed))
}
signature := automationHTTPStringValue(parsed, "signature")
if !strings.Contains(signature, "Best regards") || !strings.Contains(signature, "ChatGPT") {
t.Fatalf("unexpected signature: %q parsed=%s", signature, automationHTTPMarshal(t, parsed))
}
var hookResp automationHTTPHookEnvelopeResponse
hookErr := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/automation/hooks/mail/probe-message", map[string]any{
"params": map[string]any{
"inboxUrl": fixtureServer.URL,
},
"timeoutMs": 45000,
}, &hookResp)
if hookErr != nil {
t.Fatalf("run public hook via http failed: %v", hookErr)
}
if !hookResp.OK || hookResp.Status != "success" {
t.Fatalf("unexpected hook response: %+v", hookResp)
}
if got := automationHTTPStringValue(hookResp.Result, "verificationCode"); got != "429792" {
t.Fatalf("unexpected hook verificationCode: %q resp=%s", got, automationHTTPMarshal(t, hookResp))
}
if got := automationHTTPStringValue(hookResp.Result, "senderEmail"); got != "noreply@tm.openai.com" {
t.Fatalf("unexpected hook senderEmail: %q resp=%s", got, automationHTTPMarshal(t, hookResp))
}
t.Logf("automation http result: %s", automationHTTPMarshal(t, map[string]any{
"profileId": createResp.ProfileID,
"launchCode": createResp.LaunchCode,
"runStatus": runResp.Data.Run.Status,
"runSummary": runResp.Data.Run.Summary,
"hookStatus": hookResp.Status,
"hookSummary": hookResp.Summary,
"mailboxName": automationHTTPStringValue(parsed, "mailboxName"),
"senderEmail": automationHTTPStringValue(parsed, "senderEmail"),
"recipientEmail": automationHTTPStringValue(parsed, "recipientEmail"),
"verificationCode": automationHTTPStringValue(parsed, "verificationCode"),
"signature": signature,
"subject": automationHTTPStringValue(parsed, "subject"),
}))
}
func lookupAutomationHTTPProbeNode(t *testing.T) string {
t.Helper()
const preferred = `D:\code\plugin\nodejs\node.exe`
if _, err := os.Stat(preferred); err == nil {
return preferred
}
return lookupAutomationTestNode(t)
}
func lookupAutomationHTTPProbeChrome(t *testing.T) string {
t.Helper()
const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe`
if _, err := os.Stat(preferred); err == nil {
return preferred
}
t.Skip("system chrome is not installed")
return ""
}
func automationHTTPRepoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := goruntime.Caller(0)
if !ok {
t.Fatal("resolve repo root failed")
}
return filepath.Dir(filepath.Dir(file))
}
func automationHTTPFreePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("allocate port failed: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error {
repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion))
if _, err := os.Stat(repoRuntimeDir); err != nil {
return fmt.Errorf("repo runtime not found: %w", err)
}
if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil {
return err
}
if err := automationHTTPCopyFile(
filepath.Join(repoRuntimeDir, "runner.cjs"),
filepath.Join(tempRuntimeDir, "runner.cjs"),
); err != nil {
return err
}
return automationHTTPCopyDir(
filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"),
filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"),
)
}
func automationHTTPCopyFile(src string, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func automationHTTPCopyDir(src string, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
relativePath, err := filepath.Rel(src, path)
if err != nil {
return err
}
targetPath := filepath.Join(dst, relativePath)
if info.IsDir() {
return os.MkdirAll(targetPath, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
return os.WriteFile(targetPath, data, info.Mode())
})
}
func automationHTTPRequestJSON(method string, url string, payload any, target any) error {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw)))
}
if target == nil {
return nil
}
if err := json.Unmarshal(raw, target); err != nil {
return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw))
}
return nil
}
func automationHTTPHasScript(items []struct {
ID string `json:"id"`
}, scriptID string) bool {
for _, item := range items {
if strings.TrimSpace(item.ID) == scriptID {
return true
}
}
return false
}
func automationHTTPStringValue(payload map[string]any, key string) string {
if payload == nil {
return ""
}
value, _ := payload[key].(string)
return strings.TrimSpace(value)
}
func automationHTTPMarshal(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal debug payload failed: %v", err)
}
return string(data)
}
const automationHTTPMailFixtureHTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mail Fixture</title>
<script>
window.__notificationProbe = {
supported: typeof Notification !== 'undefined',
requested: false,
result: '',
error: '',
};
document.addEventListener('DOMContentLoaded', () => {
if (typeof Notification === 'undefined' || typeof Notification.requestPermission !== 'function') {
document.documentElement.setAttribute('data-notification-probe', 'unsupported');
return;
}
window.__notificationProbe.requested = true;
Notification.requestPermission()
.then((result) => {
window.__notificationProbe.result = String(result || '');
document.documentElement.setAttribute('data-notification-probe', window.__notificationProbe.result || 'empty');
})
.catch((error) => {
window.__notificationProbe.error = String(error && error.message ? error.message : error);
document.documentElement.setAttribute('data-notification-probe', 'error');
});
});
</script>
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f6f7fb; }
main { display: flex; gap: 20px; padding: 24px; min-height: 100vh; box-sizing: border-box; }
.sidebar { width: 32%; min-width: 320px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 20px; box-sizing: border-box; }
.viewer { width: 60%; min-height: 420px; background: #fff; border: 1px solid #d9dce6; border-radius: 12px; padding: 24px; box-sizing: border-box; }
input { width: 100%; height: 42px; padding: 0 12px; font-size: 16px; box-sizing: border-box; }
[role="row"] { margin-top: 16px; min-height: 56px; border: 1px solid #c8cfdd; border-radius: 10px; padding: 16px; cursor: pointer; background: #fafbff; }
p { margin: 0 0 12px; line-height: 1.55; }
h1 { margin: 0 0 16px; font-size: 28px; }
</style>
</head>
<body>
<main>
<section class="sidebar">
<div role="dialog" tabindex="-1" data-focus-root="1" class="overlay no-outline" data-testid="overlay-button" id="advanced-search-overlay-14">
<input
type="search"
readonly
title="关键词"
placeholder="搜索邮件"
value=""
aria-label="Search messages"
data-testid="search-keyword"
class="input-element w-full cursor-text"
/>
</div>
<div role="row">target@example.com ChatGPT verification code 429792</div>
</section>
<article role="article" class="viewer">
<h1>Your ChatGPT verification code</h1>
<p>From: ChatGPT &lt;noreply@tm.openai.com&gt;</p>
<p>To: target@example.com</p>
<p>Hello,</p>
<p>Your verification code is 429792.</p>
<p>Please use this code to continue signing in.</p>
<p>Best regards</p>
<p>ChatGPT</p>
</article>
</main>
</body>
</html>`
const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
const normalizeText = (value) => String(value == null ? '' : value).trim()
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
? Math.max(5000, Math.round(Number(params.timeoutMs)))
: 45000
const inboxUrl = normalizeText(params.inboxUrl)
if (!inboxUrl) {
throw new Error('inboxUrl is required')
}
const session = await launch({
selector,
skipDefaultStartUrls: true,
startUrls: [inboxUrl],
})
const connection = await connect(session, { timeoutMs })
const browser = connection.browser
if (!browser) {
throw new Error('browser connection is unavailable')
}
const context =
connection.context ||
browser.contexts()[0] ||
(typeof browser.newContext === 'function' ? await browser.newContext() : null)
if (!context) {
throw new Error('browser context is unavailable')
}
const opened = await openPage(connection, {
url: inboxUrl,
timeoutMs,
permissions: ['notifications'],
})
const page = opened.page
await page.waitForLoadState('networkidle', {
timeout: Math.min(timeoutMs, 2500),
}).catch(() => {})
const result = await page.evaluate(() => {
const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
const article = document.querySelector('article')
const lines = Array.from(document.querySelectorAll('article p'))
.map((node) => normalizeText(node.textContent))
.filter(Boolean)
const subject = normalizeText(document.querySelector('article h1')?.textContent)
const fromLine = lines.find((line) => line.startsWith('From:')) || ''
const toLine = lines.find((line) => line.startsWith('To:')) || ''
const articleText = normalizeText(article?.textContent)
const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*</)
const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
const signature = lines.slice(-2).join('\n')
return {
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
subject,
verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
signature,
}
})
return {
ok: true,
permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
summary: '已提取测试邮件内容',
...result,
}
}`
var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
automationHTTPMailProbeScriptTextRaw,
"summary: 'mail probe extracted message',",
)
+164
View File
@@ -2,6 +2,9 @@ package backend
import (
"fmt"
"path/filepath"
"runtime"
"sort"
"strings"
"ant-chrome/backend/internal/automation"
@@ -9,6 +12,17 @@ import (
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type AutomationScriptImportIssue struct {
Path string `json:"path"`
Message string `json:"message"`
}
type AutomationScriptBatchImportResult struct {
Imported []automation.ScriptRecord `json:"imported"`
Failed []AutomationScriptImportIssue `json:"failed"`
Scanned int `json:"scanned"`
}
func (a *App) AutomationScriptImportText(text string) (*automation.ScriptRecord, error) {
bundle, err := automation.ImportBundleFromBytesWithOptions("automation-template.json", []byte(strings.TrimSpace(text)), "文本导入", a.automationScriptImportOptions())
if err != nil {
@@ -65,6 +79,24 @@ func (a *App) AutomationScriptImportLocalDirectory() (*automation.ScriptRecord,
return a.saveImportedAutomationBundle(bundle)
}
func (a *App) AutomationScriptImportLocalLibrary() (*AutomationScriptBatchImportResult, error) {
if a.ctx == nil {
return nil, fmt.Errorf("应用上下文未初始化")
}
path, err := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "选择脚本库目录",
})
if err != nil {
return nil, fmt.Errorf("打开目录对话框失败: %w", err)
}
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("未选择脚本库目录")
}
return a.importAutomationLocalLibrary(path)
}
func (a *App) AutomationScriptImportRemote(rawURL string) (*automation.ScriptRecord, error) {
bundle, err := a.loadAutomationRemoteBundle(strings.TrimSpace(rawURL))
if err != nil {
@@ -100,6 +132,7 @@ func (a *App) AutomationScriptRefresh(scriptID string) (*automation.ScriptRecord
bundle.Record.ID = existing.ID
bundle.Record.CreatedAt = existing.CreatedAt
bundle.Record.Status = existing.Status
bundle.Record.PublicAPI = existing.PublicAPI
record, err := a.automationScriptStore().ImportBundle(bundle)
if err != nil {
@@ -116,6 +149,137 @@ func (a *App) saveImportedAutomationBundle(bundle automation.ImportedBundle) (*a
return &record, nil
}
func (a *App) importAutomationLocalLibrary(rootDir string) (*AutomationScriptBatchImportResult, error) {
directories, err := automation.DiscoverImportableScriptDirectoriesWithOptions(rootDir)
if err != nil {
return nil, err
}
existingBySource, err := a.automationScriptsBySourceKey()
if err != nil {
return nil, err
}
result := &AutomationScriptBatchImportResult{
Imported: make([]automation.ScriptRecord, 0, len(directories)),
Failed: []AutomationScriptImportIssue{},
Scanned: len(directories),
}
store := a.automationScriptStore()
for _, dir := range directories {
bundle, err := automation.ImportBundleFromDirectoryWithOptions(dir, "", "本地目录 "+dir, a.automationScriptImportOptions())
if err != nil {
result.Failed = append(result.Failed, AutomationScriptImportIssue{
Path: dir,
Message: err.Error(),
})
continue
}
sourceKey := automationScriptSourceKey(bundle.Record.Source)
if existing, exists := existingBySource[sourceKey]; exists {
bundle.Record.ID = existing.ID
bundle.Record.CreatedAt = existing.CreatedAt
bundle.Record.Status = existing.Status
bundle.Record.PublicAPI = existing.PublicAPI
}
record, err := store.ImportBundle(bundle)
if err != nil {
result.Failed = append(result.Failed, AutomationScriptImportIssue{
Path: dir,
Message: err.Error(),
})
continue
}
enriched := a.enrichAutomationScriptRecord(record)
result.Imported = append(result.Imported, enriched)
existingBySource[automationScriptSourceKey(record.Source)] = record
}
sort.Slice(result.Imported, func(i, j int) bool {
return strings.TrimSpace(result.Imported[i].UpdatedAt) > strings.TrimSpace(result.Imported[j].UpdatedAt)
})
if len(result.Imported) == 0 {
if len(result.Failed) == 0 {
return nil, fmt.Errorf("未导入任何脚本")
}
firstFailure := result.Failed[0]
if len(result.Failed) == 1 {
return nil, fmt.Errorf("导入失败: %s", firstFailure.Message)
}
return nil, fmt.Errorf("导入失败: %s;另有 %d 个脚本包也失败", firstFailure.Message, len(result.Failed)-1)
}
return result, nil
}
func (a *App) automationScriptsBySourceKey() (map[string]automation.ScriptRecord, error) {
items, err := a.automationScriptStore().List()
if err != nil {
return nil, err
}
result := make(map[string]automation.ScriptRecord, len(items))
for _, item := range items {
key := automationScriptSourceKey(item.Source)
if key == "" {
continue
}
result[key] = item
}
return result, nil
}
func automationScriptSourceKey(source automation.ScriptSource) string {
sourceType := strings.TrimSpace(source.Type)
if sourceType == "" {
return ""
}
normalizeLocalPath := func(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
cleaned := filepath.Clean(trimmed)
if strings.EqualFold(cleaned, ".") {
return ""
}
if runtime.GOOS == "windows" {
return strings.ToLower(cleaned)
}
return cleaned
}
uri := strings.TrimSpace(source.URI)
ref := strings.TrimSpace(source.Ref)
path := strings.TrimSpace(source.Path)
switch sourceType {
case "local-file", "local-dir":
uri = normalizeLocalPath(firstNonBlank(uri, path))
path = ""
case "git":
if path != "" {
path = filepath.ToSlash(filepath.Clean(path))
}
default:
uri = strings.TrimSpace(uri)
path = strings.TrimSpace(path)
}
return strings.Join([]string{
strings.ToLower(sourceType),
uri,
ref,
path,
}, "|")
}
func (a *App) automationScriptImportOptions() automation.ImportOptions {
if a.config == nil {
return automation.ImportOptions{}
@@ -21,6 +21,8 @@ const (
func (a *App) loadAutomationBundleFromSource(source automation.ScriptSource) (automation.ImportedBundle, error) {
sourceType := strings.TrimSpace(source.Type)
switch sourceType {
case "builtin":
return automation.ImportBuiltinBundleFromSource(source)
case "local-file":
path := firstNonBlank(source.URI, source.Path)
if path == "" {
@@ -0,0 +1,192 @@
package backend
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"ant-chrome/backend/internal/launchcode"
)
const automationScriptPublicAPIInvokeDefaultTimeout = 31 * time.Minute
type AutomationScriptPublicAPIInvokeInput struct {
URL string `json:"url"`
Method string `json:"method"`
BodyText string `json:"bodyText"`
APIKey string `json:"apiKey"`
AuthHeader string `json:"authHeader"`
TimeoutMs int `json:"timeoutMs"`
}
type AutomationScriptPublicAPIInvokeResult struct {
OK bool `json:"ok"`
Status int `json:"status"`
StatusText string `json:"statusText"`
BodyText string `json:"bodyText"`
BodyJSON interface{} `json:"bodyJson"`
}
func (a *App) AutomationScriptInvokePublicAPI(input AutomationScriptPublicAPIInvokeInput) (*AutomationScriptPublicAPIInvokeResult, error) {
requestURL, err := normalizeAutomationScriptInvokeURL(input.URL)
if err != nil {
return nil, err
}
method := strings.ToUpper(strings.TrimSpace(input.Method))
if method == "" {
method = http.MethodPost
}
bodyText := strings.TrimSpace(input.BodyText)
if bodyText == "" {
bodyText = "{}"
}
timeout := automationScriptPublicAPIInvokeDefaultTimeout
if input.TimeoutMs > 0 {
timeout = time.Duration(input.TimeoutMs) * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
method,
requestURL.String(),
bytes.NewBufferString(bodyText),
)
if err != nil {
return nil, fmt.Errorf("create invoke request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
authHeader := strings.TrimSpace(input.AuthHeader)
if authHeader == "" {
authHeader = launchcode.DefaultAPIKeyHeader
}
apiKey := strings.TrimSpace(input.APIKey)
if apiKey == "" {
if autoHeader, autoKey := a.resolveAutomationScriptInvokeAuth(requestURL); autoKey != "" {
authHeader = autoHeader
apiKey = autoKey
}
}
if authHeader != "" && apiKey != "" {
req.Header.Set(authHeader, apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("invoke public api failed: %w", ctxErr)
}
return nil, fmt.Errorf("invoke public api failed: %w", err)
}
defer resp.Body.Close()
rawBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("read public api response failed: %w", err)
}
result := &AutomationScriptPublicAPIInvokeResult{
OK: resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices,
Status: resp.StatusCode,
StatusText: http.StatusText(resp.StatusCode),
BodyText: string(rawBody),
BodyJSON: nil,
}
trimmedBody := bytes.TrimSpace(rawBody)
if len(trimmedBody) > 0 {
var decoded interface{}
if err := json.Unmarshal(trimmedBody, &decoded); err == nil {
result.BodyJSON = decoded
}
}
return result, nil
}
func normalizeAutomationScriptInvokeURL(rawURL string) (*url.URL, error) {
normalizedURL := strings.TrimSpace(rawURL)
if normalizedURL == "" {
return nil, fmt.Errorf("接口地址不能为空")
}
parsedURL, err := url.Parse(normalizedURL)
if err != nil {
return nil, fmt.Errorf("接口地址不合法: %w", err)
}
if !parsedURL.IsAbs() {
return nil, fmt.Errorf("接口地址必须是完整 URL")
}
switch strings.ToLower(parsedURL.Scheme) {
case "http", "https":
default:
return nil, fmt.Errorf("接口地址仅支持 http 或 https")
}
if strings.TrimSpace(parsedURL.Host) == "" {
return nil, fmt.Errorf("接口地址缺少主机")
}
return parsedURL, nil
}
func (a *App) resolveAutomationScriptInvokeAuth(targetURL *url.URL) (string, string) {
if targetURL == nil || a.launchServer == nil || a.config == nil {
return "", ""
}
if !a.launchServer.APIAuthEnabled() {
return "", ""
}
apiKey := strings.TrimSpace(a.config.LaunchServer.Auth.APIKey)
if apiKey == "" {
return "", ""
}
launchPort := a.launchServer.Port()
if launchPort <= 0 {
return "", ""
}
requestPort := targetURL.Port()
if requestPort == "" {
switch strings.ToLower(targetURL.Scheme) {
case "https":
requestPort = "443"
default:
requestPort = "80"
}
}
if requestPort != strconv.Itoa(launchPort) {
return "", ""
}
host := strings.TrimSpace(strings.ToLower(targetURL.Hostname()))
if host == "" {
return "", ""
}
if parsedIP := net.ParseIP(host); parsedIP != nil {
if !parsedIP.IsLoopback() {
return "", ""
}
} else if host != "localhost" {
return "", ""
}
return a.launchServer.APIAuthHeader(), apiKey
}
@@ -0,0 +1,118 @@
package backend
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/launchcode"
)
func TestAutomationScriptInvokePublicAPIReturnsJSON(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
if strings.TrimSpace(string(body)) != `{"hello":"world"}` {
t.Fatalf("unexpected request body: %s", string(body))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"verificationCode":"429792"}`))
}))
defer server.Close()
app := NewApp(t.TempDir())
result, err := app.AutomationScriptInvokePublicAPI(AutomationScriptPublicAPIInvokeInput{
URL: server.URL + "/api/automation/hooks/test",
Method: http.MethodPost,
BodyText: `{"hello":"world"}`,
})
if err != nil {
t.Fatalf("AutomationScriptInvokePublicAPI returned error: %v", err)
}
if result == nil {
t.Fatal("AutomationScriptInvokePublicAPI returned nil result")
}
if !result.OK {
t.Fatalf("expected ok result, got %+v", result)
}
if result.Status != http.StatusOK {
t.Fatalf("expected status 200, got %d", result.Status)
}
bodyJSON, ok := result.BodyJSON.(map[string]interface{})
if !ok {
t.Fatalf("expected bodyJson object, got %#v", result.BodyJSON)
}
if bodyJSON["verificationCode"] != "429792" {
t.Fatalf("expected verificationCode 429792, got %#v", bodyJSON["verificationCode"])
}
}
func TestAutomationScriptInvokePublicAPIAutoUsesLaunchServerAuth(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Test-Key"); got != "secret-123" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"ok":false}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"mailboxName":"ChatGPT"}`))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test server url failed: %v", err)
}
port, err := strconv.Atoi(parsedURL.Port())
if err != nil {
t.Fatalf("parse test server port failed: %v", err)
}
app := NewApp(t.TempDir())
app.config = config.DefaultConfig()
app.config.LaunchServer.Auth.Enabled = true
app.config.LaunchServer.Auth.APIKey = "secret-123"
app.config.LaunchServer.Auth.Header = "X-Test-Key"
app.launchServer = launchcode.NewLaunchServer(nil, nil, nil, port)
app.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{
Enabled: true,
APIKey: "secret-123",
Header: "X-Test-Key",
})
result, err := app.AutomationScriptInvokePublicAPI(AutomationScriptPublicAPIInvokeInput{
URL: server.URL + "/api/automation/hooks/test",
Method: http.MethodPost,
BodyText: `{}`,
})
if err != nil {
t.Fatalf("AutomationScriptInvokePublicAPI returned error: %v", err)
}
if result == nil {
t.Fatal("AutomationScriptInvokePublicAPI returned nil result")
}
if !result.OK {
t.Fatalf("expected ok result, got %+v", result)
}
if result.Status != http.StatusOK {
t.Fatalf("expected status 200, got %d", result.Status)
}
}
+8 -1
View File
@@ -40,7 +40,14 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest)
StartedAt: startedAt.Format(time.RFC3339),
}
script, err := a.automationScriptStore().Get(run.ScriptID)
store := a.automationScriptStore()
if err := a.ensureAutomationScriptDefaults(store); err != nil {
run.Summary = "脚本读取失败"
run.Error = err.Error()
return a.finalizeAutomationScriptRun(run, startedAt)
}
script, err := store.Get(run.ScriptID)
if err != nil {
run.Summary = "脚本读取失败"
run.Error = err.Error()
@@ -2,6 +2,7 @@ package backend
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -10,8 +11,10 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"ant-chrome/backend/internal/automation"
"ant-chrome/backend/internal/browser"
@@ -228,6 +231,212 @@ func TestAutomationScriptRunWithOptionsPrestartsStoredTargetForConnectOnlyScript
}
}
func TestAutomationScriptRunWithOptionsPrestartsManualCodeTargetForConnectOnlyScript(t *testing.T) {
app, cleanup := newAutomationPlaywrightRunTestApp(t, automationTestConnectProbePlaywrightModule)
defer cleanup()
var debugHits atomic.Int32
debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
debugHits.Add(1)
if r.URL.Path != "/json/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"Browser": "Chrome/123.0.0.0",
})
}))
defer debugServer.Close()
profile := createAutomationRunningProfileWithCode(
t,
app,
"buyer-manual-code",
"BUYER_001",
automationTestServerPort(t, debugServer.URL),
)
saved, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: "playwright-connect-manual-code",
Name: "Playwright Connect Manual Code",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
ScriptText: "module.exports.run = async ({ connect }) => {\n" +
" const { browser } = await connect()\n" +
" return { ok: true, summary: 'connected through manual code', contextCount: browser.contexts().length }\n" +
"}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
}
run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{
ScriptID: saved.ID,
SelectorText: `{"code":"BUYER_001"}`,
UseScriptSelector: false,
UseScriptParams: true,
})
if err != nil {
t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err)
}
if run == nil {
t.Fatalf("AutomationScriptRunWithOptions returned nil result")
}
if run.Status != "success" {
t.Fatalf("expected success status, got %+v", run)
}
if !strings.Contains(run.Summary, "connected through manual code") {
t.Fatalf("unexpected run summary: %q", run.Summary)
}
if !strings.Contains(run.ResultText, `"contextCount":1`) {
t.Fatalf("expected connect result payload, got %s", run.ResultText)
}
if debugHits.Load() == 0 {
t.Fatalf("expected connect() to hit active debug endpoint through launch server")
}
if app.launchServer == nil {
t.Fatal("expected launch server to be initialized")
}
activeProfileID, _, _ := app.launchServer.ActiveProfile()
if activeProfileID != profile.ProfileId {
t.Fatalf("expected active profile %s, got %s", profile.ProfileId, activeProfileID)
}
}
func TestAutomationScriptRunWithOptionsAllowsSameScriptOnDifferentProfiles(t *testing.T) {
app, cleanup := newAutomationPlaywrightRunTestApp(t, "module.exports = { chromium: {} }\n")
defer cleanup()
debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer debugServer.Close()
debugPort := automationTestServerPort(t, debugServer.URL)
profileA := createAutomationRunningProfileWithCode(t, app, "buyer-a", "BUYER_A", debugPort)
profileB := createAutomationRunningProfileWithCode(t, app, "buyer-b", "BUYER_B", debugPort)
saved, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: "slow-shared-script",
Name: "Slow Shared Script",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok' }\n}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
}
results := runAutomationScriptsConcurrently(t, 2, func(index int) (*automation.ScriptRunRecord, error) {
profileID := profileA.ProfileId
if index == 1 {
profileID = profileB.ProfileId
}
return app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{
ScriptID: saved.ID,
SelectorText: fmt.Sprintf(`{"profileId":"%s"}`, profileID),
UseScriptSelector: false,
UseScriptParams: true,
TimeoutMs: 5000,
})
})
for _, result := range results {
if result.err != nil {
t.Fatalf("AutomationScriptRunWithOptions returned error: %v", result.err)
}
if result.run == nil {
t.Fatal("AutomationScriptRunWithOptions returned nil result")
}
if result.run.Status != "success" {
t.Fatalf("expected both runs to succeed on different profiles, got %+v", result.run)
}
}
}
func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *testing.T) {
app, cleanup := newAutomationPlaywrightRunTestApp(t, "module.exports = { chromium: {} }\n")
defer cleanup()
debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer debugServer.Close()
profile := createAutomationRunningProfileWithCode(
t,
app,
"buyer-shared",
"BUYER_SHARED",
automationTestServerPort(t, debugServer.URL),
)
firstScript, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: "slow-script-a",
Name: "Slow Script A",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok a' }\n}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
}
secondScript, err := app.AutomationScriptSave(automation.ScriptRecord{
ID: "slow-script-b",
Name: "Slow Script B",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "scripts/index.cjs",
ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok b' }\n}\n",
})
if err != nil {
t.Fatalf("AutomationScriptSave returned error: %v", err)
}
results := runAutomationScriptsConcurrently(t, 2, func(index int) (*automation.ScriptRunRecord, error) {
scriptID := firstScript.ID
if index == 1 {
scriptID = secondScript.ID
}
return app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{
ScriptID: scriptID,
SelectorText: fmt.Sprintf(`{"profileId":"%s"}`, profile.ProfileId),
UseScriptSelector: false,
UseScriptParams: true,
TimeoutMs: 5000,
})
})
successCount := 0
failedCount := 0
for _, result := range results {
if result.err != nil {
t.Fatalf("AutomationScriptRunWithOptions returned error: %v", result.err)
}
if result.run == nil {
t.Fatal("AutomationScriptRunWithOptions returned nil result")
}
switch result.run.Status {
case "success":
successCount++
case "failed":
failedCount++
if !strings.Contains(result.run.Error, "已有自动化任务在运行中") {
t.Fatalf("expected target lock failure, got %+v", result.run)
}
default:
t.Fatalf("unexpected run status: %+v", result.run)
}
}
if successCount != 1 || failedCount != 1 {
t.Fatalf("expected one success and one failure on same profile, got success=%d failed=%d results=%+v", successCount, failedCount, results)
}
}
func lookupAutomationTestNode(t *testing.T) string {
t.Helper()
@@ -345,3 +554,116 @@ main().catch((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
}
+70 -8
View File
@@ -6,6 +6,7 @@ import (
"strings"
"ant-chrome/backend/internal/automation"
"ant-chrome/backend/internal/browser"
)
func automationSelectorProfileID(selector map[string]any) string {
@@ -17,16 +18,73 @@ func automationSelectorProfileID(selector map[string]any) string {
return strings.TrimSpace(profileID)
}
func (a *App) ensurePlaywrightTargetReady(selector map[string]any) error {
profileID := automationSelectorProfileID(selector)
if profileID == "" {
func automationSelectorCode(selector map[string]any) string {
if selector == nil {
return ""
}
code, _ := selector["code"].(string)
return strings.ToUpper(strings.TrimSpace(code))
}
func cloneAutomationSelector(selector map[string]any) map[string]any {
if selector == nil {
return nil
}
if _, err := a.BrowserInstanceStart(profileID); err != nil {
return fmt.Errorf("预启动脚本目标实例失败: %w", err)
cloned := make(map[string]any, len(selector))
for key, value := range selector {
cloned[key] = value
}
return nil
return cloned
}
func (a *App) ensurePlaywrightTargetReady(selector map[string]any) (map[string]any, string, error) {
normalized := cloneAutomationSelector(selector)
profileID := automationSelectorProfileID(normalized)
code := automationSelectorCode(normalized)
if profileID == "" && code == "" {
return normalized, "", nil
}
var (
profile *browser.Profile
err error
)
switch {
case profileID != "":
profile, err = a.BrowserInstanceStart(profileID)
case code != "":
profile, err = a.BrowserInstanceStartByCode(code)
}
if err != nil {
return nil, "", fmt.Errorf("预启动脚本目标实例失败: %w", err)
}
if profile == nil {
return nil, "", fmt.Errorf("预启动脚本目标实例失败:未返回实例")
}
resolvedProfileID := strings.TrimSpace(profile.ProfileId)
if resolvedProfileID != "" && normalized != nil {
normalized["profileId"] = resolvedProfileID
}
resolvedCode := strings.ToUpper(strings.TrimSpace(profile.LaunchCode))
if resolvedCode == "" {
resolvedCode = code
}
if resolvedCode != "" && normalized != nil {
normalized["code"] = resolvedCode
}
return normalized, resolvedProfileID, nil
}
func automationScriptTaskKey(scriptID string, selector map[string]any) string {
if profileID := automationSelectorProfileID(selector); profileID != "" {
return profileID
}
return "script:" + strings.TrimSpace(scriptID)
}
func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) {
@@ -60,7 +118,8 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR
if err != nil {
return "", "脚本执行失败", err.Error()
}
if err := a.ensurePlaywrightTargetReady(selector); err != nil {
selector, taskProfileID, err := a.ensurePlaywrightTargetReady(selector)
if err != nil {
return "", "脚本执行失败", err.Error()
}
if err := ctx.Err(); err != nil {
@@ -86,7 +145,7 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR
}
taskResult, err := a.automationMgr.RunScriptTask(ctx, automation.ScriptTaskRequest{
TaskKey: "script:" + script.ID,
TaskKey: automationScriptTaskKey(script.ID, selector),
ScriptPath: scriptPath,
Selector: selector,
Params: params,
@@ -99,6 +158,9 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR
if err != nil {
return "", "脚本执行失败", err.Error()
}
if taskResult.TaskKey == "" && taskProfileID != "" {
taskResult.TaskKey = taskProfileID
}
if !taskResult.OK {
errorText := strings.TrimSpace(taskResult.Error)
if errorText == "" {
+51
View File
@@ -64,6 +64,57 @@ func TestPreparePlaywrightScriptWorkspaceCopiesScriptDirectory(t *testing.T) {
if info, err := os.Stat(artifactDir); err != nil || !info.IsDir() {
t.Fatalf("expected artifact dir to exist, got err=%v info=%v", err, info)
}
if !strings.Contains(filepath.ToSlash(artifactDir), "data/automation/artifacts/workspace-script/") {
t.Fatalf("expected default artifact dir under data/automation/artifacts, got %s", artifactDir)
}
}
func TestPreparePlaywrightScriptWorkspaceUsesConfiguredArtifactsDir(t *testing.T) {
appRoot := t.TempDir()
app := NewApp(appRoot)
customRoot := filepath.Join(t.TempDir(), "custom-artifacts")
app.config = DefaultConfig()
app.config.Automation.ArtifactsDir = customRoot
script := automation.ScriptRecord{
ID: "custom-artifact-script",
Name: "自定义输出脚本",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "index.cjs",
ScriptText: "module.exports.run = async () => ({ ok: true })",
}
runtimeDir := filepath.Join(t.TempDir(), "runtime")
_, artifactDir, cleanup, err := app.preparePlaywrightScriptWorkspace(runtimeDir, script)
if err != nil {
t.Fatalf("preparePlaywrightScriptWorkspace returned error: %v", err)
}
defer cleanup()
if !strings.HasPrefix(artifactDir, customRoot+string(os.PathSeparator)) {
t.Fatalf("expected artifact dir under custom root, got %s want-prefix %s", artifactDir, customRoot)
}
if info, err := os.Stat(artifactDir); err != nil || !info.IsDir() {
t.Fatalf("expected custom artifact dir to exist, got err=%v info=%v", err, info)
}
if !strings.Contains(filepath.ToSlash(artifactDir), "/custom-artifact-script/") {
t.Fatalf("expected artifact dir to include script id, got %s", artifactDir)
}
relativeApp := NewApp(appRoot)
relativeApp.config = DefaultConfig()
relativeApp.config.Automation.ArtifactsDir = "exports/automation"
_, relativeArtifactDir, relativeCleanup, err := relativeApp.preparePlaywrightScriptWorkspace(runtimeDir, script)
if err != nil {
t.Fatalf("preparePlaywrightScriptWorkspace with relative dir returned error: %v", err)
}
defer relativeCleanup()
expectedRelativeRoot := filepath.Join(appRoot, "exports", "automation")
if !strings.HasPrefix(relativeArtifactDir, expectedRelativeRoot+string(os.PathSeparator)) {
t.Fatalf("expected relative artifact dir under app root, got %s want-prefix %s", relativeArtifactDir, expectedRelativeRoot)
}
}
func TestPreparePlaywrightScriptWorkspaceFallsBackWhenScriptDirMissing(t *testing.T) {
+98 -3
View File
@@ -15,13 +15,26 @@ const defaultAutomationCreateNameTemplate = "${templateName}-${timestamp}"
func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord, input automation.ScriptRunRequest, required bool) (map[string]any, string, error) {
overrideSelectorText := strings.TrimSpace(input.SelectorText)
if !input.UseScriptSelector && overrideSelectorText != "" {
if automationScriptTargetMode(script) == "manual" && !input.UseScriptSelector && overrideSelectorText != "" {
selector, err := parseAutomationJSONObject(overrideSelectorText, required)
return selector, "", err
}
if strings.TrimSpace(script.TargetConfig.Mode) != "" && !strings.EqualFold(script.TargetConfig.Mode, "manual") {
return a.resolveAutomationScriptTarget(script)
if automationScriptTargetMode(script) != "manual" {
targetInput := input.TargetInput
if targetInput == nil && !input.UseScriptSelector && overrideSelectorText != "" {
selector, err := parseAutomationJSONObject(overrideSelectorText, false)
if err != nil {
return nil, "", err
}
targetInput = selector
}
input.TargetInput = targetInput
effectiveScript, err := applyAutomationRunTargetInput(script, input.TargetInput)
if err != nil {
return nil, "", err
}
return a.resolveAutomationScriptTarget(effectiveScript)
}
selectorText := resolveAutomationRunJSONText(input.SelectorText, script.SelectorText, input.UseScriptSelector)
@@ -29,6 +42,88 @@ func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord,
return selector, "", err
}
func automationScriptTargetMode(script automation.ScriptRecord) string {
mode := strings.ToLower(strings.TrimSpace(script.TargetConfig.Mode))
switch mode {
case "existing", "create", "rotate":
return mode
default:
return "manual"
}
}
func applyAutomationRunTargetInput(script automation.ScriptRecord, value any) (automation.ScriptRecord, error) {
if value == nil {
return script, nil
}
payload, err := marshalAutomationRunTargetInput(value)
if err != nil {
return script, err
}
if len(payload) == 0 {
return script, nil
}
switch automationScriptTargetMode(script) {
case "existing":
selector, err := decodeAutomationRunTargetSelector(payload)
if err != nil {
return script, fmt.Errorf("已有实例配置无效: %w", err)
}
script.TargetConfig.Selector = selector
case "rotate":
selector, err := decodeAutomationRunTargetSelector(payload)
if err != nil {
return script, fmt.Errorf("条件轮询配置无效: %w", err)
}
script.TargetConfig.Selector = selector
case "create":
var input struct {
TemplateSelector automation.ScriptTargetSelector `json:"templateSelector"`
Selector automation.ScriptTargetSelector `json:"selector"`
CreateNameTemplate string `json:"createNameTemplate"`
ProfileName string `json:"profileName"`
}
if err := json.Unmarshal(payload, &input); err != nil {
return script, fmt.Errorf("targetInput must be a JSON object")
}
selector := input.TemplateSelector
if automationTargetSelectorEmpty(normalizeAutomationTargetSelector(selector)) {
selector = input.Selector
}
script.TargetConfig.TemplateSelector = selector
if name := strings.TrimSpace(input.CreateNameTemplate); name != "" {
script.TargetConfig.CreateNameTemplate = name
} else if name := strings.TrimSpace(input.ProfileName); name != "" {
script.TargetConfig.CreateNameTemplate = name
}
}
return script, nil
}
func marshalAutomationRunTargetInput(value any) ([]byte, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("targetInput must be a JSON object")
}
var object map[string]any
if err := json.Unmarshal(data, &object); err != nil || object == nil {
return nil, fmt.Errorf("targetInput must be a JSON object")
}
if len(object) == 0 {
return nil, nil
}
return data, nil
}
func decodeAutomationRunTargetSelector(payload []byte) (automation.ScriptTargetSelector, error) {
var selector automation.ScriptTargetSelector
if err := json.Unmarshal(payload, &selector); err != nil {
return selector, fmt.Errorf("targetInput must be a JSON object")
}
return selector, nil
}
func (a *App) resolveAutomationScriptTarget(script automation.ScriptRecord) (map[string]any, string, error) {
switch strings.ToLower(strings.TrimSpace(script.TargetConfig.Mode)) {
case "existing":
+12 -1
View File
@@ -13,7 +13,7 @@ import (
func (a *App) preparePlaywrightScriptWorkspace(runtimeDir string, script automation.ScriptRecord) (string, string, func(), error) {
execRoot := filepath.Join(runtimeDir, "tmp", "script-run", fmt.Sprintf("%s-%d", strings.TrimSpace(script.ID), time.Now().UnixNano()))
scriptPath := filepath.Join(execRoot, filepath.FromSlash(script.EntryFile))
artifactDir := filepath.Join(a.appDataDir(), "automation", "artifacts", strings.TrimSpace(script.ID), time.Now().Format("20060102-150405"))
artifactDir := filepath.Join(a.automationArtifactsRootDir(), strings.TrimSpace(script.ID), time.Now().Format("20060102-150405"))
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
return "", "", nil, fmt.Errorf("create script artifact dir failed: %w", err)
@@ -45,6 +45,17 @@ func (a *App) preparePlaywrightScriptWorkspace(runtimeDir string, script automat
return scriptPath, artifactDir, cleanup, nil
}
func (a *App) automationArtifactsRootDir() string {
configured := ""
if a.config != nil {
configured = strings.TrimSpace(a.config.Automation.ArtifactsDir)
}
if configured == "" {
configured = filepath.ToSlash(filepath.Join("data", "automation", "artifacts"))
}
return a.resolveAppPath(configured)
}
func copyAutomationScriptDir(srcDir string, dstDir string) error {
if err := filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
+7
View File
@@ -18,6 +18,7 @@ func (a *App) automationStatePayload() map[string]interface{} {
"headlessDefault": false,
"keepRuntimeOnDisable": true,
"allowTypeScriptBuild": false,
"artifactsDir": "data/automation/artifacts",
"nodeSource": config.DefaultAutomationNodeSource,
"systemNodePath": "",
"nodeVersion": config.DefaultAutomationNodeVersion,
@@ -46,6 +47,7 @@ func (a *App) automationStatePayload() map[string]interface{} {
settings["headlessDefault"] = a.config.Automation.HeadlessDefault
settings["keepRuntimeOnDisable"] = a.config.Automation.KeepRuntimeOnDisable
settings["allowTypeScriptBuild"] = a.config.Automation.AllowTypeScriptBuild
settings["artifactsDir"] = a.config.Automation.ArtifactsDir
settings["nodeSource"] = a.config.Automation.NodeSource
settings["systemNodePath"] = a.config.Automation.SystemNodePath
settings["nodeVersion"] = a.config.Automation.NodeVersion
@@ -102,6 +104,11 @@ func applyAutomationConfigDefaults(auto *config.AutomationConfig) {
if !auto.KeepRuntimeOnDisable {
auto.KeepRuntimeOnDisable = true
}
if strings.TrimSpace(auto.ArtifactsDir) == "" {
auto.ArtifactsDir = "data/automation/artifacts"
} else {
auto.ArtifactsDir = strings.TrimSpace(auto.ArtifactsDir)
}
}
func normalizeAutomationNodeSourceInput(value string) string {
+111
View File
@@ -0,0 +1,111 @@
package backend
import (
"errors"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
)
type browserUserDataProcess struct {
PID int `json:"pid"`
DebugPort int `json:"debugPort"`
CommandLine string `json:"commandLine"`
}
type browserRuntimeDetection struct {
PID int
DebugPort int
DebugReady bool
}
var errBrowserStartHandledByRecoveredRuntime = errors.New("browser start handled by recovered runtime")
var findBrowserUserDataProcesses = findBrowserUserDataProcessesOS
var terminateBrowserUserDataProcess = terminateBrowserUserDataProcessOS
var remoteDebuggingPortPattern = regexp.MustCompile(`(?i)--remote-debugging-port=(\d+)`)
func parseRemoteDebuggingPort(commandLine string) int {
matches := remoteDebuggingPortPattern.FindStringSubmatch(commandLine)
if len(matches) < 2 {
return 0
}
port, err := strconv.Atoi(matches[1])
if err != nil || port <= 0 {
return 0
}
return port
}
func detectBrowserRuntimeByUserDataDir(userDataDir string) (browserRuntimeDetection, bool) {
userDataDir = strings.TrimSpace(userDataDir)
if userDataDir == "" {
return browserRuntimeDetection{}, false
}
if detection, ok := detectBrowserRuntimeByActivePort(userDataDir); ok {
return detection, true
}
processes, err := findBrowserUserDataProcesses(userDataDir)
if err != nil || len(processes) == 0 {
return browserRuntimeDetection{}, false
}
for _, process := range processes {
debugPort := process.DebugPort
if debugPort <= 0 {
debugPort = parseRemoteDebuggingPort(process.CommandLine)
}
if debugPort > 0 {
if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil {
return browserRuntimeDetection{PID: process.PID, DebugPort: debugPort, DebugReady: true}, true
}
}
}
first := processes[0]
return browserRuntimeDetection{PID: first.PID, DebugPort: first.DebugPort}, true
}
func detectBrowserRuntimeByActivePort(userDataDir string) (browserRuntimeDetection, bool) {
userDataDir = strings.TrimSpace(userDataDir)
if userDataDir == "" {
return browserRuntimeDetection{}, false
}
if debugPort, err := readBrowserDebugPortFile(userDataDir); err == nil && debugPort > 0 {
if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil {
return browserRuntimeDetection{DebugPort: debugPort, DebugReady: true}, true
}
}
return browserRuntimeDetection{}, false
}
func terminateBrowserProcessesByUserDataDir(userDataDir string, timeout time.Duration) (bool, error) {
processes, err := findBrowserUserDataProcesses(userDataDir)
if err != nil {
return false, err
}
if len(processes) == 0 {
return false, nil
}
var errs []error
terminated := false
for _, process := range processes {
if process.PID <= 0 || process.PID == os.Getpid() {
continue
}
terminated = true
if err := terminateBrowserUserDataProcess(process.PID, timeout); err != nil {
errs = append(errs, fmt.Errorf("pid %d: %w", process.PID, err))
}
}
return terminated, errors.Join(errs...)
}
@@ -0,0 +1,28 @@
//go:build !windows
// +build !windows
package backend
import (
"fmt"
"os"
"time"
)
func findBrowserUserDataProcessesOS(userDataDir string) ([]browserUserDataProcess, error) {
return nil, nil
}
func terminateBrowserUserDataProcessOS(pid int, timeout time.Duration) error {
if pid <= 0 {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
if err := process.Kill(); err != nil {
return err
}
return fmt.Errorf("process termination fallback does not wait for pid %d", pid)
}
+200
View File
@@ -0,0 +1,200 @@
package backend
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
)
func TestParseRemoteDebuggingPort(t *testing.T) {
t.Parallel()
got := parseRemoteDebuggingPort(`chrome.exe --user-data-dir="D:\data\p1" --remote-debugging-port=49152`)
if got != 49152 {
t.Fatalf("expected port 49152, got %d", got)
}
}
func TestBrowserInstanceStatusRecoversRunningProfileByUserDataDir(t *testing.T) {
server := newRecordedCDPServer(t)
defer server.Close()
app := newRuntimeRecoveryTestApp(t)
profile := &BrowserProfile{
ProfileId: "recover-status",
ProfileName: "Recover Status",
UserDataDir: "recover-status",
Running: false,
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
userDataDir := app.browserMgr.ResolveUserDataDir(profile)
writeDevToolsActivePort(t, userDataDir, server.Port())
snapshot, err := app.BrowserInstanceStatus(profile.ProfileId)
if err != nil {
t.Fatalf("BrowserInstanceStatus returned error: %v", err)
}
if snapshot == nil || !snapshot.Running || !snapshot.DebugReady {
t.Fatalf("expected recovered running profile, got %+v", snapshot)
}
if snapshot.DebugPort != server.Port() {
t.Fatalf("expected debug port %d, got %d", server.Port(), snapshot.DebugPort)
}
}
func TestBrowserInstanceStartRecoversRunningProfileBeforeSessionCleanup(t *testing.T) {
server := newRecordedCDPServer(t)
defer server.Close()
app := newRuntimeRecoveryTestApp(t)
profile := &BrowserProfile{
ProfileId: "recover-start",
ProfileName: "Recover Start",
UserDataDir: "recover-start",
Running: false,
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
userDataDir := app.browserMgr.ResolveUserDataDir(profile)
writeDevToolsActivePort(t, userDataDir, server.Port())
snapshot, err := app.BrowserInstanceStart(profile.ProfileId)
if err != nil {
t.Fatalf("BrowserInstanceStart returned error: %v", err)
}
if snapshot == nil || !snapshot.Running || !snapshot.DebugReady {
t.Fatalf("expected recovered running profile, got %+v", snapshot)
}
if snapshot.DebugPort != server.Port() {
t.Fatalf("expected debug port %d, got %d", server.Port(), snapshot.DebugPort)
}
}
func TestPrepareBrowserLaunchContextSkipsProcessScanWhenNoActivePort(t *testing.T) {
app := newRuntimeRecoveryTestApp(t)
profile := &BrowserProfile{
ProfileId: "cold-start",
ProfileName: "Cold Start",
UserDataDir: "cold-start",
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
originalFind := findBrowserUserDataProcesses
defer func() { findBrowserUserDataProcesses = originalFind }()
findCalls := 0
findBrowserUserDataProcesses = func(string) ([]browserUserDataProcess, error) {
findCalls++
return nil, nil
}
_, _, _, _, err := app.prepareBrowserLaunchContext(newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "", ""), profile, nil)
if err != nil {
t.Fatalf("prepareBrowserLaunchContext returned error: %v", err)
}
if findCalls != 0 {
t.Fatalf("cold start should not scan browser processes, got %d calls", findCalls)
}
}
func TestPrepareBrowserLaunchContextTerminatesProcessAndRetriesSessionCleanup(t *testing.T) {
app := newRuntimeRecoveryTestApp(t)
profile := &BrowserProfile{
ProfileId: "recover-lock",
ProfileName: "Recover Lock",
UserDataDir: "recover-lock",
}
app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile}
userDataDir := app.browserMgr.ResolveUserDataDir(profile)
sessionsDir := filepath.Join(userDataDir, "Default", "Sessions")
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
t.Fatalf("create sessions dir failed: %v", err)
}
originalFind := findBrowserUserDataProcesses
originalTerminate := terminateBrowserUserDataProcess
originalClear := clearBrowserSessionRestoreData
defer func() {
findBrowserUserDataProcesses = originalFind
terminateBrowserUserDataProcess = originalTerminate
clearBrowserSessionRestoreData = originalClear
}()
findBrowserUserDataProcesses = func(string) ([]browserUserDataProcess, error) {
return []browserUserDataProcess{{PID: 4321}}, nil
}
terminatedPID := 0
terminateBrowserUserDataProcess = func(pid int, timeout time.Duration) error {
terminatedPID = pid
return nil
}
clearCalls := 0
clearBrowserSessionRestoreData = func(string) error {
clearCalls++
if clearCalls == 1 {
return errors.New("remove sessions dir: locked")
}
return nil
}
_, _, _, _, err := app.prepareBrowserLaunchContext(newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "", ""), profile, nil)
if err != nil {
t.Fatalf("prepareBrowserLaunchContext returned error: %v", err)
}
if terminatedPID != 4321 {
t.Fatalf("expected terminating pid 4321, got %d", terminatedPID)
}
if clearCalls != 2 {
t.Fatalf("expected session cleanup to be retried once, got %d calls", clearCalls)
}
}
func newRuntimeRecoveryTestApp(t *testing.T) *App {
t.Helper()
appRoot := t.TempDir()
cfg := config.DefaultConfig()
cfg.Browser.UserDataRoot = "data"
cfg.Browser.RestoreLastSession = false
exePath := createRuntimeRecoveryFakeBrowserExecutable(t, appRoot)
cfg.Browser.Cores = []config.BrowserCore{
{CoreId: "runtime-recovery-core", CoreName: "Runtime Recovery Core", CorePath: exePath, IsDefault: true},
}
app := NewApp(appRoot)
app.config = cfg
app.browserMgr = browser.NewManager(cfg, appRoot)
app.browserMgr.Profiles = make(map[string]*BrowserProfile)
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
return app
}
func createRuntimeRecoveryFakeBrowserExecutable(t *testing.T, appRoot string) string {
t.Helper()
path := filepath.Join(appRoot, "chrome", "chrome.exe")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create fake browser dir failed: %v", err)
}
if err := os.WriteFile(path, []byte("fake"), 0o644); err != nil {
t.Fatalf("write fake browser failed: %v", err)
}
return filepath.Dir(path)
}
func writeDevToolsActivePort(t *testing.T, userDataDir string, debugPort int) {
t.Helper()
if err := os.MkdirAll(userDataDir, 0o755); err != nil {
t.Fatalf("create user data dir failed: %v", err)
}
content := fmt.Sprintf("%d\n/devtools/browser/test\n", debugPort)
if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0o644); err != nil {
t.Fatalf("write DevToolsActivePort failed: %v", err)
}
}
@@ -0,0 +1,166 @@
//go:build windows
// +build windows
package backend
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func findBrowserUserDataProcessesOS(userDataDir string) ([]browserUserDataProcess, error) {
userDataDir = strings.TrimSpace(userDataDir)
if userDataDir == "" {
return nil, nil
}
fullUserDataDir, err := filepath.Abs(userDataDir)
if err != nil {
fullUserDataDir = userDataDir
}
psScript := `param([string]$UserDataDir)
$ErrorActionPreference = 'SilentlyContinue'
$target = [System.IO.Path]::GetFullPath($UserDataDir).TrimEnd('\')
$items = @()
Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -match '--user-data-dir=' } | ForEach-Object {
$cmd = [string]$_.CommandLine
$match = [regex]::Match($cmd, '--user-data-dir=(?:"([^"]+)"|([^\s]+))')
if (-not $match.Success) { return }
$dir = $match.Groups[1].Value
if ([string]::IsNullOrWhiteSpace($dir)) { $dir = $match.Groups[2].Value }
if ([string]::IsNullOrWhiteSpace($dir)) { return }
try { $dir = [System.IO.Path]::GetFullPath($dir).TrimEnd('\') } catch {}
if (-not $dir.Equals($target, [System.StringComparison]::OrdinalIgnoreCase)) { return }
$port = 0
$portMatch = [regex]::Match($cmd, '--remote-debugging-port=(\d+)')
if ($portMatch.Success) { [void][int]::TryParse($portMatch.Groups[1].Value, [ref]$port) }
$items += [pscustomobject]@{ pid = [int]$_.ProcessId; debugPort = [int]$port; commandLine = $cmd }
}
$items | ConvertTo-Json -Compress
`
output, err := runPowerShellJSON(psScript, "-UserDataDir", fullUserDataDir)
if err != nil {
return nil, err
}
output = bytes.TrimSpace(output)
if len(output) == 0 || bytes.Equal(output, []byte("null")) {
return nil, nil
}
var list []browserUserDataProcess
if bytes.HasPrefix(output, []byte("[")) {
if err := json.Unmarshal(output, &list); err != nil {
return nil, err
}
} else {
var item browserUserDataProcess
if err := json.Unmarshal(output, &item); err != nil {
return nil, err
}
list = append(list, item)
}
return list, nil
}
func terminateBrowserUserDataProcessOS(pid int, timeout time.Duration) error {
if pid <= 0 {
return nil
}
if timeout <= 0 {
timeout = 5 * time.Second
}
if tryCloseBrowserViaCDPPID(pid, timeout) {
return nil
}
softKillCmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T")
hideWindow(softKillCmd)
_ = softKillCmd.Run()
if waitProcessExitWindows(pid, timeout) {
return nil
}
forceKillCmd := exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid), "/T")
hideWindow(forceKillCmd)
if err := forceKillCmd.Run(); err != nil {
return err
}
if !waitProcessExitWindows(pid, 2*time.Second) {
return fmt.Errorf("process still running")
}
return nil
}
func tryCloseBrowserViaCDPPID(pid int, timeout time.Duration) bool {
processes, err := findBrowserProcessByPID(pid)
if err != nil || len(processes) == 0 {
return false
}
debugPort := processes[0].DebugPort
if debugPort <= 0 {
debugPort = parseRemoteDebuggingPort(processes[0].CommandLine)
}
return tryCloseBrowserViaCDP(debugPort, timeout)
}
func findBrowserProcessByPID(pid int) ([]browserUserDataProcess, error) {
if pid <= 0 {
return nil, nil
}
psScript := `param([int]$PidValue)
$ErrorActionPreference = 'SilentlyContinue'
$p = Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq $PidValue } | Select-Object -First 1
if ($null -eq $p) { exit 0 }
$cmd = [string]$p.CommandLine
$port = 0
$portMatch = [regex]::Match($cmd, '--remote-debugging-port=(\d+)')
if ($portMatch.Success) { [void][int]::TryParse($portMatch.Groups[1].Value, [ref]$port) }
[pscustomobject]@{ pid = [int]$p.ProcessId; debugPort = [int]$port; commandLine = $cmd } | ConvertTo-Json -Compress
`
output, err := runPowerShellJSON(psScript, "-PidValue", strconv.Itoa(pid))
if err != nil {
return nil, err
}
output = bytes.TrimSpace(output)
if len(output) == 0 || bytes.Equal(output, []byte("null")) {
return nil, nil
}
var item browserUserDataProcess
if err := json.Unmarshal(output, &item); err != nil {
return nil, err
}
return []browserUserDataProcess{item}, nil
}
func runPowerShellJSON(script string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
powershellPath := `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
if _, err := exec.LookPath(powershellPath); err != nil {
if fallbackPath, lookErr := exec.LookPath("powershell.exe"); lookErr == nil {
powershellPath = fallbackPath
}
}
commandArgs := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
commandArgs = append(commandArgs, args...)
cmd := exec.CommandContext(ctx, powershellPath, commandArgs...)
hideWindow(cmd)
output, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("powershell query timed out")
}
if err != nil {
return nil, err
}
return output, nil
}
+48
View File
@@ -0,0 +1,48 @@
package backend
import "strings"
func (a *App) ensureProfileLaunchCode(profile *BrowserProfile) string {
if profile == nil {
return ""
}
profileID := strings.TrimSpace(profile.ProfileId)
if profileID == "" {
return strings.TrimSpace(profile.LaunchCode)
}
if a == nil || a.launchCodeSvc == nil {
return strings.TrimSpace(profile.LaunchCode)
}
code, err := a.launchCodeSvc.EnsureCode(profileID)
if err != nil {
return strings.TrimSpace(profile.LaunchCode)
}
code = strings.TrimSpace(code)
if code != "" {
profile.LaunchCode = code
}
return code
}
func (a *App) setManagedProfileLaunchCode(profileID string, launchCode string) {
if a == nil || a.browserMgr == nil {
return
}
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return
}
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
profile, ok := a.browserMgr.Profiles[profileID]
if !ok || profile == nil {
return
}
profile.LaunchCode = strings.TrimSpace(launchCode)
}
@@ -0,0 +1,81 @@
package backend
import (
"os"
"testing"
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/launchcode"
)
func newBrowserLaunchCodeTestApp(t *testing.T) *App {
t.Helper()
app := NewApp(t.TempDir())
app.config = config.DefaultConfig()
app.browserMgr = browser.NewManager(app.config, app.appRoot)
app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
app.browserMgr.CodeProvider = app.launchCodeSvc
return app
}
func TestBrowserProfileSetCodeUpdatesManagedProfile(t *testing.T) {
app := newBrowserLaunchCodeTestApp(t)
profile, err := app.browserMgr.Create(browser.ProfileInput{ProfileName: "mail-profile"})
if err != nil {
t.Fatalf("create profile failed: %v", err)
}
if profile == nil {
t.Fatal("create profile returned nil")
}
code, err := app.BrowserProfileSetCode(profile.ProfileId, "mail01")
if err != nil {
t.Fatalf("BrowserProfileSetCode failed: %v", err)
}
if code != "MAIL01" {
t.Fatalf("expected normalized code MAIL01, got %s", code)
}
if got := app.browserMgr.Profiles[profile.ProfileId].LaunchCode; got != "MAIL01" {
t.Fatalf("expected managed profile launch code MAIL01, got %s", got)
}
}
func TestEnsurePlaywrightTargetReadyResolvesUpdatedLaunchCode(t *testing.T) {
app := newBrowserLaunchCodeTestApp(t)
profile, err := app.browserMgr.Create(browser.ProfileInput{ProfileName: "mail-profile"})
if err != nil {
t.Fatalf("create profile failed: %v", err)
}
if profile == nil {
t.Fatal("create profile returned nil")
}
if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, "MAIL01"); err != nil {
t.Fatalf("set launch code failed: %v", err)
}
managed := app.browserMgr.Profiles[profile.ProfileId]
managed.Running = true
managed.Pid = os.Getpid()
selector, taskProfileID, err := app.ensurePlaywrightTargetReady(map[string]any{
"profileId": profile.ProfileId,
})
if err != nil {
t.Fatalf("ensurePlaywrightTargetReady failed: %v", err)
}
if taskProfileID != profile.ProfileId {
t.Fatalf("expected task profile id %s, got %s", profile.ProfileId, taskProfileID)
}
if got, _ := selector["code"].(string); got != "MAIL01" {
t.Fatalf("expected selector code MAIL01, got %v", selector["code"])
}
if managed.LaunchCode != "MAIL01" {
t.Fatalf("expected managed profile launch code to sync to MAIL01, got %s", managed.LaunchCode)
}
}
+9 -1
View File
@@ -165,7 +165,15 @@ func (a *App) waitForBrowserDebugReady(profileId string, debugPort int, timeout
func (a *App) waitBrowserDebugReadyAsync(profileId string, debugPort int, timeout time.Duration) {
snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, timeout)
if snapshot == nil || !changed {
if snapshot == nil {
return
}
if warningSnapshot, warningChanged := a.finalizeDeferredStartTargets(profileId, debugPort); warningSnapshot != nil {
snapshot = warningSnapshot
changed = changed || warningChanged
}
if !changed {
return
}
+204
View File
@@ -0,0 +1,204 @@
package backend
import (
"encoding/json"
"fmt"
"strings"
"time"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
)
func browserLightStartEnabled(cfg *config.Config) bool {
if cfg == nil || cfg.Browser.LightStartEnabled == nil {
return true
}
return *cfg.Browser.LightStartEnabled
}
func resolveConfiguredStartTargets(startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool) []string {
normalizedStartURLs := normalizeNonEmptyStrings(startURLs)
if len(normalizedStartURLs) > 0 {
return normalizedStartURLs
}
if skipDefaultStartURLs {
return nil
}
return normalizeNonEmptyStrings(defaultStartURLs)
}
func buildBrowserLaunchTargets(startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool, lightStartEnabled bool) ([]string, []string) {
configuredTargets := resolveConfiguredStartTargets(startURLs, defaultStartURLs, skipDefaultStartURLs)
if lightStartEnabled && len(configuredTargets) > 0 {
return []string{"about:blank"}, configuredTargets
}
if len(configuredTargets) > 0 {
return configuredTargets, nil
}
if !restoreLastSession {
return []string{"about:blank"}, nil
}
return nil, nil
}
func deferredStartTargetsWarning(targets []string, err error) string {
normalized := normalizeNonEmptyStrings(targets)
if len(normalized) <= 1 {
return fmt.Sprintf("浏览器已启动,但启动页未能自动打开:%v。可稍后手动打开。", err)
}
return fmt.Sprintf("浏览器已启动,但 %d 个启动页未能自动打开:%v。可稍后手动打开。", len(normalized), err)
}
func (a *App) storeDeferredStartTargets(profileId string, targets []string) {
if a == nil {
return
}
normalized := normalizeNonEmptyStrings(targets)
a.deferredStartTargetsMu.Lock()
defer a.deferredStartTargetsMu.Unlock()
if len(normalized) == 0 {
delete(a.deferredStartTargets, profileId)
return
}
a.deferredStartTargets[profileId] = append([]string{}, normalized...)
}
func (a *App) consumeDeferredStartTargets(profileId string) []string {
if a == nil {
return nil
}
a.deferredStartTargetsMu.Lock()
defer a.deferredStartTargetsMu.Unlock()
targets := append([]string{}, a.deferredStartTargets[profileId]...)
delete(a.deferredStartTargets, profileId)
return targets
}
func (a *App) clearDeferredStartTargets(profileId string) {
if a == nil {
return
}
a.deferredStartTargetsMu.Lock()
delete(a.deferredStartTargets, profileId)
a.deferredStartTargetsMu.Unlock()
}
func cdpBrowserCallResult(debugPort int, method string, params map[string]any) (map[string]any, error) {
body, err := cdpGetEndpointBody(debugPort, "/json/version")
if err != nil {
return nil, fmt.Errorf("CDP /json/version 请求失败: %w", err)
}
var version cdpBrowserVersion
if err := json.Unmarshal(body, &version); err != nil {
return nil, fmt.Errorf("CDP browser target 解析失败: %w", err)
}
wsURL := strings.TrimSpace(version.WebSocketDebuggerUrl)
if wsURL == "" {
return nil, fmt.Errorf("未找到浏览器级 WebSocket 调试地址")
}
conn, err := cdpDialWebSocket(wsURL)
if err != nil {
return nil, fmt.Errorf("浏览器级 WebSocket 连接失败: %w", err)
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout))
msg := cdpMessage{Id: 1, Method: method, Params: params}
if err := conn.WriteJSON(msg); err != nil {
return nil, fmt.Errorf("浏览器级 CDP 命令发送失败: %w", err)
}
var cdpResp cdpResponse
if err := conn.ReadJSON(&cdpResp); err != nil {
return nil, fmt.Errorf("浏览器级 CDP 响应读取失败: %w", err)
}
if cdpResp.Error != nil {
return nil, fmt.Errorf("浏览器级 CDP 错误: %s", cdpResp.Error.Message)
}
return cdpResp.Result, nil
}
func createBrowserStartTarget(debugPort int, url string) error {
_, err := cdpBrowserCallResult(debugPort, "Target.createTarget", map[string]any{
"url": url,
})
return err
}
func openBrowserStartTargets(debugPort int, targets []string) error {
normalized := normalizeNonEmptyStrings(targets)
if len(normalized) == 0 {
return nil
}
_, navigateErr := cdpCall(debugPort, "Page.navigate", map[string]any{
"url": normalized[0],
})
if navigateErr == nil {
for _, url := range normalized[1:] {
if err := createBrowserStartTarget(debugPort, url); err != nil {
return err
}
}
return nil
}
for _, url := range normalized {
if err := createBrowserStartTarget(debugPort, url); err != nil {
return fmt.Errorf("首个页面改为新标签回退失败:navigate=%v, createTarget=%w", navigateErr, err)
}
}
return nil
}
func (a *App) setProfileRuntimeWarning(profileId string, debugPort int, warning string) (*BrowserProfile, bool) {
if a == nil || a.browserMgr == nil {
return nil, false
}
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
profile, exists := a.browserMgr.Profiles[profileId]
if !exists || profile == nil || !profile.Running || profile.DebugPort != debugPort {
return nil, false
}
if profile.RuntimeWarning == warning && profile.LastError == "" {
return copyBrowserProfileSnapshot(profile), false
}
profile.RuntimeWarning = warning
profile.LastError = ""
return copyBrowserProfileSnapshot(profile), true
}
func (a *App) finalizeDeferredStartTargets(profileId string, debugPort int) (*BrowserProfile, bool) {
targets := a.consumeDeferredStartTargets(profileId)
if len(targets) == 0 {
return nil, false
}
if err := openBrowserStartTargets(debugPort, targets); err != nil {
warning := deferredStartTargetsWarning(targets, err)
snapshot, changed := a.setProfileRuntimeWarning(profileId, debugPort, warning)
logger.New("Browser").Warn("浏览器已就绪,但启动页延后打开失败",
logger.F("profile_id", profileId),
logger.F("debug_port", debugPort),
logger.F("target_count", len(targets)),
logger.F("error", err.Error()),
logger.F("warning", warning),
)
return snapshot, changed
}
return nil, false
}
+172
View File
@@ -0,0 +1,172 @@
package backend
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"github.com/gorilla/websocket"
)
func TestBuildBrowserLaunchTargetsDefersConfiguredTargetsWhenLightStartEnabled(t *testing.T) {
t.Parallel()
launchTargets, deferredTargets := buildBrowserLaunchTargets(
[]string{"https://one.example/", "https://two.example/"},
nil,
false,
false,
true,
)
if !reflect.DeepEqual(launchTargets, []string{"about:blank"}) {
t.Fatalf("expected blank-page launch target, got %v", launchTargets)
}
if !reflect.DeepEqual(deferredTargets, []string{"https://one.example/", "https://two.example/"}) {
t.Fatalf("expected deferred targets to be preserved, got %v", deferredTargets)
}
}
func TestBuildBrowserLaunchTargetsPreservesSessionRestoreWhenNoConfiguredTargets(t *testing.T) {
t.Parallel()
launchTargets, deferredTargets := buildBrowserLaunchTargets(nil, nil, false, true, true)
if len(launchTargets) != 0 {
t.Fatalf("expected no launch targets when restore-last-session is enabled, got %v", launchTargets)
}
if len(deferredTargets) != 0 {
t.Fatalf("expected no deferred targets, got %v", deferredTargets)
}
}
func TestOpenBrowserStartTargetsNavigatesFirstPageAndCreatesRemainingTargets(t *testing.T) {
t.Parallel()
server := newRecordedCDPServer(t)
defer server.Close()
if err := openBrowserStartTargets(server.Port(), []string{"https://one.example/", "https://two.example/"}); err != nil {
t.Fatalf("openBrowserStartTargets returned error: %v", err)
}
want := []recordedCDPCommand{
{Scope: "page", Method: "Page.navigate", URL: "https://one.example/"},
{Scope: "browser", Method: "Target.createTarget", URL: "https://two.example/"},
}
if got := server.Commands(); !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected CDP command sequence:\n got=%v\nwant=%v", got, want)
}
}
type recordedCDPCommand struct {
Scope string
Method string
URL string
}
type recordedCDPServer struct {
server *httptest.Server
upgrader websocket.Upgrader
mu sync.Mutex
commands []recordedCDPCommand
}
func newRecordedCDPServer(t *testing.T) *recordedCDPServer {
t.Helper()
recorder := &recordedCDPServer{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
},
}
mux := http.NewServeMux()
mux.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) {
wsURL := recorder.wsURL("/devtools/page/page-1")
payload := []map[string]any{
{
"type": "page",
"webSocketDebuggerUrl": wsURL,
},
}
_ = json.NewEncoder(w).Encode(payload)
})
mux.HandleFunc("/json/version", func(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"Browser": "Chrome/142.0",
"webSocketDebuggerUrl": recorder.wsURL("/devtools/browser/browser-1"),
}
_ = json.NewEncoder(w).Encode(payload)
})
mux.HandleFunc("/devtools/page/page-1", func(w http.ResponseWriter, r *http.Request) {
recorder.handleWebsocket(w, r, "page")
})
mux.HandleFunc("/devtools/browser/browser-1", func(w http.ResponseWriter, r *http.Request) {
recorder.handleWebsocket(w, r, "browser")
})
recorder.server = httptest.NewServer(mux)
return recorder
}
func (s *recordedCDPServer) Close() {
if s == nil || s.server == nil {
return
}
s.server.Close()
}
func (s *recordedCDPServer) Port() int {
parsed, err := url.Parse(s.server.URL)
if err != nil {
return 0
}
port, _ := strconv.Atoi(parsed.Port())
return port
}
func (s *recordedCDPServer) Commands() []recordedCDPCommand {
s.mu.Lock()
defer s.mu.Unlock()
return append([]recordedCDPCommand{}, s.commands...)
}
func (s *recordedCDPServer) wsURL(path string) string {
return "ws" + strings.TrimPrefix(s.server.URL, "http") + path
}
func (s *recordedCDPServer) handleWebsocket(w http.ResponseWriter, r *http.Request, scope string) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var msg cdpMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
command := recordedCDPCommand{
Scope: scope,
Method: msg.Method,
}
if urlValue, _ := msg.Params["url"].(string); urlValue != "" {
command.URL = urlValue
}
s.mu.Lock()
s.commands = append(s.commands, command)
s.mu.Unlock()
_ = conn.WriteJSON(cdpResponse{
Id: msg.Id,
Result: map[string]any{"targetId": "target-1"},
})
}
+505 -4
View File
@@ -15,6 +15,14 @@ function normalizeTimeout(value, fallback) {
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));
}
@@ -210,6 +218,278 @@ function toSerializable(value, seen = new WeakSet()) {
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 = {};
@@ -225,6 +505,8 @@ function buildLaunchRequestBody(defaultSelector, options) {
'tags',
'groupId',
'matchMode',
'proxyId',
'proxyConfig',
'launchArgs',
'startUrls',
'skipDefaultStartUrls',
@@ -358,7 +640,9 @@ async function runScriptTask(payload, chromium) {
return response.body;
};
const connect = async (session = {}) => {
const connect = async (session = {}, options = {}) => {
const connectOptions =
options && typeof options === 'object' && !Array.isArray(options) ? options : {};
const endpoints = buildConnectEndpoints(payload, session);
if (endpoints.length === 0) {
throw new Error(
@@ -368,7 +652,8 @@ async function runScriptTask(payload, chromium) {
);
}
const deadline = Date.now() + timeout;
const connectTimeout = normalizeTimeout(connectOptions.timeoutMs, timeout);
const deadline = Date.now() + connectTimeout;
let lastError = null;
while (Date.now() <= deadline) {
@@ -380,7 +665,7 @@ async function runScriptTask(payload, chromium) {
try {
const browser = await chromium.connectOverCDP(endpoint, {
timeout: Math.max(1000, Math.min(remaining, timeout)),
timeout: Math.max(1000, Math.min(remaining, connectTimeout)),
});
connectedBrowsers.add(browser);
const context = browser.contexts()[0] || null;
@@ -409,14 +694,230 @@ async function runScriptTask(payload, chromium) {
const lastMessage =
lastError && lastError.message ? lastError.message : String(lastError || 'unknown error');
throw new Error(
`cdp endpoint is not ready after ${timeout} ms (endpoints: ${endpoints.join(', ')}): ${lastMessage}`
`cdp endpoint is not ready after ${connectTimeout} ms (endpoints: ${endpoints.join(', ')}): ${lastMessage}`
);
};
const resolveConnectionContext = async (connection) => {
const browser = connection && connection.browser ? connection.browser : null;
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');
}
return {
browser,
context,
};
};
const grantPermissions = async (target, options = {}) => {
const permissionOptions =
options && typeof options === 'object' && !Array.isArray(options) ? options : {};
const permissions = normalizePermissionList(permissionOptions.permissions);
const origin = normalizeOrigin(permissionOptions.origin);
let context = null;
if (target && typeof target.grantPermissions === 'function') {
context = target;
} else if (target && typeof target === 'object') {
context = target.context || null;
if (!context && target.browser) {
const resolved = await resolveConnectionContext(target);
context = resolved.context;
}
}
if (!context) {
return {
applied: false,
permissions,
origin,
reason: 'browser context is unavailable',
};
}
if (!origin) {
return {
applied: false,
permissions,
origin: '',
reason: 'origin is required',
};
}
if (permissions.length === 0) {
return {
applied: false,
permissions,
origin,
reason: 'permissions are required',
};
}
if (typeof context.grantPermissions !== 'function') {
return {
applied: false,
permissions,
origin,
reason: 'grantPermissions is unavailable',
};
}
try {
await context.grantPermissions(permissions, { origin });
return {
applied: true,
permissions,
origin,
strategy: 'grantPermissions',
};
} catch (error) {
return {
applied: false,
permissions,
origin,
reason: error && error.message ? error.message : String(error),
};
}
};
const openPage = async (connection, options = {}) => {
const openOptions =
options && typeof options === 'object' && !Array.isArray(options) ? options : {};
const { browser, context } = await resolveConnectionContext(connection);
const shouldReuseCurrentPage = openOptions.reuseCurrentPage === true;
let page = null;
if (
shouldReuseCurrentPage &&
connection &&
connection.page &&
typeof connection.page.isClosed === 'function' &&
!connection.page.isClosed()
) {
page = connection.page;
}
if (!page) {
page = await context.newPage();
}
if (typeof page.bringToFront === 'function' && openOptions.bringToFront !== false) {
await page.bringToFront().catch(() => {});
}
const permissionResult =
openOptions.permissions !== undefined
? await grantPermissions(context, {
origin:
typeof openOptions.permissionOrigin === 'string' && openOptions.permissionOrigin.trim()
? openOptions.permissionOrigin
: openOptions.url,
permissions: openOptions.permissions,
})
: {
applied: false,
permissions: [],
origin: '',
reason: '',
};
const targetURL = String(openOptions.url || '').trim();
if (targetURL) {
const waitUntil = ALLOWED_WAIT_UNTIL.has(String(openOptions.waitUntil || '').trim())
? String(openOptions.waitUntil).trim()
: 'domcontentloaded';
await page.goto(targetURL, {
waitUntil,
timeout: normalizeTimeout(openOptions.timeoutMs, timeout),
});
}
return {
browser,
context,
page,
permissionResult,
reusedPage: page === (connection && connection.page ? connection.page : null),
};
};
const resolvePageTarget = (target) => {
if (target && typeof target.evaluate === 'function') {
return target;
}
if (target && target.page && typeof target.page.evaluate === 'function') {
return target.page;
}
throw new Error('page api target must be a Playwright page or an object containing page');
};
const callPageAPI = async (target, urlOrRequest, options = {}) => {
const page = resolvePageTarget(target);
const request = normalizePageAPIRequest(urlOrRequest, options);
const response = await page.evaluate(executePageAPIRequest, request);
if (request.throwOnError && (!response || response.ok !== true)) {
const status = response && response.status ? response.status : 0;
const message =
(response && typeof response.error === 'string' && response.error.trim()) ||
(status ? `page api returned http ${status}` : 'page api request failed');
throw new Error(message);
}
return response;
};
const browserFetch = callPageAPI;
const pageAPI = callPageAPI;
const useBrowser = async (options = {}) => {
const runOptions = options && typeof options === 'object' && !Array.isArray(options) ? options : {};
const launchOptions =
runOptions.launch && typeof runOptions.launch === 'object' && !Array.isArray(runOptions.launch)
? runOptions.launch
: runOptions;
const connectOptions =
runOptions.connect && typeof runOptions.connect === 'object' && !Array.isArray(runOptions.connect)
? runOptions.connect
: {};
const openOptions =
runOptions.open && typeof runOptions.open === 'object' && !Array.isArray(runOptions.open)
? runOptions.open
: {
url: runOptions.url,
waitUntil: runOptions.waitUntil,
timeoutMs: runOptions.timeoutMs,
permissions: runOptions.permissions,
permissionOrigin: runOptions.permissionOrigin,
reuseCurrentPage: runOptions.reuseCurrentPage,
bringToFront: runOptions.bringToFront,
};
const session = await launch(launchOptions);
const connection = await connect(session, connectOptions);
const opened = await openPage(connection, openOptions);
return {
session,
connection,
...opened,
};
};
const api = {
chromium,
launch,
connect,
grantPermissions,
openPage,
useBrowser,
callPageAPI,
pageAPI,
browserFetch,
selector,
params,
log,
@@ -0,0 +1,220 @@
package automation
import (
"embed"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"time"
)
const (
DualInstanceRuntimeScriptID = "dual-instance-runtime-switch"
NewsQueryTXTScriptID = "news-query-txt"
WebImageGenerateScriptID = "web-image-generate-download"
builtinScriptLibraryRoot = "demo-library"
)
var builtinScriptLibraryPackageDirs = map[string]string{
DualInstanceRuntimeScriptID: builtinScriptLibraryRoot + "/" + DualInstanceRuntimeScriptID,
NewsQueryTXTScriptID: builtinScriptLibraryRoot + "/" + NewsQueryTXTScriptID,
WebImageGenerateScriptID: builtinScriptLibraryRoot + "/" + WebImageGenerateScriptID,
}
var builtinScriptLibraryDefaultOrder = []string{
DualInstanceRuntimeScriptID,
NewsQueryTXTScriptID,
WebImageGenerateScriptID,
}
//go:embed demo-library
var builtinScriptLibraryFS embed.FS
func DefaultScriptBundles() ([]ImportedBundle, error) {
bundles := make([]ImportedBundle, 0, len(builtinScriptLibraryDefaultOrder))
for _, scriptID := range builtinScriptLibraryDefaultOrder {
dir, ok := builtinScriptLibraryPackageDirs[scriptID]
if !ok {
return nil, fmt.Errorf("内置脚本 %q 不存在", scriptID)
}
bundle, err := importBuiltinScriptLibraryBundle(dir)
if err != nil {
return nil, err
}
bundles = append(bundles, bundle)
}
return bundles, nil
}
func ImportBuiltinBundleFromSource(source ScriptSource) (ImportedBundle, error) {
dir, err := resolveBuiltinScriptLibraryDir(source)
if err != nil {
return ImportedBundle{}, err
}
bundle, err := importBuiltinScriptLibraryBundle(dir)
if err != nil {
return ImportedBundle{}, err
}
bundle.Record.Source.ImportedAt = time.Now().Format(time.RFC3339)
return bundle, nil
}
func resolveBuiltinScriptLibraryDir(source ScriptSource) (string, error) {
candidates := []string{
strings.TrimSpace(source.Path),
path.Base(strings.TrimSpace(strings.TrimPrefix(source.URI, "repo://"))),
path.Base(strings.TrimSpace(source.URI)),
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if dir, ok := builtinScriptLibraryPackageDirs[candidate]; ok {
return dir, nil
}
}
switch {
case strings.TrimSpace(source.Path) != "":
return "", fmt.Errorf("内置脚本 %q 不存在", strings.TrimSpace(source.Path))
case strings.TrimSpace(source.URI) != "":
return "", fmt.Errorf("内置脚本来源 %q 不存在", strings.TrimSpace(source.URI))
default:
return "", fmt.Errorf("内置脚本来源缺失")
}
}
func importBuiltinScriptLibraryBundle(packageDir string) (ImportedBundle, error) {
manifestPath := path.Join(packageDir, scriptPackageManifestName)
manifestData, err := fs.ReadFile(builtinScriptLibraryFS, manifestPath)
if err != nil {
return ImportedBundle{}, fmt.Errorf("read built-in script manifest failed: %w", err)
}
descriptor, err := parseImportManifest(manifestData)
if err != nil {
return ImportedBundle{}, err
}
entryFile := normalizeScriptEntryFile(mapStringValueAny(descriptor, "entryFile"))
if entryFile == "" {
return ImportedBundle{}, fmt.Errorf("built-in script manifest is missing entryFile")
}
entryData, err := fs.ReadFile(builtinScriptLibraryFS, path.Join(packageDir, entryFile))
if err != nil {
return ImportedBundle{}, fmt.Errorf("read built-in script entry failed: %w", err)
}
files, err := collectImportedBundleFilesFromFS(builtinScriptLibraryFS, packageDir)
if err != nil {
return ImportedBundle{}, err
}
record, err := buildImportedRecord(scriptImportEnvelope{
Format: mapStringValueAny(descriptor, "format"),
PackageFormat: mapStringValueAny(descriptor, "packageFormat"),
ManifestVersion: mapIntValueAny(descriptor, "manifestVersion"),
Name: mapStringValueAny(descriptor, "name"),
Description: mapStringValueAny(descriptor, "description"),
Type: mapStringValueAny(descriptor, "type"),
Status: mapStringValueAny(descriptor, "status"),
EntryFile: entryFile,
Tags: mapStringSliceValue(descriptor, "tags"),
Selector: descriptor["selector"],
SelectorText: descriptor["selectorText"],
Params: descriptor["params"],
ParamsText: descriptor["paramsText"],
ScriptText: string(entryData),
Notes: mapStringValueAny(descriptor, "notes"),
TargetConfig: mapObjectValue(descriptor, "targetConfig"),
PublicAPI: mapObjectValue(descriptor, "publicAPI"),
Source: mapObjectValue(descriptor, "source"),
}, path.Base(packageDir), "")
if err != nil {
return ImportedBundle{}, err
}
if manifestID := strings.TrimSpace(mapStringValueAny(descriptor, "id")); manifestID != "" {
record.ID = manifestID
}
bundle := ImportedBundle{
Record: record,
Files: files,
}
if err := validateImportedBundle(bundle.Record, bundle.Files); err != nil {
return ImportedBundle{}, err
}
return bundle, nil
}
func collectImportedBundleFilesFromFS(fsys fs.FS, root string) ([]ImportedBundleFile, error) {
files := make([]ImportedBundleFile, 0, 8)
totalSize := 0
err := fs.WalkDir(fsys, root, func(current string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if current == root {
return nil
}
relativePath := strings.TrimPrefix(current, root)
relativePath = strings.TrimPrefix(relativePath, "/")
if relativePath == "." {
return nil
}
if entry.IsDir() {
name := strings.ToLower(strings.TrimSpace(entry.Name()))
if name == ".git" {
return fs.SkipDir
}
if name == "node_modules" {
return fmt.Errorf("script bundle must not include node_modules")
}
return nil
}
normalizedPath, err := normalizeBundleFilePath(relativePath)
if err != nil {
return err
}
if isImportManifestPath(normalizedPath) {
return nil
}
content, err := fs.ReadFile(fsys, current)
if err != nil {
return err
}
totalSize += len(content)
if totalSize > maxImportedBundleBytes {
return fmt.Errorf("script bundle is too large")
}
if len(files) >= maxImportedBundleFiles {
return fmt.Errorf("script bundle contains too many files")
}
files = append(files, ImportedBundleFile{
Path: normalizedPath,
Content: content,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("collect script bundle files failed: %w", err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files, nil
}
@@ -0,0 +1,85 @@
package automation
import "testing"
func TestDefaultScriptBundles(t *testing.T) {
bundles, err := DefaultScriptBundles()
if err != nil {
t.Fatalf("DefaultScriptBundles returned error: %v", err)
}
if len(bundles) != 3 {
t.Fatalf("expected three default script bundles, got %d", len(bundles))
}
expected := []struct {
id string
name string
uri string
publicAPI string
}{
{
id: DualInstanceRuntimeScriptID,
name: "双实例启动与 Runtime 切换",
uri: "repo://backend/internal/automation/demo-library/dual-instance-runtime-switch",
publicAPI: "",
},
{
id: NewsQueryTXTScriptID,
name: "查询新闻并写 TXT",
uri: "repo://backend/internal/automation/demo-library/news-query-txt",
publicAPI: "",
},
{
id: WebImageGenerateScriptID,
name: "网页图片生成并下载",
uri: "repo://backend/internal/automation/demo-library/web-image-generate-download",
publicAPI: "image/chatgpt-generate-download",
},
}
for index, item := range expected {
bundle := bundles[index]
if bundle.Record.ID != item.id {
t.Fatalf("unexpected bundle id at %d: want %q got %q", index, item.id, bundle.Record.ID)
}
if bundle.Record.Name != item.name {
t.Fatalf("unexpected bundle name at %d: want %q got %q", index, item.name, bundle.Record.Name)
}
if bundle.Record.EntryFile != "index.cjs" {
t.Fatalf("unexpected entry file for %q: %q", bundle.Record.ID, bundle.Record.EntryFile)
}
if bundle.Record.Source.Type != "builtin" || bundle.Record.Source.URI != item.uri {
t.Fatalf("unexpected source for %q: %+v", bundle.Record.ID, bundle.Record.Source)
}
if bundle.Record.PublicAPI.Path != item.publicAPI {
t.Fatalf("unexpected public api path for %q: %+v", bundle.Record.ID, bundle.Record.PublicAPI)
}
if item.publicAPI != "" && !bundle.Record.PublicAPI.Enabled {
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)
}
}
if len(bundle.Files) == 0 {
t.Fatalf("expected bundled files for %q", bundle.Record.ID)
}
}
}
func TestImportBuiltinBundleFromSourcePreservesManifestID(t *testing.T) {
bundle, err := ImportBuiltinBundleFromSource(ScriptSource{
Type: "builtin",
Path: NewsQueryTXTScriptID,
})
if err != nil {
t.Fatalf("ImportBuiltinBundleFromSource returned error: %v", err)
}
if bundle.Record.ID != NewsQueryTXTScriptID {
t.Fatalf("expected manifest id %q, got %q", NewsQueryTXTScriptID, bundle.Record.ID)
}
if bundle.Record.Source.ImportedAt == "" {
t.Fatalf("expected importedAt to be populated")
}
}
@@ -0,0 +1,9 @@
*
!.gitignore
!README.md
!dual-instance-runtime-switch/
!dual-instance-runtime-switch/**
!news-query-txt/
!news-query-txt/**
!web-image-generate-download/
!web-image-generate-download/**
@@ -0,0 +1,7 @@
# Automation Demo Library
这里只放会提交到 git 的演示脚本包。
- 每个脚本一个独立目录,可直接整体复制到别的脚本库里使用
- 运行时 / 用户自定义脚本不放这里
- 运行时脚本统一落到 `data/automation/scripts`,并由 `.gitignore` 忽略
@@ -0,0 +1,42 @@
{
"format": "ant-automation-script",
"packageFormat": "ant-automation-script",
"manifestVersion": 1,
"id": "dual-instance-runtime-switch",
"name": "双实例启动与 Runtime 切换",
"description": "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。",
"type": "launch-api",
"status": "ready",
"entryFile": "index.cjs",
"tags": [
"Launch API",
"OpenClaw",
"双实例"
],
"params": {
"browsers": [
{
"code": "BUYER_001",
"skipDefaultStartUrls": true,
"startUrls": [
"https://finance.sina.com.cn/"
]
},
{
"code": "BUYER_002",
"skipDefaultStartUrls": true,
"startUrls": [
"https://map.baidu.com/"
]
}
],
"timeoutMs": 45000
},
"notes": "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。",
"source": {
"type": "builtin",
"uri": "repo://backend/internal/automation/demo-library/dual-instance-runtime-switch",
"ref": "HEAD",
"path": "dual-instance-runtime-switch"
}
}
@@ -0,0 +1,117 @@
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,
["BUYER_001", "BUYER_002"][index] || "",
["https://finance.sina.com.cn/", "https://map.baidu.com/"][index] || [],
defaultSkipDefaultStartUrls,
),
)
.filter(Boolean)
: []
if (browsers.length === 0) {
browsers = [
normalizeBrowserInput(
{ code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
"BUYER_001",
["https://finance.sina.com.cn/"],
defaultSkipDefaultStartUrls,
),
normalizeBrowserInput(
{ code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
"BUYER_002",
["https://map.baidu.com/"],
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 (requestPath, payload) => {
const response = await fetch(`${baseUrl}${requestPath}`, {
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(`${requestPath} 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,
}
}
@@ -0,0 +1,32 @@
{
"format": "ant-automation-script",
"packageFormat": "ant-automation-script",
"manifestVersion": 1,
"id": "news-query-txt",
"name": "查询新闻并写 TXT",
"description": "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。",
"type": "playwright-cdp",
"status": "ready",
"entryFile": "index.cjs",
"tags": [
"Playwright",
"新闻",
"TXT"
],
"params": {
"keyword": "OpenAI",
"limit": 10,
"timeRange": "week",
"outputFileName": "openai-news.txt",
"timeoutMs": 30000,
"waitAfterLoadMs": 1500,
"captureScreenshot": false
},
"notes": "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行时可直接点“创建 Demo 并执行”,成功后在结果里的 outputPath 查看文件。",
"source": {
"type": "builtin",
"uri": "repo://backend/internal/automation/demo-library/news-query-txt",
"ref": "HEAD",
"path": "news-query-txt"
}
}
@@ -1,175 +1,4 @@
package automation
const DualInstanceRuntimeScriptID = "dual-instance-runtime-switch"
func DefaultScripts() []ScriptRecord {
return []ScriptRecord{
{
ID: DualInstanceRuntimeScriptID,
Name: "双实例启动与 Runtime 切换",
Description: "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。",
Type: "launch-api",
Status: "ready",
EntryFile: "index.cjs",
Tags: []string{"Launch API", "OpenClaw", "双实例"},
ParamsText: `{
"browsers": [
{
"code": "BUYER_001",
"skipDefaultStartUrls": true,
"startUrls": ["https://finance.sina.com.cn/"]
},
{
"code": "BUYER_002",
"skipDefaultStartUrls": true,
"startUrls": ["https://map.baidu.com/"]
}
],
"timeoutMs": 45000
}`,
ScriptText: `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,
['BUYER_001', 'BUYER_002'][index] || '',
['https://finance.sina.com.cn/', 'https://map.baidu.com/'][index] || [],
defaultSkipDefaultStartUrls,
),
)
.filter(Boolean)
: []
if (browsers.length === 0) {
browsers = [
normalizeBrowserInput(
{ code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
'BUYER_001',
['https://finance.sina.com.cn/'],
defaultSkipDefaultStartUrls,
),
normalizeBrowserInput(
{ code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
'BUYER_002',
['https://map.baidu.com/'],
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,
}
}`,
Notes: "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。",
Source: ScriptSource{
Type: "builtin",
URI: "repo://backend/internal/automation/default_scripts.go",
Ref: "HEAD",
Path: DualInstanceRuntimeScriptID,
},
},
{
ID: "news-query-txt",
Name: "查询新闻并写 TXT",
Description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "index.cjs",
Tags: []string{"Playwright", "新闻", "TXT"},
ParamsText: `{
"keyword": "OpenAI",
"limit": 10,
"timeRange": "week",
"outputFileName": "openai-news.txt",
"timeoutMs": 30000,
"waitAfterLoadMs": 1500,
"captureScreenshot": false
}`,
ScriptText: `const fs = require('fs')
const fs = require('fs')
const DEFAULT_EXCLUDED_DOMAINS = [
'zhihu.com',
@@ -792,14 +621,4 @@ module.exports.run = async ({ launch, connect, selector, params, log, artifact }
scannedPageCount: scannedPageCount,
firstTitle: acceptedItems[0] ? acceptedItems[0].title : '',
}
}`,
Notes: "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行时可直接点“创建 Demo 并执行”,成功后在结果里的 outputPath 查看文件。",
Source: ScriptSource{
Type: "builtin",
URI: "repo://backend/internal/automation/default_scripts.go",
Ref: "HEAD",
Path: "news-query-txt",
},
},
}
}
@@ -0,0 +1,58 @@
{
"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"
}
}
@@ -0,0 +1,321 @@
const fs = require('fs')
const path = require('path')
function normalizeText(value) {
return String(value || '').trim()
}
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 sanitizeFileName(value, fallback) {
const normalized = normalizeText(value || fallback)
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, ' ')
.trim()
return normalized || fallback
}
function ensurePngExtension(fileName) {
if (/\.(png|jpg|jpeg|webp)$/i.test(fileName)) {
return fileName
}
return fileName + '.png'
}
function resolveOutputPath(outputDir, outputFileName) {
const safeName = ensurePngExtension(sanitizeFileName(outputFileName, 'generated-image.png'))
return path.join(outputDir || process.cwd(), safeName)
}
function buildMissingSetup(selectors, pageUrl) {
const missing = []
if (!pageUrl) {
missing.push('pageUrl')
}
for (const key of ['promptInput', 'generatedImage']) {
if (!normalizeText(selectors[key])) {
missing.push('selectors.' + key)
}
}
return missing
}
async function detectLoginRequired(page) {
const url = page.url()
if (/auth\.openai\.com|\/auth\/login|email-verification/i.test(url)) {
return { url, reason: 'auth_page' }
}
const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '')
if (/requires you to be logged in|需要登录|登录以获取|登录以|log in|sign in|免费注册/i.test(bodyText)) {
return { url, reason: 'login_required_text' }
}
return null
}
async function firstVisibleLocator(page, selector, timeoutMs, label) {
const locator = page.locator(selector)
const deadline = Date.now() + timeoutMs
let lastCount = 0
while (Date.now() < deadline) {
lastCount = await locator.count().catch(() => 0)
for (let index = 0; index < lastCount; index += 1) {
const candidate = locator.nth(index)
if (await candidate.isVisible().catch(() => false)) {
return candidate
}
}
await page.waitForTimeout(250)
}
throw new Error(`${label || 'selector'} 未找到可见元素:${selector},匹配数量 ${lastCount}`)
}
async function clickWhenReady(page, selector, timeoutMs, label) {
const locator = await firstVisibleLocator(page, selector, timeoutMs, label)
await locator.click({ timeout: timeoutMs })
return { step: label, selector }
}
async function fillPrompt(page, selector, prompt, timeoutMs) {
const locator = await firstVisibleLocator(page, selector, timeoutMs, 'promptInput')
const tagName = await locator.evaluate((element) => element.tagName.toLowerCase())
const isContentEditable = await locator.evaluate((element) => element.isContentEditable)
await locator.click({ timeout: timeoutMs })
if (tagName === 'textarea' || tagName === 'input') {
await locator.fill(prompt, { timeout: timeoutMs })
} else if (isContentEditable) {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A')
await page.keyboard.type(prompt, { delay: 8 })
} else {
await locator.fill(prompt, { timeout: timeoutMs })
}
return { step: 'input_prompt', selector, promptLength: prompt.length, inputMode: isContentEditable ? 'contenteditable' : tagName }
}
async function submitPrompt(page, selector, timeoutMs) {
const normalizedSelector = normalizeText(selector)
if (normalizedSelector) {
const locator = await firstVisibleLocator(page, normalizedSelector, Math.min(timeoutMs, 15000), 'sendButton')
await locator.click({ timeout: Math.min(timeoutMs, 15000) })
return { step: 'submit_prompt', selector: normalizedSelector, submitMode: 'button' }
}
await page.keyboard.press('Enter')
return { step: 'submit_prompt', selector: '', submitMode: 'keyboard-enter' }
}
async function waitForGeneratedImage(page, selector, timeoutMs) {
const locator = page.locator(selector)
const deadline = Date.now() + timeoutMs
const loginRequiredPattern = /requires you to be logged in|需要登录|登录以获取|登录以|log in|sign in/i
while (Date.now() < deadline) {
if ((await locator.count().catch(() => 0)) > 0 && await locator.first().isVisible().catch(() => false)) {
break
}
const bodyText = await page.locator('body').innerText({ timeout: 1000 }).catch(() => '')
if (loginRequiredPattern.test(bodyText)) {
return {
step: 'wait_image',
ok: false,
status: 'needs_login',
summary: 'ChatGPT 当前未登录,页面拒绝生成图片。',
}
}
await page.waitForTimeout(1500)
}
const visibleLocator = await firstVisibleLocator(page, selector, Math.max(1000, deadline - Date.now()), 'generatedImage')
const imageInfo = await visibleLocator.evaluate((element) => {
const tagName = element.tagName.toLowerCase()
const src = tagName === 'img' ? element.currentSrc || element.src || '' : ''
const rect = element.getBoundingClientRect()
return {
tagName,
src,
width: Math.round(rect.width),
height: Math.round(rect.height),
}
})
return { step: 'wait_image', selector, imageInfo }
}
async function downloadByButton(page, selector, timeoutMs) {
const downloadPromise = page.waitForEvent('download', { timeout: timeoutMs })
await page.locator(selector).first().click({ timeout: timeoutMs })
return downloadPromise
}
async function downloadByImageURL(page, imageSelector, timeoutMs, outputPath) {
const locator = await firstVisibleLocator(page, imageSelector, timeoutMs, 'generatedImage')
const imageUrl = await locator.evaluate((element) => {
if (element.tagName.toLowerCase() !== 'img') {
return ''
}
return element.currentSrc || element.src || ''
})
if (!imageUrl) {
throw new Error('已找到生成结果,但没有可下载的图片 URL。请补充 selectors.downloadButton 或确认 generatedImage 指向 img。')
}
const result = await page.evaluate(async (targetUrl) => {
const response = await fetch(targetUrl, { credentials: 'include' })
if (!response.ok) {
return {
ok: false,
status: response.status,
statusText: response.statusText,
contentType: response.headers.get('content-type') || '',
bytes: [],
}
}
const arrayBuffer = await response.arrayBuffer()
return {
ok: true,
status: response.status,
statusText: response.statusText,
contentType: response.headers.get('content-type') || '',
bytes: Array.from(new Uint8Array(arrayBuffer)),
}
}, imageUrl)
if (!result.ok || result.bytes.length === 0) {
throw new Error(`图片 URL 下载失败:HTTP ${result.status || 'unknown'} ${result.statusText || ''}`.trim())
}
const buffer = Buffer.from(result.bytes)
fs.writeFileSync(outputPath, buffer)
return { source: 'image-url', url: imageUrl, contentType: result.contentType, bytes: buffer.length }
}
async function captureScreenshotIfNeeded(page, enabled, outputDir, label) {
if (!enabled) {
return ''
}
const screenshotPath = path.join(outputDir || process.cwd(), `web-image-generate-download-${label}.png`)
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {})
return screenshotPath
}
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 timeoutMs = normalizeInt(params.timeoutMs, 300000, 5000, 900000)
const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1200, 0, 30000)
const settleMs = normalizeInt(params.settleMs, 2500, 0, 60000)
const captureScreenshot = Boolean(params.captureScreenshot)
const outputFileName = ensurePngExtension(sanitizeFileName(params.outputFileName, 'generated-image.png'))
const outputPath = typeof artifact === 'function'
? artifact(outputFileName)
: resolveOutputPath(artifactsDir, outputFileName)
const steps = []
const missing = buildMissingSetup(selectors, pageUrl)
if (missing.length > 0) {
return {
ok: false,
status: 'needs_page_info',
summary: '网页图片生成脚手架已创建,等待补充页面 URL 和选择器。',
missing,
expectedFlow: [
'open_page',
'create_new_session',
'send_image_prompt',
'wait_image_generated',
'download_image',
],
}
}
log && log('web-image-generate-download:start', {
pageUrl,
promptLength: prompt.length,
hasDownloadButton: Boolean(normalizeText(selectors.downloadButton)),
})
if (typeof useBrowser !== 'function') {
throw new Error('automation runtime does not provide useBrowser')
}
const { page } = await useBrowser({
url: pageUrl,
waitUntil: 'domcontentloaded',
timeoutMs,
reuseCurrentPage: true,
bringToFront: true,
})
if (waitAfterLoadMs > 0) {
await page.waitForTimeout(waitAfterLoadMs)
}
steps.push({ step: 'open_page', url: pageUrl })
const loginRequired = await detectLoginRequired(page)
if (loginRequired) {
const screenshotPath = await captureScreenshotIfNeeded(page, true, path.dirname(outputPath), 'needs-login')
return {
ok: false,
status: 'needs_login',
summary: '目标实例未登录 ChatGPT,无法生成图片。',
screenshotPath,
steps: [...steps, { step: 'check_login', ok: false, ...loginRequired }],
}
}
if (normalizeText(selectors.newSessionButton)) {
steps.push(await clickWhenReady(page, selectors.newSessionButton, timeoutMs, 'create_new_session'))
} else {
steps.push({ step: 'create_new_session', skipped: true, reason: 'selectors.newSessionButton is empty' })
}
steps.push(await fillPrompt(page, selectors.promptInput, prompt, timeoutMs))
steps.push(await submitPrompt(page, selectors.sendButton, timeoutMs))
const generatedImageStep = await waitForGeneratedImage(page, selectors.generatedImage, timeoutMs)
steps.push(generatedImageStep)
if (generatedImageStep && generatedImageStep.ok === false) {
const screenshotPath = await captureScreenshotIfNeeded(page, true, path.dirname(outputPath), 'needs-login')
return {
ok: false,
status: generatedImageStep.status || 'failed',
summary: generatedImageStep.summary || '图片未生成。',
screenshotPath,
steps,
}
}
if (settleMs > 0) {
await page.waitForTimeout(settleMs)
}
let downloadInfo
if (normalizeText(selectors.downloadButton)) {
const download = await downloadByButton(page, selectors.downloadButton, timeoutMs)
await download.saveAs(outputPath)
downloadInfo = {
source: 'download-button',
suggestedFilename: download.suggestedFilename(),
}
} else {
downloadInfo = await downloadByImageURL(page, selectors.generatedImage, timeoutMs, outputPath)
}
steps.push({ step: 'download_image', outputPath, ...downloadInfo })
const screenshotPath = await captureScreenshotIfNeeded(page, captureScreenshot, path.dirname(outputPath), 'done')
return {
ok: true,
status: 'completed',
summary: '图片已生成并下载。',
outputPath,
downloadAddress: outputPath,
screenshotPath,
steps,
}
}
@@ -30,6 +30,7 @@ type scriptImportEnvelope struct {
ScriptText string `json:"scriptText"`
Notes string `json:"notes"`
TargetConfig map[string]any `json:"targetConfig"`
PublicAPI map[string]any `json:"publicAPI"`
Source map[string]any `json:"source"`
Files []scriptTemplateFile `json:"files"`
}
@@ -0,0 +1,143 @@
package automation
import (
"bytes"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const maxDiscoveredScriptLibraryPackages = 256
var automationLibraryEntryMarkers = [][]byte{
[]byte("module.exports.run"),
[]byte("exports.run"),
[]byte("export async function run"),
[]byte("export function run"),
[]byte("export const run"),
[]byte("export default"),
}
func DiscoverImportableScriptDirectoriesWithOptions(rootDir string) ([]string, error) {
baseDir := filepath.Clean(strings.TrimSpace(rootDir))
if baseDir == "" || baseDir == "." {
return nil, fmt.Errorf("script library directory is required")
}
info, err := os.Stat(baseDir)
if err != nil {
return nil, fmt.Errorf("stat script library directory failed: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("script library path is not a directory")
}
discovered := make([]string, 0, 8)
seen := make(map[string]struct{})
err = filepath.WalkDir(baseDir, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() {
return nil
}
if path != baseDir {
switch strings.ToLower(strings.TrimSpace(entry.Name())) {
case ".git", "node_modules":
return filepath.SkipDir
}
}
importable, err := isDiscoverableScriptDirectory(path)
if err != nil {
return err
}
if !importable {
return nil
}
normalizedPath := filepath.Clean(path)
if _, exists := seen[normalizedPath]; !exists {
if len(discovered) >= maxDiscoveredScriptLibraryPackages {
return fmt.Errorf("script library contains too many importable packages")
}
discovered = append(discovered, normalizedPath)
seen[normalizedPath] = struct{}{}
}
return filepath.SkipDir
})
if err != nil {
return nil, fmt.Errorf("scan script library failed: %w", err)
}
if len(discovered) == 0 {
return nil, fmt.Errorf("未在所选目录下找到可导入脚本包")
}
sort.Strings(discovered)
return discovered, nil
}
func isDiscoverableScriptDirectory(dir string) (bool, error) {
manifestPath, err := resolveImportManifest(dir)
if err != nil {
return false, err
}
if manifestPath != "" {
return true, nil
}
entryPath, found, err := findDiscoverableScriptEntryFile(dir)
if err != nil {
return false, err
}
if !found {
return false, nil
}
return looksLikeAutomationEntryFile(entryPath)
}
func findDiscoverableScriptEntryFile(dir string) (string, bool, error) {
for _, candidate := range []string{
"index.cjs",
"index.js",
"index.mjs",
"index.ts",
"index.cts",
"index.mts",
} {
entryPath := filepath.Join(dir, candidate)
info, err := os.Stat(entryPath)
if err == nil && !info.IsDir() {
return entryPath, true, nil
}
if err != nil && !os.IsNotExist(err) {
return "", false, fmt.Errorf("stat script entry failed: %w", err)
}
}
return "", false, nil
}
func looksLikeAutomationEntryFile(path string) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("read script entry failed: %w", err)
}
compact := bytes.ToLower(bytes.TrimSpace(data))
if len(compact) == 0 {
return false, nil
}
for _, marker := range automationLibraryEntryMarkers {
if bytes.Contains(compact, bytes.ToLower(marker)) {
return true, nil
}
}
return false, nil
}
@@ -0,0 +1,71 @@
package automation
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestDiscoverImportableScriptDirectoriesWithOptions(t *testing.T) {
rootDir := t.TempDir()
writeLibraryPackageFile(t, filepath.Join(rootDir, "manifest-script", "automation.script.json"), `{
"name": "Manifest Script",
"type": "playwright-cdp",
"entryFile": "index.cjs"
}`)
writeLibraryPackageFile(t, filepath.Join(rootDir, "manifest-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'manifest' })")
writeLibraryPackageFile(t, filepath.Join(rootDir, "entry-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'entry' })")
writeLibraryPackageFile(t, filepath.Join(rootDir, "entry-no-run", "index.ts"), "export const value = 'not-a-script'")
writeLibraryPackageFile(t, filepath.Join(rootDir, "nested", "child-script", "automation.script.json"), `{
"name": "Nested Script",
"type": "playwright-cdp",
"entryFile": "index.cjs"
}`)
writeLibraryPackageFile(t, filepath.Join(rootDir, "nested", "child-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'nested' })")
writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "automation.script.json"), `{
"name": "Parent Script",
"type": "playwright-cdp",
"entryFile": "index.cjs"
}`)
writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'parent' })")
writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "child-script", "automation.script.json"), `{
"name": "Child Script",
"type": "playwright-cdp",
"entryFile": "index.cjs"
}`)
writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "child-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'child' })")
writeLibraryPackageFile(t, filepath.Join(rootDir, ".git", "ignored-script", "automation.script.json"), `{"name":"Ignored","entryFile":"index.cjs"}`)
writeLibraryPackageFile(t, filepath.Join(rootDir, "node_modules", "ignored-script", "automation.script.json"), `{"name":"Ignored","entryFile":"index.cjs"}`)
directories, err := DiscoverImportableScriptDirectoriesWithOptions(rootDir)
if err != nil {
t.Fatalf("DiscoverImportableScriptDirectoriesWithOptions returned error: %v", err)
}
expected := []string{
filepath.Join(rootDir, "entry-script"),
filepath.Join(rootDir, "manifest-script"),
filepath.Join(rootDir, "nested", "child-script"),
filepath.Join(rootDir, "parent-script"),
}
if !reflect.DeepEqual(directories, expected) {
t.Fatalf("unexpected discovered directories:\nwant: %#v\ngot: %#v", expected, directories)
}
}
func writeLibraryPackageFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create package directory failed: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write package file failed: %v", err)
}
}
@@ -64,6 +64,9 @@ func buildImportedRecord(envelope scriptImportEnvelope, defaultName string, sour
if envelope.TargetConfig != nil {
descriptor["targetConfig"] = envelope.TargetConfig
}
if envelope.PublicAPI != nil {
descriptor["publicAPI"] = envelope.PublicAPI
}
if envelope.Selector != nil {
descriptor["selector"] = envelope.Selector
}
@@ -115,6 +118,7 @@ func buildImportedRecord(envelope scriptImportEnvelope, defaultName string, sour
ScriptText: scriptText,
Notes: mapStringValueAny(descriptor, "notes"),
TargetConfig: mapScriptTargetConfigValue(descriptor["targetConfig"]),
PublicAPI: mapScriptPublicAPIValue(descriptor["publicAPI"]),
Source: source,
CreatedAt: now,
UpdatedAt: now,
@@ -179,6 +179,63 @@ func mapScriptTargetConfigValue(value any) ScriptTargetConfig {
}
}
func mapScriptPublicAPIValue(value any) ScriptPublicAPIConfig {
object, ok := value.(map[string]any)
if !ok || object == nil {
return ScriptPublicAPIConfig{}
}
return ScriptPublicAPIConfig{
Enabled: mapBoolValueAny(object, "enabled"),
Method: mapStringValueAny(object, "method"),
Path: firstNonEmpty(mapStringValueAny(object, "path"), mapStringValueAny(object, "route")),
RequestMode: mapStringValueAny(object, "requestMode"),
ResponseMode: mapStringValueAny(object, "responseMode"),
TimeoutMs: mapIntValueAny(object, "timeoutMs"),
RequestBodyText: stringifyImportJSONValue(firstNonNil(object["requestBodyText"], object["requestBody"], object["requestExample"])),
ResponseBodyText: stringifyImportJSONValue(firstNonNil(object["responseBodyText"], object["responseBody"], object["responseExample"])),
Variables: mapScriptPublicAPIVariablesValue(object["variables"]),
}
}
func mapScriptPublicAPIVariablesValue(value any) []ScriptPublicAPIVariable {
switch typed := value.(type) {
case []ScriptPublicAPIVariable:
return normalizeScriptPublicAPIVariables(typed)
case []any:
variables := make([]ScriptPublicAPIVariable, 0, len(typed))
for _, item := range typed {
object, ok := item.(map[string]any)
if !ok || object == nil {
continue
}
variables = append(variables, ScriptPublicAPIVariable{
Name: firstNonEmpty(mapStringValueAny(object, "name"), mapStringValueAny(object, "key")),
DefaultValue: firstNonEmpty(mapStringValueAny(object, "defaultValue"), mapStringValueAny(object, "default"), mapStringValueAny(object, "value")),
Description: firstNonEmpty(mapStringValueAny(object, "description"), mapStringValueAny(object, "label"), mapStringValueAny(object, "note")),
Required: mapBoolValueAny(object, "required"),
})
}
return normalizeScriptPublicAPIVariables(variables)
case map[string]any:
variables := make([]ScriptPublicAPIVariable, 0, len(typed))
for key, rawValue := range typed {
variable := ScriptPublicAPIVariable{Name: key}
if object, ok := rawValue.(map[string]any); ok && object != nil {
variable.DefaultValue = firstNonEmpty(mapStringValueAny(object, "defaultValue"), mapStringValueAny(object, "default"), mapStringValueAny(object, "value"))
variable.Description = firstNonEmpty(mapStringValueAny(object, "description"), mapStringValueAny(object, "label"), mapStringValueAny(object, "note"))
variable.Required = mapBoolValueAny(object, "required")
} else if rawValue != nil {
variable.DefaultValue = strings.TrimSpace(fmt.Sprint(rawValue))
}
variables = append(variables, variable)
}
return normalizeScriptPublicAPIVariables(variables)
default:
return nil
}
}
func mapScriptTargetSelectorValue(value any) ScriptTargetSelector {
object, ok := value.(map[string]any)
if !ok || object == nil {
@@ -195,6 +252,32 @@ func mapScriptTargetSelectorValue(value any) ScriptTargetSelector {
}
}
func mapBoolValueAny(payload map[string]any, key string) bool {
if payload == nil {
return false
}
value, exists := payload[key]
if !exists || value == nil {
return false
}
switch typed := value.(type) {
case bool:
return typed
case string:
switch strings.ToLower(strings.TrimSpace(typed)) {
case "true", "1", "yes", "enabled", "on":
return true
}
case float64:
return typed != 0
case int:
return typed != 0
case int64:
return typed != 0
}
return false
}
func firstNonNil(values ...any) any {
for _, value := range values {
if value != nil {
@@ -22,6 +22,16 @@ func TestImportBundleFromBytesSupportsInlineJSONPackage(t *testing.T) {
"params": {
"url": "https://example.com"
},
"publicAPI": {
"enabled": true,
"path": "mail/demo-script",
"requestMode": "params-only",
"responseMode": "result-only",
"variables": [
{"name": "recipientQuery", "defaultValue": "target@example.com", "description": "收件人", "required": true},
{"name": "senderEmail", "defaultValue": "otp@tm1.openai.com, noreply@tm.openai.com"}
]
},
"script": "module.exports.run = async () => ({ ok: true })"
}`), "远程地址 https://example.com/demo-script.json")
if err != nil {
@@ -49,6 +59,12 @@ func TestImportBundleFromBytesSupportsInlineJSONPackage(t *testing.T) {
if len(bundle.Files) != 1 || bundle.Files[0].Path != "index.cjs" {
t.Fatalf("unexpected bundle files: %+v", bundle.Files)
}
if bundle.Record.PublicAPI.Path != "mail/demo-script" || !bundle.Record.PublicAPI.Enabled {
t.Fatalf("unexpected public api import result: %+v", bundle.Record.PublicAPI)
}
if len(bundle.Record.PublicAPI.Variables) != 2 || bundle.Record.PublicAPI.Variables[0].Name != "recipientQuery" || !bundle.Record.PublicAPI.Variables[0].Required {
t.Fatalf("unexpected public api variables: %+v", bundle.Record.PublicAPI.Variables)
}
if bundle.Record.Source.Type != "remote-url" {
t.Fatalf("expected remote-url source, got %+v", bundle.Record.Source)
}
@@ -29,6 +29,7 @@ type ScriptRunRecord struct {
type ScriptRunRequest struct {
ScriptID string `json:"scriptId"`
SelectorText string `json:"selectorText"`
TargetInput any `json:"targetInput,omitempty"`
ParamsText string `json:"paramsText"`
UseScriptSelector bool `json:"useScriptSelector"`
UseScriptParams bool `json:"useScriptParams"`
@@ -34,6 +34,7 @@ func MarshalScriptPackageManifest(record ScriptRecord) ([]byte, error) {
"tags": append([]string{}, normalized.Tags...),
"notes": normalized.Notes,
"targetConfig": normalized.TargetConfig,
"publicAPI": normalized.PublicAPI,
"source": map[string]any{
"type": normalized.Source.Type,
"uri": normalized.Source.URI,
@@ -82,12 +83,24 @@ func MarshalScriptTemplate(bundle ImportedBundle) ([]byte, error) {
"tags": append([]string{}, record.Tags...),
"notes": record.Notes,
"targetConfig": record.TargetConfig,
"publicAPI": record.PublicAPI,
"source": record.Source,
"createdAt": record.CreatedAt,
"updatedAt": record.UpdatedAt,
},
ScriptText: record.ScriptText,
Notes: record.Notes,
PublicAPI: map[string]any{
"enabled": record.PublicAPI.Enabled,
"method": record.PublicAPI.Method,
"path": record.PublicAPI.Path,
"requestMode": record.PublicAPI.RequestMode,
"responseMode": record.PublicAPI.ResponseMode,
"timeoutMs": record.PublicAPI.TimeoutMs,
"requestBodyText": record.PublicAPI.RequestBodyText,
"responseBodyText": record.PublicAPI.ResponseBodyText,
"variables": record.PublicAPI.Variables,
},
Source: map[string]any{
"type": record.Source.Type,
"uri": record.Source.URI,
@@ -18,6 +18,17 @@ func TestMarshalScriptTemplateRoundTripsAdditionalFiles(t *testing.T) {
SelectorText: `{"code":"DEMO_TEMPLATE"}`,
ParamsText: `{"url":"https://example.com"}`,
ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()",
PublicAPI: ScriptPublicAPIConfig{
Enabled: true,
Path: "mail/template-roundtrip",
RequestMode: "params-only",
ResponseMode: "result-only",
RequestBodyText: "{\n \"recipientQuery\": \"target@example.com\"\n}",
ResponseBodyText: "{\n \"verificationCode\": \"429792\"\n}",
Variables: []ScriptPublicAPIVariable{
{Name: "recipientQuery", DefaultValue: "target@example.com", Description: "收件人", Required: true},
},
},
},
Files: []ImportedBundleFile{
{
@@ -62,6 +73,12 @@ func TestMarshalScriptTemplateRoundTripsAdditionalFiles(t *testing.T) {
if !hasBundleFile(imported.Files, "assets/raw.bin", []byte{0x00, 0x01, 0x02, 0xff}) {
t.Fatalf("expected binary file to round-trip, got %+v", imported.Files)
}
if imported.Record.PublicAPI.Path != "mail/template-roundtrip" || !imported.Record.PublicAPI.Enabled {
t.Fatalf("expected public api to round-trip, got %+v", imported.Record.PublicAPI)
}
if len(imported.Record.PublicAPI.Variables) != 1 || imported.Record.PublicAPI.Variables[0].Name != "recipientQuery" {
t.Fatalf("expected public api variables to round-trip, got %+v", imported.Record.PublicAPI.Variables)
}
}
func TestScriptStoreExportBundleIncludesNestedFiles(t *testing.T) {
+62 -33
View File
@@ -13,8 +13,10 @@ const (
defaultScriptPackageFormat = "ant-automation-script"
defaultScriptManifestVersion = 1
defaultScriptCreateNameTemplate = "${templateName}-${timestamp}"
defaultScriptPublicAPITimeoutMs = 300000
scriptStoreConfigFileName = "config"
scriptStoreLegacyConfigName = "manifest.json"
scriptPublicAPIBasePath = "/api/automation/hooks"
)
type ScriptSource struct {
@@ -41,24 +43,44 @@ type ScriptTargetConfig struct {
CreateNameTemplate string `json:"createNameTemplate"`
}
type ScriptPublicAPIConfig struct {
Enabled bool `json:"enabled"`
Method string `json:"method"`
Path string `json:"path"`
RequestMode string `json:"requestMode"`
ResponseMode string `json:"responseMode"`
TimeoutMs int `json:"timeoutMs"`
RequestBodyText string `json:"requestBodyText"`
ResponseBodyText string `json:"responseBodyText"`
Variables []ScriptPublicAPIVariable `json:"variables"`
}
type ScriptPublicAPIVariable struct {
Name string `json:"name"`
DefaultValue string `json:"defaultValue"`
Description string `json:"description"`
Required bool `json:"required"`
}
type ScriptRecord struct {
PackageFormat string `json:"packageFormat"`
ManifestVersion int `json:"manifestVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
EntryFile string `json:"entryFile"`
Tags []string `json:"tags"`
SelectorText string `json:"selectorText"`
ParamsText string `json:"paramsText"`
ScriptText string `json:"scriptText"`
Notes string `json:"notes"`
TargetConfig ScriptTargetConfig `json:"targetConfig"`
Source ScriptSource `json:"source"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
PackageFormat string `json:"packageFormat"`
ManifestVersion int `json:"manifestVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
EntryFile string `json:"entryFile"`
Tags []string `json:"tags"`
SelectorText string `json:"selectorText"`
ParamsText string `json:"paramsText"`
ScriptText string `json:"scriptText"`
Notes string `json:"notes"`
TargetConfig ScriptTargetConfig `json:"targetConfig"`
PublicAPI ScriptPublicAPIConfig `json:"publicAPI"`
Source ScriptSource `json:"source"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type ImportedBundleFile struct {
@@ -72,22 +94,23 @@ type ImportedBundle struct {
}
type scriptStoreConfig struct {
PackageFormat string `json:"packageFormat"`
ManifestVersion int `json:"manifestVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
EntryFile string `json:"entryFile"`
Tags []string `json:"tags"`
SelectorText string `json:"selectorText"`
ParamsText string `json:"paramsText"`
Notes string `json:"notes"`
TargetConfig ScriptTargetConfig `json:"targetConfig"`
Source ScriptSource `json:"source"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
PackageFormat string `json:"packageFormat"`
ManifestVersion int `json:"manifestVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
EntryFile string `json:"entryFile"`
Tags []string `json:"tags"`
SelectorText string `json:"selectorText"`
ParamsText string `json:"paramsText"`
Notes string `json:"notes"`
TargetConfig ScriptTargetConfig `json:"targetConfig"`
PublicAPI ScriptPublicAPIConfig `json:"publicAPI"`
Source ScriptSource `json:"source"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type ScriptStore struct {
@@ -154,6 +177,9 @@ func (s *ScriptStore) Save(input ScriptRecord) (ScriptRecord, error) {
if err != nil {
return ScriptRecord{}, err
}
if err := s.validateRecord(record); err != nil {
return ScriptRecord{}, err
}
return s.writeRecord(dir, record, existing, nil)
}
@@ -209,6 +235,9 @@ func (s *ScriptStore) ImportBundle(bundle ImportedBundle) (ScriptRecord, error)
}
existing, _ := s.readScriptDir(dir)
if err := s.validateRecord(record); err != nil {
return ScriptRecord{}, err
}
return s.writeRecord(dir, record, existing, bundle.Files)
}
@@ -41,6 +41,7 @@ func (s *ScriptStore) readScriptDir(dir string) (ScriptRecord, error) {
ParamsText: config.ParamsText,
Notes: config.Notes,
TargetConfig: config.TargetConfig,
PublicAPI: config.PublicAPI,
Source: config.Source,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
@@ -122,6 +123,7 @@ func (s *ScriptStore) writeRecord(dir string, record ScriptRecord, existing Scri
ParamsText: record.ParamsText,
Notes: record.Notes,
TargetConfig: record.TargetConfig,
PublicAPI: record.PublicAPI,
Source: record.Source,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
@@ -2,6 +2,7 @@ package automation
import (
"fmt"
"path"
"path/filepath"
"strings"
"time"
@@ -47,6 +48,7 @@ func normalizeScriptRecord(input ScriptRecord, existing ScriptRecord) (ScriptRec
ScriptText: normalizeScriptText(input.ScriptText),
Notes: strings.TrimSpace(input.Notes),
TargetConfig: normalizeScriptTargetConfig(input.TargetConfig),
PublicAPI: normalizeScriptPublicAPIConfig(input.PublicAPI),
Source: normalizeScriptSource(input.Source, existing.Source),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
@@ -195,6 +197,149 @@ func normalizeScriptTargetSelector(input ScriptTargetSelector) ScriptTargetSelec
}
}
func normalizeScriptPublicAPIConfig(input ScriptPublicAPIConfig) ScriptPublicAPIConfig {
return ScriptPublicAPIConfig{
Enabled: input.Enabled,
Method: normalizeScriptPublicAPIMethod(input.Method),
Path: normalizeScriptPublicAPIPath(input.Path),
RequestMode: normalizeScriptPublicAPIRequestMode(input.RequestMode),
ResponseMode: normalizeScriptPublicAPIResponseMode(input.ResponseMode),
TimeoutMs: normalizeScriptPublicAPITimeout(input.TimeoutMs),
RequestBodyText: normalizeScriptJSONText(input.RequestBodyText),
ResponseBodyText: normalizeScriptJSONText(input.ResponseBodyText),
Variables: normalizeScriptPublicAPIVariables(input.Variables),
}
}
func normalizeScriptPublicAPIVariables(variables []ScriptPublicAPIVariable) []ScriptPublicAPIVariable {
seen := make(map[string]struct{}, len(variables))
result := make([]ScriptPublicAPIVariable, 0, len(variables))
for _, variable := range variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
result = append(result, ScriptPublicAPIVariable{
Name: name,
DefaultValue: strings.TrimSpace(variable.DefaultValue),
Description: strings.TrimSpace(variable.Description),
Required: variable.Required,
})
}
return result
}
func normalizeScriptPublicAPIMethod(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "POST":
return "POST"
default:
return "POST"
}
}
func normalizeScriptPublicAPIRequestMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "params-only":
return "params-only"
default:
return "standard"
}
}
func normalizeScriptPublicAPIResponseMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "result-only":
return "result-only"
default:
return "envelope"
}
}
func normalizeScriptPublicAPITimeout(value int) int {
if value <= 0 {
return defaultScriptPublicAPITimeoutMs
}
if value < 1000 {
return 1000
}
if value > 30*60*1000 {
return 30 * 60 * 1000
}
return value
}
func normalizeScriptPublicAPIPath(value string) string {
normalized := strings.TrimSpace(value)
if normalized == "" {
return ""
}
normalized = strings.ReplaceAll(normalized, "\\", "/")
lower := strings.ToLower(normalized)
if strings.HasPrefix(lower, scriptPublicAPIBasePath+"/") {
normalized = normalized[len(scriptPublicAPIBasePath)+1:]
} else if strings.HasPrefix(lower, strings.TrimPrefix(scriptPublicAPIBasePath, "/")+"/") {
normalized = normalized[len(strings.TrimPrefix(scriptPublicAPIBasePath, "/"))+1:]
}
cleaned := path.Clean("/" + normalized)
cleaned = strings.Trim(cleaned, "/")
if cleaned == "" || cleaned == "." {
return ""
}
parts := strings.Split(cleaned, "/")
result := make([]string, 0, len(parts))
for _, part := range parts {
if normalizedPart := normalizeScriptPublicAPIPathSegment(part); normalizedPart != "" {
result = append(result, normalizedPart)
}
}
return strings.Join(result, "/")
}
func normalizeScriptPublicAPIPathSegment(value string) string {
var builder strings.Builder
lastDash := false
for _, ch := range strings.TrimSpace(value) {
switch {
case ch >= 'a' && ch <= 'z':
builder.WriteRune(ch)
lastDash = false
case ch >= 'A' && ch <= 'Z':
builder.WriteRune(ch + 32)
lastDash = false
case ch >= '0' && ch <= '9':
builder.WriteRune(ch)
lastDash = false
case ch == '-', ch == '_', ch == '.':
builder.WriteRune(ch)
lastDash = false
default:
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}
func scriptPublicAPIRoute(pathValue string) string {
pathValue = normalizeScriptPublicAPIPath(pathValue)
if pathValue == "" {
return scriptPublicAPIBasePath
}
return scriptPublicAPIBasePath + "/" + pathValue
}
func isSafeScriptID(value string) bool {
for _, ch := range value {
switch {
@@ -179,6 +179,95 @@ func TestScriptStorePersistsTargetConfig(t *testing.T) {
}
}
func TestScriptStorePersistsPublicAPIConfig(t *testing.T) {
store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts"))
saved, err := store.Save(ScriptRecord{
ID: "public-api-script",
Name: "对外接口脚本",
EntryFile: "index.cjs",
ScriptText: "module.exports.run = async () => ({ ok: true })",
PublicAPI: ScriptPublicAPIConfig{
Enabled: true,
Method: "post",
Path: "/API/Automation/Hooks/Mail/Proton First Message",
RequestMode: "params-only",
ResponseMode: "result-only",
TimeoutMs: 999,
RequestBodyText: "{\n \"recipientQuery\": \"target@example.com\"\n}",
ResponseBodyText: "{\n \"verificationCode\": \"429792\"\n}",
Variables: []ScriptPublicAPIVariable{
{Name: "recipientQuery", DefaultValue: "target@example.com", Description: "收件人", Required: true},
{Name: "recipientQuery", DefaultValue: "duplicate"},
{Name: "senderEmail", DefaultValue: "otp@tm1.openai.com, noreply@tm.openai.com"},
},
},
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
if !saved.PublicAPI.Enabled {
t.Fatalf("expected public api to be enabled")
}
if saved.PublicAPI.Method != "POST" {
t.Fatalf("expected public api method POST, got %+v", saved.PublicAPI)
}
if saved.PublicAPI.Path != "mail/proton-first-message" {
t.Fatalf("expected normalized public api path, got %+v", saved.PublicAPI)
}
if saved.PublicAPI.TimeoutMs != 1000 {
t.Fatalf("expected normalized public api timeout 1000, got %+v", saved.PublicAPI)
}
if len(saved.PublicAPI.Variables) != 2 || saved.PublicAPI.Variables[0].Name != "recipientQuery" || !saved.PublicAPI.Variables[0].Required {
t.Fatalf("expected normalized public api variables, got %+v", saved.PublicAPI.Variables)
}
loaded, err := store.Get(saved.ID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded.PublicAPI.Path != "mail/proton-first-message" {
t.Fatalf("unexpected persisted public api path: %+v", loaded.PublicAPI)
}
if loaded.PublicAPI.RequestMode != "params-only" || loaded.PublicAPI.ResponseMode != "result-only" {
t.Fatalf("unexpected persisted public api modes: %+v", loaded.PublicAPI)
}
if len(loaded.PublicAPI.Variables) != 2 || loaded.PublicAPI.Variables[1].Name != "senderEmail" {
t.Fatalf("unexpected persisted public api variables: %+v", loaded.PublicAPI.Variables)
}
}
func TestScriptStoreRejectsDuplicatePublicAPIPath(t *testing.T) {
store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts"))
if _, err := store.Save(ScriptRecord{
ID: "hook-a",
Name: "Hook A",
EntryFile: "index.cjs",
ScriptText: "module.exports.run = async () => ({ ok: true })",
PublicAPI: ScriptPublicAPIConfig{
Enabled: true,
Path: "mail/proton-first-message",
},
}); err != nil {
t.Fatalf("initial Save returned error: %v", err)
}
if _, err := store.Save(ScriptRecord{
ID: "hook-b",
Name: "Hook B",
EntryFile: "index.cjs",
ScriptText: "module.exports.run = async () => ({ ok: true })",
PublicAPI: ScriptPublicAPIConfig{
Enabled: false,
Path: "/api/automation/hooks/mail/proton-first-message",
},
}); err == nil {
t.Fatalf("expected duplicate public api path to fail")
}
}
func TestScriptStoreReadsLegacyManifestAndMigratesOnSave(t *testing.T) {
store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts"))
scriptDir := filepath.Join(store.rootDir, "legacy-script")
@@ -0,0 +1,96 @@
package automation
import (
"fmt"
"strings"
)
func (s *ScriptStore) validateRecord(record ScriptRecord) error {
if err := validateScriptPublicAPIConfig(record.PublicAPI); err != nil {
return err
}
return s.validatePublicAPIUniqueness(record)
}
func validateScriptPublicAPIConfig(config ScriptPublicAPIConfig) error {
for _, variable := range config.Variables {
if !isScriptPublicAPIVariableName(variable.Name) {
return fmt.Errorf("public api variable name %q is invalid", variable.Name)
}
}
if !config.Enabled && strings.TrimSpace(config.Path) == "" {
return nil
}
if method := normalizeScriptPublicAPIMethod(config.Method); method != "POST" {
return fmt.Errorf("public api method %q is not supported", method)
}
if normalizeScriptPublicAPIRequestMode(config.RequestMode) == "" {
return fmt.Errorf("public api request mode is invalid")
}
if normalizeScriptPublicAPIResponseMode(config.ResponseMode) == "" {
return fmt.Errorf("public api response mode is invalid")
}
pathValue := normalizeScriptPublicAPIPath(config.Path)
if config.Enabled && pathValue == "" {
return fmt.Errorf("public api path is required when enabled")
}
if pathValue == "" {
return nil
}
for _, part := range strings.Split(pathValue, "/") {
if strings.TrimSpace(part) == "" {
return fmt.Errorf("public api path is invalid")
}
}
return nil
}
func isScriptPublicAPIVariableName(value string) bool {
name := strings.TrimSpace(value)
if name == "" {
return false
}
for index, ch := range name {
switch {
case ch >= 'a' && ch <= 'z':
case ch >= 'A' && ch <= 'Z':
case ch == '_':
case index > 0 && ch >= '0' && ch <= '9':
default:
return false
}
}
return true
}
func (s *ScriptStore) validatePublicAPIUniqueness(record ScriptRecord) error {
targetPath := normalizeScriptPublicAPIPath(record.PublicAPI.Path)
if targetPath == "" {
return nil
}
items, err := s.List()
if err != nil {
return err
}
for _, item := range items {
if strings.TrimSpace(item.ID) == strings.TrimSpace(record.ID) {
continue
}
if normalizeScriptPublicAPIPath(item.PublicAPI.Path) != targetPath {
continue
}
targetName := strings.TrimSpace(item.Name)
if targetName == "" {
targetName = strings.TrimSpace(item.ID)
}
return fmt.Errorf("public api path %q is already used by script %s", scriptPublicAPIRoute(targetPath), targetName)
}
return nil
}
@@ -135,6 +135,230 @@ module.exports.run = async ({ launch, connect, selector, params, log, artifact }
}
}
func TestRunScriptTaskOpenPageCreatesFreshPageAndGrantsPermissions(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)
}
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",
"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-open-page.cjs")
scriptSource := `module.exports.run = async ({ launch, connect, openPage, selector, params }) => {
const session = await launch({
selector,
startUrls: [params.url],
skipDefaultStartUrls: true,
})
const connection = await connect(session)
const opened = await openPage(connection, {
url: params.url,
timeoutMs: params.timeoutMs || 30000,
permissions: ['notifications'],
})
return {
ok: true,
summary: 'openPage helper ok',
url: opened.page.url(),
permissionApplied: opened.permissionResult.applied,
permissionOrigin: opened.permissionResult.origin,
permissionStrategy: opened.permissionResult.strategy || '',
reusedPage: opened.reusedPage,
}
}`
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:open-page",
ScriptPath: scriptPath,
Selector: map[string]any{"code": "BUYER_001"},
Params: map[string]any{"url": "https://example.com/inbox", "timeoutMs": 30000},
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)
}
parsed := map[string]any{}
if err := json.Unmarshal([]byte(result.ResultText), &parsed); err != nil {
t.Fatalf("parse result text failed: %v result=%s", err, result.ResultText)
}
if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 0 {
parsed = nested
}
if parsed["permissionApplied"] != true {
t.Fatalf("expected permissionApplied to be true, got %+v", parsed)
}
if parsed["permissionOrigin"] != "https://example.com" {
t.Fatalf("unexpected permissionOrigin: %+v", parsed)
}
if parsed["reusedPage"] != false {
t.Fatalf("expected reusedPage to be false, got %+v", parsed)
}
if parsed["url"] != "https://example.com/inbox" {
t.Fatalf("unexpected url: %+v", parsed)
}
}
func TestRunScriptTaskCallPageAPIUsesBrowserContext(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)
}
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-page-api",
"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-page-api.cjs")
scriptSource := `module.exports.run = async ({ useBrowser, callPageAPI, browserFetch, selector, params }) => {
const runtime = await useBrowser({
selector,
startUrls: [params.url],
skipDefaultStartUrls: true,
url: params.url,
reuseCurrentPage: true,
timeoutMs: 30000,
})
const created = await callPageAPI(runtime, {
url: '/api/order/create',
method: 'POST',
query: {
source: 'automation',
tag: ['a', 'b'],
},
headers: {
'X-Test': 'page-api',
},
json: {
skuId: params.skuId,
count: 2,
},
})
const ping = await browserFetch(runtime.page, '/api/ping', { method: 'GET' })
return {
ok: true,
summary: 'page api helper ok',
status: created.status,
requestUrl: created.json.url,
method: created.json.method,
credentials: created.json.credentials,
contentType: created.json.headers['Content-Type'],
testHeader: created.json.headers['X-Test'],
requestBody: created.json.body,
pingMethod: ping.json.method,
}
}`
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:page-api",
ScriptPath: scriptPath,
Selector: map[string]any{"code": "BUYER_001"},
Params: map[string]any{"url": "https://example.com/app", "skuId": "sku-123"},
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)
}
parsed := map[string]any{}
if err := json.Unmarshal([]byte(result.ResultText), &parsed); err != nil {
t.Fatalf("parse result text failed: %v result=%s", err, result.ResultText)
}
if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 0 {
parsed = nested
}
if parsed["status"] != float64(201) {
t.Fatalf("unexpected status: %+v", parsed)
}
if parsed["method"] != "POST" || parsed["pingMethod"] != "GET" {
t.Fatalf("unexpected methods: %+v", parsed)
}
if parsed["credentials"] != "include" {
t.Fatalf("expected credentials=include, got %+v", parsed)
}
if parsed["contentType"] != "application/json" || parsed["testHeader"] != "page-api" {
t.Fatalf("unexpected headers: %+v", parsed)
}
if !strings.Contains(fmt.Sprint(parsed["requestUrl"]), "/api/order/create?source=automation&tag=a&tag=b") {
t.Fatalf("unexpected requestUrl: %+v", parsed)
}
if !strings.Contains(fmt.Sprint(parsed["requestBody"]), `"skuId":"sku-123"`) {
t.Fatalf("unexpected requestBody: %+v", parsed)
}
}
func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) {
nodeExecPath := lookupNodeExecutable(t)
@@ -167,6 +391,8 @@ func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) {
Tags []string `json:"tags"`
GroupID string `json:"groupId"`
MatchMode string `json:"matchMode"`
ProxyID string `json:"proxyId"`
ProxyConfig string `json:"proxyConfig"`
Selector map[string]any `json:"selector"`
LaunchArgs []string `json:"launchArgs"`
StartURLs []string `json:"startUrls"`
@@ -244,6 +470,103 @@ func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) {
if receivedBody.Keyword != "" {
t.Fatalf("expected non-launch params to be filtered, got keyword=%q", receivedBody.Keyword)
}
if receivedBody.ProxyID != "" || receivedBody.ProxyConfig != "" {
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) {
@@ -395,6 +718,71 @@ func TestRunScriptTaskClosesBrowserConnections(t *testing.T) {
}
}
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)
@@ -488,6 +876,61 @@ func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expe
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 {
@@ -519,21 +962,58 @@ function createPage() {
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();
},
+294 -5
View File
@@ -3,14 +3,62 @@ package browser
import (
"ant-chrome/backend/internal/logger"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
)
const (
copyModeRegular = "regular"
copyModeAutoFingerprint = "auto_fingerprint"
copyAutomationTargetSeed = "seed"
copyAutomationTargetIdentity = "identity"
copyAutomationTargetLocale = "locale"
copyAutomationTargetScreen = "screen"
copyAutomationTargetHardware = "hardware"
copyAutomationTargetRender = "render"
copyAutomationTargetFonts = "fonts"
copyAutomationTargetNetwork = "network"
copyAutomationTargetDevices = "devices"
)
var profileCopyNameSuffixPattern = regexp.MustCompile(`[[:space:]]*(?:\([[:space:]]*副本[[:space:]]*\)|(副本))[[:space:]]*(?:[0-9]{12})?$`)
// Copy 复制实例配置(除指纹参数外全部复制,指纹使用默认值生成新种子)
func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
return m.copyProfile(profileId, newName, func(*Profile) []string {
return append([]string{}, m.Config.Browser.DefaultFingerprintArgs...)
})
}
// CopyWithMode 按模式复制实例配置。
// regular: 保留原实例指纹参数。
// auto_fingerprint: 保留原指纹模板,但移除显式种子,让新实例自动生成新种子。
func (m *Manager) CopyWithMode(profileId string, newName string, mode string) (*Profile, error) {
return m.CopyWithOptions(profileId, newName, ProfileCopyOptions{Mode: mode})
}
// CopyWithOptions 按结构化选项复制实例配置。
func (m *Manager) CopyWithOptions(profileId string, newName string, options ProfileCopyOptions) (*Profile, error) {
normalizedMode := normalizeCopyMode(options.Mode)
normalizedTargets, err := normalizeCopyAutomationTargets(options.AutomationTargets)
if err != nil {
return nil, err
}
return m.copyProfile(profileId, newName, func(src *Profile) []string {
switch normalizedMode {
case copyModeRegular:
return append([]string{}, src.FingerprintArgs...)
default:
return buildAutoFingerprintArgs(src.FingerprintArgs, m.Config.Browser.DefaultFingerprintArgs, normalizedTargets)
}
})
}
func (m *Manager) copyProfile(profileId string, newName string, fingerprintResolver func(*Profile) []string) (*Profile, error) {
log := logger.New("Browser")
m.InitData()
m.Mutex.Lock()
@@ -27,12 +75,13 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
return nil, fmt.Errorf("profile not found")
}
now := time.Now().Format(time.RFC3339)
now := time.Now()
nowText := now.Format(time.RFC3339)
newId := uuid.NewString()
profileName := strings.TrimSpace(newName)
if profileName == "" {
profileName = src.ProfileName + " (副本)"
profileName = buildProfileCopyName(src.ProfileName, now)
}
profile := &Profile{
@@ -40,7 +89,7 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
ProfileName: profileName,
UserDataDir: newId,
CoreId: normalizeProfileCoreID(src.CoreId),
FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...),
FingerprintArgs: fingerprintResolver(src),
ProxyId: src.ProxyId,
ProxyConfig: src.ProxyConfig,
ProxyBindSourceID: src.ProxyBindSourceID,
@@ -55,8 +104,8 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
DebugPort: 0,
Pid: 0,
LastError: "",
CreatedAt: now,
UpdatedAt: now,
CreatedAt: nowText,
UpdatedAt: nowText,
}
m.Profiles[newId] = profile
@@ -69,3 +118,243 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
m.ensureProfileLaunchCode(profile)
return profile, nil
}
func buildProfileCopyName(sourceName string, now time.Time) string {
baseName := normalizeProfileCopyBaseName(sourceName)
if baseName == "" {
baseName = "未命名实例"
}
return fmt.Sprintf("%s(副本)%s", baseName, now.Format("060102150405"))
}
func normalizeProfileCopyBaseName(sourceName string) string {
trimmed := strings.TrimSpace(sourceName)
if trimmed == "" {
return ""
}
baseName := trimmed
for baseName != "" {
nextName := strings.TrimSpace(profileCopyNameSuffixPattern.ReplaceAllString(baseName, ""))
if nextName == baseName {
break
}
if nextName == "" {
return trimmed
}
baseName = nextName
}
return baseName
}
func normalizeCopyMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case copyModeRegular:
return copyModeRegular
default:
return copyModeAutoFingerprint
}
}
func normalizeCopyAutomationTargets(targets []string) ([]string, error) {
if len(targets) == 0 {
return defaultCopyAutomationTargets(), nil
}
normalized := make([]string, 0, len(targets))
seen := make(map[string]struct{}, len(targets))
unknown := make([]string, 0)
for _, target := range targets {
value := strings.ToLower(strings.TrimSpace(target))
if value == "" {
continue
}
if _, ok := copyAutomationTargetArgPrefixes()[value]; !ok {
unknown = append(unknown, value)
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
if len(unknown) > 0 {
return nil, fmt.Errorf("复制实例失败:包含不支持的自动化指纹项(%s)。", strings.Join(unknown, ", "))
}
if len(normalized) == 0 {
return nil, fmt.Errorf("复制实例失败:请至少选择一个自动化指纹项。")
}
return normalized, nil
}
func defaultCopyAutomationTargets() []string {
return []string{copyAutomationTargetSeed}
}
func buildAutoFingerprintArgs(sourceArgs []string, defaultArgs []string, targets []string) []string {
base := sourceArgs
if len(base) == 0 {
base = defaultArgs
}
if len(targets) == 0 {
targets = defaultCopyAutomationTargets()
}
return applyCopyAutomationTargets(base, defaultArgs, targets)
}
func applyCopyAutomationTargets(baseArgs []string, defaultArgs []string, targets []string) []string {
if len(baseArgs) == 0 {
return []string{}
}
targetSet := make(map[string]struct{}, len(targets))
for _, target := range targets {
targetSet[target] = struct{}{}
}
defaultArgsByKey := mapArgsByKey(defaultArgs)
outputArgs := make([]string, 0, len(baseArgs))
outputKeys := make(map[string]struct{}, len(baseArgs))
for _, arg := range baseArgs {
trimmed := strings.TrimSpace(arg)
if trimmed == "" {
continue
}
key, ok := copyFingerprintArgKey(trimmed)
if !ok {
outputArgs = append(outputArgs, trimmed)
continue
}
target, ok := copyFingerprintArgTarget(key)
if !ok {
outputArgs = append(outputArgs, trimmed)
outputKeys[key] = struct{}{}
continue
}
if _, targeted := targetSet[target]; !targeted {
outputArgs = append(outputArgs, trimmed)
outputKeys[key] = struct{}{}
continue
}
if replacement, ok := defaultArgsByKey[key]; ok {
if _, exists := outputKeys[key]; !exists {
outputArgs = append(outputArgs, replacement)
outputKeys[key] = struct{}{}
}
}
}
for _, arg := range defaultArgs {
trimmed := strings.TrimSpace(arg)
if trimmed == "" {
continue
}
key, ok := copyFingerprintArgKey(trimmed)
if !ok {
continue
}
target, ok := copyFingerprintArgTarget(key)
if !ok {
continue
}
if _, targeted := targetSet[target]; !targeted {
continue
}
if _, exists := outputKeys[key]; exists {
continue
}
outputArgs = append(outputArgs, trimmed)
outputKeys[key] = struct{}{}
}
return outputArgs
}
func mapArgsByKey(args []string) map[string]string {
out := make(map[string]string, len(args))
for _, arg := range args {
trimmed := strings.TrimSpace(arg)
if trimmed == "" {
continue
}
key, ok := copyFingerprintArgKey(trimmed)
if !ok {
continue
}
out[key] = trimmed
}
return out
}
func copyFingerprintArgKey(arg string) (string, bool) {
trimmed := strings.TrimSpace(arg)
if trimmed == "" || !strings.HasPrefix(trimmed, "--") {
return "", false
}
eqIdx := strings.Index(trimmed, "=")
if eqIdx <= 0 {
return strings.ToLower(trimmed), true
}
return strings.ToLower(trimmed[:eqIdx]), true
}
func copyFingerprintArgTarget(key string) (string, bool) {
for target, prefixes := range copyAutomationTargetArgPrefixes() {
for _, prefix := range prefixes {
if key == prefix {
return target, true
}
}
}
return "", false
}
func copyAutomationTargetArgPrefixes() map[string][]string {
return map[string][]string{
copyAutomationTargetSeed: {
"--fingerprint",
},
copyAutomationTargetIdentity: {
"--fingerprint-brand",
"--fingerprint-platform",
},
copyAutomationTargetLocale: {
"--lang",
"--timezone",
},
copyAutomationTargetScreen: {
"--window-size",
"--fingerprint-color-depth",
},
copyAutomationTargetHardware: {
"--fingerprint-hardware-concurrency",
"--fingerprint-device-memory",
},
copyAutomationTargetRender: {
"--fingerprint-canvas-noise",
"--fingerprint-webgl-vendor",
"--fingerprint-webgl-renderer",
"--fingerprint-audio-noise",
},
copyAutomationTargetFonts: {
"--fingerprint-fonts",
},
copyAutomationTargetNetwork: {
"--webrtc-ip-handling-policy",
"--fingerprint-do-not-track",
},
copyAutomationTargetDevices: {
"--fingerprint-media-devices",
"--fingerprint-touch-points",
},
}
}
@@ -0,0 +1,211 @@
package browser
import (
"ant-chrome/backend/internal/config"
"reflect"
"regexp"
"strings"
"testing"
)
func TestCopyWithModeRegularKeepsFingerprintArgs(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.DefaultFingerprintArgs = []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"}
mgr := NewManager(cfg, t.TempDir())
source := &Profile{
ProfileId: "src-regular",
ProfileName: "源实例",
UserDataDir: "src-regular",
CoreId: "core-1",
FingerprintArgs: []string{"--fingerprint=12345", "--fingerprint-brand=Edge", "--fingerprint-platform=linux"},
ProxyId: "proxy-1",
ProxyConfig: "socks5://127.0.0.1:1080",
LaunchArgs: []string{"--disable-sync"},
Tags: []string{"tag-1"},
Keywords: []string{"kw-1"},
}
mgr.Profiles[source.ProfileId] = source
copied, err := mgr.CopyWithMode(source.ProfileId, "源实例-副本", copyModeRegular)
if err != nil {
t.Fatalf("CopyWithMode regular failed: %v", err)
}
if !reflect.DeepEqual(copied.FingerprintArgs, source.FingerprintArgs) {
t.Fatalf("expected regular copy to preserve fingerprint args, got=%v want=%v", copied.FingerprintArgs, source.FingerprintArgs)
}
}
func TestCopyWithModeAutoFingerprintRemovesSeedButKeepsTemplate(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.DefaultFingerprintArgs = []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"}
mgr := NewManager(cfg, t.TempDir())
source := &Profile{
ProfileId: "src-auto",
ProfileName: "源实例",
UserDataDir: "src-auto",
FingerprintArgs: []string{"--fingerprint=67890", "--fingerprint-brand=Edge", "--fingerprint-platform=linux", "--lang=en-US"},
}
mgr.Profiles[source.ProfileId] = source
copied, err := mgr.CopyWithMode(source.ProfileId, "源实例-自动指纹", copyModeAutoFingerprint)
if err != nil {
t.Fatalf("CopyWithMode auto_fingerprint failed: %v", err)
}
if hasFingerprintSeedArg(copied.FingerprintArgs) {
t.Fatalf("expected auto fingerprint copy to remove explicit seed, got=%v", copied.FingerprintArgs)
}
want := []string{"--fingerprint-brand=Edge", "--fingerprint-platform=linux", "--lang=en-US"}
if !reflect.DeepEqual(copied.FingerprintArgs, want) {
t.Fatalf("expected auto fingerprint copy to keep source template, got=%v want=%v", copied.FingerprintArgs, want)
}
}
func TestCopyWithOptionsAutoFingerprintReplacesSelectedGroups(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.DefaultFingerprintArgs = []string{
"--fingerprint-brand=Chrome",
"--fingerprint-platform=windows",
"--lang=zh-CN",
"--timezone=Asia/Shanghai",
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
"--fingerprint-do-not-track=false",
"--fingerprint-media-devices=1,1,0",
"--fingerprint-touch-points=0",
}
mgr := NewManager(cfg, t.TempDir())
source := &Profile{
ProfileId: "src-auto-groups",
ProfileName: "源实例",
UserDataDir: "src-auto-groups",
FingerprintArgs: []string{
"--fingerprint=67890",
"--fingerprint-brand=Edge",
"--fingerprint-platform=linux",
"--lang=en-US",
"--timezone=America/New_York",
"--window-size=1440,900",
"--fingerprint-hardware-concurrency=16",
"--fingerprint-device-memory=16",
"--fingerprint-canvas-noise=true",
"--fingerprint-fonts=Arial,Helvetica",
"--webrtc-ip-handling-policy=default_public_interface_only",
"--fingerprint-do-not-track=true",
"--fingerprint-media-devices=2,1,0",
"--fingerprint-touch-points=5",
},
}
mgr.Profiles[source.ProfileId] = source
copied, err := mgr.CopyWithOptions(source.ProfileId, "源实例-自动化指纹", ProfileCopyOptions{
Mode: copyModeAutoFingerprint,
AutomationTargets: []string{
copyAutomationTargetSeed,
copyAutomationTargetIdentity,
copyAutomationTargetLocale,
copyAutomationTargetNetwork,
copyAutomationTargetDevices,
},
})
if err != nil {
t.Fatalf("CopyWithOptions auto_fingerprint failed: %v", err)
}
want := []string{
"--fingerprint-brand=Chrome",
"--fingerprint-platform=windows",
"--lang=zh-CN",
"--timezone=Asia/Shanghai",
"--window-size=1440,900",
"--fingerprint-hardware-concurrency=16",
"--fingerprint-device-memory=16",
"--fingerprint-canvas-noise=true",
"--fingerprint-fonts=Arial,Helvetica",
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
"--fingerprint-do-not-track=false",
"--fingerprint-media-devices=1,1,0",
"--fingerprint-touch-points=0",
}
if !reflect.DeepEqual(copied.FingerprintArgs, want) {
t.Fatalf("expected auto fingerprint copy to replace selected groups, got=%v want=%v", copied.FingerprintArgs, want)
}
}
func TestCopyKeepsLegacyDefaultFingerprintBehavior(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Browser.DefaultFingerprintArgs = []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"}
mgr := NewManager(cfg, t.TempDir())
source := &Profile{
ProfileId: "src-legacy",
ProfileName: "源实例",
UserDataDir: "src-legacy",
FingerprintArgs: []string{"--fingerprint=99999", "--fingerprint-brand=Edge", "--fingerprint-platform=linux"},
}
mgr.Profiles[source.ProfileId] = source
copied, err := mgr.Copy(source.ProfileId, "源实例-旧复制")
if err != nil {
t.Fatalf("Copy failed: %v", err)
}
if !reflect.DeepEqual(copied.FingerprintArgs, cfg.Browser.DefaultFingerprintArgs) {
t.Fatalf("expected legacy copy to use default fingerprint args, got=%v want=%v", copied.FingerprintArgs, cfg.Browser.DefaultFingerprintArgs)
}
}
func TestCopyBlankNameUsesTimestampedCopyName(t *testing.T) {
cfg := config.DefaultConfig()
mgr := NewManager(cfg, t.TempDir())
source := &Profile{
ProfileId: "src-copy-name",
ProfileName: "邮箱测试 (副本)",
UserDataDir: "src-copy-name",
}
mgr.Profiles[source.ProfileId] = source
copied, err := mgr.Copy(source.ProfileId, "")
if err != nil {
t.Fatalf("Copy failed: %v", err)
}
matched := regexp.MustCompile(`^邮箱测试(副本)\d{12}$`).MatchString(copied.ProfileName)
if !matched {
t.Fatalf("expected timestamped copy name without duplicated suffix, got=%q", copied.ProfileName)
}
}
func TestCopyWithOptionsRejectsUnknownAutomationTarget(t *testing.T) {
cfg := config.DefaultConfig()
mgr := NewManager(cfg, t.TempDir())
source := &Profile{
ProfileId: "src-invalid-target",
ProfileName: "源实例",
UserDataDir: "src-invalid-target",
FingerprintArgs: []string{"--fingerprint=12345", "--fingerprint-brand=Chrome"},
}
mgr.Profiles[source.ProfileId] = source
_, err := mgr.CopyWithOptions(source.ProfileId, "源实例-失败", ProfileCopyOptions{
Mode: copyModeAutoFingerprint,
AutomationTargets: []string{"unknown_target"},
})
if err == nil {
t.Fatal("expected CopyWithOptions to reject unknown automation target")
}
}
func hasFingerprintSeedArg(args []string) bool {
for _, arg := range args {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(arg)), "--fingerprint=") {
return true
}
}
return false
}
+15 -5
View File
@@ -24,15 +24,25 @@ func ClearSessionRestoreData(userDataDir string) error {
}
profileDir := filepath.Join(rootDir, "Default")
if _, err := os.Stat(profileDir); os.IsNotExist(err) {
return nil
} else if err != nil {
return fmt.Errorf("stat profile dir: %w", err)
}
sessionsDir := filepath.Join(profileDir, "Sessions")
var errs []error
if err := os.RemoveAll(sessionsDir); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove sessions dir: %w", err))
} else if err == nil {
if mkErr := os.MkdirAll(sessionsDir, 0o755); mkErr != nil {
errs = append(errs, fmt.Errorf("recreate sessions dir: %w", mkErr))
if _, statErr := os.Stat(sessionsDir); statErr == nil {
if err := os.RemoveAll(sessionsDir); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove sessions dir: %w", err))
} else if err == nil {
if mkErr := os.MkdirAll(sessionsDir, 0o755); mkErr != nil {
errs = append(errs, fmt.Errorf("recreate sessions dir: %w", mkErr))
}
}
} else if statErr != nil && !os.IsNotExist(statErr) {
errs = append(errs, fmt.Errorf("stat sessions dir: %w", statErr))
}
for _, name := range sessionRestoreLegacyFiles {
@@ -52,3 +52,31 @@ func TestClearSessionRestoreDataRemovesSessionArtifactsOnly(t *testing.T) {
t.Fatalf("Preferences 不应被删除: %v", err)
}
}
func TestClearSessionRestoreDataSkipsMissingProfileDir(t *testing.T) {
t.Parallel()
userDataDir := t.TempDir()
if err := ClearSessionRestoreData(userDataDir); err != nil {
t.Fatalf("ClearSessionRestoreData 返回错误: %v", err)
}
if _, err := os.Stat(filepath.Join(userDataDir, "Default")); !os.IsNotExist(err) {
t.Fatalf("缺少会话数据时不应创建 Default 目录: err=%v", err)
}
}
func TestClearSessionRestoreDataDoesNotCreateMissingSessionsDir(t *testing.T) {
t.Parallel()
userDataDir := t.TempDir()
profileDir := filepath.Join(userDataDir, "Default")
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatalf("创建 profile 目录失败: %v", err)
}
if err := ClearSessionRestoreData(userDataDir); err != nil {
t.Fatalf("ClearSessionRestoreData 返回错误: %v", err)
}
if _, err := os.Stat(filepath.Join(profileDir, "Sessions")); !os.IsNotExist(err) {
t.Fatalf("缺少会话数据时不应创建 Sessions 目录: err=%v", err)
}
}
+7
View File
@@ -51,6 +51,12 @@ type ProfileInput struct {
GroupId string `json:"groupId"` // 所属分组ID
}
// ProfileCopyOptions 复制实例时的附加选项。
type ProfileCopyOptions struct {
Mode string `json:"mode"`
AutomationTargets []string `json:"automationTargets"`
}
// Tab 浏览器标签页
type Tab struct {
TabId string `json:"tabId"`
@@ -65,6 +71,7 @@ type Settings struct {
DefaultFingerprintArgs []string `json:"defaultFingerprintArgs"`
DefaultLaunchArgs []string `json:"defaultLaunchArgs"`
DefaultStartURLs []string `json:"defaultStartUrls"`
LightStartEnabled bool `json:"lightStartEnabled"`
RestoreLastSession bool `json:"restoreLastSession"`
StartReadyTimeoutMs int `json:"startReadyTimeoutMs"`
StartStableWindowMs int `json:"startStableWindowMs"`
+2
View File
@@ -71,6 +71,7 @@ type AutomationConfig struct {
HeadlessDefault bool `yaml:"headless_default,omitempty"`
KeepRuntimeOnDisable bool `yaml:"keep_runtime_on_disable,omitempty"`
AllowTypeScriptBuild bool `yaml:"allow_typescript_build,omitempty"`
ArtifactsDir string `yaml:"artifacts_dir,omitempty"`
NodeSource string `yaml:"node_source,omitempty"`
SystemNodePath string `yaml:"system_node_path,omitempty"`
NodeVersion string `yaml:"node_version,omitempty"`
@@ -145,6 +146,7 @@ type BrowserConfig struct {
DefaultFingerprintArgs []string `yaml:"default_fingerprint_args"`
DefaultLaunchArgs []string `yaml:"default_launch_args"`
DefaultStartURLs []string `yaml:"default_start_urls"`
LightStartEnabled *bool `yaml:"light_start_enabled,omitempty"`
RestoreLastSession bool `yaml:"restore_last_session"`
StartReadyTimeoutMs int `yaml:"start_ready_timeout_ms,omitempty"`
StartStableWindowMs int `yaml:"start_stable_window_ms,omitempty"`
@@ -110,6 +110,9 @@ func normalizeConfig(config *Config) {
} else if isLegacyVerificationStartURLs(config.Browser.DefaultStartURLs) {
config.Browser.DefaultStartURLs = []string{}
}
if config.Browser.LightStartEnabled == nil {
config.Browser.LightStartEnabled = defaultConfig.Browser.LightStartEnabled
}
if config.Browser.StartReadyTimeoutMs <= 0 {
config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs
}
@@ -154,6 +157,7 @@ func normalizeConfig(config *Config) {
!config.Automation.KeepRuntimeOnDisable &&
strings.TrimSpace(config.Automation.InstallPolicy) == "" &&
strings.TrimSpace(config.Automation.RuntimeVersion) == "" &&
strings.TrimSpace(config.Automation.ArtifactsDir) == "" &&
strings.TrimSpace(config.Automation.NodeSource) == "" &&
strings.TrimSpace(config.Automation.SystemNodePath) == "" &&
strings.TrimSpace(config.Automation.NodeVersion) == "" &&
@@ -170,6 +174,11 @@ func normalizeConfig(config *Config) {
if strings.TrimSpace(config.Automation.PlaywrightCoreVersion) == "" {
config.Automation.PlaywrightCoreVersion = defaultConfig.Automation.PlaywrightCoreVersion
}
if strings.TrimSpace(config.Automation.ArtifactsDir) == "" {
config.Automation.ArtifactsDir = defaultConfig.Automation.ArtifactsDir
} else {
config.Automation.ArtifactsDir = strings.TrimSpace(config.Automation.ArtifactsDir)
}
config.Automation.NodeSource = normalizeAutomationNodeSource(config.Automation.NodeSource)
config.Automation.SystemNodePath = strings.TrimSpace(config.Automation.SystemNodePath)
if strings.TrimSpace(config.Automation.RuntimeVersion) == "" {
@@ -233,6 +242,7 @@ func DefaultConfig() *Config {
DefaultFingerprintArgs: defaultFingerprintArgsForOS(goruntime.GOOS),
DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"},
DefaultStartURLs: DefaultBrowserStartURLs(),
LightStartEnabled: boolPtr(true),
RestoreLastSession: false,
StartReadyTimeoutMs: 3000,
StartStableWindowMs: 1200,
@@ -280,6 +290,7 @@ func DefaultConfig() *Config {
HeadlessDefault: false,
KeepRuntimeOnDisable: true,
AllowTypeScriptBuild: false,
ArtifactsDir: "data/automation/artifacts",
NodeSource: DefaultAutomationNodeSource,
SystemNodePath: "",
NodeVersion: DefaultAutomationNodeVersion,
@@ -312,3 +323,8 @@ func normalizeAutomationNodeSource(value string) string {
return AutomationNodeSourceAuto
}
}
func boolPtr(value bool) *bool {
v := value
return &v
}
+4
View File
@@ -233,6 +233,7 @@ automation:
headless_default: true
keep_runtime_on_disable: false
allow_typescript_build: true
artifacts_dir: D:/automation-outputs
node_source: system
system_node_path: C:/tools/node/node.exe
node_version: 22.15.1
@@ -304,6 +305,9 @@ automation:
if !cfg.Automation.AllowTypeScriptBuild {
t.Fatalf("Automation.AllowTypeScriptBuild 显式 true 被覆盖")
}
if cfg.Automation.ArtifactsDir != "D:/automation-outputs" {
t.Fatalf("Automation.ArtifactsDir 显式配置被覆盖: got=%q", cfg.Automation.ArtifactsDir)
}
}
func TestLoadMigratesLegacyRootLogPath(t *testing.T) {
+92 -98
View File
@@ -11,9 +11,15 @@ import (
"ant-chrome/backend/internal/automation"
)
const (
automationMinTimeoutMs = 1000
automationMaxTimeoutMs = 30 * 60 * 1000
)
type automationScriptRunAPIRequest struct {
ScriptID string `json:"scriptId"`
Selector json.RawMessage `json:"selector"`
TargetInput json.RawMessage `json:"targetInput"`
Params json.RawMessage `json:"params"`
UseScriptSelector *bool `json:"useScriptSelector"`
UseScriptParams *bool `json:"useScriptParams"`
@@ -21,19 +27,20 @@ type automationScriptRunAPIRequest struct {
}
type automationScriptSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
EntryFile string `json:"entryFile"`
Tags []string `json:"tags"`
Selector map[string]interface{} `json:"selector"`
Params map[string]interface{} `json:"params"`
Notes string `json:"notes"`
TargetConfig automation.ScriptTargetConfig `json:"targetConfig"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
EntryFile string `json:"entryFile"`
Tags []string `json:"tags"`
Selector map[string]interface{} `json:"selector"`
Params map[string]interface{} `json:"params"`
Notes string `json:"notes"`
TargetConfig automation.ScriptTargetConfig `json:"targetConfig"`
PublicAPI automation.ScriptPublicAPIConfig `json:"publicAPI"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type automationScriptDetail struct {
@@ -45,28 +52,19 @@ type automationScriptDetail struct {
func (s *LaunchServer) handleAutomationScripts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
"ok": false,
"error": "method not allowed",
})
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
return
}
lister, ok := s.starter.(AutomationScriptLister)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
"ok": false,
"error": "automation script api is unavailable",
})
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
items, err := lister.AutomationScriptList()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
"ok": false,
"error": err.Error(),
})
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
@@ -75,37 +73,27 @@ func (s *LaunchServer) handleAutomationScripts(w http.ResponseWriter, r *http.Re
result = append(result, summarizeAutomationScript(item))
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"count": len(result),
"items": result,
writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIListData[automationScriptSummary]{
Items: result,
Count: len(result),
})
}
func (s *LaunchServer) handleAutomationScriptByID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
"ok": false,
"error": "method not allowed",
})
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
return
}
scriptID, ok := parseAutomationScriptPathID(r.URL.Path)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]interface{}{
"ok": false,
"error": "script not found",
})
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "script not found", "")
return
}
getter, ok := s.starter.(AutomationScriptGetter)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
"ok": false,
"error": "automation script api is unavailable",
})
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
@@ -113,47 +101,31 @@ func (s *LaunchServer) handleAutomationScriptByID(w http.ResponseWriter, r *http
if err != nil {
message := strings.TrimSpace(err.Error())
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]interface{}{
"ok": false,
"error": "script not found",
})
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "script not found", "")
return
}
if strings.Contains(strings.ToLower(message), "script id is invalid") || strings.Contains(strings.ToLower(message), "script id is required") {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
"ok": false,
"error": message,
})
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", message, "scriptId")
return
}
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
"ok": false,
"error": message,
})
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", message, "")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"item": detailAutomationScript(*item),
writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIItemData[automationScriptDetail]{
Item: detailAutomationScript(*item),
})
}
func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
"ok": false,
"error": "method not allowed",
})
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
return
}
runner, ok := s.starter.(AutomationScriptRunner)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
"ok": false,
"error": "automation script api is unavailable",
})
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
@@ -161,52 +133,41 @@ func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http.
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
"ok": false,
"error": "invalid request body",
})
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "")
return
}
input, err := normalizeAutomationRunRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
"ok": false,
"error": err.Error(),
})
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err))
return
}
run, err := runner.AutomationScriptRunWithOptions(input)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
"ok": false,
"error": err.Error(),
})
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"run": run,
})
data := automationAPIRunData{
Run: run,
Summary: run.Summary,
}
if result := decodeAutomationRunResult(run.ResultText); result != nil {
data.Result = result
}
writeAutomationAPISuccess(w, http.StatusOK, run.Summary, data)
}
func (s *LaunchServer) handleAutomationScriptRuns(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
"ok": false,
"error": "method not allowed",
})
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
return
}
lister, ok := s.starter.(AutomationScriptRunLister)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
"ok": false,
"error": "automation script api is unavailable",
})
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
@@ -225,18 +186,14 @@ func (s *LaunchServer) handleAutomationScriptRuns(w http.ResponseWriter, r *http
items, err := lister.AutomationScriptRunList(limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
"ok": false,
"error": err.Error(),
})
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"count": len(items),
"limit": limit,
"items": items,
writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIListData[automation.ScriptRunRecord]{
Items: items,
Count: len(items),
Limit: limit,
})
}
@@ -253,6 +210,7 @@ func summarizeAutomationScript(record automation.ScriptRecord) automationScriptS
Params: parseJSONObjectText(record.ParamsText),
Notes: strings.TrimSpace(record.Notes),
TargetConfig: record.TargetConfig,
PublicAPI: record.PublicAPI,
CreatedAt: strings.TrimSpace(record.CreatedAt),
UpdatedAt: strings.TrimSpace(record.UpdatedAt),
}
@@ -287,6 +245,10 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
if err != nil {
return automation.ScriptRunRequest{}, err
}
targetInput, hasTargetInput, err := decodeJSONObjectRaw(req.TargetInput, "targetInput")
if err != nil {
return automation.ScriptRunRequest{}, err
}
params, hasParams, err := decodeJSONObjectRaw(req.Params, "params")
if err != nil {
return automation.ScriptRunRequest{}, err
@@ -300,6 +262,9 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
if err != nil {
return automation.ScriptRunRequest{}, err
}
if err := validateAutomationTimeoutMs(req.TimeoutMs); err != nil {
return automation.ScriptRunRequest{}, err
}
selectorText := ""
if !useScriptSelector {
@@ -322,13 +287,24 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio
return automation.ScriptRunRequest{
ScriptID: scriptID,
SelectorText: selectorText,
TargetInput: targetInput,
ParamsText: paramsText,
UseScriptSelector: useScriptSelector,
UseScriptSelector: useScriptSelector && !hasTargetInput,
UseScriptParams: useScriptParams,
TimeoutMs: req.TimeoutMs,
}, nil
}
func validateAutomationTimeoutMs(timeoutMs int) error {
if timeoutMs == 0 {
return nil
}
if timeoutMs < automationMinTimeoutMs || timeoutMs > automationMaxTimeoutMs {
return badAutomationRequest("timeoutMs must be between 1000 and 1800000")
}
return nil
}
func resolveUseScriptField(name string, explicit *bool, hasObject bool) (bool, error) {
if explicit == nil {
return !hasObject, nil
@@ -360,6 +336,14 @@ func decodeJSONObjectRaw(raw json.RawMessage, fieldName string) (map[string]inte
return obj, true, nil
}
func decodeAutomationRunResult(raw string) interface{} {
_, result, ok := decodeAutomationRunPayloadValue(raw)
if !ok {
return nil
}
return result
}
func parseJSONObjectText(text string) map[string]interface{} {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
@@ -390,6 +374,16 @@ func badAutomationRequest(message string) error {
return automationRequestError(strings.TrimSpace(message))
}
func automationRequestErrorField(err error) string {
message := strings.TrimSpace(err.Error())
for _, field := range []string{"scriptId", "selector", "targetInput", "params"} {
if strings.Contains(message, field) {
return field
}
}
return ""
}
type automationRequestError string
func (e automationRequestError) Error() string {
@@ -0,0 +1,553 @@
package launchcode
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"strings"
"ant-chrome/backend/internal/automation"
)
const automationPublicHookRoutePrefix = "/api/automation/hooks/"
func (s *LaunchServer) handleAutomationPublicHook(w http.ResponseWriter, r *http.Request) {
hookPath, ok := parseAutomationPublicHookPath(r.URL.Path)
if !ok {
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
return
}
record, err := s.findAutomationPublicHookScript(hookPath)
if err != nil {
if err == errAutomationHookServiceUnavailable {
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
if os.IsNotExist(err) {
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
return
}
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
if !record.PublicAPI.Enabled {
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
return
}
if r.Method != record.PublicAPI.Method {
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
return
}
runner, ok := s.starter.(AutomationScriptRunner)
if !ok {
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "")
return
}
input, err := buildAutomationPublicHookRunRequest(*record, r, body)
if err != nil {
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err))
return
}
run, err := runner.AutomationScriptRunWithOptions(input)
if err != nil {
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
writeAutomationPublicHookResponse(w, *record, run)
}
var errAutomationHookServiceUnavailable = automationRequestError("automation hook service unavailable")
func (s *LaunchServer) findAutomationPublicHookScript(hookPath string) (*automation.ScriptRecord, error) {
lister, ok := s.starter.(AutomationScriptLister)
if !ok {
return nil, errAutomationHookServiceUnavailable
}
items, err := lister.AutomationScriptList()
if err != nil {
return nil, err
}
for _, item := range items {
if normalizeAutomationPublicHookPath(item.PublicAPI.Path) != hookPath {
continue
}
record := item
return &record, nil
}
return nil, os.ErrNotExist
}
func parseAutomationPublicHookPath(urlPath string) (string, bool) {
trimmed := strings.TrimSpace(urlPath)
if !strings.HasPrefix(trimmed, automationPublicHookRoutePrefix) {
return "", false
}
trimmed = strings.TrimPrefix(trimmed, automationPublicHookRoutePrefix)
trimmed = normalizeAutomationPublicHookPath(trimmed)
if trimmed == "" {
return "", false
}
return trimmed, true
}
func normalizeAutomationPublicHookPath(value string) string {
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if value == "" {
return ""
}
cleaned := strings.Trim(path.Clean("/"+value), "/")
if cleaned == "" || cleaned == "." {
return ""
}
return strings.ToLower(cleaned)
}
func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http.Request, body []byte) (automation.ScriptRunRequest, error) {
if shouldApplyAutomationPublicHookVariables(record) {
resolvedBody, err := resolveAutomationPublicHookRequestBody(record, body)
if err != nil {
return automation.ScriptRunRequest{}, err
}
body = resolvedBody
}
input, err := decodeAutomationPublicHookRequestBody(body)
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
}
if err := validateAutomationTimeoutMs(input.TimeoutMs); err != nil {
return automation.ScriptRunRequest{}, err
}
params := mergeAutomationPublicHookDefaultParamsObject(record, input.Params)
paramsText, err := encodeAutomationPublicHookJSONObject(params)
if err != nil {
return automation.ScriptRunRequest{}, badAutomationRequest("params must be a JSON object")
}
return automation.ScriptRunRequest{
ScriptID: record.ID,
SelectorText: selectorText,
ParamsText: paramsText,
UseScriptSelector: useScriptSelector,
UseScriptParams: false,
TimeoutMs: resolveAutomationPublicHookTimeout(r, input.TimeoutMs, record.PublicAPI.TimeoutMs),
}, nil
}
type automationPublicHookRequestBody struct {
Code string `json:"code"`
Params map[string]interface{} `json:"params"`
TimeoutMs int `json:"timeoutMs"`
}
func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookRequestBody, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return automationPublicHookRequestBody{}, nil
}
var input automationPublicHookRequestBody
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&input); err != nil {
return automationPublicHookRequestBody{}, badAutomationRequest("invalid request body")
}
if input.Params == nil {
input.Params = map[string]interface{}{}
}
return input, nil
}
func encodeAutomationPublicHookJSONObject(obj map[string]interface{}) (string, error) {
encoded, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(encoded), nil
}
func shouldApplyAutomationPublicHookVariables(record automation.ScriptRecord) bool {
if strings.TrimSpace(record.PublicAPI.RequestBodyText) == "" {
return false
}
for _, variable := range record.PublicAPI.Variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
continue
}
if strings.Contains(record.PublicAPI.RequestBodyText, "{{"+name+"}}") || strings.Contains(record.PublicAPI.RequestBodyText, "${"+name+"}") {
return true
}
}
return false
}
func replaceAutomationPublicHookPlaceholderValue(bodyText string, name string, rawValue interface{}) string {
value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue))
escapedValue := escapeAutomationPublicHookJSONString(value)
for _, placeholder := range []string{"{{" + name + "}}", "${" + name + "}"} {
bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue)
}
return bodyText
}
func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body []byte) ([]byte, error) {
config := record.PublicAPI
input, err := decodeAutomationPublicHookRequestBody(body)
if err != nil {
return nil, err
}
values := input.Params
bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", input.Code)
for _, variable := range config.Variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
continue
}
placeholders := []string{"{{" + name + "}}", "${" + name + "}"}
used := false
for _, placeholder := range placeholders {
if strings.Contains(bodyText, placeholder) {
used = true
break
}
}
if !used {
continue
}
rawValue := interface{}(variable.DefaultValue)
if incomingValue, ok := values[name]; ok {
rawValue = incomingValue
}
value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue))
if variable.Required && value == "" {
return nil, badAutomationRequest("missing required variable: " + name)
}
escapedValue := escapeAutomationPublicHookJSONString(value)
for _, placeholder := range placeholders {
bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue)
}
}
var decoded interface{}
if err := json.Unmarshal([]byte(bodyText), &decoded); err != nil {
return nil, badAutomationRequest("resolved request body must be a JSON object")
}
decodedBody, ok := decoded.(map[string]interface{})
if !ok {
return nil, badAutomationRequest("resolved request body must be a JSON object")
}
mergedBody := mergeAutomationPublicHookDefaultParams(record, decodedBody)
encoded, err := json.Marshal(mergedBody)
if err != nil {
return nil, badAutomationRequest("resolved request body must be a JSON object")
}
return encoded, nil
}
func mergeAutomationPublicHookDefaultParams(record automation.ScriptRecord, body map[string]interface{}) map[string]interface{} {
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
if !ok || len(defaultParams) == 0 {
return body
}
if record.PublicAPI.RequestMode == "params-only" {
return mergeAutomationPublicHookJSONObjects(defaultParams, body)
}
rawParams, ok := body["params"].(map[string]interface{})
if !ok {
return body
}
nextBody := make(map[string]interface{}, len(body))
for key, value := range body {
nextBody[key] = value
}
nextBody["params"] = mergeAutomationPublicHookJSONObjects(defaultParams, rawParams)
return nextBody
}
func mergeAutomationPublicHookDefaultParamsObject(record automation.ScriptRecord, param map[string]interface{}) map[string]interface{} {
if param == nil {
param = map[string]interface{}{}
}
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
if !ok || len(defaultParams) == 0 {
return param
}
return mergeAutomationPublicHookJSONObjects(defaultParams, param)
}
func parseAutomationPublicHookJSONObject(text string) (map[string]interface{}, bool) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil, false
}
var value interface{}
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
return nil, false
}
object, ok := value.(map[string]interface{})
return object, ok
}
func mergeAutomationPublicHookJSONObjects(base map[string]interface{}, patch map[string]interface{}) map[string]interface{} {
merged := make(map[string]interface{}, len(base)+len(patch))
for key, value := range base {
merged[key] = value
}
for key, value := range patch {
baseObject, baseOK := merged[key].(map[string]interface{})
patchObject, patchOK := value.(map[string]interface{})
if baseOK && patchOK {
merged[key] = mergeAutomationPublicHookJSONObjects(baseObject, patchObject)
continue
}
merged[key] = value
}
return merged
}
func formatAutomationPublicHookVariableValue(value interface{}) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case float64, bool, int, int64, json.Number:
return strings.TrimSpace(strings.Trim(fmt.Sprint(typed), "\""))
default:
encoded, err := json.Marshal(typed)
if err != nil {
return fmt.Sprint(typed)
}
return string(encoded)
}
}
func escapeAutomationPublicHookJSONString(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
return value
}
text := string(encoded)
if len(text) >= 2 {
return text[1 : len(text)-1]
}
return text
}
func decodeAutomationRunAPIRequestBody(body []byte) (automationScriptRunAPIRequest, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return automationScriptRunAPIRequest{}, nil
}
var req automationScriptRunAPIRequest
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return automationScriptRunAPIRequest{}, badAutomationRequest("invalid request body")
}
return req, nil
}
func decodeJSONObjectBody(body []byte, fieldName string) (map[string]interface{}, bool, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, false, nil
}
var value interface{}
if err := json.Unmarshal(trimmed, &value); err != nil {
return nil, false, badAutomationRequest(fieldName + " must be a JSON object")
}
obj, ok := value.(map[string]interface{})
if !ok {
return nil, false, badAutomationRequest(fieldName + " must be a JSON object")
}
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,54 @@
package launchcode
import "net/http"
type automationAPIResponse[T any] struct {
OK bool `json:"ok"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Error *automationAPIError `json:"error,omitempty"`
Data T `json:"data,omitempty"`
}
type automationAPIError struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
}
type automationAPIListData[T any] struct {
Items []T `json:"items"`
Count int `json:"count"`
Limit int `json:"limit,omitempty"`
}
type automationAPIItemData[T any] struct {
Item T `json:"item"`
}
type automationAPIRunData struct {
Run interface{} `json:"run,omitempty"`
Summary string `json:"summary,omitempty"`
Result interface{} `json:"result,omitempty"`
}
func writeAutomationAPIError(w http.ResponseWriter, status int, code string, message string, field string) {
writeJSON(w, status, automationAPIResponse[struct{}]{
OK: false,
Status: "failed",
Error: &automationAPIError{
Code: code,
Message: message,
Field: field,
},
})
}
func writeAutomationAPISuccess[T any](w http.ResponseWriter, status int, message string, data T) {
writeJSON(w, status, automationAPIResponse[T]{
OK: true,
Status: "success",
Message: message,
Data: data,
})
}
@@ -9,6 +9,7 @@ func (s *LaunchServer) buildMux() *http.ServeMux {
mux.HandleFunc("/api/automation/scripts/", s.handleAutomationScriptByID)
mux.HandleFunc("/api/automation/scripts/run", s.handleAutomationScriptRun)
mux.HandleFunc("/api/automation/scripts/runs", s.handleAutomationScriptRuns)
mux.HandleFunc("/api/automation/hooks/", s.handleAutomationPublicHook)
mux.HandleFunc("/api/profiles", s.handleProfiles)
mux.HandleFunc("/api/profiles/", s.handleProfileByID)
mux.HandleFunc("/api/runtime/active", s.handleRuntimeActive)
+419 -43
View File
@@ -117,30 +117,33 @@ func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) {
}
var resp struct {
OK bool `json:"ok"`
Count int `json:"count"`
Items []struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Selector map[string]interface{} `json:"selector"`
Params map[string]interface{} `json:"params"`
} `json:"items"`
OK bool `json:"ok"`
Data struct {
Count int `json:"count"`
Items []struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Selector map[string]interface{} `json:"selector"`
Params map[string]interface{} `json:"params"`
} `json:"items"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Count != 1 || len(resp.Items) != 1 {
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
t.Fatalf("响应结构错误: %+v", resp)
}
if resp.Items[0].ID != "news-query-txt" || resp.Items[0].Type != "playwright-cdp" || resp.Items[0].Status != "ready" {
t.Fatalf("脚本元数据错误: %+v", resp.Items[0])
item := resp.Data.Items[0]
if item.ID != "news-query-txt" || item.Type != "playwright-cdp" || item.Status != "ready" {
t.Fatalf("脚本元数据错误: %+v", item)
}
if resp.Items[0].Selector["code"] != "BUYER_001" {
t.Fatalf("selector 解析错误: %+v", resp.Items[0].Selector)
if item.Selector["code"] != "BUYER_001" {
t.Fatalf("selector 解析错误: %+v", item.Selector)
}
if resp.Items[0].Params["keyword"] != "OpenAI" {
t.Fatalf("params 解析错误: %+v", resp.Items[0].Params)
if item.Params["keyword"] != "OpenAI" {
t.Fatalf("params 解析错误: %+v", item.Params)
}
}
@@ -189,28 +192,31 @@ func TestAutomationScriptDetailEndpointReturnsSingleScript(t *testing.T) {
var resp struct {
OK bool `json:"ok"`
Item struct {
ID string `json:"id"`
PackageFormat string `json:"packageFormat"`
ManifestVersion int `json:"manifestVersion"`
Source automation.ScriptSource `json:"source"`
Selector map[string]interface{} `json:"selector"`
} `json:"item"`
Data struct {
Item struct {
ID string `json:"id"`
PackageFormat string `json:"packageFormat"`
ManifestVersion int `json:"manifestVersion"`
Source automation.ScriptSource `json:"source"`
Selector map[string]interface{} `json:"selector"`
} `json:"item"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Item.ID != "news-query-txt" {
item := resp.Data.Item
if !resp.OK || item.ID != "news-query-txt" {
t.Fatalf("详情响应错误: %+v", resp)
}
if resp.Item.PackageFormat != "ant-automation-script" || resp.Item.ManifestVersion != 1 {
t.Fatalf("详情元数据错误: %+v", resp.Item)
if item.PackageFormat != "ant-automation-script" || item.ManifestVersion != 1 {
t.Fatalf("详情元数据错误: %+v", item)
}
if resp.Item.Source.Type != "git" || resp.Item.Source.URI != "https://example.com/repo.git" {
t.Fatalf("source 返回错误: %+v", resp.Item.Source)
if item.Source.Type != "git" || item.Source.URI != "https://example.com/repo.git" {
t.Fatalf("source 返回错误: %+v", item.Source)
}
if resp.Item.Selector["code"] != "BUYER_001" {
t.Fatalf("selector 解析错误: %+v", resp.Item.Selector)
if item.Selector["code"] != "BUYER_001" {
t.Fatalf("selector 解析错误: %+v", item.Selector)
}
}
@@ -232,9 +238,10 @@ func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.runResult = &automation.ScriptRunRecord{
ID: "run-1",
ScriptID: "news-query-txt",
Status: "success",
ID: "run-1",
ScriptID: "news-query-txt",
Status: "success",
ResultText: `{"ok":true,"summary":"done","result":{"subject":"Hello","contentText":"Mail body"}}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
@@ -264,18 +271,24 @@ func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) {
}
var resp struct {
OK bool `json:"ok"`
Run struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"run"`
OK bool `json:"ok"`
Data struct {
Result map[string]interface{} `json:"result"`
Run struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"run"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Run.ID != "run-1" || resp.Run.Status != "success" {
if !resp.OK || resp.Data.Run.ID != "run-1" || resp.Data.Run.Status != "success" {
t.Fatalf("run 响应错误: %+v", resp)
}
if resp.Data.Result["subject"] != "Hello" || resp.Data.Result["contentText"] != "Mail body" {
t.Fatalf("expected parsed result payload, got %+v", resp.Data.Result)
}
}
func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testing.T) {
@@ -299,6 +312,350 @@ func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testi
}
}
func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "proton-mail-first-message",
Name: "Proton 邮件搜索并读取最新邮件",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/proton-first-message",
RequestMode: "standard",
ResponseMode: "envelope",
TimeoutMs: 120000,
},
},
}
starter.runResult = &automation.ScriptRunRecord{
ID: "run-hook-1",
ScriptID: "proton-mail-first-message",
ScriptName: "Proton 邮件搜索并读取最新邮件",
Status: "success",
Summary: "已返回最新命中邮件内容",
ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{
"code":"BUYER_001",
"params":{"recipientQuery":"target@example.com"}
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText)
}
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
}
var resp struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if !resp.OK || resp.Status != "success" {
t.Fatalf("hook 响应错误: %+v", resp)
}
if resp.Data["verificationCode"] != "429792" {
t.Fatalf("expected data payload, got %+v", resp.Data)
}
}
func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "proton-mail-first-message",
Name: "Proton 邮件搜索并读取最新邮件",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/proton-result-only",
RequestMode: "params-only",
ResponseMode: "result-only",
TimeoutMs: 45000,
},
},
}
starter.runResult = &automation.ScriptRunRecord{
ID: "run-hook-2",
ScriptID: "proton-mail-first-message",
ScriptName: "Proton 邮件搜索并读取最新邮件",
Status: "success",
Summary: "已返回最新命中邮件内容",
ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
"code":"BUYER_001",
"params":{"recipientQuery":"target@example.com"}
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
}
if starter.lastRunRequest.TimeoutMs != 60000 {
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
t.Fatalf("expected data payload, got %+v", resp)
}
}
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "grok-image-generate-download",
Name: "Grok 生成图片并下载",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "image/grok-generate-download",
RequestMode: "params-only",
ResponseMode: "result-only",
TimeoutMs: 300000,
},
},
}
starter.runResult = &automation.ScriptRunRecord{
ID: "run-grok-image",
ScriptID: "grok-image-generate-download",
ScriptName: "Grok 生成图片并下载",
Status: "success",
Summary: "Grok 图片已生成并下载",
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
t.Fatalf("下载字段缺失: %+v", resp)
}
if _, exists := data["steps"]; exists {
t.Fatalf("不应返回冗余 steps: %+v", resp)
}
if _, exists := data["runId"]; exists {
t.Fatalf("不应返回 runId: %+v", resp)
}
}
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "image-generate",
Name: "图片生成",
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "image/generate",
RequestMode: "standard",
ResponseMode: "envelope",
TimeoutMs: 300000,
RequestBodyText: `{"code":"{{code}}","params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
Variables: []automation.ScriptPublicAPIVariable{
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
{Name: "outputFileName", DefaultValue: "generated-image.png"},
},
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
"code":"BUYER_001",
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastRunRequest.UseScriptParams {
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
}
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest)
}
var params map[string]interface{}
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), &params); err != nil {
t.Fatalf("解析 paramsText 失败: %v", err)
}
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
t.Fatalf("变量未映射到 params: %+v", params)
}
selectors, ok := params["selectors"].(map[string]interface{})
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
}
if params["timeoutMs"] != float64(300000) {
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
}
if starter.lastRunRequest.TimeoutMs != 300000 {
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
}
}
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "disabled-hook",
Name: "Disabled Hook",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: false,
Path: "mail/disabled-hook",
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("期望 404,实际 %dbody=%s", w.Code, w.Body.String())
}
}
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "strict-hook",
Name: "Strict Hook",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/strict-hook",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
cases := []struct {
name string
body string
}{
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if resp.OK || resp.Error.Code != "invalid_request" {
t.Fatalf("错误 envelope 不正确: %+v", resp)
}
})
}
}
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
starter.scripts = []automation.ScriptRecord{
{
ID: "strict-hook-timeout",
Name: "Strict Hook Timeout",
PublicAPI: automation.ScriptPublicAPIConfig{
Enabled: true,
Method: "POST",
Path: "mail/strict-hook-timeout",
TimeoutMs: 120000,
},
},
}
handler := buildTestHandlerWithManager(svc, starter, nil)
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
}
func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
svc := newInMemoryService()
starter := newMockAutomationStarter()
@@ -320,14 +677,16 @@ func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
}
var resp struct {
OK bool `json:"ok"`
Count int `json:"count"`
Items []automation.ScriptRunRecord `json:"items"`
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.Count != 1 || len(resp.Items) != 1 {
if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 {
t.Fatalf("runs 响应错误: %+v", resp)
}
}
@@ -374,4 +733,21 @@ func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
})
t.Run("timeout-must-be-in-range", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{
"scriptId":"news-query-txt",
"timeoutMs":1800001
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望 400,实际 %dbody=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
t.Fatalf("错误信息不正确: %s", w.Body.String())
}
})
}
+13
View File
@@ -0,0 +1,13 @@
本目录是运行时数据目录,不参与常规 Git 提交。
与自动化脚本相关的约定:
- 用户新建、导入、刷新后的脚本快照默认落在 `data/automation/scripts/`
- `.gitignore` 已忽略 `data/*`,所以这些本地脚本不会直接被提交到 GitHub。
- 目前仓库里真正跟代码一起维护的只有内置脚本;其他临时脚本建议继续放在 `data/automation/scripts/`
如果需要按仓库管理脚本源码:
- 可以把要发布的脚本单独放在一个明确的子目录里,例如 `scripts/published/demo`
- Git 导入时填写“脚本路径”,系统只会扫描那个指定子目录,不会把整个仓库都当成一个脚本包。
- 留空“脚本路径”时,才会从仓库根目录解析。
+3 -1
View File
@@ -318,7 +318,9 @@ function main() {
childKilledByRssLimit = false
child = spawn(nodeExecutable, nodeArgs.args, {
cwd: frontendDir,
stdio: 'inherit',
// Keep stdin open for Vite. In detached Windows launches the inherited
// stdin can close immediately, which makes Vite exit cleanly after boot.
stdio: ['pipe', 'inherit', 'inherit'],
env: childEnv,
})
+20 -3
View File
@@ -1,4 +1,6 @@
import type { BrowserProfile, BrowserProfileInput } from '../types'
import { applyBrowserProfileCopyOptionsToArgs, createBrowserProfileCopyOptions } from '../copyOptions'
import { buildBrowserProfileCopyName } from '../copyName'
import type { BrowserProfile, BrowserProfileCopyOptions, BrowserProfileInput } from '../types'
import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime'
export async function fetchBrowserProfiles(): Promise<BrowserProfile[]> {
@@ -80,8 +82,18 @@ export async function deleteBrowserProfile(profileId: string): Promise<boolean>
return true
}
export async function copyBrowserProfile(profileId: string, newName: string): Promise<BrowserProfile | null> {
export async function copyBrowserProfile(
profileId: string,
newName: string,
options: BrowserProfileCopyOptions = createBrowserProfileCopyOptions(),
): Promise<BrowserProfile | null> {
const bindings: any = await getBindings()
if (bindings?.BrowserProfileCopyWithOptions) {
return (await bindings.BrowserProfileCopyWithOptions(profileId, newName, options)) || null
}
if (bindings?.BrowserProfileCopyWithMode) {
return (await bindings.BrowserProfileCopyWithMode(profileId, newName, options.mode)) || null
}
if (bindings?.BrowserProfileCopy) {
return (await bindings.BrowserProfileCopy(profileId, newName)) || null
}
@@ -96,8 +108,13 @@ export async function copyBrowserProfile(profileId: string, newName: string): Pr
const copy: BrowserProfile = {
...source,
profileId: `mock-${timestamp}`,
profileName: newName || `${source.profileName} (副本)`,
profileName: newName.trim() || buildBrowserProfileCopyName(source.profileName),
userDataDir: `mock-${timestamp}`,
fingerprintArgs: applyBrowserProfileCopyOptionsToArgs(
source.fingerprintArgs || [],
[],
options,
),
launchCode,
running: false,
debugReady: false,
@@ -22,6 +22,7 @@ export function createDefaultBrowserSettings(): BrowserSettings {
defaultFingerprintArgs: [],
defaultLaunchArgs: [],
defaultStartUrls: [],
lightStartEnabled: true,
restoreLastSession: false,
startReadyTimeoutMs: 3000,
startStableWindowMs: 1200,
@@ -2,6 +2,7 @@ import {
exportAutomationScript,
importAutomationScript,
loadAutomationScripts,
normalizeAutomationScriptPublicAPIConfig,
normalizeAutomationScriptRecordPayload,
normalizeAutomationScriptTargetConfig,
saveAutomationScripts,
@@ -47,6 +48,7 @@ function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord {
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 || ""),
@@ -97,6 +99,46 @@ export interface AutomationScriptExportResult {
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 {
@@ -104,6 +146,7 @@ function normalizeAutomationScriptRunInput(
return {
scriptId: input,
selectorText: "",
targetInput: {},
paramsText: "",
useScriptSelector: true,
useScriptParams: true,
@@ -116,6 +159,10 @@ function normalizeAutomationScriptRunInput(
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,
@@ -255,6 +302,46 @@ export async function importAutomationScriptFromLocalDirectory(): Promise<Automa
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) {
@@ -555,3 +642,79 @@ export async function fetchAutomationScriptRuns(
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,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,568 @@
import {
AUTOMATION_SCRIPT_MANIFEST_VERSION,
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 function createNewsTxtScriptDraft(): AutomationScriptRecord {
const createdAt = nowIso();
return {
packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT,
manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION,
id: "news-query-txt",
name: "查询新闻并写 TXT",
description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。",
type: "playwright-cdp",
status: "ready",
entryFile: "index.cjs",
tags: ["Playwright", "新闻", "TXT"],
selectorText: "",
paramsText: `{
"keyword": "OpenAI",
"limit": 10,
"timeRange": "week",
"outputFileName": "openai-news.txt",
"timeoutMs": 30000,
"waitAfterLoadMs": 1500,
"captureScreenshot": false
}`,
scriptText: BACKEND_BUILTIN_SCRIPT_PLACEHOLDER,
notes:
"脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行成功后可在结果里的 outputPath 找到文件。",
targetConfig: normalizeAutomationScriptTargetConfig(null),
publicAPI: createAutomationScriptPublicAPIConfig(),
source: {
type: "builtin",
uri: "repo://backend/internal/automation/demo-library/news-query-txt",
ref: "HEAD",
path: "news-query-txt",
importedAt: "",
},
createdAt,
updatedAt: createdAt,
};
}
export function createDualInstanceRuntimeScriptDraft(): AutomationScriptRecord {
const createdAt = nowIso();
return {
packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT,
manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION,
id: DUAL_INSTANCE_RUNTIME_SCRIPT_ID,
name: "双实例启动与 Runtime 切换",
description:
"通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。",
type: "launch-api",
status: "ready",
entryFile: "index.cjs",
tags: ["Launch API", "OpenClaw", "双实例"],
selectorText: "",
paramsText: buildDualInstanceRuntimeParamsText(),
scriptText: buildDualInstanceRuntimeScriptText(),
notes:
"先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。",
targetConfig: normalizeAutomationScriptTargetConfig(null),
publicAPI: createAutomationScriptPublicAPIConfig(),
source: {
type: "builtin",
uri: "repo://backend/internal/automation/demo-library/dual-instance-runtime-switch",
ref: "HEAD",
path: "dual-instance-runtime-switch",
importedAt: "",
},
createdAt,
updatedAt: createdAt,
};
}
export function createWebImageGenerateDownloadScriptDraft(): AutomationScriptRecord {
const createdAt = nowIso();
return {
packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT,
manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION,
id: "web-image-generate-download",
name: "网页图片生成并下载",
description:
"打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。",
type: "playwright-cdp",
status: "draft",
entryFile: "index.cjs",
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
}`,
scriptText: BACKEND_BUILTIN_SCRIPT_PLACEHOLDER,
notes:
"脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。",
targetConfig: normalizeAutomationScriptTargetConfig(null),
publicAPI: {
...createAutomationScriptPublicAPIConfig(),
enabled: true,
path: "image/chatgpt-generate-download",
timeoutMs: 300000,
requestBodyText: `{
"params": {
"prompt": "{{prompt}}"
}
}`,
responseBodyText: `{
"ok": true,
"outputPath": "\${artifactsDir}/generated-image.png",
"downloadAddress": "\${artifactsDir}/generated-image.png"
}`,
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",
importedAt: "",
},
createdAt,
updatedAt: createdAt,
};
}
export function buildDefaultAutomationScripts(): AutomationScriptRecord[] {
return [
createNewsTxtScriptDraft(),
createDualInstanceRuntimeScriptDraft(),
createWebImageGenerateDownloadScriptDraft(),
];
}
@@ -0,0 +1,142 @@
export type AutomationScriptType = "playwright-cdp" | "launch-api";
export type AutomationScriptStatus = "draft" | "ready" | "disabled";
export type AutomationScriptTargetMode =
| "manual"
| "existing"
| "create"
| "rotate";
export type AutomationScriptPublicAPIRequestMode =
| "standard"
| "params-only";
export type AutomationScriptPublicAPIResponseMode =
| "envelope"
| "result-only";
export interface AutomationScriptPublicAPIVariable {
name: string;
defaultValue: string;
description: string;
required: boolean;
}
export interface AutomationScriptSource {
type: string;
uri: string;
ref: string;
path: string;
importedAt: string;
}
export interface AutomationScriptTargetSelector {
code: string;
profileId: string;
profileName: string;
groupId: string;
keywords: string[];
tags: string[];
}
export interface AutomationScriptTargetConfig {
mode: AutomationScriptTargetMode;
selector: AutomationScriptTargetSelector;
templateSelector: AutomationScriptTargetSelector;
createNameTemplate: string;
}
export interface AutomationScriptPublicAPIConfig {
enabled: boolean;
method: "POST";
path: string;
requestMode: AutomationScriptPublicAPIRequestMode;
responseMode: AutomationScriptPublicAPIResponseMode;
timeoutMs: number;
requestBodyText: string;
responseBodyText: string;
variables: AutomationScriptPublicAPIVariable[];
}
export interface AutomationScriptRecord {
packageFormat: string;
manifestVersion: number;
id: string;
name: string;
description: string;
type: AutomationScriptType;
status: AutomationScriptStatus;
entryFile: string;
tags: string[];
selectorText: string;
paramsText: string;
scriptText: string;
notes: string;
targetConfig: AutomationScriptTargetConfig;
publicAPI: AutomationScriptPublicAPIConfig;
source: AutomationScriptSource;
createdAt: string;
updatedAt: string;
}
export interface AutomationScriptRunRecord {
id: string;
scriptId: string;
scriptName: string;
scriptType: string;
status: "success" | "failed" | "running";
summary: string;
error: string;
resultText: string;
startedAt: string;
finishedAt: string;
durationMs: number;
}
export interface AutomationScriptRunInput {
scriptId: string;
selectorText?: string;
targetInput?: Record<string, unknown>;
paramsText?: string;
useScriptSelector?: boolean;
useScriptParams?: boolean;
timeoutMs?: number;
launchCode?: string;
startByCodeBeforeRun?: boolean;
}
export const AUTOMATION_SCRIPT_PACKAGE_FORMAT = "ant-automation-script";
export const AUTOMATION_SCRIPT_MANIFEST_VERSION = 1;
export const AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH =
"/api/automation/hooks";
export const AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS = 300000;
export const AUTOMATION_SCRIPT_TYPE_OPTIONS: Array<{
value: AutomationScriptType;
label: string;
}> = [
{ value: "playwright-cdp", label: "Playwright CDP" },
{ value: "launch-api", label: "Launch API" },
];
export const AUTOMATION_SCRIPT_STATUS_OPTIONS: Array<{
value: AutomationScriptStatus;
label: string;
}> = [
{ value: "draft", label: "草稿" },
{ value: "ready", label: "可用" },
{ value: "disabled", label: "停用" },
];
export const AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS: Array<{
value: AutomationScriptTargetMode;
label: string;
}> = [
{ value: "manual", label: "传入实例" },
{ value: "existing", label: "传入实例" },
{ value: "create", label: "按模板新建实例" },
{ value: "rotate", label: "按条件轮询实例" },
];
export const DUAL_INSTANCE_RUNTIME_SCRIPT_ID = "dual-instance-runtime-switch";
@@ -0,0 +1,91 @@
import {
AUTOMATION_SCRIPT_STATUS_OPTIONS,
AUTOMATION_SCRIPT_TYPE_OPTIONS,
type AutomationScriptSource,
type AutomationScriptStatus,
type AutomationScriptType,
} from "./definitions";
export function normalizeAutomationScriptSource(
source: unknown,
): AutomationScriptSource {
if (!source || typeof source !== "object") {
return {
type: "",
uri: "",
ref: "",
path: "",
importedAt: "",
};
}
const raw = source as Partial<AutomationScriptSource>;
return {
type: typeof raw.type === "string" ? raw.type.trim() : "",
uri: typeof raw.uri === "string" ? raw.uri.trim() : "",
ref: typeof raw.ref === "string" ? raw.ref.trim() : "",
path: typeof raw.path === "string" ? raw.path.trim() : "",
importedAt:
typeof raw.importedAt === "string" ? raw.importedAt.trim() : "",
};
}
export function getAutomationScriptSourceLabel(source: AutomationScriptSource): string {
switch (source.type) {
case "builtin":
return "内置基线";
case "local-file":
return "本地文件";
case "local-dir":
return "本地目录";
case "remote-url":
return "远程 URL";
case "git":
return "Git";
case "text":
return "文本导入";
case "manual":
return "手动维护";
default:
return source.type || "未标记";
}
}
export function canRefreshAutomationScriptSource(
source: AutomationScriptSource,
): boolean {
return (
source.type === "builtin" ||
source.type === "local-file" ||
source.type === "local-dir" ||
source.type === "remote-url" ||
source.type === "git"
);
}
export function getAutomationScriptRefreshLabel(
source: AutomationScriptSource,
): string {
if (source.type === "builtin") {
return "恢复基线";
}
return source.type === "git" ? "重新拉取" : "重新导入";
}
export function getAutomationScriptTypeLabel(
type: AutomationScriptType,
): string {
return (
AUTOMATION_SCRIPT_TYPE_OPTIONS.find((item) => item.value === type)?.label ||
type
);
}
export function getAutomationScriptStatusLabel(
status: AutomationScriptStatus,
): string {
return (
AUTOMATION_SCRIPT_STATUS_OPTIONS.find((item) => item.value === status)
?.label || status
);
}
@@ -0,0 +1,445 @@
import {
AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH,
AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS,
type AutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIRequestMode,
type AutomationScriptPublicAPIResponseMode,
type AutomationScriptRecord,
} from "./definitions";
import {
normalizeAutomationScriptPublicAPIVariableList,
safeParseAutomationScriptPublicAPIJSONObject,
} from "./publicApiUtils";
export {
applyAutomationScriptPublicAPIVariables,
collectAutomationScriptPublicAPIVariableValues,
isAutomationScriptPublicAPIVariableName,
} from "./publicApiUtils";
export const AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS = [
{ value: "POST", label: "POST" },
] as const;
export const AUTOMATION_SCRIPT_PUBLIC_API_REQUEST_MODE_OPTIONS: Array<{
value: AutomationScriptPublicAPIRequestMode;
label: string;
}> = [
{ value: "standard", label: "标准请求" },
{ value: "params-only", label: "仅透传 params" },
];
export const AUTOMATION_SCRIPT_PUBLIC_API_RESPONSE_MODE_OPTIONS: Array<{
value: AutomationScriptPublicAPIResponseMode;
label: string;
}> = [
{ value: "envelope", label: "标准信封" },
{ value: "result-only", label: "仅返回 result" },
];
function normalizeAutomationScriptPublicAPIPathSegment(value: string): string {
let result = "";
let lastDash = false;
for (const char of value.trim()) {
const lower = char.toLowerCase();
if (
(lower >= "a" && lower <= "z") ||
(lower >= "0" && lower <= "9")
) {
result += lower;
lastDash = false;
continue;
}
if (lower === "-" || lower === "_" || lower === ".") {
result += lower;
lastDash = false;
continue;
}
if (!lastDash) {
result += "-";
lastDash = true;
}
}
return result.replace(/^-+|-+$/g, "");
}
function normalizeAutomationScriptPublicAPIPath(value: unknown): string {
const source = String(value || "")
.trim()
.replace(/\\/g, "/");
if (!source) {
return "";
}
const lower = source.toLowerCase();
const lowerBase = AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH.toLowerCase();
const trimmed =
lower.startsWith(`${lowerBase}/`)
? source.slice(AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH.length + 1)
: lower.startsWith(`${lowerBase.slice(1)}/`)
? source.slice(AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH.length)
: source;
const segments = trimmed
.split("/")
.map((item) => normalizeAutomationScriptPublicAPIPathSegment(item))
.filter(Boolean);
return segments.join("/");
}
export function buildAutomationScriptPublicAPIPath(path: string): string {
const normalizedPath = normalizeAutomationScriptPublicAPIPath(path);
return normalizedPath
? `${AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH}/${normalizedPath}`
: AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH;
}
export function suggestAutomationScriptPublicAPIPath(
script: Pick<AutomationScriptRecord, "id" | "name">,
): string {
const namePath = normalizeAutomationScriptPublicAPIPath(script.name);
if (namePath) {
return namePath;
}
return normalizeAutomationScriptPublicAPIPath(script.id);
}
export function createAutomationScriptPublicAPIConfig(): AutomationScriptPublicAPIConfig {
return {
enabled: false,
method: "POST",
path: "",
requestMode: "standard",
responseMode: "envelope",
timeoutMs: AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS,
requestBodyText: "",
responseBodyText: "",
variables: [],
};
}
export function normalizeAutomationScriptPublicAPIConfig(
config: unknown,
): AutomationScriptPublicAPIConfig {
if (!config || typeof config !== "object") {
return createAutomationScriptPublicAPIConfig();
}
const raw = config as Partial<AutomationScriptPublicAPIConfig>;
const timeoutMs = Number.isFinite(Number(raw.timeoutMs))
? Math.round(Number(raw.timeoutMs))
: AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS;
return {
enabled: raw.enabled === true,
method: "POST",
path: normalizeAutomationScriptPublicAPIPath(raw.path),
requestMode: raw.requestMode === "params-only" ? "params-only" : "standard",
responseMode:
raw.responseMode === "result-only" ? "result-only" : "envelope",
timeoutMs:
timeoutMs < 1000
? 1000
: timeoutMs > 30 * 60 * 1000
? 30 * 60 * 1000
: timeoutMs,
requestBodyText:
typeof raw.requestBodyText === "string" ? raw.requestBodyText.trim() : "",
responseBodyText:
typeof raw.responseBodyText === "string"
? raw.responseBodyText.trim()
: "",
variables: normalizeAutomationScriptPublicAPIVariableList(
(raw as { variables?: unknown }).variables,
),
};
}
function isPlainAutomationJSONObject(
value: unknown,
): value is Record<string, unknown> {
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;
}
for (let index = 0; index < leftKeys.length; index += 1) {
if (leftKeys[index] !== rightKeys[index]) {
return false;
}
}
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;
});
}
function buildAutomationScriptPublicAPIDefaultRequestExample(
script: Pick<AutomationScriptRecord, "paramsText" | "selectorText">,
config: AutomationScriptPublicAPIConfig,
): string {
const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {};
return JSON.stringify(
{
code: "",
params,
timeoutMs: config.timeoutMs,
},
null,
2,
);
}
function buildAutomationScriptPublicAPIDefaultResponseExample(): string {
return JSON.stringify(
{
ok: true,
status: "success",
message: "已返回脚本结果",
data: {
verificationCode: "429792",
},
},
null,
2,
);
}
function isLegacyAutomationScriptPublicAPIRequestExample(
script: Pick<AutomationScriptRecord, "paramsText" | "selectorText">,
parsedBody: Record<string, unknown>,
): boolean {
const allowedLegacyKeys = new Set(["code", "launchCode", "param", "params", "timeoutMs"]);
if (Object.keys(parsedBody).some((key) => !allowedLegacyKeys.has(key))) {
return false;
}
const targetCode = String(parsedBody.code || parsedBody.launchCode || "").trim();
if (targetCode) {
return false;
}
const paramsValue = "param" in parsedBody ? parsedBody.param : parsedBody.params;
if (paramsValue !== undefined && !isPlainAutomationJSONObject(paramsValue)) {
return false;
}
const expectedParams = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText);
if (
expectedParams &&
paramsValue !== undefined &&
!hasSameAutomationJSONShape(paramsValue, expectedParams)
) {
return false;
}
return true;
}
function shouldUseDerivedAutomationScriptPublicAPIRequestBody(
script: Pick<AutomationScriptRecord, "paramsText" | "selectorText">,
config: AutomationScriptPublicAPIConfig,
): boolean {
const sourceText = config.requestBodyText.trim();
if (!sourceText) {
return true;
}
if (
sourceText ===
buildAutomationScriptPublicAPIDefaultRequestExample(script, {
...config,
requestBodyText: "",
})
) {
return true;
}
const parsedBody = safeParseAutomationScriptPublicAPIJSONObject(sourceText);
if (!parsedBody) {
return false;
}
if (isLegacyAutomationScriptPublicAPIRequestExample(script, parsedBody)) {
return true;
}
const allowedKeys = new Set(["code", "params", "timeoutMs"]);
if (Object.keys(parsedBody).some((key) => !allowedKeys.has(key))) {
return false;
}
if (!("code" in parsedBody)) {
return false;
}
const targetCode = String(parsedBody.code || "").trim();
if (targetCode) {
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)
) {
return false;
}
if (
"timeoutMs" in parsedBody &&
!Number.isFinite(Number(parsedBody.timeoutMs))
) {
return false;
}
return true;
}
function shouldUseDerivedAutomationScriptPublicAPIResponseBody(
config: AutomationScriptPublicAPIConfig,
): boolean {
const sourceText = config.responseBodyText.trim();
if (!sourceText) {
return true;
}
return (
sourceText ===
buildAutomationScriptPublicAPIDefaultResponseExample()
);
}
export function buildAutomationScriptPublicAPIRequestExample(
script: Pick<AutomationScriptRecord, "paramsText" | "selectorText">,
config: AutomationScriptPublicAPIConfig,
): string {
if (!shouldUseDerivedAutomationScriptPublicAPIRequestBody(script, config)) {
return config.requestBodyText.trim();
}
return buildAutomationScriptPublicAPIDefaultRequestExample(script, {
...config,
requestBodyText: "",
});
}
export function buildAutomationScriptPublicAPIResponseExample(
_script: Pick<AutomationScriptRecord, "id" | "name">,
config: AutomationScriptPublicAPIConfig,
): string {
if (!shouldUseDerivedAutomationScriptPublicAPIResponseBody(config)) {
return config.responseBodyText.trim();
}
return buildAutomationScriptPublicAPIDefaultResponseExample();
}
export function prepareAutomationScriptPublicAPIConfigForSave(
script: Pick<
AutomationScriptRecord,
"id" | "name" | "paramsText" | "selectorText"
> & {
publicAPI: unknown;
},
): AutomationScriptPublicAPIConfig {
const config = normalizeAutomationScriptPublicAPIConfig(script.publicAPI);
return {
...config,
requestBodyText: shouldUseDerivedAutomationScriptPublicAPIRequestBody(
script,
config,
)
? ""
: config.requestBodyText.trim(),
responseBodyText: shouldUseDerivedAutomationScriptPublicAPIResponseBody(config)
? ""
: config.responseBodyText.trim(),
};
}
export function resolveAutomationScriptPublicAPIConfig(
script: Pick<
AutomationScriptRecord,
"id" | "name" | "paramsText" | "selectorText" | "publicAPI"
>,
): AutomationScriptPublicAPIConfig {
const config = prepareAutomationScriptPublicAPIConfigForSave(script);
const path = config.path.trim()
? config.path
: suggestAutomationScriptPublicAPIPath(script);
const requestBodyText = buildAutomationScriptPublicAPIRequestExample(script, {
...config,
path,
requestBodyText: config.requestBodyText,
});
const responseBodyText = buildAutomationScriptPublicAPIResponseExample(
script,
{
...config,
path,
responseBodyText: config.responseBodyText,
},
);
return {
...config,
path,
requestBodyText,
responseBodyText,
};
}
export function getAutomationScriptPublicAPIRequestModeLabel(
mode: AutomationScriptPublicAPIRequestMode,
): string {
return (
AUTOMATION_SCRIPT_PUBLIC_API_REQUEST_MODE_OPTIONS.find(
(item) => item.value === mode,
)?.label || mode
);
}
export function getAutomationScriptPublicAPIResponseModeLabel(
mode: AutomationScriptPublicAPIResponseMode,
): string {
return (
AUTOMATION_SCRIPT_PUBLIC_API_RESPONSE_MODE_OPTIONS.find(
(item) => item.value === mode,
)?.label || mode
);
}
@@ -0,0 +1,173 @@
import type {
AutomationScriptPublicAPIConfig,
AutomationScriptPublicAPIVariable,
} from "./definitions";
export function parseAutomationScriptPublicAPIJSONText(
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 safeParseAutomationScriptPublicAPIJSONObject(
text: string,
): Record<string, unknown> | null {
const parsed = parseAutomationScriptPublicAPIJSONText(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>;
}
export function stringifyAutomationScriptPublicAPIJSONBlock(
value: unknown,
fallback: string,
): string {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (value && typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return fallback;
}
}
return fallback;
}
function normalizeAutomationScriptPublicAPIVariables(
value: unknown,
): AutomationScriptPublicAPIVariable[] {
const rawItems: unknown[] = Array.isArray(value)
? value
: value && typeof value === "object"
? Object.entries(value as Record<string, unknown>).map(
([name, rawValue]) => {
if (
rawValue &&
typeof rawValue === "object" &&
!Array.isArray(rawValue)
) {
return { name, ...(rawValue as Record<string, unknown>) };
}
return { name, defaultValue: rawValue };
},
)
: [];
const seen = new Set<string>();
const variables: AutomationScriptPublicAPIVariable[] = [];
for (const item of rawItems) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const raw = item as Record<string, unknown>;
const name = String(raw.name ?? raw.key ?? "").trim();
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
variables.push({
name,
defaultValue: String(
raw.defaultValue ?? raw.default ?? raw.value ?? "",
).trim(),
description: String(
raw.description ?? raw.label ?? raw.note ?? "",
).trim(),
required:
raw.required === true ||
String(raw.required ?? "").trim().toLowerCase() === "true",
});
}
return variables;
}
export function normalizeAutomationScriptPublicAPIVariableList(
value: unknown,
): AutomationScriptPublicAPIVariable[] {
return normalizeAutomationScriptPublicAPIVariables(value);
}
export function isAutomationScriptPublicAPIVariableName(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.trim());
}
function escapeAutomationScriptPublicAPIRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeAutomationScriptPublicAPIJSONStringValue(value: string): string {
const encoded = JSON.stringify(String(value ?? ""));
return encoded.slice(1, -1);
}
export function collectAutomationScriptPublicAPIVariableValues(
config: AutomationScriptPublicAPIConfig,
): Record<string, string> {
return config.variables.reduce<Record<string, string>>((values, variable) => {
values[variable.name] = variable.defaultValue;
return values;
}, {});
}
export function applyAutomationScriptPublicAPIVariables(
text: string,
variables: AutomationScriptPublicAPIVariable[],
values: Record<string, string> = {},
): { bodyText: string; missingRequired: string[]; usedVariables: string[] } {
const sourceText = String(text || "");
let bodyText = sourceText;
const missingRequired: string[] = [];
const usedVariables: string[] = [];
for (const variable of variables) {
if (!isAutomationScriptPublicAPIVariableName(variable.name)) {
continue;
}
const rawValue = values[variable.name] ?? variable.defaultValue ?? "";
const value = String(rawValue);
const placeholders = [`\${${variable.name}}`, `{{${variable.name}}}`];
const used = placeholders.some((placeholder) =>
sourceText.includes(placeholder),
);
if (!used) {
continue;
}
usedVariables.push(variable.name);
if (variable.required && !value.trim()) {
missingRequired.push(variable.name);
}
for (const placeholder of placeholders) {
bodyText = bodyText.replace(
new RegExp(escapeAutomationScriptPublicAPIRegex(placeholder), "g"),
escapeAutomationScriptPublicAPIJSONStringValue(value),
);
}
}
return { bodyText, missingRequired, usedVariables };
}
@@ -0,0 +1,234 @@
import type { BrowserProfile } from "../types";
import {
AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS,
type AutomationScriptTargetConfig,
type AutomationScriptTargetMode,
type AutomationScriptTargetSelector,
} from "./definitions";
function normalizeTargetTerms(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const deduped = new Set<string>();
for (const item of value) {
const normalized = String(item || "").trim();
if (normalized) {
deduped.add(normalized);
}
}
return Array.from(deduped);
}
export function normalizeAutomationScriptTargetSelector(
selector: unknown,
): AutomationScriptTargetSelector {
if (!selector || typeof selector !== "object") {
return createAutomationScriptTargetSelector();
}
const raw = selector as Partial<AutomationScriptTargetSelector>;
return {
code:
typeof raw.code === "string"
? raw.code.trim().toUpperCase()
: typeof (selector as { launchCode?: unknown }).launchCode === "string"
? String((selector as { launchCode?: unknown }).launchCode)
.trim()
.toUpperCase()
: "",
profileId:
typeof raw.profileId === "string" ? raw.profileId.trim() : "",
profileName:
typeof raw.profileName === "string" ? raw.profileName.trim() : "",
groupId: typeof raw.groupId === "string" ? raw.groupId.trim() : "",
keywords: normalizeTargetTerms(raw.keywords),
tags: normalizeTargetTerms(raw.tags),
};
}
export function createAutomationScriptTargetSelector(): AutomationScriptTargetSelector {
return {
code: "",
profileId: "",
profileName: "",
groupId: "",
keywords: [],
tags: [],
};
}
export function normalizeAutomationScriptTargetConfig(
config: unknown,
): AutomationScriptTargetConfig {
if (!config || typeof config !== "object") {
return {
mode: "manual",
selector: createAutomationScriptTargetSelector(),
templateSelector: createAutomationScriptTargetSelector(),
createNameTemplate: "",
};
}
const raw = config as Partial<AutomationScriptTargetConfig>;
const mode: AutomationScriptTargetMode =
raw.mode === "existing" ||
raw.mode === "create" ||
raw.mode === "rotate"
? raw.mode
: "manual";
return {
mode,
selector: normalizeAutomationScriptTargetSelector(raw.selector),
templateSelector: normalizeAutomationScriptTargetSelector(
raw.templateSelector,
),
createNameTemplate:
typeof raw.createNameTemplate === "string"
? raw.createNameTemplate.trim()
: "",
};
}
function selectorSummaryParts(selector: AutomationScriptTargetSelector): string[] {
const parts: string[] = [];
if (selector.code) {
parts.push(`Code=${selector.code}`);
}
if (selector.profileName) {
parts.push(`实例=${selector.profileName}`);
}
if (selector.profileId && !selector.code) {
parts.push(`实例ID=${selector.profileId}`);
}
if (selector.groupId) {
parts.push(`分组=${selector.groupId}`);
}
if (selector.tags.length > 0) {
parts.push(`标签=${selector.tags.join(" / ")}`);
}
if (selector.keywords.length > 0) {
parts.push(`关键字=${selector.keywords.join(" / ")}`);
}
return parts;
}
export function getAutomationScriptTargetModeLabel(
mode: AutomationScriptTargetMode,
): string {
return (
AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS.find((item) => item.value === mode)
?.label || mode
);
}
function normalizeSelectorCode(value?: string): string {
return String(value || "")
.trim()
.toUpperCase();
}
function normalizeSelectorText(value?: string): string {
return String(value || "").trim();
}
export function findAutomationTargetProfile(
selector: AutomationScriptTargetSelector,
profiles: BrowserProfile[],
): BrowserProfile | null {
const normalizedProfileId = normalizeSelectorText(selector.profileId);
if (normalizedProfileId) {
const matchedById = profiles.find(
(profile) => normalizeSelectorText(profile.profileId) === normalizedProfileId,
);
if (matchedById) {
return matchedById;
}
}
const normalizedCode = normalizeSelectorCode(selector.code);
if (normalizedCode) {
const matchedByCode = profiles.find(
(profile) => normalizeSelectorCode(profile.launchCode) === normalizedCode,
);
if (matchedByCode) {
return matchedByCode;
}
}
const normalizedProfileName = normalizeSelectorText(selector.profileName);
if (normalizedProfileName) {
const matchedByName = profiles.find(
(profile) =>
normalizeSelectorText(profile.profileName).toLowerCase() ===
normalizedProfileName.toLowerCase(),
);
if (matchedByName) {
return matchedByName;
}
}
return null;
}
export function formatAutomationTargetIdentity(
selector: AutomationScriptTargetSelector,
profiles: BrowserProfile[],
options?: {
includeProfileId?: boolean;
fallback?: string;
},
): string {
const profile = findAutomationTargetProfile(selector, profiles);
const code = normalizeSelectorCode(profile?.launchCode || selector.code);
const profileName = normalizeSelectorText(
profile?.profileName || selector.profileName,
);
const profileId = normalizeSelectorText(profile?.profileId || selector.profileId);
const parts = [code, profileName].filter(Boolean);
if (options?.includeProfileId && profileId) {
parts.push(profileId);
}
if (parts.length > 0) {
return parts.join(" · ");
}
if (profileId) {
return options?.includeProfileId ? profileId : `实例 ID ${profileId}`;
}
return options?.fallback || "-";
}
export function describeAutomationScriptTargetConfig(
config: AutomationScriptTargetConfig,
): string {
switch (config.mode) {
case "existing": {
const parts = selectorSummaryParts(config.selector);
return parts.length > 0
? `传入实例:${parts.join(" · ")}`
: "传入实例";
}
case "create": {
const parts = selectorSummaryParts(config.templateSelector);
const namePart = config.createNameTemplate
? `命名=${config.createNameTemplate}`
: "";
return ["按模板新建实例", ...parts, namePart]
.filter(Boolean)
.join(" · ");
}
case "rotate": {
const parts = selectorSummaryParts(config.selector);
return parts.length > 0
? `按条件轮询实例:${parts.join(" · ")}`
: "按条件轮询实例";
}
default:
return "传入实例";
}
}
@@ -0,0 +1,250 @@
import type { ReactNode } from "react";
import { FormItem, Input, Select } from "../../../shared/components";
import type { AutomationScriptTargetMode } from "../automationScripts";
import type { BrowserProfile } from "../types";
export type AutomationInstanceSelectorMode =
| AutomationScriptTargetMode
| "select"
| "code";
export interface AutomationInstanceOption {
value: string;
label: string;
}
interface AutomationInstanceSelectorProps {
title?: string;
mode: AutomationInstanceSelectorMode;
modes?: AutomationInstanceSelectorMode[];
codeSelectLabel?: string;
disabled?: boolean;
loading?: boolean;
required?: boolean;
showFields?: boolean;
error?: string;
selectedProfileId?: string;
selectedCode?: string;
createName?: string;
templateProfileId?: string;
profiles?: BrowserProfile[];
profileOptions?: AutomationInstanceOption[];
templateOptions?: AutomationInstanceOption[];
selectPlaceholder?: string;
templatePlaceholder?: string;
codePlaceholder?: string;
createNamePlaceholder?: string;
hint?: string;
extra?: ReactNode;
onModeChange?: (mode: AutomationInstanceSelectorMode) => void;
onSelectProfile?: (profileId: string) => void;
onCodeChange?: (code: string) => void;
onCreateNameChange?: (name: string) => void;
onTemplateChange?: (profileId: string) => void;
}
function normalizeLaunchCode(value: unknown): string {
return String(value || "").trim().toUpperCase();
}
function buildCodeOptions(profiles: BrowserProfile[]): AutomationInstanceOption[] {
return profiles
.filter((profile) => normalizeLaunchCode(profile.launchCode))
.map((profile) => {
const code = normalizeLaunchCode(profile.launchCode);
return {
value: code,
label: `${code} · ${profile.profileName || profile.profileId}`,
};
});
}
function modeLabel(mode: AutomationInstanceSelectorMode): string {
if (mode === "manual") return "传入实例";
if (mode === "existing") return "传入实例";
if (mode === "create") return "模板创建";
if (mode === "rotate") return "条件轮询";
if (mode === "code") return "输入 Code";
return "已有实例";
}
function optionsWithFallback(
options: AutomationInstanceOption[],
placeholder: string,
loading?: boolean,
): AutomationInstanceOption[] {
if (options.length > 0) return options;
return [{ value: "", label: loading ? "正在加载..." : placeholder }];
}
export function AutomationInstanceSelector({
title = "实例选择",
mode,
modes = ["select", "create", "code"],
codeSelectLabel = "选择实例",
disabled = false,
loading = false,
required = false,
showFields = true,
error = "",
selectedProfileId = "",
selectedCode = "",
createName = "",
templateProfileId = "",
profiles = [],
profileOptions,
templateOptions = [],
selectPlaceholder = "暂无可选实例",
templatePlaceholder = "暂无模板",
codePlaceholder = "例如 EQV8K0",
createNamePlaceholder = "实例名称",
hint,
extra,
onModeChange,
onSelectProfile,
onCodeChange,
onCreateNameChange,
onTemplateChange,
}: AutomationInstanceSelectorProps) {
const resolvedProfileOptions = profileOptions || buildCodeOptions(profiles);
const codeOptions = optionsWithFallback(
resolvedProfileOptions,
selectPlaceholder,
loading,
);
const selectableCodeOptions = resolvedProfileOptions.length
? [{ value: "", label: selectPlaceholder }, ...resolvedProfileOptions]
: codeOptions;
const selectedCodeOption = resolvedProfileOptions.some(
(option) => option.value === selectedCode,
)
? selectedCode
: "";
const showTabs = modes.length > 1;
const isExistingMode = mode === "select";
const showManualInstanceFields = mode === "manual" || mode === "existing";
const renderCodeSelector = (selectValue: string) => (
<div className="grid grid-cols-1 gap-3 md:grid-cols-[13rem_minmax(0,1fr)]">
<FormItem label="实例 Code" required={required} error={error}>
<Input
value={selectedCode}
onChange={(event) => onCodeChange?.(event.target.value)}
placeholder={codePlaceholder}
className="font-mono uppercase"
disabled={disabled}
/>
</FormItem>
<FormItem label={codeSelectLabel}>
<Select
value={selectValue}
options={selectableCodeOptions}
onChange={(event) => {
const value = event.target.value;
if (onSelectProfile) {
onSelectProfile(value);
return;
}
onCodeChange?.(value);
}}
disabled={disabled || resolvedProfileOptions.length === 0}
/>
</FormItem>
</div>
);
return (
<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-semibold text-[var(--color-text-primary)]">
{title}
</div>
{showTabs ? (
<div className="inline-flex rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-0.5">
{modes.map((item) => (
<button
key={item}
type="button"
className={`min-w-[78px] rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === item
? "border border-[var(--color-border-strong)] bg-black text-white"
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)]"
}`}
onClick={() => onModeChange?.(item)}
disabled={disabled}
>
{modeLabel(item)}
</button>
))}
</div>
) : null}
</div>
{showFields && isExistingMode ? (
<div className="mt-3 space-y-2">
<FormItem label="已有实例" required={required} error={error}>
<Select
value={selectedProfileId}
options={optionsWithFallback(
resolvedProfileOptions,
selectPlaceholder,
loading,
)}
onChange={(event) => onSelectProfile?.(event.target.value)}
disabled={disabled || resolvedProfileOptions.length === 0}
/>
</FormItem>
{extra}
</div>
) : null}
{showFields && showManualInstanceFields ? (
<div className="mt-3 space-y-2">
{renderCodeSelector(onSelectProfile ? selectedProfileId : selectedCodeOption)}
{extra}
</div>
) : null}
{showFields && mode === "create" ? (
<div className="mt-3 space-y-2">
<div className="grid grid-cols-1 gap-3 md:grid-cols-[13rem_minmax(0,1fr)]">
<FormItem label="新实例名称" required={required}>
<Input
value={createName}
onChange={(event) => onCreateNameChange?.(event.target.value)}
placeholder={createNamePlaceholder}
disabled={disabled}
/>
</FormItem>
<FormItem label="模板实例" required={required} error={error}>
<Select
value={templateProfileId}
options={optionsWithFallback(
templateOptions,
templatePlaceholder,
loading,
)}
onChange={(event) => onTemplateChange?.(event.target.value)}
disabled={disabled || templateOptions.length === 0}
/>
</FormItem>
</div>
{extra}
</div>
) : null}
{showFields && mode === "rotate" ? (
<div className="mt-3 space-y-2">{extra}</div>
) : null}
{showFields && mode === "code" ? (
<div className="mt-3">
{renderCodeSelector(selectedCodeOption)}
</div>
) : null}
{hint ? (
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{hint}</p>
) : null}
</div>
);
}
@@ -0,0 +1,875 @@
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 {
invokeAutomationScriptPublicApi,
type AutomationScriptPublicApiInvokeResult,
} from "../automationScriptApi";
import { AutomationInstanceSelector } from "./AutomationInstanceSelector";
import type { BrowserProfile } from "../types";
import {
AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS,
applyAutomationScriptPublicAPIVariables,
buildAutomationScriptPublicAPIPath,
buildAutomationScriptPublicAPIRequestExample,
buildAutomationScriptPublicAPIResponseExample,
collectAutomationScriptPublicAPIVariableValues,
DUAL_INSTANCE_RUNTIME_SCRIPT_ID,
isAutomationScriptPublicAPIVariableName,
normalizeAutomationScriptPublicAPIConfig,
prepareAutomationScriptPublicAPIConfigForSave,
resolveAutomationScriptPublicAPIConfig,
suggestAutomationScriptPublicAPIPath,
type AutomationScriptPublicAPIConfig,
type AutomationScriptPublicAPIVariable,
type AutomationScriptRecord,
} from "../automationScripts";
interface AutomationScriptPublicApiModalProps {
open: boolean;
script: AutomationScriptRecord;
busy?: boolean;
launchBaseUrl: string;
apiAuthEnabled: boolean;
apiAuthHeader: string;
profiles?: BrowserProfile[];
focusTestTrigger?: number;
onClose: () => void;
onChange: (config: AutomationScriptPublicAPIConfig) => void;
onBeforeInvoke?: (
config: AutomationScriptPublicAPIConfig,
) => 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,
busy = false,
launchBaseUrl,
apiAuthEnabled,
apiAuthHeader,
profiles = [],
focusTestTrigger = 0,
onClose,
onChange,
onBeforeInvoke,
}: AutomationScriptPublicApiModalProps) {
const storedConfig = prepareAutomationScriptPublicAPIConfigForSave(script);
const resolvedConfig = resolveAutomationScriptPublicAPIConfig(script);
const fullPath = buildAutomationScriptPublicAPIPath(resolvedConfig.path);
const fullURL = `${launchBaseUrl}${fullPath}`;
const requestExampleFallback = buildAutomationScriptPublicAPIRequestExample(
script,
{
...resolvedConfig,
requestBodyText: "",
},
);
const responseExampleFallback = buildAutomationScriptPublicAPIResponseExample(
script,
{
...resolvedConfig,
responseBodyText: "",
},
);
const resolvedRequestBody = applyAutomationScriptPublicAPIVariables(
resolvedConfig.requestBodyText,
resolvedConfig.variables,
collectAutomationScriptPublicAPIVariableValues(resolvedConfig),
);
const resolvedRequestBodyText = resolvedRequestBody.bodyText;
const invalidVariableNames = resolvedConfig.variables
.filter((variable) => !isAutomationScriptPublicAPIVariableName(variable.name))
.map((variable) => variable.name);
const variableError = invalidVariableNames.length
? `变量名只能使用字母、数字、下划线,且不能以数字开头:${invalidVariableNames.join(", ")}`
: resolvedRequestBody.missingRequired.length
? `必填变量缺少默认值:${resolvedRequestBody.missingRequired.join(", ")}`
: "";
const responseBodyValidation = parseJSONText(resolvedConfig.responseBodyText);
const requestBodyError =
resolvedRequestBodyText.trim() && !safeParseJSONObject(resolvedRequestBodyText)
? "替换变量后的请求 Body 必须是 JSON 对象"
: "";
const responseBodyError =
resolvedConfig.responseBodyText.trim() && !responseBodyValidation.ok
? `响应示例不是合法 JSON${responseBodyValidation.error}`
: "";
const isDualInstanceRuntimeScript = script.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID;
const selectedTargetCode = readPublicApiTargetCode(resolvedRequestBodyText);
const selectedPrimaryTargetCode = readPublicApiDualTargetCode(
resolvedRequestBodyText,
0,
);
const selectedSecondaryTargetCode = readPublicApiDualTargetCode(
resolvedRequestBodyText,
1,
);
const targetCodeError =
isDualInstanceRuntimeScript
? selectedPrimaryTargetCode && selectedSecondaryTargetCode
? ""
: "两个实例 Code 必填"
: selectedTargetCode
? ""
: "实例 Code 必填";
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);
useEffect(() => {
if (!open) {
setInvoking(false);
setInvokeResult(null);
setInvokeError("");
}
}, [open, script.id]);
useEffect(() => {
if (!open || focusTestTrigger <= 0) {
return;
}
const frameId = window.requestAnimationFrame(() => {
testSectionRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [focusTestTrigger, open]);
const updateConfig = (patch: Partial<AutomationScriptPublicAPIConfig>) => {
const nextConfig = normalizeAutomationScriptPublicAPIConfig({
...storedConfig,
...patch,
});
onChange(nextConfig);
};
const handleApplySuggestedPath = () => {
updateConfig({ path: suggestAutomationScriptPublicAPIPath(script) });
};
const handleTargetCodeChange = (code: string) => {
updateConfig({
requestBodyText: buildRequestBodyWithTargetCode(
resolvedConfig.requestBodyText,
requestExampleFallback,
code,
),
});
};
const handleDualTargetCodeChange = (index: number, code: string) => {
updateConfig({
requestBodyText: buildRequestBodyWithDualTargetCode(
resolvedConfig.requestBodyText,
requestExampleFallback,
index,
code,
),
});
};
const updateVariable = (
index: number,
patch: Partial<AutomationScriptPublicAPIVariable>,
) => {
updateConfig({
variables: resolvedConfig.variables.map((variable, variableIndex) =>
variableIndex === index ? { ...variable, ...patch } : variable,
),
});
};
const handleAddVariable = () => {
const existingNames = new Set(
resolvedConfig.variables.map((variable) => variable.name),
);
const baseName = [
"searchQuery",
"senderEmail",
"recipient",
"mailboxName",
].find((name) => !existingNames.has(name));
updateConfig({
variables: [
...resolvedConfig.variables,
{
name: baseName || `variable${resolvedConfig.variables.length + 1}`,
defaultValue: "",
description: "",
required: false,
},
],
});
};
const handleRemoveVariable = (index: number) => {
updateConfig({
variables: resolvedConfig.variables.filter(
(_variable, variableIndex) => variableIndex !== index,
),
});
};
const handleInvoke = async () => {
if (!resolvedConfig.enabled) {
toast.warning("请先启用对外接口");
return;
}
if (variableError) {
toast.warning(variableError);
return;
}
if (requestBodyError) {
toast.warning(requestBodyError);
return;
}
if (responseBodyError) {
toast.warning(responseBodyError);
return;
}
if (targetCodeError) {
toast.warning(targetCodeError);
return;
}
setInvoking(true);
setInvokeError("");
setInvokeResult(null);
try {
if (onBeforeInvoke) {
const allowed = await onBeforeInvoke(resolvedConfig);
if (!allowed) {
return;
}
}
const result = await invokeAutomationScriptPublicApi({
url: fullURL,
method: resolvedConfig.method,
bodyText: resolvedRequestBodyText,
apiKey,
authHeader: apiAuthHeader,
timeoutMs: resolvedConfig.timeoutMs + 10000,
});
setInvokeResult(result);
if (result.ok) {
toast.success("测试完成");
} else {
toast.warning(`接口返回 ${result.status}`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "测试失败";
setInvokeError(message);
toast.error(message);
} finally {
setInvoking(false);
}
};
return (
<Modal
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>
);
}
File diff suppressed because it is too large Load Diff
@@ -253,6 +253,15 @@ export function BrowserListSettingsModal({
placeholder="启动 URL"
/>
</FormItem>
<FormItem label="轻启动模式" hint="先起空白页,实例就绪后再打开默认页面">
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border-default)] px-3 py-2">
<span className="text-sm text-[var(--color-text-primary)]"></span>
<Switch
checked={settings.lightStartEnabled}
onChange={(checked) => onSettingsChange({ lightStartEnabled: checked })}
/>
</div>
</FormItem>
<FormItem label="恢复上次关闭的标签页" hint="关闭后只打开默认启动页或空白页">
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border-default)] px-3 py-2">
<div>
@@ -138,7 +138,7 @@ export function KeywordInlineRow({ keywords }: KeywordInlineRowProps) {
}, [keywords])
if (!keywords?.length) {
return <span className="text-xs text-[var(--color-text-muted)] italic"></span>
return <span className="text-xs text-[var(--color-text-muted)]">-</span>
}
return (
@@ -0,0 +1,161 @@
import clsx from 'clsx'
import { FormItem, Input } from '../../../shared/components'
import {
BROWSER_PROFILE_AUTOMATION_TARGET_OPTIONS,
isBrowserProfileCopyOptionsValid,
} from '../copyOptions'
import type { BrowserProfileAutomationTarget, BrowserProfileCopyOptions } from '../types'
interface BrowserProfileCopyFormProps {
sourceName?: string
copyName: string
copyOptions: BrowserProfileCopyOptions
onCopyNameChange: (value: string) => void
onCopyOptionsChange: (value: BrowserProfileCopyOptions) => void
autoFocusName?: boolean
}
export function BrowserProfileCopyForm({
sourceName,
copyName,
copyOptions,
onCopyNameChange,
onCopyOptionsChange,
autoFocusName = false,
}: BrowserProfileCopyFormProps) {
const setMode = (mode: BrowserProfileCopyOptions['mode']) => {
onCopyOptionsChange({
...copyOptions,
mode,
})
}
const toggleAutomationTarget = (target: BrowserProfileAutomationTarget) => {
const nextTargets = copyOptions.automationTargets.includes(target)
? copyOptions.automationTargets.filter((item) => item !== target)
: [...copyOptions.automationTargets, target]
onCopyOptionsChange({
...copyOptions,
automationTargets: nextTargets,
})
}
const automationInvalid =
copyOptions.mode === 'auto_fingerprint' && !isBrowserProfileCopyOptionsValid(copyOptions)
const selectedAutomationCount = copyOptions.automationTargets.length
return (
<div className="space-y-5">
<div className="space-y-4">
{sourceName ? (
<div className="rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)] px-3 py-2 text-sm text-[var(--color-text-secondary)]">
{sourceName}
</div>
) : null}
<FormItem label="新实例名称" required>
<Input
value={copyName}
onChange={(event) => onCopyNameChange(event.target.value)}
placeholder="请输入新实例名称"
autoFocus={autoFocusName}
/>
</FormItem>
</div>
<div className="space-y-3">
<div className="text-sm font-medium text-[var(--color-text-secondary)]"></div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label
className={clsx(
'flex min-w-0 cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 transition-colors',
copyOptions.mode === 'regular'
? 'border-[var(--color-border-strong)] bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] shadow-[inset_0_0_0_1px_var(--color-border-strong)]'
: 'border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-strong)]',
)}
>
<input
type="radio"
name="browser-profile-copy-mode"
className="h-4 w-4 shrink-0 border-[var(--color-border-strong)] text-black focus:ring-black"
checked={copyOptions.mode === 'regular'}
onChange={() => setMode('regular')}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--color-text-primary)]"></span>
<span className="block text-xs text-[var(--color-text-muted)]"></span>
</span>
</label>
<label
className={clsx(
'flex min-w-0 cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 transition-colors',
copyOptions.mode === 'auto_fingerprint'
? 'border-[var(--color-border-strong)] bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] shadow-[inset_0_0_0_1px_var(--color-border-strong)]'
: 'border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-strong)]',
)}
>
<input
type="radio"
name="browser-profile-copy-mode"
className="h-4 w-4 shrink-0 border-[var(--color-border-strong)] text-black focus:ring-black"
checked={copyOptions.mode === 'auto_fingerprint'}
onChange={() => setMode('auto_fingerprint')}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--color-text-primary)]"></span>
<span className="block text-xs text-[var(--color-text-muted)]"></span>
</span>
</label>
</div>
{copyOptions.mode === 'auto_fingerprint' ? (
<div className="rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4">
<FormItem
label={
<span className="flex items-center justify-between gap-3">
<span><span className="ml-0.5 text-[var(--color-error)]">*</span></span>
<span className="text-xs font-normal text-[var(--color-text-muted)]"> {selectedAutomationCount} </span>
</span>
}
error={automationInvalid ? '请至少勾选一项' : undefined}
>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3">
{BROWSER_PROFILE_AUTOMATION_TARGET_OPTIONS.map((option) => {
const checked = copyOptions.automationTargets.includes(option.value)
return (
<label
key={option.value}
className={clsx(
'flex min-w-0 cursor-pointer items-start gap-3 rounded-lg border px-3 py-3 transition-colors',
checked
? 'border-[var(--color-border-strong)] bg-[var(--color-bg-secondary)] shadow-[inset_0_0_0_1px_var(--color-border-strong)]'
: 'border-[var(--color-border-default)] bg-[var(--color-bg-surface)] hover:border-[var(--color-border-strong)]',
)}
>
<input
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--color-border-strong)] text-black focus:ring-black"
checked={checked}
onChange={() => toggleAutomationTarget(option.value)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium leading-5 text-[var(--color-text-primary)]">
{option.label}
</span>
<span className="mt-0.5 block text-xs leading-4 text-[var(--color-text-muted)]">
{option.detail}
</span>
</span>
</label>
)
})}
</div>
</FormItem>
</div>
) : null}
</div>
</div>
)
}

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