mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat: merge automation workflow updates
This commit is contained in:
+29
@@ -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/
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 重新生成实例的 LaunchCode(Wails 绑定)
|
||||
@@ -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 自定义设置实例 LaunchCode(Wails 绑定)
|
||||
@@ -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 绑定)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <noreply@tm.openai.com></p>
|
||||
<p>To: target@example.com</p>
|
||||
<p>Hello,</p>
|
||||
<p>Your verification code is 429792.</p>
|
||||
<p>Please use this code to continue signing in.</p>
|
||||
<p>Best regards</p>
|
||||
<p>ChatGPT</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => {
|
||||
const normalizeText = (value) => String(value == null ? '' : value).trim()
|
||||
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
|
||||
? Math.max(5000, Math.round(Number(params.timeoutMs)))
|
||||
: 45000
|
||||
const inboxUrl = normalizeText(params.inboxUrl)
|
||||
|
||||
if (!inboxUrl) {
|
||||
throw new Error('inboxUrl is required')
|
||||
}
|
||||
|
||||
const session = await launch({
|
||||
selector,
|
||||
skipDefaultStartUrls: true,
|
||||
startUrls: [inboxUrl],
|
||||
})
|
||||
const connection = await connect(session, { timeoutMs })
|
||||
const browser = connection.browser
|
||||
if (!browser) {
|
||||
throw new Error('browser connection is unavailable')
|
||||
}
|
||||
|
||||
const context =
|
||||
connection.context ||
|
||||
browser.contexts()[0] ||
|
||||
(typeof browser.newContext === 'function' ? await browser.newContext() : null)
|
||||
if (!context) {
|
||||
throw new Error('browser context is unavailable')
|
||||
}
|
||||
|
||||
const opened = await openPage(connection, {
|
||||
url: inboxUrl,
|
||||
timeoutMs,
|
||||
permissions: ['notifications'],
|
||||
})
|
||||
const page = opened.page
|
||||
await page.waitForLoadState('networkidle', {
|
||||
timeout: Math.min(timeoutMs, 2500),
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
||||
const article = document.querySelector('article')
|
||||
const lines = Array.from(document.querySelectorAll('article p'))
|
||||
.map((node) => normalizeText(node.textContent))
|
||||
.filter(Boolean)
|
||||
const subject = normalizeText(document.querySelector('article h1')?.textContent)
|
||||
const fromLine = lines.find((line) => line.startsWith('From:')) || ''
|
||||
const toLine = lines.find((line) => line.startsWith('To:')) || ''
|
||||
const articleText = normalizeText(article?.textContent)
|
||||
const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s*</)
|
||||
const senderEmailMatch = fromLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
|
||||
const recipientEmailMatch = toLine.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)
|
||||
const verificationCodeMatch = articleText.match(/\b\d{6}\b/)
|
||||
const signature = lines.slice(-2).join('\n')
|
||||
|
||||
return {
|
||||
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : '',
|
||||
notificationProbe: document.documentElement.getAttribute('data-notification-probe') || '',
|
||||
mailboxName: mailboxMatch ? normalizeText(mailboxMatch[1]) : '',
|
||||
senderEmail: senderEmailMatch ? senderEmailMatch[0] : '',
|
||||
recipientEmail: recipientEmailMatch ? recipientEmailMatch[0] : '',
|
||||
subject,
|
||||
verificationCode: verificationCodeMatch ? verificationCodeMatch[0] : '',
|
||||
signature,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
permissionApplied: opened.permissionResult && opened.permissionResult.applied === true,
|
||||
permissionOrigin: opened.permissionResult && opened.permissionResult.origin ? opened.permissionResult.origin : '',
|
||||
summary: '已提取测试邮件内容',
|
||||
...result,
|
||||
}
|
||||
}`
|
||||
|
||||
var automationHTTPMailProbeScriptSummaryLine = regexp.MustCompile(`summary:[^\n]+`)
|
||||
|
||||
var automationHTTPMailProbeScriptText = automationHTTPMailProbeScriptSummaryLine.ReplaceAllString(
|
||||
automationHTTPMailProbeScriptTextRaw,
|
||||
"summary: 'mail probe extracted message',",
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
@@ -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` 忽略
|
||||
+42
@@ -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
-182
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+58
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.ScriptID != "proton-mail-first-message" {
|
||||
t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Status != "success" {
|
||||
t.Fatalf("hook 响应错误: %+v", resp)
|
||||
}
|
||||
if resp.Data["verificationCode"] != "429792" {
|
||||
t.Fatalf("expected data payload, got %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "proton-mail-first-message",
|
||||
Name: "Proton 邮件搜索并读取最新邮件",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/proton-result-only",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 45000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-hook-2",
|
||||
ScriptID: "proton-mail-first-message",
|
||||
ScriptName: "Proton 邮件搜索并读取最新邮件",
|
||||
Status: "success",
|
||||
Summary: "已返回最新命中邮件内容",
|
||||
ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"recipientQuery":"target@example.com"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` {
|
||||
t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 60000 {
|
||||
t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" {
|
||||
t.Fatalf("expected data payload, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "grok-image-generate-download",
|
||||
Name: "Grok 生成图片并下载",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/grok-generate-download",
|
||||
RequestMode: "params-only",
|
||||
ResponseMode: "result-only",
|
||||
TimeoutMs: 300000,
|
||||
},
|
||||
},
|
||||
}
|
||||
starter.runResult = &automation.ScriptRunRecord{
|
||||
ID: "run-grok-image",
|
||||
ScriptID: "grok-image-generate-download",
|
||||
ScriptName: "Grok 生成图片并下载",
|
||||
Status: "success",
|
||||
Summary: "Grok 图片已生成并下载",
|
||||
ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`,
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" {
|
||||
t.Fatalf("下载字段缺失: %+v", resp)
|
||||
}
|
||||
if _, exists := data["steps"]; exists {
|
||||
t.Fatalf("不应返回冗余 steps: %+v", resp)
|
||||
}
|
||||
if _, exists := data["runId"]; exists {
|
||||
t.Fatalf("不应返回 runId: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "image-generate",
|
||||
Name: "图片生成",
|
||||
ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`,
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "image/generate",
|
||||
RequestMode: "standard",
|
||||
ResponseMode: "envelope",
|
||||
TimeoutMs: 300000,
|
||||
RequestBodyText: `{"code":"{{code}}","params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`,
|
||||
Variables: []automation.ScriptPublicAPIVariable{
|
||||
{Name: "prompt", DefaultValue: "默认提示词", Required: true},
|
||||
{Name: "outputFileName", DefaultValue: "generated-image.png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{
|
||||
"code":"BUYER_001",
|
||||
"params":{"prompt":"海边的机器人","outputFileName":"robot.png"}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastRunRequest.UseScriptParams {
|
||||
t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest)
|
||||
}
|
||||
if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` {
|
||||
t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest)
|
||||
}
|
||||
|
||||
var params map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil {
|
||||
t.Fatalf("解析 paramsText 失败: %v", err)
|
||||
}
|
||||
if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" {
|
||||
t.Fatalf("变量未映射到 params: %+v", params)
|
||||
}
|
||||
selectors, ok := params["selectors"].(map[string]interface{})
|
||||
if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" {
|
||||
t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if params["timeoutMs"] != float64(300000) {
|
||||
t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params)
|
||||
}
|
||||
if starter.lastRunRequest.TimeoutMs != 300000 {
|
||||
t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "disabled-hook",
|
||||
Name: "Disabled Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: false,
|
||||
Path: "mail/disabled-hook",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook",
|
||||
Name: "Strict Hook",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`},
|
||||
{name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp.OK || resp.Error.Code != "invalid_request" {
|
||||
t.Fatalf("错误 envelope 不正确: %+v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
starter.scripts = []automation.ScriptRecord{
|
||||
{
|
||||
ID: "strict-hook-timeout",
|
||||
Name: "Strict Hook Timeout",
|
||||
PublicAPI: automation.ScriptPublicAPIConfig{
|
||||
Enabled: true,
|
||||
Method: "POST",
|
||||
Path: "mail/strict-hook-timeout",
|
||||
TimeoutMs: 120000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := buildTestHandlerWithManager(svc, starter, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockAutomationStarter()
|
||||
@@ -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,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") {
|
||||
t.Fatalf("错误信息不正确: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
本目录是运行时数据目录,不参与常规 Git 提交。
|
||||
|
||||
与自动化脚本相关的约定:
|
||||
|
||||
- 用户新建、导入、刷新后的脚本快照默认落在 `data/automation/scripts/`。
|
||||
- `.gitignore` 已忽略 `data/*`,所以这些本地脚本不会直接被提交到 GitHub。
|
||||
- 目前仓库里真正跟代码一起维护的只有内置脚本;其他临时脚本建议继续放在 `data/automation/scripts/`。
|
||||
|
||||
如果需要按仓库管理脚本源码:
|
||||
|
||||
- 可以把要发布的脚本单独放在一个明确的子目录里,例如 `scripts/published/demo`。
|
||||
- Git 导入时填写“脚本路径”,系统只会扫描那个指定子目录,不会把整个仓库都当成一个脚本包。
|
||||
- 留空“脚本路径”时,才会从仓库根目录解析。
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user