package backend import ( "archive/zip" "bytes" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "sort" "strings" "testing" "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" ) func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { 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) } } func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) { app := NewApp(t.TempDir()) sourceDir := filepath.Join(t.TempDir(), "local-dir-script") if err := os.MkdirAll(filepath.Join(sourceDir, "scripts", "helpers"), 0o755); err != nil { t.Fatalf("create local dir source failed: %v", err) } if err := os.WriteFile(filepath.Join(sourceDir, "automation.script.json"), []byte(`{ "name": "本地目录脚本", "type": "playwright-cdp", "entryFile": "scripts/index.cjs" }`), 0o644); err != nil { t.Fatalf("write local dir manifest failed: %v", err) } if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "index.cjs"), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), 0o644); err != nil { t.Fatalf("write local dir entry failed: %v", err) } if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil { t.Fatalf("write local dir helper failed: %v", err) } saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "refresh-local-dir", Name: "旧本地目录脚本", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: false })", Source: automation.ScriptSource{ Type: "local-dir", URI: sourceDir, }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } refreshed, err := app.AutomationScriptRefresh(saved.ID) if err != nil { t.Fatalf("AutomationScriptRefresh returned error: %v", err) } if refreshed == nil { t.Fatalf("AutomationScriptRefresh returned nil result") } if refreshed.ID != saved.ID { t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) } if refreshed.Status != "ready" { t.Fatalf("expected status to be preserved, got %q", refreshed.Status) } if refreshed.EntryFile != "scripts/index.cjs" { t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile) } if !strings.Contains(refreshed.ScriptText, "helper.run()") { t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText) } } func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) { app := NewApp(t.TempDir()) libraryRoot := filepath.Join(t.TempDir(), "script-library") firstScriptDir := filepath.Join(libraryRoot, "first-script") writeAutomationScriptLibraryPackage(t, firstScriptDir, `{ "name": "脚本一", "type": "playwright-cdp", "entryFile": "index.cjs" }`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })") secondScriptDir := filepath.Join(libraryRoot, "second-script") if err := os.MkdirAll(secondScriptDir, 0o755); err != nil { t.Fatalf("create second script dir failed: %v", err) } if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil { t.Fatalf("write second script entry failed: %v", err) } existing, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "existing-local-library-script", Name: "旧脚本一", Type: "launch-api", Status: "disabled", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: false })", Source: automation.ScriptSource{ Type: "local-dir", URI: firstScriptDir, }, PublicAPI: automation.ScriptPublicAPIConfig{ Enabled: true, Path: "library/existing-script", }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } result, err := app.importAutomationLocalLibrary(libraryRoot) if err != nil { t.Fatalf("importAutomationLocalLibrary returned error: %v", err) } if result == nil { t.Fatalf("importAutomationLocalLibrary returned nil result") } if result.Scanned != 2 { t.Fatalf("expected scanned count 2, got %d", result.Scanned) } if len(result.Imported) != 2 { t.Fatalf("expected two imported scripts, got %d", len(result.Imported)) } if len(result.Failed) != 0 { t.Fatalf("expected no failed imports, got %+v", result.Failed) } updatedFirst, err := app.AutomationScriptGet(existing.ID) if err != nil { t.Fatalf("AutomationScriptGet returned error: %v", err) } if updatedFirst.Name != "脚本一" { t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name) } if updatedFirst.Status != "disabled" { t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status) } if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir { t.Fatalf("unexpected updated source: %+v", updatedFirst.Source) } if !strings.Contains(updatedFirst.ScriptText, "first-script") { t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText) } if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled { t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI) } allScripts, err := app.automationScriptStore().List() if err != nil { t.Fatalf("List returned error: %v", err) } if len(allScripts) != 2 { t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts)) } } func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) { app := NewApp(t.TempDir()) libraryRoot := filepath.Join(t.TempDir(), "script-library") goodDir := filepath.Join(libraryRoot, "good-script") writeAutomationScriptLibraryPackage(t, goodDir, `{ "name": "好脚本", "type": "playwright-cdp", "entryFile": "index.cjs" }`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })") badDir := filepath.Join(libraryRoot, "bad-script") writeAutomationScriptLibraryPackage(t, badDir, `{ "name": "坏脚本", "type": "playwright-cdp", "entryFile": "missing.cjs" }`, "") result, err := app.importAutomationLocalLibrary(libraryRoot) if err != nil { t.Fatalf("importAutomationLocalLibrary returned error: %v", err) } if result == nil { t.Fatalf("importAutomationLocalLibrary returned nil result") } if result.Scanned != 2 { t.Fatalf("expected scanned count 2, got %d", result.Scanned) } if len(result.Imported) != 1 { t.Fatalf("expected one imported script, got %d", len(result.Imported)) } if len(result.Failed) != 1 { t.Fatalf("expected one failed script, got %+v", result.Failed) } if result.Failed[0].Path != badDir { t.Fatalf("unexpected failed path: %+v", result.Failed[0]) } if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") { t.Fatalf("unexpected failed message: %+v", result.Failed[0]) } } func TestAutomationScriptRefreshFromRemote(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{ "manifest": { "name": "远程刷新脚本", "description": "来自远程", "type": "playwright-cdp", "entryFile": "index.cjs" }, "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })" }`)) })) defer server.Close() app := NewApp(t.TempDir()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "refresh-remote", Name: "旧远程脚本", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: false })", Source: automation.ScriptSource{ Type: "remote-url", URI: server.URL + "/script.json", }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } refreshed, err := app.AutomationScriptRefresh(saved.ID) if err != nil { t.Fatalf("AutomationScriptRefresh returned error: %v", err) } if refreshed == nil { t.Fatalf("AutomationScriptRefresh returned nil result") } if refreshed.ID != saved.ID { t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) } if refreshed.Name != "远程刷新脚本" { t.Fatalf("expected remote manifest name, got %q", refreshed.Name) } if refreshed.Status != "ready" { t.Fatalf("expected status to be preserved, got %q", refreshed.Status) } if !strings.Contains(refreshed.ScriptText, "source: 'remote'") { t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText) } if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" { t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) } } func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) { app := NewApp(t.TempDir()) zipData := buildAutomationZipBytesForTest(t, map[string]string{ "automation.script.json": `{ "name": "远程 ZIP", "type": "playwright-cdp", "entryFile": "scripts/index.cjs" }`, "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })", }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/zip") _, _ = w.Write(zipData) })) defer server.Close() bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip") if err != nil { t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) } if bundle.Record.Name != "远程 ZIP" { t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) } if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" { t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) } if !strings.Contains(bundle.Record.ScriptText, "remote-zip") { t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) } } func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) { app := NewApp(t.TempDir()) app.config = config.DefaultConfig() app.config.Automation.AllowTypeScriptBuild = true server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte(`export async function run() { return { ok: true, source: 'remote-ts' } }`)) })) defer server.Close() bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts") if err != nil { t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) } if bundle.Record.EntryFile != "demo-script.cjs" { t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) } if !strings.Contains(bundle.Record.ScriptText, "remote-ts") { t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) } if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" { t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) } } func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`export async function run() { return { ok: true, source: 'remote-ts-refresh' } }`)) })) defer server.Close() app := NewApp(t.TempDir()) app.config = config.DefaultConfig() app.config.Automation.AllowTypeScriptBuild = true saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "refresh-remote-ts", Name: "旧远程 TS 脚本", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: false })", Source: automation.ScriptSource{ Type: "remote-url", URI: server.URL + "/refresh-script.ts", }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } refreshed, err := app.AutomationScriptRefresh(saved.ID) if err != nil { t.Fatalf("AutomationScriptRefresh returned error: %v", err) } if refreshed.EntryFile != "refresh-script.cjs" { t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile) } if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") { t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText) } if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" { t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) } } func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } repoDir := filepath.Join(t.TempDir(), "automation-ts-repo") if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil { t.Fatalf("create repo dir failed: %v", err) } if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ "name": "Git TS 导入", "type": "playwright-cdp", "entryFile": "index.ts" }`), 0o644); err != nil { t.Fatalf("write git manifest failed: %v", err) } if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag' export async function run() { return { ok: flag, source: 'git-ts' } }`), 0o644); err != nil { t.Fatalf("write git entry file failed: %v", err) } if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil { t.Fatalf("write git helper file failed: %v", err) } runGitForTest(t, repoDir, "init") runGitForTest(t, repoDir, "config", "user.email", "test@example.com") runGitForTest(t, repoDir, "config", "user.name", "Test User") runGitForTest(t, repoDir, "add", ".") runGitForTest(t, repoDir, "commit", "-m", "init") app := NewApp(t.TempDir()) app.config = config.DefaultConfig() app.config.Automation.AllowTypeScriptBuild = true bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo") if err != nil { t.Fatalf("loadAutomationGitBundle returned error: %v", err) } if bundle.Record.Name != "Git TS 导入" { t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) } if bundle.Record.EntryFile != "index.cjs" { t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) } if !strings.Contains(bundle.Record.ScriptText, "git-ts") { t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) } if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" { t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) } } func TestAutomationScriptRefreshFromGit(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } repoDir := filepath.Join(t.TempDir(), "automation-repo") if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil { t.Fatalf("create repo dir failed: %v", err) } if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ "name": "Git 刷新脚本", "type": "playwright-cdp", "entryFile": "index.cjs" }`), 0o644); err != nil { t.Fatalf("write git manifest failed: %v", err) } if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil { t.Fatalf("write git entry file failed: %v", err) } runGitForTest(t, repoDir, "init") runGitForTest(t, repoDir, "config", "user.email", "test@example.com") runGitForTest(t, repoDir, "config", "user.name", "Test User") runGitForTest(t, repoDir, "add", ".") runGitForTest(t, repoDir, "commit", "-m", "init") app := NewApp(t.TempDir()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "refresh-git", Name: "旧 Git 脚本", Type: "launch-api", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: false })", Source: automation.ScriptSource{ Type: "git", URI: repoDir, Path: "scripts/demo", }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } refreshed, err := app.AutomationScriptRefresh(saved.ID) if err != nil { t.Fatalf("AutomationScriptRefresh returned error: %v", err) } if refreshed == nil { t.Fatalf("AutomationScriptRefresh returned nil result") } if refreshed.ID != saved.ID { t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) } if refreshed.Name != "Git 刷新脚本" { t.Fatalf("expected git manifest name, got %q", refreshed.Name) } if refreshed.Status != "ready" { t.Fatalf("expected status to be preserved, got %q", refreshed.Status) } if !strings.Contains(refreshed.ScriptText, "source: 'git'") { t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText) } if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" { t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) } } func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) { app := NewApp(t.TempDir()) saved, err := app.AutomationScriptSave(automation.ScriptRecord{ ID: "refresh-manual", Name: "手动脚本", Type: "playwright-cdp", Status: "ready", EntryFile: "index.cjs", ScriptText: "module.exports.run = async () => ({ ok: true })", Source: automation.ScriptSource{ Type: "manual", }, }) if err != nil { t.Fatalf("AutomationScriptSave returned error: %v", err) } if _, err := app.AutomationScriptRefresh(saved.ID); err == nil { t.Fatalf("expected unsupported source refresh to fail") } } func runGitForTest(t *testing.T, workdir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = workdir output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) } } func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte { t.Helper() var buf bytes.Buffer writer := zip.NewWriter(&buf) paths := make([]string, 0, len(files)) for relativePath := range files { paths = append(paths, relativePath) } sort.Strings(paths) for _, relativePath := range paths { entry, err := writer.Create(relativePath) if err != nil { t.Fatalf("create zip entry failed: %v", err) } if _, err := entry.Write([]byte(files[relativePath])); err != nil { t.Fatalf("write zip entry failed: %v", err) } } if err := writer.Close(); err != nil { t.Fatalf("close zip writer failed: %v", err) } return buf.Bytes() } func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("create script library package dir failed: %v", err) } if strings.TrimSpace(manifest) != "" { if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil { t.Fatalf("write script library manifest failed: %v", err) } } if strings.TrimSpace(entry) != "" { if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil { t.Fatalf("write script library entry failed: %v", err) } } }