package backend import ( "os" "path/filepath" "strings" "testing" "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" ) func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { app := NewApp(t.TempDir()) items, err := app.AutomationScriptList() if err != nil { t.Fatalf("AutomationScriptList returned error: %v", err) } if len(items) != 3 { t.Fatalf("expected three default scripts, got %d", len(items)) } byID := make(map[string]automation.ScriptRecord, len(items)) for _, script := range items { byID[script.ID] = script } expectedNames := map[string]string{ "dual-instance-runtime-switch": "双实例启动与 Runtime 切换", "news-query-txt": "查询新闻并写 TXT", "web-image-generate-download": "网页图片生成并下载", } for scriptID, expectedName := range expectedNames { script, ok := byID[scriptID] if !ok { t.Fatalf("missing default script %q", scriptID) } if script.Name != expectedName { t.Fatalf("unexpected default script name for %q: %q", scriptID, script.Name) } 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 { t.Fatalf("expected default config to exist for %q: %v", scriptID, err) } if _, err := os.Stat(filepath.Join(scriptDir, script.EntryFile)); err != nil { t.Fatalf("expected default entry file to exist for %q: %v", scriptID, err) } } dualScript := byID[automation.DualInstanceRuntimeScriptID] if !strings.Contains(dualScript.ParamsText, `"browsers"`) { t.Fatalf("expected dual-instance default params to use browsers array, got %s", dualScript.ParamsText) } if strings.Contains(dualScript.ParamsText, `"primaryCode"`) { t.Fatalf("expected dual-instance default params to drop legacy primaryCode fields, got %s", dualScript.ParamsText) } for scriptID := range expectedNames { if err := app.AutomationScriptDelete(scriptID); err != nil { t.Fatalf("AutomationScriptDelete returned error for %q: %v", scriptID, err) } } items, err = app.AutomationScriptList() if err != nil { t.Fatalf("AutomationScriptList returned error after delete: %v", err) } if len(items) != 0 { t.Fatalf("expected deleted default script not to be re-seeded, got %d items", len(items)) } } 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()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "app-script", Name: "App 脚本", Type: "playwright-cdp", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: true })", }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } if saved == nil { t.Fatalf("AutomationScriptSave returned nil result") } if saved.ID != "app-script" { t.Fatalf("expected saved id app-script, got %q", saved.ID) } items, err := app.AutomationScriptList() if err != nil { t.Fatalf("AutomationScriptList returned error: %v", err) } if len(items) != 1 { t.Fatalf("expected one script, got %d", len(items)) } if err := app.AutomationScriptDelete(saved.ID); err != nil { t.Fatalf("AutomationScriptDelete returned error: %v", err) } items, err = app.AutomationScriptList() if err != nil { t.Fatalf("AutomationScriptList returned error after delete: %v", err) } if len(items) != 0 { t.Fatalf("expected zero scripts after delete, got %d", len(items)) } } func TestAutomationScriptSaveHydratesExactTargetSelectorWithCode(t *testing.T) { app := newAutomationTargetTestApp(t) profile := createAutomationTargetProfile(t, app, browser.ProfileInput{ ProfileName: "buyer-001", }) code, err := app.launchCodeSvc.SetCode(profile.ProfileId, "BUYER_001") if err != nil { t.Fatalf("set code failed: %v", err) } saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "app-script", Name: "App 脚本", Type: "playwright-cdp", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: true })", TargetConfig: automation.ScriptTargetConfig{ Mode: "existing", Selector: automation.ScriptTargetSelector{ ProfileID: profile.ProfileId, }, }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } if saved == nil { t.Fatalf("AutomationScriptSave returned nil result") } if saved.TargetConfig.Selector.ProfileID != profile.ProfileId { t.Fatalf("expected profileId to be preserved, got %+v", saved.TargetConfig.Selector) } if saved.TargetConfig.Selector.Code != code { t.Fatalf("expected code snapshot %q, got %+v", code, saved.TargetConfig.Selector) } } func TestAutomationScriptRunRecordsUnsupportedType(t *testing.T) { app := NewApp(t.TempDir()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "playwright-script", Name: "Playwright 脚本", Type: "playwright-cdp", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: true })", }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } run, err := app.AutomationScriptRun(saved.ID) if err != nil { t.Fatalf("AutomationScriptRun returned error: %v", err) } if run == nil { t.Fatalf("AutomationScriptRun returned nil result") } if run.Status != "failed" { t.Fatalf("expected unsupported script to fail, got %q", run.Status) } if run.Error == "" { t.Fatalf("expected unsupported script run to contain error") } runs, err := app.AutomationScriptRunList(10) if err != nil { t.Fatalf("AutomationScriptRunList returned error: %v", err) } if len(runs) != 1 { t.Fatalf("expected one run record, got %d", len(runs)) } } func TestAutomationScriptRunWithOptionsInvalidSelector(t *testing.T) { app := NewApp(t.TempDir()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "launch-script", Name: "Launch 脚本", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", SelectorText: `{"code":"BUYER_001"}`, ParamsText: `{"startUrls":["https://example.com"]}`, ScriptText: "export async function run() {}", }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ ScriptID: saved.ID, SelectorText: "{invalid", 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 != "failed" { t.Fatalf("expected invalid selector run to fail, got %q", run.Status) } if run.Error == "" { t.Fatalf("expected invalid selector run to contain error") } if run.Summary != "脚本执行失败" { t.Fatalf("expected invalid selector summary, got %q", run.Summary) } } func TestAutomationScriptRunWithOptionsAllowsEmptySelectorForDualInstanceRuntimeScript(t *testing.T) { app := NewApp(t.TempDir()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: automation.DualInstanceRuntimeScriptID, Name: "双实例启动与 Runtime 切换", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", ParamsText: `{"browsers":[{"code":"BUYER_001"},{"code":"BUYER_002"}],"timeoutMs":45000}`, ScriptText: "export async function run() {}", }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ ScriptID: saved.ID, SelectorText: "", 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.Summary != "双实例流程执行失败" { t.Fatalf("expected dual-instance flow to bypass selector validation, got %+v", run) } if strings.Contains(run.Error, "selector is required") { t.Fatalf("expected dual-instance script to allow empty selector, got %+v", run) } } 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()) sourcePath := filepath.Join(t.TempDir(), "demo-script.cjs") if err := os.WriteFile(sourcePath, []byte("module.exports.run = async () => ({ ok: true, source: 'local-file' })"), 0o644); err != nil { t.Fatalf("write source file failed: %v", err) } saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "refresh-local-file", Name: "本地文件脚本", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: false })", Source: automation.ScriptSource{ Type: "local-file", URI: sourcePath, }, }) 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.Type != "playwright-cdp" { t.Fatalf("expected type to follow imported source, got %q", refreshed.Type) } if refreshed.EntryFile != "demo-script.cjs" { t.Fatalf("expected entry file from source bundle, got %q", refreshed.EntryFile) } if !strings.Contains(refreshed.ScriptText, "source: 'local-file'") { t.Fatalf("expected refreshed script text from local file, got %q", refreshed.ScriptText) } if refreshed.Source.Type != "local-file" || refreshed.Source.URI != sourcePath { t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) } if refreshed.Source.ImportedAt == "" { t.Fatalf("expected refreshed source importedAt to be populated") } } 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) } }