From 0a5d387ff97fd171c81767a3b6b90019f8106050 Mon Sep 17 00:00:00 2001 From: ant-black <1016930479@qq.com> Date: Tue, 23 Jun 2026 17:32:41 +0800 Subject: [PATCH] test: trim low-value unit tests --- backend/app_backup_ops_test.go | 128 ---- backend/app_bookmark_test.go | 169 ----- backend/app_browser_settings_test.go | 85 --- backend/app_close_behavior_test.go | 89 --- backend/app_proxy_core_download_test.go | 216 ------- backend/app_proxy_diagnostics_test.go | 36 -- backend/app_proxy_health_test.go | 138 ---- backend/app_proxy_import_test.go | 121 ---- backend/app_proxy_location_test.go | 38 -- backend/app_proxy_warmup_test.go | 60 -- backend/app_reload_test.go | 60 -- backend/automation_api_test.go | 108 ---- backend/automation_demo_api_test.go | 124 ---- backend/automation_script_api_helpers_test.go | 69 -- backend/automation_script_api_test.go | 472 -------------- backend/automation_script_import_test.go | 195 ------ ...utomation_script_public_api_invoke_test.go | 118 ---- backend/automation_script_remote_test.go | 321 ---------- backend/automation_script_run_test.go | 170 ----- .../automation_script_target_resolver_test.go | 215 ------- backend/browser_profile_launchcode_test.go | 81 --- backend/internal/apppath/apppath_test.go | 149 ----- .../runtime_manager_helpers_test.go | 119 ---- .../automation/runtime_manager_test.go | 441 ------------- .../automation/script_run_store_test.go | 55 -- .../automation/task_runner_launch_test.go | 596 ------------------ .../internal/automation/task_runner_test.go | 491 --------------- .../task_runner_test_helpers_test.go | 274 -------- backend/internal/backup/spec_test.go | 124 ---- backend/internal/browser/bookmarks_test.go | 185 ------ backend/internal/browser/connector_test.go | 20 - backend/internal/browser/core_test.go | 210 ------ backend/internal/browser/dashboard_test.go | 43 -- backend/internal/browser/environment_test.go | 69 -- .../browser/extension_manager_test.go | 119 ---- backend/internal/browser/profile_copy_test.go | 211 ------- .../internal/browser/profile_defaults_test.go | 116 ---- .../internal/browser/profile_delete_test.go | 99 --- .../browser/profile_proxy_input_test.go | 129 ---- .../internal/browser/proxy_binding_test.go | 81 --- .../internal/browser/proxy_queries_test.go | 59 -- .../internal/browser/session_restore_test.go | 82 --- backend/internal/config/config_test.go | 362 ----------- backend/internal/fsutil/path_test.go | 85 --- .../launchcode/server_auth_internal_test.go | 29 - .../internal/launchcode/server_start_test.go | 56 -- backend/internal/logger/formatter_test.go | 280 -------- backend/internal/logger/interceptor_test.go | 466 -------------- backend/internal/logger/interfaces_test.go | 163 ----- backend/internal/logger/logger_test.go | 439 ------------- backend/internal/logger/rotation_test.go | 197 ------ backend/internal/logger/writer_test.go | 194 ------ backend/internal/proxy/check_config_test.go | 92 --- backend/internal/proxy/diagnostics_test.go | 128 ---- backend/internal/proxy/iphealth_test.go | 53 -- backend/internal/proxy/page_probe_test.go | 72 --- backend/internal/proxy/runtime_config_test.go | 178 ------ .../proxy/singbox_launch_lock_test.go | 43 -- .../proxy/singbox_runtime_helpers_test.go | 65 -- backend/internal/proxy/speedtest_test.go | 324 ---------- .../internal/proxy/xray_launch_lock_test.go | 58 -- .../proxy/xray_runtime_helpers_test.go | 139 ---- backend/internal/snapshot/archive_test.go | 38 -- backend/internal/snapshot/files_test.go | 50 -- backend/license_state_test.go | 100 --- window_sizing_test.go | 57 -- 66 files changed, 10353 deletions(-) delete mode 100644 backend/app_backup_ops_test.go delete mode 100644 backend/app_bookmark_test.go delete mode 100644 backend/app_browser_settings_test.go delete mode 100644 backend/app_close_behavior_test.go delete mode 100644 backend/app_proxy_core_download_test.go delete mode 100644 backend/app_proxy_diagnostics_test.go delete mode 100644 backend/app_proxy_health_test.go delete mode 100644 backend/app_proxy_import_test.go delete mode 100644 backend/app_proxy_location_test.go delete mode 100644 backend/app_proxy_warmup_test.go delete mode 100644 backend/app_reload_test.go delete mode 100644 backend/automation_api_test.go delete mode 100644 backend/automation_demo_api_test.go delete mode 100644 backend/automation_script_api_helpers_test.go delete mode 100644 backend/automation_script_api_test.go delete mode 100644 backend/automation_script_import_test.go delete mode 100644 backend/automation_script_public_api_invoke_test.go delete mode 100644 backend/automation_script_remote_test.go delete mode 100644 backend/automation_script_run_test.go delete mode 100644 backend/automation_script_target_resolver_test.go delete mode 100644 backend/browser_profile_launchcode_test.go delete mode 100644 backend/internal/apppath/apppath_test.go delete mode 100644 backend/internal/automation/runtime_manager_helpers_test.go delete mode 100644 backend/internal/automation/runtime_manager_test.go delete mode 100644 backend/internal/automation/script_run_store_test.go delete mode 100644 backend/internal/automation/task_runner_launch_test.go delete mode 100644 backend/internal/automation/task_runner_test.go delete mode 100644 backend/internal/automation/task_runner_test_helpers_test.go delete mode 100644 backend/internal/backup/spec_test.go delete mode 100644 backend/internal/browser/bookmarks_test.go delete mode 100644 backend/internal/browser/connector_test.go delete mode 100644 backend/internal/browser/core_test.go delete mode 100644 backend/internal/browser/dashboard_test.go delete mode 100644 backend/internal/browser/environment_test.go delete mode 100644 backend/internal/browser/extension_manager_test.go delete mode 100644 backend/internal/browser/profile_copy_test.go delete mode 100644 backend/internal/browser/profile_defaults_test.go delete mode 100644 backend/internal/browser/profile_delete_test.go delete mode 100644 backend/internal/browser/profile_proxy_input_test.go delete mode 100644 backend/internal/browser/proxy_binding_test.go delete mode 100644 backend/internal/browser/proxy_queries_test.go delete mode 100644 backend/internal/browser/session_restore_test.go delete mode 100644 backend/internal/config/config_test.go delete mode 100644 backend/internal/fsutil/path_test.go delete mode 100644 backend/internal/launchcode/server_auth_internal_test.go delete mode 100644 backend/internal/launchcode/server_start_test.go delete mode 100644 backend/internal/logger/formatter_test.go delete mode 100644 backend/internal/logger/interceptor_test.go delete mode 100644 backend/internal/logger/interfaces_test.go delete mode 100644 backend/internal/logger/logger_test.go delete mode 100644 backend/internal/logger/rotation_test.go delete mode 100644 backend/internal/logger/writer_test.go delete mode 100644 backend/internal/proxy/check_config_test.go delete mode 100644 backend/internal/proxy/diagnostics_test.go delete mode 100644 backend/internal/proxy/iphealth_test.go delete mode 100644 backend/internal/proxy/page_probe_test.go delete mode 100644 backend/internal/proxy/runtime_config_test.go delete mode 100644 backend/internal/proxy/singbox_launch_lock_test.go delete mode 100644 backend/internal/proxy/singbox_runtime_helpers_test.go delete mode 100644 backend/internal/proxy/speedtest_test.go delete mode 100644 backend/internal/proxy/xray_launch_lock_test.go delete mode 100644 backend/internal/proxy/xray_runtime_helpers_test.go delete mode 100644 backend/internal/snapshot/archive_test.go delete mode 100644 backend/internal/snapshot/files_test.go delete mode 100644 backend/license_state_test.go delete mode 100644 window_sizing_test.go diff --git a/backend/app_backup_ops_test.go b/backend/app_backup_ops_test.go deleted file mode 100644 index d3e0cf28..00000000 --- a/backend/app_backup_ops_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/config" - "os" - "path/filepath" - "testing" -) - -func TestBackupEnsureZipSuffix(t *testing.T) { - if got := backupEnsureZipSuffix("c:/tmp/a.zip"); got != "c:/tmp/a.zip" { - t.Fatalf("zip 后缀重复追加: %s", got) - } - if got := backupEnsureZipSuffix("c:/tmp/a"); got != "c:/tmp/a.zip" { - t.Fatalf("zip 后缀追加失败: %s", got) - } -} - -func TestBackupMergeConfigDedup(t *testing.T) { - current := config.DefaultConfig() - current.App.MaxProfileLimit = 12 - current.App.UsedCDKeys = []string{"A1", "B2"} - current.Browser.DefaultBookmarks = []config.BrowserBookmark{ - {Name: "Google", URL: "https://www.google.com/"}, - } - current.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://proxy.invalid:8080"}, - } - current.Browser.Cores = []config.BrowserCore{ - {CoreId: "c1", CoreName: "C1", CorePath: "chrome/c1"}, - } - current.Browser.Profiles = []config.BrowserProfileConfig{ - {ProfileId: "u1", ProfileName: "U1", UserDataDir: "u1"}, - } - - incoming := config.DefaultConfig() - incoming.App.UsedCDKeys = []string{"b2", "C3"} - incoming.Browser.DefaultBookmarks = []config.BrowserBookmark{ - {Name: "Google Dup", URL: "https://www.google.com/"}, - {Name: "ChatGPT", URL: "https://chatgpt.com/"}, - } - incoming.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://proxy.invalid:8080"}, - {ProxyId: "p2", ProxyName: "P2", ProxyConfig: "socks5://127.0.0.1:1080"}, - } - incoming.Browser.Cores = []config.BrowserCore{ - {CoreId: "c1", CoreName: "C1 Dup", CorePath: "chrome/c1"}, - {CoreId: "c2", CoreName: "C2", CorePath: "chrome/c2"}, - } - incoming.Browser.Profiles = []config.BrowserProfileConfig{ - {ProfileId: "u1", ProfileName: "U1 Dup", UserDataDir: "u1"}, - {ProfileId: "u2", ProfileName: "U2", UserDataDir: "u2"}, - } - - merged := backupMergeConfig(current, incoming) - if merged == nil { - t.Fatalf("merged 为空") - } - - if merged.App.MaxProfileLimit != 12 { - t.Fatalf("license limit 不应被导入配置改写: got=%d", merged.App.MaxProfileLimit) - } - if len(merged.App.UsedCDKeys) != 2 { - t.Fatalf("used cd keys 不应被导入配置改写: %+v", merged.App.UsedCDKeys) - } - if len(merged.Browser.DefaultBookmarks) != 2 { - t.Fatalf("bookmarks 判重失败: %+v", merged.Browser.DefaultBookmarks) - } - if len(merged.Browser.Proxies) != 2 { - t.Fatalf("proxies 判重失败: %+v", merged.Browser.Proxies) - } - if len(merged.Browser.Cores) != 2 { - t.Fatalf("cores 判重失败: %+v", merged.Browser.Cores) - } - if len(merged.Browser.Profiles) != 2 { - t.Fatalf("profiles 判重失败: %+v", merged.Browser.Profiles) - } -} - -func TestBackupSyncDirConflictAndOverwrite(t *testing.T) { - src := filepath.Join(t.TempDir(), "src") - dst := filepath.Join(t.TempDir(), "dst") - if err := os.MkdirAll(src, 0755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(dst, 0755); err != nil { - t.Fatal(err) - } - - srcFile := filepath.Join(src, "a.txt") - dstFile := filepath.Join(dst, "a.txt") - if err := os.WriteFile(srcFile, []byte("new-content"), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dstFile, []byte("old-content"), 0644); err != nil { - t.Fatal(err) - } - - stats := &backupMergeStats{} - if err := backupSyncDir(src, dst, false, stats, nil); err != nil { - t.Fatal(err) - } - if stats.Conflicts != 1 || stats.Imported != 0 { - t.Fatalf("非覆盖模式统计异常: %+v", stats) - } - got, err := os.ReadFile(dstFile) - if err != nil { - t.Fatal(err) - } - if string(got) != "old-content" { - t.Fatalf("非覆盖模式不应改写目标文件: %s", string(got)) - } - - stats2 := &backupMergeStats{} - if err := backupSyncDir(src, dst, true, stats2, nil); err != nil { - t.Fatal(err) - } - if stats2.Imported != 1 { - t.Fatalf("覆盖模式导入统计异常: %+v", stats2) - } - got2, err := os.ReadFile(dstFile) - if err != nil { - t.Fatal(err) - } - if string(got2) != "new-content" { - t.Fatalf("覆盖模式应改写目标文件: %s", string(got2)) - } -} diff --git a/backend/app_bookmark_test.go b/backend/app_bookmark_test.go deleted file mode 100644 index 03ca5f96..00000000 --- a/backend/app_bookmark_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package backend - -import ( - internalbrowser "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestBookmarkSyncToProfilesAppliesCurrentDefaults(t *testing.T) { - t.Parallel() - - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = "data" - cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{ - {Name: "默认书签", URL: "https://default.example/"}, - } - - app := NewApp(appRoot) - app.config = cfg - app.browserMgr = internalbrowser.NewManager(cfg, appRoot) - app.browserMgr.Profiles["profile-1"] = &internalbrowser.Profile{ - ProfileId: "profile-1", - ProfileName: "实例 1", - UserDataDir: "profile-1", - } - - result := app.BookmarkSyncToProfiles() - if result.Total != 1 || result.Synced != 1 || result.Skipped != 0 || result.Failed != 0 { - t.Fatalf("unexpected sync result: %+v", result) - } - - bookmarksPath := filepath.Join(appRoot, "data", "profile-1", "Default", "Bookmarks") - data, err := os.ReadFile(bookmarksPath) - if err != nil { - t.Fatalf("read bookmarks: %v", err) - } - var root map[string]interface{} - if err := json.Unmarshal(data, &root); err != nil { - t.Fatalf("unmarshal bookmarks: %v", err) - } - if countBookmarkURLInRoot(root, "https://default.example/") != 1 { - t.Fatalf("default bookmark was not synced once: %s", string(data)) - } -} - -func TestBookmarkListAlwaysIncludesVerificationBookmarks(t *testing.T) { - t.Parallel() - - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{ - {Name: "用户默认书签", URL: "https://user.example/"}, - } - - app := NewApp(appRoot) - app.config = cfg - app.browserMgr = internalbrowser.NewManager(cfg, appRoot) - - bookmarks := app.BookmarkList() - for _, url := range []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} { - if countBookmarkItemsByURL(bookmarks, url) != 1 { - t.Fatalf("expected verification bookmark %s exactly once, got %+v", url, bookmarks) - } - } -} - -func TestBookmarkSavePersistsVerificationBookmarks(t *testing.T) { - t.Parallel() - - appRoot := t.TempDir() - cfg := config.DefaultConfig() - app := NewApp(appRoot) - app.config = cfg - app.browserMgr = internalbrowser.NewManager(cfg, appRoot) - - if err := app.BookmarkSave([]config.BrowserBookmark{{Name: "用户默认书签", URL: "https://user.example/", OpenOnStart: true}}); err != nil { - t.Fatalf("BookmarkSave returned error: %v", err) - } - if item, ok := findBookmarkItemByURL(app.config.Browser.DefaultBookmarks, "https://user.example/"); !ok || !item.OpenOnStart { - t.Fatalf("expected open_on_start to be preserved, got %+v", app.config.Browser.DefaultBookmarks) - } - for _, url := range []string{"https://ippure.com/", "https://iplark.com/", "https://ping0.cc/"} { - if countBookmarkItemsByURL(app.config.Browser.DefaultBookmarks, url) != 1 { - t.Fatalf("expected saved verification bookmark %s exactly once, got %+v", url, app.config.Browser.DefaultBookmarks) - } - } -} - -func TestBrowserDefaultStartURLsIncludesOpenOnStartBookmarks(t *testing.T) { - t.Parallel() - - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.DefaultStartURLs = []string{"https://home.example/"} - cfg.Browser.DefaultBookmarks = []config.BrowserBookmark{ - {Name: "启动打开", URL: "https://open.example/", OpenOnStart: true}, - {Name: "普通书签", URL: "https://closed.example/"}, - {Name: "重复启动页", URL: "https://home.example/", OpenOnStart: true}, - } - - app := NewApp(appRoot) - app.config = cfg - app.browserMgr = internalbrowser.NewManager(cfg, appRoot) - - got := app.browserDefaultStartURLs() - want := []string{"https://home.example/", "https://open.example/"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("default start urls mismatch: got=%v want=%v", got, want) - } -} - -func countBookmarkURLInRoot(root map[string]interface{}, url string) int { - count := 0 - roots, ok := root["roots"].(map[string]interface{}) - if !ok { - return count - } - for _, item := range roots { - folder, ok := item.(map[string]interface{}) - if !ok { - continue - } - if children, ok := folder["children"].([]interface{}); ok { - count += countBookmarkURLInNodes(children, url) - } - } - return count -} - -func countBookmarkURLInNodes(nodes []interface{}, url string) int { - count := 0 - for _, item := range nodes { - node, ok := item.(map[string]interface{}) - if !ok { - continue - } - if node["type"] == "url" && node["url"] == url { - count++ - } - if children, ok := node["children"].([]interface{}); ok { - count += countBookmarkURLInNodes(children, url) - } - } - return count -} - -func findBookmarkItemByURL(items []config.BrowserBookmark, url string) (config.BrowserBookmark, bool) { - for _, item := range items { - if item.URL == url { - return item, true - } - } - return config.BrowserBookmark{}, false -} - -func countBookmarkItemsByURL(items []config.BrowserBookmark, url string) int { - count := 0 - for _, item := range items { - if item.URL == url { - count++ - } - } - return count -} diff --git a/backend/app_browser_settings_test.go b/backend/app_browser_settings_test.go deleted file mode 100644 index 2edf4bb2..00000000 --- a/backend/app_browser_settings_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/config" - "testing" -) - -func TestBrowserStartTimingSettingsUsesDefaultsWhenUnset(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.StartReadyTimeoutMs = 0 - cfg.Browser.StartStableWindowMs = -1 - - readyMs := browserStartReadyTimeoutMillis(cfg) - stableMs := browserStartStableWindowMillis(cfg) - - if readyMs != 3000 { - t.Fatalf("expected default ready timeout 3000ms, got %d", readyMs) - } - if stableMs != 1200 { - t.Fatalf("expected default stable window 1200ms, got %d", stableMs) - } -} - -func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Browser.StartReadyTimeoutMs = 15000 - app.config.Browser.StartStableWindowMs = 2400 - - if err := app.SaveBrowserSettings(BrowserSettings{ - 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) - } - - if app.config.Browser.StartReadyTimeoutMs != 15000 { - t.Fatalf("expected ready timeout to be preserved, got %d", app.config.Browser.StartReadyTimeoutMs) - } - if app.config.Browser.StartStableWindowMs != 2400 { - t.Fatalf("expected stable window to be preserved, got %d", app.config.Browser.StartStableWindowMs) - } - 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) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - - if err := app.SaveBrowserSettings(BrowserSettings{ - UserDataRoot: app.config.Browser.UserDataRoot, - DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), - DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), - DefaultStartURLs: []string{}, - LightStartEnabled: false, - RestoreLastSession: true, - StartReadyTimeoutMs: 18000, - StartStableWindowMs: 3000, - }); err != nil { - t.Fatalf("SaveBrowserSettings returned error: %v", err) - } - - if app.config.Browser.StartReadyTimeoutMs != 18000 { - t.Fatalf("expected ready timeout 18000ms, got %d", app.config.Browser.StartReadyTimeoutMs) - } - if app.config.Browser.StartStableWindowMs != 3000 { - t.Fatalf("expected stable window 3000ms, got %d", app.config.Browser.StartStableWindowMs) - } - 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") - } -} diff --git a/backend/app_close_behavior_test.go b/backend/app_close_behavior_test.go deleted file mode 100644 index c920e74d..00000000 --- a/backend/app_close_behavior_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "context" - goruntime "runtime" - "testing" -) - -func TestPlatformSupportsTrayCloseFlowForOS(t *testing.T) { - if !platformSupportsTrayCloseFlowForOS("windows") { - t.Fatal("expected Windows to keep tray close flow enabled") - } - if platformSupportsTrayCloseFlowForOS("linux") { - t.Fatal("expected Linux to skip tray close flow") - } -} - -func TestShouldBlockClose_NonWindowsDoesNotIntercept(t *testing.T) { - if goruntime.GOOS == "windows" { - t.Skip("Windows keeps the tray-based close confirmation flow") - } - - app := NewApp("") - if ShouldBlockClose(app, context.Background()) { - t.Fatal("expected non-Windows close to proceed without interception") - } -} - -func TestQuitAppOnlyKeepsTrackedBrowsers(t *testing.T) { - app := NewApp("") - app.browserMgr = browser.NewManager(config.DefaultConfig(), "") - app.browserMgr.Profiles = map[string]*BrowserProfile{ - "profile-1": { - ProfileId: "profile-1", - Running: true, - }, - } - app.browserMgr.BrowserProcesses["profile-1"] = nil - - app.QuitAppOnly() - - if !app.forceQuit { - t.Fatal("expected QuitAppOnly to set forceQuit") - } - if app.quitMode != quitModeAppOnly { - t.Fatalf("expected quitModeAppOnly, got %v", app.quitMode) - } - if app.shouldStopRuntimeServicesOnShutdown() { - t.Fatal("expected app-only quit to skip runtime service shutdown") - } - if _, ok := app.browserMgr.BrowserProcesses["profile-1"]; !ok { - t.Fatal("expected tracked browser to remain untouched before process shutdown") - } - if !app.browserMgr.Profiles["profile-1"].Running { - t.Fatal("expected app-only quit to keep running profile state intact") - } -} - -func TestForceQuitStopsTrackedBrowsers(t *testing.T) { - app := NewApp("") - app.browserMgr = browser.NewManager(config.DefaultConfig(), "") - app.browserMgr.Profiles = map[string]*BrowserProfile{ - "profile-1": { - ProfileId: "profile-1", - Running: true, - }, - } - app.browserMgr.BrowserProcesses["profile-1"] = nil - - app.ForceQuit() - - if !app.forceQuit { - t.Fatal("expected ForceQuit to set forceQuit") - } - if app.quitMode != quitModeFull { - t.Fatalf("expected quitModeFull, got %v", app.quitMode) - } - if !app.shouldStopRuntimeServicesOnShutdown() { - t.Fatal("expected full quit to stop runtime services") - } - if _, ok := app.browserMgr.BrowserProcesses["profile-1"]; ok { - t.Fatal("expected ForceQuit to clear tracked browser processes") - } - if app.browserMgr.Profiles["profile-1"].Running { - t.Fatal("expected ForceQuit to mark the profile as stopped") - } -} diff --git a/backend/app_proxy_core_download_test.go b/backend/app_proxy_core_download_test.go deleted file mode 100644 index e4b2beab..00000000 --- a/backend/app_proxy_core_download_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/config" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestWriteArchiveFileRejectsParentDirectoryEntry(t *testing.T) { - targetDir := t.TempDir() - err := writeArchiveFile(targetDir, "..", 0o644, false, func() (io.ReadCloser, error) { - return io.NopCloser(strings.NewReader("bad")), nil - }) - if err == nil { - t.Fatalf("expected parent directory archive entry to be rejected") - } -} - -func TestSelectProxyCoreAssetPrefersMihomoCompatibleWindows(t *testing.T) { - spec, err := normalizeProxyCoreSpec("mihomo") - if err != nil { - t.Fatalf("normalizeProxyCoreSpec returned error: %v", err) - } - asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{ - {Name: "mihomo-windows-amd64-v1-v1.19.27.zip"}, - {Name: "mihomo-windows-amd64-compatible-v1.19.27.zip"}, - }, "windows", "amd64") - if err != nil { - t.Fatalf("selectProxyCoreAsset returned error: %v", err) - } - if asset.Name != "mihomo-windows-amd64-compatible-v1.19.27.zip" { - t.Fatalf("unexpected asset: %s", asset.Name) - } -} - -func TestSelectProxyCoreAssetSupportsMihomoLinuxGzip(t *testing.T) { - spec, err := normalizeProxyCoreSpec("mihomo") - if err != nil { - t.Fatalf("normalizeProxyCoreSpec returned error: %v", err) - } - asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{ - {Name: "mihomo-linux-amd64-compatible-v1.19.27.gz"}, - {Name: "mihomo-linux-amd64-v1.19.27.gz"}, - }, "linux", "amd64") - if err != nil { - t.Fatalf("selectProxyCoreAsset returned error: %v", err) - } - if asset.Name != "mihomo-linux-amd64-compatible-v1.19.27.gz" { - t.Fatalf("unexpected asset: %s", asset.Name) - } -} - -func TestSelectProxyCoreAssetMatchesXrayLinux64(t *testing.T) { - spec, err := normalizeProxyCoreSpec("xray") - if err != nil { - t.Fatalf("normalizeProxyCoreSpec returned error: %v", err) - } - asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{ - {Name: "Xray-linux-arm64-v8a.zip"}, - {Name: "Xray-linux-64.zip"}, - {Name: "Xray-linux-64.zip.dgst"}, - }, "linux", "amd64") - if err != nil { - t.Fatalf("selectProxyCoreAsset returned error: %v", err) - } - if asset.Name != "Xray-linux-64.zip" { - t.Fatalf("unexpected asset: %s", asset.Name) - } -} - -func TestNormalizeProxyCoreTargetAliases(t *testing.T) { - target, err := normalizeProxyCoreTarget("macos", "x64") - if err != nil { - t.Fatalf("normalizeProxyCoreTarget returned error: %v", err) - } - if target.GOOS != "darwin" || target.GOARCH != "amd64" { - t.Fatalf("unexpected target: %+v", target) - } -} - -func TestNormalizeProxyCoreSpecPinsStableVersions(t *testing.T) { - cases := map[string]string{ - "xray": "v26.3.27", - "mihomo": "v1.19.27", - "sing-box": "v1.13.13", - } - for core, want := range cases { - spec, err := normalizeProxyCoreSpec(core) - if err != nil { - t.Fatalf("normalizeProxyCoreSpec(%q) returned error: %v", core, err) - } - if spec.Version != want { - t.Fatalf("%s version = %q, want %q", core, spec.Version, want) - } - } -} - -func TestProxyCoreReleaseURLUsesPinnedTag(t *testing.T) { - got := proxyCoreReleaseURL("MetaCubeX/mihomo", "v1.19.27") - want := "https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.27" - if got != want { - t.Fatalf("release URL = %q, want %q", got, want) - } -} - -func TestSelectProxyCoreAssetMatchesDarwinAMD64(t *testing.T) { - spec, err := normalizeProxyCoreSpec("sing-box") - if err != nil { - t.Fatalf("normalizeProxyCoreSpec returned error: %v", err) - } - asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{ - {Name: "sing-box-1.13.13-darwin-arm64.tar.gz"}, - {Name: "sing-box-1.13.13-darwin-amd64.tar.gz"}, - }, "darwin", "amd64") - if err != nil { - t.Fatalf("selectProxyCoreAsset returned error: %v", err) - } - if asset.Name != "sing-box-1.13.13-darwin-amd64.tar.gz" { - t.Fatalf("unexpected asset: %s", asset.Name) - } -} - -func TestSelectProxyCoreAssetPrefersGenericSingBoxLinux(t *testing.T) { - spec, err := normalizeProxyCoreSpec("sing-box") - if err != nil { - t.Fatalf("normalizeProxyCoreSpec returned error: %v", err) - } - asset, err := selectProxyCoreAsset(spec, []githubReleaseAsset{ - {Name: "sing-box-1.13.13-linux-amd64-glibc.tar.gz"}, - {Name: "sing-box-1.13.13-linux-amd64-musl.tar.gz"}, - {Name: "sing-box-1.13.13-linux-amd64.tar.gz"}, - }, "linux", "amd64") - if err != nil { - t.Fatalf("selectProxyCoreAsset returned error: %v", err) - } - if asset.Name != "sing-box-1.13.13-linux-amd64.tar.gz" { - t.Fatalf("unexpected asset: %s", asset.Name) - } -} - -func TestProxyCoreStatusFindsRuntimePlatformBin(t *testing.T) { - root := t.TempDir() - spec, err := normalizeProxyCoreSpec("xray") - if err != nil { - t.Fatalf("normalizeProxyCoreSpec returned error: %v", err) - } - target := proxyCoreTarget{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH} - binaryName := "xray" - if runtime.GOOS == "windows" { - binaryName = "xray.exe" - } - binaryPath := filepath.Join(root, "bin", runtime.GOOS+"-"+runtime.GOARCH, binaryName) - if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil { - t.Fatalf("MkdirAll returned error: %v", err) - } - if err := os.WriteFile(binaryPath, []byte("test"), 0o755); err != nil { - t.Fatalf("WriteFile returned error: %v", err) - } - - app := &App{ - appRoot: root, - config: &config.Config{}, - } - app.config.Browser.DefaultConnectorType = "xray" - - status := app.proxyCoreStatus(spec, target) - if !status.Installed || !status.Active { - t.Fatalf("status = %+v, want installed active", status) - } - if status.Message != "已启用" { - t.Fatalf("message = %q, want 已启用", status.Message) - } - if status.BinaryPath == "" { - t.Fatalf("expected binary path, got empty") - } -} - -func TestFindProxyCoreBinaryMatchesVersionedMihomoWindowsExe(t *testing.T) { - dir := t.TempDir() - binaryPath := filepath.Join(dir, "mihomo-windows-amd64-compatible.exe") - if err := os.WriteFile(binaryPath, []byte("test"), 0o755); err != nil { - t.Fatalf("WriteFile returned error: %v", err) - } - - got, err := findProxyCoreBinary(dir, "mihomo", "windows") - if err != nil { - t.Fatalf("findProxyCoreBinary returned error: %v", err) - } - if got != binaryPath { - t.Fatalf("binary path = %q, want %q", got, binaryPath) - } -} - -func TestNormalizeInstalledProxyCoreBinaryRenamesMihomoExe(t *testing.T) { - dir := t.TempDir() - binaryPath := filepath.Join(dir, "mihomo-windows-amd64-compatible.exe") - if err := os.WriteFile(binaryPath, []byte("test"), 0o755); err != nil { - t.Fatalf("WriteFile returned error: %v", err) - } - - got, err := normalizeInstalledProxyCoreBinary(binaryPath, dir, "mihomo", "windows") - if err != nil { - t.Fatalf("normalizeInstalledProxyCoreBinary returned error: %v", err) - } - want := filepath.Join(dir, "mihomo.exe") - if got != want { - t.Fatalf("normalized path = %q, want %q", got, want) - } - if _, err := os.Stat(want); err != nil { - t.Fatalf("expected normalized binary to exist: %v", err) - } -} diff --git a/backend/app_proxy_diagnostics_test.go b/backend/app_proxy_diagnostics_test.go deleted file mode 100644 index a8f01ecb..00000000 --- a/backend/app_proxy_diagnostics_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package backend - -import ( - "testing" - "time" -) - -func TestBuildProxyBrowserProbeConfigLimitsConcurrency(t *testing.T) { - cfg := buildProxyBrowserProbeConfig(ProxyBrowserProbeRequest{ - URLs: []string{"https://example.com/a", " ", "https://example.com/b"}, - Concurrency: 99, - TimeoutMs: 1234, - }) - if cfg.Concurrency != 16 { - t.Fatalf("concurrency = %d, want 16", cfg.Concurrency) - } - if cfg.Timeout != 1234*time.Millisecond { - t.Fatalf("timeout = %v, want 1234ms", cfg.Timeout) - } - if len(cfg.URLs) != 2 || cfg.URLs[0] != "https://example.com/a" || cfg.URLs[1] != "https://example.com/b" { - t.Fatalf("urls = %#v, want trimmed request urls", cfg.URLs) - } -} - -func TestBuildProxyBrowserProbeConfigUsesDefaults(t *testing.T) { - cfg := buildProxyBrowserProbeConfig(ProxyBrowserProbeRequest{}) - if cfg.Concurrency <= 0 { - t.Fatalf("expected default concurrency, got %d", cfg.Concurrency) - } - if cfg.Timeout <= 0 { - t.Fatalf("expected default timeout, got %v", cfg.Timeout) - } - if len(cfg.URLs) == 0 { - t.Fatalf("expected default urls") - } -} diff --git a/backend/app_proxy_health_test.go b/backend/app_proxy_health_test.go deleted file mode 100644 index b8fb508e..00000000 --- a/backend/app_proxy_health_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "bufio" - "errors" - "fmt" - "net" - "strings" - "sync/atomic" - "testing" - "time" -) - -func TestBuildProxyIPHealthResultPreservesErrorSourceMetadata(t *testing.T) { - result := buildProxyIPHealthResult("proxy-1", map[string]interface{}{ - "_source": "trace", - "_targetUrl": "https://example.invalid/trace", - "_parser": "cloudflare_trace", - }, errors.New("request failed")) - - if result.Source != "trace" { - t.Fatalf("source = %q, want trace", result.Source) - } - if result.Error != "request failed" { - t.Fatalf("error = %q, want request failed", result.Error) - } - rawError, _ := result.RawData["error"].(string) - if rawError != "request failed" { - t.Fatalf("raw error = %q, want request failed", rawError) - } - if got, _ := result.RawData["_targetUrl"].(string); got != "https://example.invalid/trace" { - t.Fatalf("target url = %q, want trace url", got) - } -} - -func TestProxySpeedWithConnectorHonorsXrayConnector(t *testing.T) { - var requests atomic.Int32 - proxyURL, closeProxy := startBackendDelayedHTTPProxy(t, 10*time.Millisecond, &requests) - t.Cleanup(closeProxy) - - cfg := config.DefaultConfig() - app := NewApp(t.TempDir()) - app.config = cfg - app.browserMgr = browser.NewManager(cfg, t.TempDir()) - - result := app.testProxySpeedWithConnector( - "proxy-1", - []BrowserProxy{{ProxyId: "proxy-1", ProxyConfig: proxyURL}}, - config.BrowserConnectorXray, - ) - if !result.Ok { - t.Fatalf("testProxySpeedWithConnector failed: %+v", result) - } - if result.Engine != "native" { - t.Fatalf("engine = %q, want native", result.Engine) - } - if requests.Load() != 2 { - t.Fatalf("requests = %d, want unified-delay HTTP request pair", requests.Load()) - } -} - -func TestProxySpeedBatchConcurrencyDefaultsAreConservative(t *testing.T) { - if defaultProxySpeedConcurrency != 5 { - t.Fatalf("defaultProxySpeedConcurrency = %d, want 5", defaultProxySpeedConcurrency) - } - if maxProxySpeedConcurrency != 10 { - t.Fatalf("maxProxySpeedConcurrency = %d, want 10", maxProxySpeedConcurrency) - } -} - -func TestProxySpeedWithXrayUsesSingBoxProtocolPath(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - app := NewApp(t.TempDir()) - app.config = cfg - app.browserMgr = browser.NewManager(cfg, t.TempDir()) - - result := app.testProxySpeedWithConnector( - "hy2-proxy", - []BrowserProxy{{ProxyId: "hy2-proxy", ProxyConfig: "hysteria2://pass@example.com:443?sni=example.com"}}, - config.BrowserConnectorXray, - ) - if result.Ok { - t.Fatalf("hysteria2 speed test should fail without sing-box manager: %+v", result) - } - if result.Engine != "sing-box" { - t.Fatalf("engine = %q, want sing-box", result.Engine) - } - if !strings.Contains(result.Error, "sing-box 管理器未初始化") { - t.Fatalf("error = %q, want sing-box manager guidance", result.Error) - } -} - -func startBackendDelayedHTTPProxy(t *testing.T, delay time.Duration, requests *atomic.Int32) (string, func()) { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - done := make(chan struct{}) - go func() { - defer close(done) - for { - conn, err := listener.Accept() - if err != nil { - return - } - go handleBackendDelayedHTTPProxyConn(conn, delay, requests) - } - }() - return "http://" + listener.Addr().String(), func() { - _ = listener.Close() - <-done - } -} - -func handleBackendDelayedHTTPProxyConn(conn net.Conn, delay time.Duration, requests *atomic.Int32) { - defer conn.Close() - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - return - } - for { - header, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(header) == "" { - break - } - } - if strings.HasPrefix(line, "HEAD ") { - requests.Add(1) - time.Sleep(delay) - _, _ = fmt.Fprint(conn, "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") - } -} diff --git a/backend/app_proxy_import_test.go b/backend/app_proxy_import_test.go deleted file mode 100644 index 5ca4eb87..00000000 --- a/backend/app_proxy_import_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package backend - -import ( - "encoding/base64" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -const testClashSubscriptionYAML = ` -proxies: - - name: test-node - type: http - server: example.com - port: 8080 -` - -func TestBrowserProxyFetchClashByURLFallbackAfterHTTPStatus(t *testing.T) { - var seenUserAgents []string - var seenAccept string - var seenCacheControl string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seenUserAgents = append(seenUserAgents, r.Header.Get("User-Agent")) - if len(seenUserAgents) == 1 { - seenAccept = r.Header.Get("Accept") - seenCacheControl = r.Header.Get("Cache-Control") - http.Error(w, "forbidden", http.StatusForbidden) - return - } - fmt.Fprint(w, testClashSubscriptionYAML) - })) - defer server.Close() - - result, err := (&App{}).BrowserProxyFetchClashByURL(server.URL + "/sub?token=test-token") - if err != nil { - t.Fatalf("BrowserProxyFetchClashByURL returned error: %v", err) - } - if got := result["proxyCount"]; got != 1 { - t.Fatalf("proxyCount = %v, want 1", got) - } - if len(seenUserAgents) != 2 { - t.Fatalf("request count = %d, want 2", len(seenUserAgents)) - } - if seenUserAgents[0] != clashSubscriptionUserAgents[0] { - t.Fatalf("first User-Agent = %q, want %q", seenUserAgents[0], clashSubscriptionUserAgents[0]) - } - if seenUserAgents[1] != clashSubscriptionUserAgents[1] { - t.Fatalf("second User-Agent = %q, want %q", seenUserAgents[1], clashSubscriptionUserAgents[1]) - } - if seenAccept != "application/yaml,text/yaml,text/plain,*/*" { - t.Fatalf("Accept = %q", seenAccept) - } - if seenCacheControl != "no-cache" { - t.Fatalf("Cache-Control = %q", seenCacheControl) - } -} - -func TestBrowserProxyFetchClashByURLFallbackAfterHTMLContent(t *testing.T) { - var requestCount int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - if requestCount == 1 { - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, "client not supported") - return - } - fmt.Fprint(w, testClashSubscriptionYAML) - })) - defer server.Close() - - result, err := (&App{}).BrowserProxyFetchClashByURL(server.URL) - if err != nil { - t.Fatalf("BrowserProxyFetchClashByURL returned error: %v", err) - } - if got := result["proxyCount"]; got != 1 { - t.Fatalf("proxyCount = %v, want 1", got) - } - if requestCount != 2 { - t.Fatalf("request count = %d, want 2", requestCount) - } -} - -func TestBrowserProxyFetchClashByURLAllFallbackErrorsHideURL(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "forbidden", http.StatusForbidden) - })) - defer server.Close() - - rawURL := server.URL + "/sub/path?token=secret-token" - _, err := (&App{}).BrowserProxyFetchClashByURL(rawURL) - if err == nil { - t.Fatal("BrowserProxyFetchClashByURL returned nil error, want failure") - } - errText := err.Error() - for _, forbidden := range []string{rawURL, "secret-token", "token=", "/sub/path"} { - if strings.Contains(errText, forbidden) { - t.Fatalf("error %q leaked %q", errText, forbidden) - } - } -} - -func TestNormalizeClashSubscriptionContentSupportsBase64URIList(t *testing.T) { - raw := "anytls://secret@example.com:443?sni=sni.example.com&insecure=1#AnyTLS%20Node\n" + - "trojan://pass@trojan.example.com:8443?sni=trojan-sni.example.com#Trojan%20Node" - encoded := base64.StdEncoding.EncodeToString([]byte(raw)) - content, payload, err := normalizeClashSubscriptionContent([]byte(encoded)) - if err != nil { - t.Fatalf("normalizeClashSubscriptionContent returned error: %v", err) - } - if count := clashProxyCount(payload); count != 2 { - t.Fatalf("proxy count = %d, want 2", count) - } - if !strings.Contains(content, "type: anytls") { - t.Fatalf("content does not contain anytls node: %s", content) - } - if !strings.Contains(content, "type: trojan") { - t.Fatalf("content does not contain trojan node: %s", content) - } -} diff --git a/backend/app_proxy_location_test.go b/backend/app_proxy_location_test.go deleted file mode 100644 index 83e20248..00000000 --- a/backend/app_proxy_location_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package backend - -import "testing" - -func TestResolveProxyLocationOptionUsesCityTimezone(t *testing.T) { - option := resolveProxyLocationOption("US", "Los Angeles") - if option.Timezone != "America/Los_Angeles" { - t.Fatalf("timezone = %q, want America/Los_Angeles", option.Timezone) - } - if option.Lang != "en-US" { - t.Fatalf("lang = %q, want en-US", option.Lang) - } -} - -func TestResolveProxyLocationOptionNormalizesCountryName(t *testing.T) { - option := resolveProxyLocationOption("Japan", "Tokyo") - if option.Timezone != "Asia/Tokyo" { - t.Fatalf("timezone = %q, want Asia/Tokyo", option.Timezone) - } - if option.Lang != "ja-JP" { - t.Fatalf("lang = %q, want ja-JP", option.Lang) - } -} - -func TestBuildProxyLocationResolveResultUnknownCountryFallsBackToManual(t *testing.T) { - result := buildProxyLocationResolveResult("proxy-1", ProxyIPHealthResult{ - ProxyId: "proxy-1", - Ok: true, - Country: "Unknownland", - City: "Nowhere", - }, "cache", "2026-06-09T00:00:00Z") - if result.Ok { - t.Fatalf("expected unknown country to fail automatic resolution") - } - if len(result.Alternates) == 0 { - t.Fatalf("expected manual alternates") - } -} diff --git a/backend/app_proxy_warmup_test.go b/backend/app_proxy_warmup_test.go deleted file mode 100644 index 713360f0..00000000 --- a/backend/app_proxy_warmup_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/config" - "testing" -) - -func TestWarmupProxyBridgeDirectProxy(t *testing.T) { - t.Parallel() - - app := &App{} - result := app.warmupProxyBridge("direct", "", []BrowserProxy{{ProxyId: "direct", ProxyConfig: "direct://"}}) - if !result.Ok { - t.Fatalf("direct warmup failed: %s", result.Error) - } - if result.Engine != "direct" { - t.Fatalf("engine = %q, want direct", result.Engine) - } - if result.SocksURL != "" { - t.Fatalf("direct warmup socks url = %q, want empty", result.SocksURL) - } -} - -func TestWarmupProxyBridgeStandardProxyDoesNotRequireBridge(t *testing.T) { - t.Parallel() - - app := &App{} - result := app.warmupProxyBridge("http", "", []BrowserProxy{{ProxyId: "http", ProxyConfig: "http://127.0.0.1:8080"}}) - if !result.Ok { - t.Fatalf("standard proxy warmup failed: %s", result.Error) - } - if result.Engine != "native" { - t.Fatalf("engine = %q, want native", result.Engine) - } -} - -func TestWarmupProxyBridgeMissingProxyConfig(t *testing.T) { - t.Parallel() - - app := &App{} - result := app.warmupProxyBridge("missing", "", []config.BrowserProxy{{ProxyId: "other", ProxyConfig: "direct://"}}) - if result.Ok { - t.Fatalf("missing proxy config unexpectedly succeeded") - } - if result.Error == "" { - t.Fatalf("missing proxy config should return an error") - } -} - -func TestResolveProxyConfigForApp(t *testing.T) { - t.Parallel() - - proxies := []BrowserProxy{{ProxyId: "p1", ProxyConfig: "direct://"}} - if got := resolveProxyConfigForApp("", proxies, "p1"); got != "direct://" { - t.Fatalf("resolveProxyConfigForApp() = %q", got) - } - if got := resolveProxyConfigForApp("http://127.0.0.1:8080", proxies, "missing"); got != "http://127.0.0.1:8080" { - t.Fatalf("fallback config = %q", got) - } -} diff --git a/backend/app_reload_test.go b/backend/app_reload_test.go deleted file mode 100644 index ca223973..00000000 --- a/backend/app_reload_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package backend - -import ( - "ant-chrome/backend/internal/config" - "path/filepath" - "testing" -) - -func TestReloadConfigLoadsFromDisk(t *testing.T) { - root := t.TempDir() - - cfg := config.DefaultConfig() - cfg.App.Name = "Reload-Test-App" - if err := cfg.Save(filepath.Join(root, "config.yaml")); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - app := NewApp(root) - app.config = config.DefaultConfig() - - if err := app.ReloadConfig(); err != nil { - t.Fatalf("ReloadConfig 失败: %v", err) - } - - if app.config == nil { - t.Fatalf("ReloadConfig 后 config 为空") - } - if app.config.App.Name != "Reload-Test-App" { - t.Fatalf("ReloadConfig 未生效,got=%q", app.config.App.Name) - } -} - -func TestReloadConfigKeepsLocalLicenseState(t *testing.T) { - root := t.TempDir() - - cfg := config.DefaultConfig() - if err := cfg.Save(filepath.Join(root, "config.yaml")); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - if err := saveLocalLicenseState(filepath.Join(root, "config.yaml"), &localLicenseState{ - MaxProfileLimit: config.GithubStarProfileTotal + config.StandardCDKeyProfileBonus, - UsedCDKeys: []string{"ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE", "GITHUB_STAR_REWARD"}, - }); err != nil { - t.Fatalf("写入本机额度状态失败: %v", err) - } - - app := NewApp(root) - app.config = config.DefaultConfig() - - if err := app.ReloadConfig(); err != nil { - t.Fatalf("ReloadConfig 失败: %v", err) - } - - if app.config.App.MaxProfileLimit != config.GithubStarProfileTotal+config.StandardCDKeyProfileBonus { - t.Fatalf("ReloadConfig 未恢复本机额度状态: got=%d", app.config.App.MaxProfileLimit) - } - if len(app.config.App.UsedCDKeys) != 2 { - t.Fatalf("ReloadConfig 未恢复兑换记录: %+v", app.config.App.UsedCDKeys) - } -} diff --git a/backend/automation_api_test.go b/backend/automation_api_test.go deleted file mode 100644 index e8e08672..00000000 --- a/backend/automation_api_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package backend - -import ( - "path/filepath" - "testing" - - "ant-chrome/backend/internal/config" -) - -func TestSaveAutomationRuntimeSettingsNormalizesAndPersists(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - - state, err := app.SaveAutomationRuntimeSettings(" SYSTEM ", " C:/tools/node/node.exe ") - if err != nil { - t.Fatalf("SaveAutomationRuntimeSettings returned error: %v", err) - } - - if app.config.Automation.NodeSource != config.AutomationNodeSourceSystem { - t.Fatalf("expected node source %q, got %q", config.AutomationNodeSourceSystem, app.config.Automation.NodeSource) - } - if app.config.Automation.SystemNodePath != "C:/tools/node/node.exe" { - t.Fatalf("expected trimmed system node path, got %q", app.config.Automation.SystemNodePath) - } - - settings, ok := state["settings"].(map[string]interface{}) - if !ok { - t.Fatalf("state.settings should be a map, got %T", state["settings"]) - } - if settings["nodeSource"] != config.AutomationNodeSourceSystem { - t.Fatalf("expected settings.nodeSource %q, got %#v", config.AutomationNodeSourceSystem, settings["nodeSource"]) - } - if settings["systemNodePath"] != "C:/tools/node/node.exe" { - t.Fatalf("expected settings.systemNodePath to be trimmed, got %#v", settings["systemNodePath"]) - } - - loaded, err := LoadConfig(filepath.Join(app.appRoot, "config.yaml")) - if err != nil { - t.Fatalf("LoadConfig returned error: %v", err) - } - if loaded.Automation.NodeSource != config.AutomationNodeSourceSystem { - t.Fatalf("expected persisted node source %q, got %q", config.AutomationNodeSourceSystem, loaded.Automation.NodeSource) - } - if loaded.Automation.SystemNodePath != "C:/tools/node/node.exe" { - t.Fatalf("expected persisted system node path, got %q", loaded.Automation.SystemNodePath) - } -} - -func TestSaveAutomationRuntimeSettingsFallsBackToAutoForUnknownSource(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - - if _, err := app.SaveAutomationRuntimeSettings("custom-source", ""); err != nil { - t.Fatalf("SaveAutomationRuntimeSettings returned error: %v", err) - } - - if app.config.Automation.NodeSource != config.AutomationNodeSourceAuto { - t.Fatalf("expected unknown source to normalize to %q, got %q", config.AutomationNodeSourceAuto, app.config.Automation.NodeSource) - } -} - -func TestSaveAutomationSettingsPreservesRuntimeStrategy(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.NodeSource = config.AutomationNodeSourceSystem - app.config.Automation.SystemNodePath = "C:/tools/node/node.exe" - - if _, err := app.SaveAutomationSettings(true, true); err != nil { - t.Fatalf("SaveAutomationSettings returned error: %v", err) - } - - if app.config.Automation.NodeSource != config.AutomationNodeSourceSystem { - t.Fatalf("expected node source to be preserved, got %q", app.config.Automation.NodeSource) - } - if app.config.Automation.SystemNodePath != "C:/tools/node/node.exe" { - t.Fatalf("expected system node path to be preserved, got %q", app.config.Automation.SystemNodePath) - } -} - -func TestSaveAutomationScriptPackageSettingsPersists(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - - state, err := app.SaveAutomationScriptPackageSettings(true) - if err != nil { - t.Fatalf("SaveAutomationScriptPackageSettings returned error: %v", err) - } - - if !app.config.Automation.AllowTypeScriptBuild { - t.Fatalf("expected allowTypeScriptBuild to be enabled in memory") - } - - settings, ok := state["settings"].(map[string]interface{}) - if !ok { - t.Fatalf("state.settings should be a map, got %T", state["settings"]) - } - if settings["allowTypeScriptBuild"] != true { - t.Fatalf("expected settings.allowTypeScriptBuild true, got %#v", settings["allowTypeScriptBuild"]) - } - - loaded, err := LoadConfig(filepath.Join(app.appRoot, "config.yaml")) - if err != nil { - t.Fatalf("LoadConfig returned error: %v", err) - } - if !loaded.Automation.AllowTypeScriptBuild { - t.Fatalf("expected persisted allowTypeScriptBuild to be true") - } -} diff --git a/backend/automation_demo_api_test.go b/backend/automation_demo_api_test.go deleted file mode 100644 index 3100280f..00000000 --- a/backend/automation_demo_api_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package backend - -import ( - "bytes" - "context" - "net/http" - "regexp" - "testing" - "time" -) - -func TestAutomationDemoLaunchCodeFormat(t *testing.T) { - code := automationDemoLaunchCode() - if matched := regexp.MustCompile(`^DEMO_[A-Z0-9]{6}$`).MatchString(code); !matched { - t.Fatalf("expected demo launch code to match DEMO_[A-Z0-9]{6}, got %q", code) - } -} - -func TestNewAutomationDemoPayloadUsesRequestedCode(t *testing.T) { - app := &App{} - payload := app.newAutomationDemoPayload(http.MethodPost, automationDemoProfilesPath, http.StatusCreated, map[string]interface{}{ - "ok": true, - "profileId": "profile-1", - }, automationDemoResultOptions{ - RequestedCode: "DEMO_ABC123", - }) - - if payload["ok"] != true { - t.Fatalf("expected ok=true, got %#v", payload["ok"]) - } - if payload["launchCode"] != "DEMO_ABC123" { - t.Fatalf("expected launchCode to fall back to requested code, got %#v", payload["launchCode"]) - } - if payload["profileId"] != "profile-1" { - t.Fatalf("expected profileId to be propagated, got %#v", payload["profileId"]) - } -} - -func TestBuildAutomationDemoCreateRequestUsesOptions(t *testing.T) { - requestedCode, payload := buildAutomationDemoCreateRequest(automationDemoCreateOptions{ - ProfileName: "我的演示实例", - LaunchCode: " demo_custom ", - StartURL: " https://example.com/order ", - LaunchArgs: []string{" --lang=en-US ", "", "--window-size=1280,800"}, - SkipDefaultStartURLs: true, - AutoLaunch: true, - }) - - if requestedCode != "DEMO_CUSTOM" { - t.Fatalf("expected requested code to be normalized, got %q", requestedCode) - } - - profile, ok := payload["profile"].(map[string]interface{}) - if !ok { - t.Fatalf("expected profile payload, got %#v", payload["profile"]) - } - if profile["profileName"] != "我的演示实例" { - t.Fatalf("expected custom profile name, got %#v", profile["profileName"]) - } - launchArgs, ok := profile["launchArgs"].([]string) - if !ok { - t.Fatalf("expected launchArgs to be []string, got %#v", profile["launchArgs"]) - } - if len(launchArgs) != 2 || launchArgs[0] != "--lang=en-US" || launchArgs[1] != "--window-size=1280,800" { - t.Fatalf("expected launchArgs to be normalized, got %#v", launchArgs) - } - - start, ok := payload["start"].(map[string]interface{}) - if !ok { - t.Fatalf("expected start payload, got %#v", payload["start"]) - } - startURLs, ok := start["startUrls"].([]string) - if !ok || len(startURLs) != 1 || startURLs[0] != "https://example.com/order" { - t.Fatalf("expected startUrls to be normalized, got %#v", start["startUrls"]) - } - if start["skipDefaultStartUrls"] != true { - t.Fatalf("expected skipDefaultStartUrls=true, got %#v", start["skipDefaultStartUrls"]) - } -} - -func TestDecodeAutomationDemoBodyFallsBackToRawText(t *testing.T) { - payload, err := decodeAutomationDemoBody(bytes.NewBufferString("plain-text-response")) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if payload["rawBody"] != "plain-text-response" { - 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) - } -} diff --git a/backend/automation_script_api_helpers_test.go b/backend/automation_script_api_helpers_test.go deleted file mode 100644 index da44bc35..00000000 --- a/backend/automation_script_api_helpers_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package backend - -import ( - "archive/zip" - "bytes" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "testing" -) - -func runGitForTest(t *testing.T, workdir string, args ...string) { - t.Helper() - - cmd := exec.Command("git", args...) - cmd.Dir = workdir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) - } -} - -func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byte { - t.Helper() - - var buf bytes.Buffer - writer := zip.NewWriter(&buf) - - paths := make([]string, 0, len(files)) - for relativePath := range files { - paths = append(paths, relativePath) - } - sort.Strings(paths) - - for _, relativePath := range paths { - entry, err := writer.Create(relativePath) - if err != nil { - t.Fatalf("create zip entry failed: %v", err) - } - if _, err := entry.Write([]byte(files[relativePath])); err != nil { - t.Fatalf("write zip entry failed: %v", err) - } - } - - if err := writer.Close(); err != nil { - t.Fatalf("close zip writer failed: %v", err) - } - return buf.Bytes() -} - -func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) { - t.Helper() - - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("create script library package dir failed: %v", err) - } - if strings.TrimSpace(manifest) != "" { - if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil { - t.Fatalf("write script library manifest failed: %v", err) - } - } - if strings.TrimSpace(entry) != "" { - if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil { - t.Fatalf("write script library entry failed: %v", err) - } - } -} diff --git a/backend/automation_script_api_test.go b/backend/automation_script_api_test.go deleted file mode 100644 index fa088a4a..00000000 --- a/backend/automation_script_api_test.go +++ /dev/null @@ -1,472 +0,0 @@ -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) != 4 { - t.Fatalf("expected four 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", - "proton-mail-first-message": "Proton 邮件搜索并读取最新邮件", - "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) != 5 { - t.Fatalf("expected custom script plus four defaults, got %d items", len(items)) - } - - expectedDefaultIDs := []string{ - automation.DualInstanceRuntimeScriptID, - automation.NewsQueryTXTScriptID, - automation.ProtonMailFirstMessageID, - 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) - } -} diff --git a/backend/automation_script_import_test.go b/backend/automation_script_import_test.go deleted file mode 100644 index af8b3db7..00000000 --- a/backend/automation_script_import_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package backend - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "ant-chrome/backend/internal/automation" -) - -func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) { - app := NewApp(t.TempDir()) - - sourceDir := filepath.Join(t.TempDir(), "local-dir-script") - if err := os.MkdirAll(filepath.Join(sourceDir, "scripts", "helpers"), 0o755); err != nil { - t.Fatalf("create local dir source failed: %v", err) - } - if err := os.WriteFile(filepath.Join(sourceDir, "automation.script.json"), []byte(`{ - "name": "本地目录脚本", - "type": "playwright-cdp", - "entryFile": "scripts/index.cjs" -}`), 0o644); err != nil { - t.Fatalf("write local dir manifest failed: %v", err) - } - if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "index.cjs"), []byte("const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()"), 0o644); err != nil { - t.Fatalf("write local dir entry failed: %v", err) - } - if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "helpers", "helper.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'local-dir' })"), 0o644); err != nil { - t.Fatalf("write local dir helper failed: %v", err) - } - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-local-dir", - Name: "旧本地目录脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "local-dir", - URI: sourceDir, - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed == nil { - t.Fatalf("AutomationScriptRefresh returned nil result") - } - if refreshed.ID != saved.ID { - t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) - } - if refreshed.Status != "ready" { - t.Fatalf("expected status to be preserved, got %q", refreshed.Status) - } - if refreshed.EntryFile != "scripts/index.cjs" { - t.Fatalf("expected nested entry file, got %q", refreshed.EntryFile) - } - if !strings.Contains(refreshed.ScriptText, "helper.run()") { - t.Fatalf("expected refreshed script text from local directory, got %q", refreshed.ScriptText) - } -} - -func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) { - app := NewApp(t.TempDir()) - libraryRoot := filepath.Join(t.TempDir(), "script-library") - - firstScriptDir := filepath.Join(libraryRoot, "first-script") - writeAutomationScriptLibraryPackage(t, firstScriptDir, `{ - "name": "脚本一", - "type": "playwright-cdp", - "entryFile": "index.cjs" -}`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })") - - secondScriptDir := filepath.Join(libraryRoot, "second-script") - if err := os.MkdirAll(secondScriptDir, 0o755); err != nil { - t.Fatalf("create second script dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil { - t.Fatalf("write second script entry failed: %v", err) - } - - existing, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "existing-local-library-script", - Name: "旧脚本一", - Type: "launch-api", - Status: "disabled", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "local-dir", - URI: firstScriptDir, - }, - PublicAPI: automation.ScriptPublicAPIConfig{ - Enabled: true, - Path: "library/existing-script", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - result, err := app.importAutomationLocalLibrary(libraryRoot) - if err != nil { - t.Fatalf("importAutomationLocalLibrary returned error: %v", err) - } - if result == nil { - t.Fatalf("importAutomationLocalLibrary returned nil result") - } - if result.Scanned != 2 { - t.Fatalf("expected scanned count 2, got %d", result.Scanned) - } - if len(result.Imported) != 2 { - t.Fatalf("expected two imported scripts, got %d", len(result.Imported)) - } - if len(result.Failed) != 0 { - t.Fatalf("expected no failed imports, got %+v", result.Failed) - } - - updatedFirst, err := app.AutomationScriptGet(existing.ID) - if err != nil { - t.Fatalf("AutomationScriptGet returned error: %v", err) - } - if updatedFirst.Name != "脚本一" { - t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name) - } - if updatedFirst.Status != "disabled" { - t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status) - } - if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir { - t.Fatalf("unexpected updated source: %+v", updatedFirst.Source) - } - if !strings.Contains(updatedFirst.ScriptText, "first-script") { - t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText) - } - if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled { - t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI) - } - - allScripts, err := app.automationScriptStore().List() - if err != nil { - t.Fatalf("List returned error: %v", err) - } - if len(allScripts) != 2 { - t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts)) - } -} - -func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) { - app := NewApp(t.TempDir()) - libraryRoot := filepath.Join(t.TempDir(), "script-library") - - goodDir := filepath.Join(libraryRoot, "good-script") - writeAutomationScriptLibraryPackage(t, goodDir, `{ - "name": "好脚本", - "type": "playwright-cdp", - "entryFile": "index.cjs" -}`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })") - - badDir := filepath.Join(libraryRoot, "bad-script") - writeAutomationScriptLibraryPackage(t, badDir, `{ - "name": "坏脚本", - "type": "playwright-cdp", - "entryFile": "missing.cjs" -}`, "") - - result, err := app.importAutomationLocalLibrary(libraryRoot) - if err != nil { - t.Fatalf("importAutomationLocalLibrary returned error: %v", err) - } - if result == nil { - t.Fatalf("importAutomationLocalLibrary returned nil result") - } - if result.Scanned != 2 { - t.Fatalf("expected scanned count 2, got %d", result.Scanned) - } - if len(result.Imported) != 1 { - t.Fatalf("expected one imported script, got %d", len(result.Imported)) - } - if len(result.Failed) != 1 { - t.Fatalf("expected one failed script, got %+v", result.Failed) - } - if result.Failed[0].Path != badDir { - t.Fatalf("unexpected failed path: %+v", result.Failed[0]) - } - if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") { - t.Fatalf("unexpected failed message: %+v", result.Failed[0]) - } -} diff --git a/backend/automation_script_public_api_invoke_test.go b/backend/automation_script_public_api_invoke_test.go deleted file mode 100644 index 2b94309c..00000000 --- a/backend/automation_script_public_api_invoke_test.go +++ /dev/null @@ -1,118 +0,0 @@ -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) - } -} diff --git a/backend/automation_script_remote_test.go b/backend/automation_script_remote_test.go deleted file mode 100644 index d199c602..00000000 --- a/backend/automation_script_remote_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package backend - -import ( - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "ant-chrome/backend/internal/automation" - "ant-chrome/backend/internal/config" -) - -func TestAutomationScriptRefreshFromRemote(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{ - "manifest": { - "name": "远程刷新脚本", - "description": "来自远程", - "type": "playwright-cdp", - "entryFile": "index.cjs" - }, - "script": "module.exports.run = async () => ({ ok: true, source: 'remote' })" -}`)) - })) - defer server.Close() - - app := NewApp(t.TempDir()) - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-remote", - Name: "旧远程脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "remote-url", - URI: server.URL + "/script.json", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed == nil { - t.Fatalf("AutomationScriptRefresh returned nil result") - } - if refreshed.ID != saved.ID { - t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) - } - if refreshed.Name != "远程刷新脚本" { - t.Fatalf("expected remote manifest name, got %q", refreshed.Name) - } - if refreshed.Status != "ready" { - t.Fatalf("expected status to be preserved, got %q", refreshed.Status) - } - if !strings.Contains(refreshed.ScriptText, "source: 'remote'") { - t.Fatalf("expected refreshed remote script text, got %q", refreshed.ScriptText) - } - if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/script.json" { - t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) - } -} - -func TestLoadAutomationRemoteBundleSupportsZip(t *testing.T) { - app := NewApp(t.TempDir()) - - zipData := buildAutomationZipBytesForTest(t, map[string]string{ - "automation.script.json": `{ - "name": "远程 ZIP", - "type": "playwright-cdp", - "entryFile": "scripts/index.cjs" -}`, - "scripts/index.cjs": "module.exports.run = async () => ({ ok: true, source: 'remote-zip' })", - }) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(zipData) - })) - defer server.Close() - - bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo.zip") - if err != nil { - t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) - } - - if bundle.Record.Name != "远程 ZIP" { - t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) - } - if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo.zip" { - t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) - } - if !strings.Contains(bundle.Record.ScriptText, "remote-zip") { - t.Fatalf("unexpected script text: %s", bundle.Record.ScriptText) - } -} - -func TestLoadAutomationRemoteBundleBuildsTypeScriptWhenEnabled(t *testing.T) { - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.AllowTypeScriptBuild = true - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = w.Write([]byte(`export async function run() { - return { ok: true, source: 'remote-ts' } -}`)) - })) - defer server.Close() - - bundle, err := app.loadAutomationRemoteBundle(server.URL + "/demo-script.ts") - if err != nil { - t.Fatalf("loadAutomationRemoteBundle returned error: %v", err) - } - - if bundle.Record.EntryFile != "demo-script.cjs" { - t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) - } - if !strings.Contains(bundle.Record.ScriptText, "remote-ts") { - t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) - } - if bundle.Record.Source.Type != "remote-url" || bundle.Record.Source.URI != server.URL+"/demo-script.ts" { - t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) - } -} - -func TestAutomationScriptRefreshFromRemoteTypeScriptWhenEnabled(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`export async function run() { - return { ok: true, source: 'remote-ts-refresh' } -}`)) - })) - defer server.Close() - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.AllowTypeScriptBuild = true - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-remote-ts", - Name: "旧远程 TS 脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "remote-url", - URI: server.URL + "/refresh-script.ts", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed.EntryFile != "refresh-script.cjs" { - t.Fatalf("unexpected refreshed entry file: %s", refreshed.EntryFile) - } - if !strings.Contains(refreshed.ScriptText, "remote-ts-refresh") { - t.Fatalf("unexpected refreshed script text: %s", refreshed.ScriptText) - } - if refreshed.Source.Type != "remote-url" || refreshed.Source.URI != server.URL+"/refresh-script.ts" { - t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) - } -} - -func TestLoadAutomationGitBundleBuildsTypeScriptWhenEnabled(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - - repoDir := filepath.Join(t.TempDir(), "automation-ts-repo") - if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo", "helpers"), 0o755); err != nil { - t.Fatalf("create repo dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ - "name": "Git TS 导入", - "type": "playwright-cdp", - "entryFile": "index.ts" -}`), 0o644); err != nil { - t.Fatalf("write git manifest failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.ts"), []byte(`import { flag } from './helpers/flag' - -export async function run() { - return { ok: flag, source: 'git-ts' } -}`), 0o644); err != nil { - t.Fatalf("write git entry file failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "helpers", "flag.ts"), []byte(`export const flag = true`), 0o644); err != nil { - t.Fatalf("write git helper file failed: %v", err) - } - - runGitForTest(t, repoDir, "init") - runGitForTest(t, repoDir, "config", "user.email", "test@example.com") - runGitForTest(t, repoDir, "config", "user.name", "Test User") - runGitForTest(t, repoDir, "add", ".") - runGitForTest(t, repoDir, "commit", "-m", "init") - - app := NewApp(t.TempDir()) - app.config = config.DefaultConfig() - app.config.Automation.AllowTypeScriptBuild = true - - bundle, err := app.loadAutomationGitBundle(repoDir, "", "scripts/demo") - if err != nil { - t.Fatalf("loadAutomationGitBundle returned error: %v", err) - } - - if bundle.Record.Name != "Git TS 导入" { - t.Fatalf("unexpected bundle name: %s", bundle.Record.Name) - } - if bundle.Record.EntryFile != "index.cjs" { - t.Fatalf("unexpected compiled entry file: %s", bundle.Record.EntryFile) - } - if !strings.Contains(bundle.Record.ScriptText, "git-ts") { - t.Fatalf("unexpected compiled script text: %s", bundle.Record.ScriptText) - } - if bundle.Record.Source.Type != "git" || bundle.Record.Source.URI != repoDir || bundle.Record.Source.Path != "scripts/demo" { - t.Fatalf("unexpected bundle source: %+v", bundle.Record.Source) - } -} - -func TestAutomationScriptRefreshFromGit(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - - repoDir := filepath.Join(t.TempDir(), "automation-repo") - if err := os.MkdirAll(filepath.Join(repoDir, "scripts", "demo"), 0o755); err != nil { - t.Fatalf("create repo dir failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "automation.script.json"), []byte(`{ - "name": "Git 刷新脚本", - "type": "playwright-cdp", - "entryFile": "index.cjs" -}`), 0o644); err != nil { - t.Fatalf("write git manifest failed: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "scripts", "demo", "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'git' })"), 0o644); err != nil { - t.Fatalf("write git entry file failed: %v", err) - } - - runGitForTest(t, repoDir, "init") - runGitForTest(t, repoDir, "config", "user.email", "test@example.com") - runGitForTest(t, repoDir, "config", "user.name", "Test User") - runGitForTest(t, repoDir, "add", ".") - runGitForTest(t, repoDir, "commit", "-m", "init") - - app := NewApp(t.TempDir()) - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-git", - Name: "旧 Git 脚本", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: false })", - Source: automation.ScriptSource{ - Type: "git", - URI: repoDir, - Path: "scripts/demo", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - refreshed, err := app.AutomationScriptRefresh(saved.ID) - if err != nil { - t.Fatalf("AutomationScriptRefresh returned error: %v", err) - } - if refreshed == nil { - t.Fatalf("AutomationScriptRefresh returned nil result") - } - if refreshed.ID != saved.ID { - t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) - } - if refreshed.Name != "Git 刷新脚本" { - t.Fatalf("expected git manifest name, got %q", refreshed.Name) - } - if refreshed.Status != "ready" { - t.Fatalf("expected status to be preserved, got %q", refreshed.Status) - } - if !strings.Contains(refreshed.ScriptText, "source: 'git'") { - t.Fatalf("expected refreshed git script text, got %q", refreshed.ScriptText) - } - if refreshed.Source.Type != "git" || refreshed.Source.URI != repoDir || refreshed.Source.Path != "scripts/demo" { - t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) - } -} - -func TestAutomationScriptRefreshRejectsUnsupportedSource(t *testing.T) { - app := NewApp(t.TempDir()) - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "refresh-manual", - Name: "手动脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: true })", - Source: automation.ScriptSource{ - Type: "manual", - }, - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - if _, err := app.AutomationScriptRefresh(saved.ID); err == nil { - t.Fatalf("expected unsupported source refresh to fail") - } -} diff --git a/backend/automation_script_run_test.go b/backend/automation_script_run_test.go deleted file mode 100644 index 2d6c1efd..00000000 --- a/backend/automation_script_run_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package backend - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "ant-chrome/backend/internal/automation" -) - -func TestPreparePlaywrightScriptWorkspaceCopiesScriptDirectory(t *testing.T) { - app := NewApp(t.TempDir()) - - saved, err := app.AutomationScriptSave(automation.ScriptRecord{ - ID: "workspace-script", - Name: "工作区脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "scripts/index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: true, source: 'workspace' })", - }) - if err != nil { - t.Fatalf("AutomationScriptSave returned error: %v", err) - } - - scriptDir, err := app.automationScriptStore().Dir(saved.ID) - if err != nil { - t.Fatalf("Dir returned error: %v", err) - } - - extraHelperPath := filepath.Join(scriptDir, "scripts", "helpers", "format.cjs") - if err := os.MkdirAll(filepath.Dir(extraHelperPath), 0o755); err != nil { - t.Fatalf("create helper dir failed: %v", err) - } - if err := os.WriteFile(extraHelperPath, []byte("module.exports.format = () => 'helper-ready'"), 0o644); err != nil { - t.Fatalf("write helper file failed: %v", err) - } - - assetPath := filepath.Join(scriptDir, "assets", "seed.txt") - if err := os.MkdirAll(filepath.Dir(assetPath), 0o755); err != nil { - t.Fatalf("create asset dir failed: %v", err) - } - if err := os.WriteFile(assetPath, []byte("seed-ready"), 0o644); err != nil { - t.Fatalf("write asset file failed: %v", err) - } - - runtimeDir := filepath.Join(t.TempDir(), "runtime") - scriptPath, artifactDir, cleanup, err := app.preparePlaywrightScriptWorkspace(runtimeDir, *saved) - if err != nil { - t.Fatalf("preparePlaywrightScriptWorkspace returned error: %v", err) - } - defer cleanup() - - execRoot := workspaceRootFromScriptPath(t, scriptPath, saved.EntryFile) - - assertFileContent(t, scriptPath, saved.ScriptText) - assertFileContent(t, filepath.Join(execRoot, "config"), `"id": "workspace-script"`) - assertFileContent(t, filepath.Join(execRoot, "scripts", "helpers", "format.cjs"), "helper-ready") - assertFileContent(t, filepath.Join(execRoot, "assets", "seed.txt"), "seed-ready") - assertFileContent(t, filepath.Join(execRoot, "node_modules", "playwright", "index.js"), "playwright-core") - assertFileContent(t, filepath.Join(execRoot, "node_modules", "playwright-core", "package.json"), `"name":"playwright-core"`) - - 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) { - app := NewApp(t.TempDir()) - - script := automation.ScriptRecord{ - ID: "orphan-script", - Name: "孤立脚本", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "nested/index.cjs", - ScriptText: "module.exports.run = async () => ({ ok: true, source: 'orphan' })", - } - - runtimeDir := filepath.Join(t.TempDir(), "runtime") - scriptPath, _, cleanup, err := app.preparePlaywrightScriptWorkspace(runtimeDir, script) - if err != nil { - t.Fatalf("preparePlaywrightScriptWorkspace returned error: %v", err) - } - - execRoot := workspaceRootFromScriptPath(t, scriptPath, script.EntryFile) - assertFileContent(t, scriptPath, script.ScriptText) - assertFileContent(t, filepath.Join(execRoot, "node_modules", "playwright", "index.js"), "playwright-core") - - cleanup() - if _, err := os.Stat(execRoot); !os.IsNotExist(err) { - t.Fatalf("expected cleanup to remove execRoot, got %v", err) - } -} - -func workspaceRootFromScriptPath(t *testing.T, scriptPath string, entryFile string) string { - t.Helper() - - entryPath := filepath.FromSlash(entryFile) - if !strings.HasSuffix(scriptPath, entryPath) { - t.Fatalf("script path %q does not end with entry file %q", scriptPath, entryPath) - } - - execRoot := strings.TrimSuffix(scriptPath, entryPath) - return strings.TrimRight(execRoot, `\/`) -} - -func assertFileContent(t *testing.T, path string, expectedSubstring string) { - t.Helper() - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read file %s failed: %v", path, err) - } - if !strings.Contains(string(data), expectedSubstring) { - t.Fatalf("file %s does not contain %q; got %q", path, expectedSubstring, string(data)) - } -} diff --git a/backend/automation_script_target_resolver_test.go b/backend/automation_script_target_resolver_test.go deleted file mode 100644 index 34a84e1d..00000000 --- a/backend/automation_script_target_resolver_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package backend - -import ( - "testing" - - "ant-chrome/backend/internal/automation" - "ant-chrome/backend/internal/browser" - "ant-chrome/backend/internal/config" - "ant-chrome/backend/internal/launchcode" -) - -func newAutomationTargetTestApp(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 createAutomationTargetProfile(t *testing.T, app *App, input browser.ProfileInput) *browser.Profile { - t.Helper() - - profile, err := app.browserMgr.Create(input) - if err != nil { - t.Fatalf("create profile failed: %v", err) - } - if profile == nil { - t.Fatal("create profile returned nil") - } - return profile -} - -func TestResolveAutomationScriptTargetUsesExistingProfile(t *testing.T) { - app := newAutomationTargetTestApp(t) - first := createAutomationTargetProfile(t, app, browser.ProfileInput{ - ProfileName: "buyer-001", - Keywords: []string{"buyer-001"}, - }) - _, err := app.launchCodeSvc.SetCode(first.ProfileId, "BUYER_001") - if err != nil { - t.Fatalf("set code failed: %v", err) - } - - selector, summary, err := app.resolveAutomationScriptTarget(automation.ScriptRecord{ - ID: "script-existing", - Name: "使用已有实例", - TargetConfig: automation.ScriptTargetConfig{ - Mode: "existing", - Selector: automation.ScriptTargetSelector{ - Code: "buyer_001", - }, - }, - }) - if err != nil { - t.Fatalf("resolveAutomationScriptTarget returned error: %v", err) - } - if selector["profileId"] != first.ProfileId { - t.Fatalf("unexpected selector: %+v want profileId=%s", selector, first.ProfileId) - } - if summary == "" { - t.Fatalf("expected target summary to be populated") - } -} - -func TestResolveAutomationScriptTargetPrefersProfileIDWhenStoredCodeIsStale(t *testing.T) { - app := newAutomationTargetTestApp(t) - first := createAutomationTargetProfile(t, app, browser.ProfileInput{ - ProfileName: "buyer-001", - }) - if _, err := app.launchCodeSvc.SetCode(first.ProfileId, "BUYER_001"); err != nil { - t.Fatalf("set initial code failed: %v", err) - } - if _, err := app.launchCodeSvc.SetCode(first.ProfileId, "BUYER_RENAMED"); err != nil { - t.Fatalf("set updated code failed: %v", err) - } - - selector, summary, err := app.resolveAutomationScriptTarget(automation.ScriptRecord{ - ID: "script-existing", - Name: "使用已有实例", - TargetConfig: automation.ScriptTargetConfig{ - Mode: "existing", - Selector: automation.ScriptTargetSelector{ - ProfileID: first.ProfileId, - Code: "BUYER_001", - }, - }, - }) - if err != nil { - t.Fatalf("resolveAutomationScriptTarget returned error: %v", err) - } - if selector["profileId"] != first.ProfileId { - t.Fatalf("unexpected selector: %+v want profileId=%s", selector, first.ProfileId) - } - updatedProfiles := app.browserMgr.List() - if len(updatedProfiles) == 0 { - t.Fatalf("expected profiles to be available after resolve") - } - expectedSummary := "" - for _, item := range updatedProfiles { - if item.ProfileId == first.ProfileId { - expectedSummary = automationProfileLabel(item) - break - } - } - if expectedSummary == "" { - t.Fatalf("expected updated profile summary to be available") - } - if summary != expectedSummary { - t.Fatalf("expected updated target summary %q, got %q", expectedSummary, summary) - } -} - -func TestResolveAutomationScriptTargetCreatesProfileFromTemplate(t *testing.T) { - app := newAutomationTargetTestApp(t) - template := createAutomationTargetProfile(t, app, browser.ProfileInput{ - ProfileName: "template-buyer", - Tags: []string{"template"}, - }) - _, err := app.launchCodeSvc.SetCode(template.ProfileId, "TPL_001") - if err != nil { - t.Fatalf("set code failed: %v", err) - } - - before := app.browserMgr.List() - selector, summary, err := app.resolveAutomationScriptTarget(automation.ScriptRecord{ - ID: "script-create", - Name: "按模板新建", - TargetConfig: automation.ScriptTargetConfig{ - Mode: "create", - TemplateSelector: automation.ScriptTargetSelector{ - Code: "TPL_001", - }, - CreateNameTemplate: "${templateName}-${scriptName}", - }, - }) - if err != nil { - t.Fatalf("resolveAutomationScriptTarget returned error: %v", err) - } - - after := app.browserMgr.List() - if len(after) != len(before)+1 { - t.Fatalf("expected profile count to grow by one: before=%d after=%d", len(before), len(after)) - } - - newProfileID, _ := selector["profileId"].(string) - if newProfileID == "" || newProfileID == template.ProfileId { - t.Fatalf("unexpected created selector: %+v", selector) - } - - var created *browser.Profile - for i := range after { - if after[i].ProfileId == newProfileID { - created = &after[i] - break - } - } - if created == nil { - t.Fatalf("created profile not found in list") - } - if created.ProfileName != "template-buyer-按模板新建" { - t.Fatalf("unexpected created profile name: %q", created.ProfileName) - } - if summary == "" { - t.Fatalf("expected create summary to be populated") - } -} - -func TestResolveAutomationScriptTargetRotatesProfiles(t *testing.T) { - app := newAutomationTargetTestApp(t) - first := createAutomationTargetProfile(t, app, browser.ProfileInput{ - ProfileName: "buyer-a", - Tags: []string{"pool"}, - }) - second := createAutomationTargetProfile(t, app, browser.ProfileInput{ - ProfileName: "buyer-b", - Tags: []string{"pool"}, - }) - - script := automation.ScriptRecord{ - ID: "script-rotate", - Name: "轮询实例", - TargetConfig: automation.ScriptTargetConfig{ - Mode: "rotate", - Selector: automation.ScriptTargetSelector{ - Tags: []string{"pool"}, - }, - }, - } - - firstSelector, _, err := app.resolveAutomationScriptTarget(script) - if err != nil { - t.Fatalf("first resolve returned error: %v", err) - } - secondSelector, _, err := app.resolveAutomationScriptTarget(script) - if err != nil { - t.Fatalf("second resolve returned error: %v", err) - } - thirdSelector, _, err := app.resolveAutomationScriptTarget(script) - if err != nil { - t.Fatalf("third resolve returned error: %v", err) - } - - if firstSelector["profileId"] != first.ProfileId { - t.Fatalf("expected first rotation profile %s, got %+v", first.ProfileId, firstSelector) - } - if secondSelector["profileId"] != second.ProfileId { - t.Fatalf("expected second rotation profile %s, got %+v", second.ProfileId, secondSelector) - } - if thirdSelector["profileId"] != first.ProfileId { - t.Fatalf("expected third rotation profile %s, got %+v", first.ProfileId, thirdSelector) - } -} diff --git a/backend/browser_profile_launchcode_test.go b/backend/browser_profile_launchcode_test.go deleted file mode 100644 index e29f57cb..00000000 --- a/backend/browser_profile_launchcode_test.go +++ /dev/null @@ -1,81 +0,0 @@ -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) - } -} diff --git a/backend/internal/apppath/apppath_test.go b/backend/internal/apppath/apppath_test.go deleted file mode 100644 index ec8a3108..00000000 --- a/backend/internal/apppath/apppath_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package apppath - -import ( - "os" - "path/filepath" - goruntime "runtime" - "strings" - "testing" -) - -func TestResolveReadOnlyLinuxInstallUsesUserDataRoot(t *testing.T) { - if goruntime.GOOS != "linux" { - t.Skip("linux-only path behavior") - } - - xdgDataHome := t.TempDir() - t.Setenv("XDG_DATA_HOME", xdgDataHome) - - installRoot := filepath.Join(t.TempDir(), "opt-app") - if err := os.MkdirAll(filepath.Join(installRoot, "bin"), 0755); err != nil { - t.Fatalf("创建 installRoot 失败: %v", err) - } - if err := os.Chmod(installRoot, 0555); err != nil { - t.Fatalf("设置 installRoot 权限失败: %v", err) - } - t.Cleanup(func() { - _ = os.Chmod(installRoot, 0755) - _ = os.Chmod(filepath.Join(installRoot, "bin"), 0755) - }) - - configPath := resolveForOS(installRoot, "config.yaml", "linux") - binPath := resolveForOS(installRoot, "bin/xray", "linux") - - expectedStateRoot := filepath.Join(xdgDataHome, appStateDirName) - if !strings.HasPrefix(configPath, expectedStateRoot+string(os.PathSeparator)) { - t.Fatalf("config path 应落到用户目录,got=%s want-prefix=%s", configPath, expectedStateRoot) - } - if binPath != filepath.Join(installRoot, "bin", "xray") { - t.Fatalf("bin path 不应迁移到用户目录,got=%s", binPath) - } -} - -func TestResolveDarwinAppBundleUsesApplicationSupportStateRoot(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - - installRoot := filepath.Join(t.TempDir(), "Ant Browser.app", "Contents", "MacOS") - if err := os.MkdirAll(filepath.Join(installRoot, "bin"), 0755); err != nil { - t.Fatalf("创建 installRoot 失败: %v", err) - } - - configPath := resolveForOS(installRoot, "config.yaml", "darwin") - binPath := resolveForOS(installRoot, "bin/xray", "darwin") - expectedStateRoot := filepath.Join(homeDir, "Library", "Application Support", appStateDirName) - - if configPath != filepath.Join(expectedStateRoot, "config.yaml") { - t.Fatalf("darwin config path 应落到 Application Support,got=%s want=%s", configPath, filepath.Join(expectedStateRoot, "config.yaml")) - } - if binPath != filepath.Join(installRoot, "bin", "xray") { - t.Fatalf("darwin bin path 不应迁移到用户目录,got=%s", binPath) - } - - root := detectForOS(installRoot, "darwin") - if !root.detached { - t.Fatal("expected darwin .app bundle root to use detached state") - } - if root.stateRoot != expectedStateRoot { - t.Fatalf("unexpected darwin state root: got=%s want=%s", root.stateRoot, expectedStateRoot) - } -} - -func TestEnsureWritableLayoutSeedsConfigAndChrome(t *testing.T) { - if goruntime.GOOS != "linux" { - t.Skip("linux-only path behavior") - } - - xdgDataHome := t.TempDir() - t.Setenv("XDG_DATA_HOME", xdgDataHome) - - installRoot := filepath.Join(t.TempDir(), "opt-app") - if err := os.MkdirAll(filepath.Join(installRoot, "chrome"), 0755); err != nil { - t.Fatalf("创建 chrome 目录失败: %v", err) - } - if err := os.WriteFile(filepath.Join(installRoot, "config.yaml"), []byte("name: linux\n"), 0644); err != nil { - t.Fatalf("写入 config.yaml 失败: %v", err) - } - if err := os.WriteFile(filepath.Join(installRoot, "chrome", "README.md"), []byte("placeholder\n"), 0644); err != nil { - t.Fatalf("写入 README 失败: %v", err) - } - if err := os.Chmod(installRoot, 0555); err != nil { - t.Fatalf("设置 installRoot 权限失败: %v", err) - } - if err := os.Chmod(filepath.Join(installRoot, "chrome"), 0555); err != nil { - t.Fatalf("设置 chrome 目录权限失败: %v", err) - } - t.Cleanup(func() { - _ = os.Chmod(installRoot, 0755) - _ = os.Chmod(filepath.Join(installRoot, "chrome"), 0755) - }) - - if err := ensureWritableLayoutForOS(installRoot, "linux"); err != nil { - t.Fatalf("EnsureWritableLayout 返回错误: %v", err) - } - - stateRoot := filepath.Join(xdgDataHome, appStateDirName) - assertFileContent(t, filepath.Join(stateRoot, "config.yaml"), "name: linux\n") - assertFileContent(t, filepath.Join(stateRoot, "chrome", "README.md"), "placeholder\n") - if _, err := os.Stat(filepath.Join(stateRoot, "data")); err != nil { - t.Fatalf("data 目录未创建: %v", err) - } -} - -func TestEnsureWritableLayoutSeedsDarwinBundleStateRoot(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - - installRoot := filepath.Join(t.TempDir(), "Ant Browser.app", "Contents", "MacOS") - if err := os.MkdirAll(filepath.Join(installRoot, "chrome"), 0755); err != nil { - t.Fatalf("创建 chrome 目录失败: %v", err) - } - if err := os.WriteFile(filepath.Join(installRoot, "config.yaml"), []byte("name: mac\n"), 0644); err != nil { - t.Fatalf("写入 config.yaml 失败: %v", err) - } - if err := os.WriteFile(filepath.Join(installRoot, "chrome", "README.md"), []byte("mac placeholder\n"), 0644); err != nil { - t.Fatalf("写入 README 失败: %v", err) - } - - if err := ensureWritableLayoutForOS(installRoot, "darwin"); err != nil { - t.Fatalf("ensureWritableLayoutForOS 返回错误: %v", err) - } - - stateRoot := filepath.Join(homeDir, "Library", "Application Support", appStateDirName) - assertFileContent(t, filepath.Join(stateRoot, "config.yaml"), "name: mac\n") - assertFileContent(t, filepath.Join(stateRoot, "chrome", "README.md"), "mac placeholder\n") - if _, err := os.Stat(filepath.Join(stateRoot, "data")); err != nil { - t.Fatalf("data 目录未创建: %v", err) - } -} - -func assertFileContent(t *testing.T, path, want string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("读取文件失败 %s: %v", path, err) - } - if string(data) != want { - t.Fatalf("文件内容不符合预期 %s: got=%q want=%q", path, string(data), want) - } -} diff --git a/backend/internal/automation/runtime_manager_helpers_test.go b/backend/internal/automation/runtime_manager_helpers_test.go deleted file mode 100644 index 8ac77db5..00000000 --- a/backend/internal/automation/runtime_manager_helpers_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package automation - -import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "crypto/sha1" - "crypto/sha256" - "encoding/hex" - "os" - "path/filepath" - "testing" -) - -func buildTestNodeZip(t *testing.T) ([]byte, string) { - t.Helper() - - var buf bytes.Buffer - writer := zip.NewWriter(&buf) - - header := &zip.FileHeader{ - Name: "node-v22.15.1-win-x64/node.exe", - Method: zip.Deflate, - } - fileWriter, err := writer.CreateHeader(header) - if err != nil { - t.Fatalf("create node zip header failed: %v", err) - } - if _, err := fileWriter.Write([]byte("fake-node-runtime")); err != nil { - t.Fatalf("write node zip failed: %v", err) - } - if err := writer.Close(); err != nil { - t.Fatalf("close node zip failed: %v", err) - } - - hash := sha256.Sum256(buf.Bytes()) - return buf.Bytes(), hex.EncodeToString(hash[:]) -} - -func buildTestPlaywrightTGZ(t *testing.T) ([]byte, string) { - t.Helper() - - var buf bytes.Buffer - gzWriter := gzip.NewWriter(&buf) - tarWriter := tar.NewWriter(gzWriter) - - payload := []byte(`{"name":"playwright-core","version":"1.59.0"}`) - header := &tar.Header{ - Name: "package/package.json", - Mode: 0o644, - Size: int64(len(payload)), - } - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatalf("write playwright header failed: %v", err) - } - if _, err := tarWriter.Write(payload); err != nil { - t.Fatalf("write playwright payload failed: %v", err) - } - if err := tarWriter.Close(); err != nil { - t.Fatalf("close playwright tar failed: %v", err) - } - if err := gzWriter.Close(); err != nil { - t.Fatalf("close playwright gzip failed: %v", err) - } - - hash := sha1.Sum(buf.Bytes()) - return buf.Bytes(), hex.EncodeToString(hash[:]) -} - -func buildTestPlayablePlaywrightTGZ(t *testing.T, version string) ([]byte, string) { - t.Helper() - - var buf bytes.Buffer - gzWriter := gzip.NewWriter(&buf) - tarWriter := tar.NewWriter(gzWriter) - - files := map[string][]byte{ - "package/package.json": []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`), - "package/index.js": []byte("exports.chromium = {};"), - } - - for name, payload := range files { - header := &tar.Header{ - Name: name, - Mode: 0o644, - Size: int64(len(payload)), - } - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatalf("write playable playwright header failed: %v", err) - } - if _, err := tarWriter.Write(payload); err != nil { - t.Fatalf("write playable playwright payload failed: %v", err) - } - } - if err := tarWriter.Close(); err != nil { - t.Fatalf("close playable playwright tar failed: %v", err) - } - if err := gzWriter.Close(); err != nil { - t.Fatalf("close playable playwright gzip failed: %v", err) - } - - hash := sha1.Sum(buf.Bytes()) - return buf.Bytes(), hex.EncodeToString(hash[:]) -} - -func writeBrokenPlaywrightModule(runtimeDir, version string) error { - moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(moduleDir, 0o755); err != nil { - return err - } - - packageJSON := []byte(`{"name":"playwright-core","version":"` + version + `","main":"index.js"}`) - if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), packageJSON, 0o644); err != nil { - return err - } - - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte("module.exports = {};"), 0o644) -} diff --git a/backend/internal/automation/runtime_manager_test.go b/backend/internal/automation/runtime_manager_test.go deleted file mode 100644 index dffb7e76..00000000 --- a/backend/internal/automation/runtime_manager_test.go +++ /dev/null @@ -1,441 +0,0 @@ -package automation - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - goruntime "runtime" - "strings" - "sync/atomic" - "testing" - - "ant-chrome/backend/internal/config" -) - -func TestEnsureInstalledDownloadsAndExtractsRuntime(t *testing.T) { - t.Parallel() - - nodeArchive, nodeSHA := buildTestNodeZip(t) - playwrightArchive, playwrightSHA := buildTestPlaywrightTGZ(t) - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v22.15.1/SHASUMS256.txt": - _, _ = w.Write([]byte(nodeSHA + " node-v22.15.1-win-x64.zip\n")) - case "/v22.15.1/node-v22.15.1-win-x64.zip": - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(nodeArchive) - case "/playwright-core/1.59.0": - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "dist": map[string]any{ - "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", - "shasum": playwrightSHA, - }, - }) - case "/tarballs/playwright-core-1.59.0.tgz": - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write(playwrightArchive) - default: - http.NotFound(w, r) - } - }) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceBundled - cfg.Automation.NodeVersion = "22.15.1" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - NodeDistBaseURL: server.URL, - NPMRegistryBaseURL: server.URL, - TargetOS: "windows", - TargetArch: "amd64", - }) - - if err := manager.EnsureInstalled(context.Background()); err != nil { - t.Fatalf("EnsureInstalled returned error: %v", err) - } - - state := manager.CurrentState() - if !state.Installed || !state.Ready { - t.Fatalf("runtime should be ready after install, got %+v", state) - } - if _, err := os.Stat(filepath.Join(state.RuntimeDir, "node", "node.exe")); err != nil { - t.Fatalf("expected node executable to exist: %v", err) - } - if _, err := os.Stat(filepath.Join(state.RuntimeDir, "node_modules", "playwright-core", "package.json")); err != nil { - t.Fatalf("expected playwright-core package.json to exist: %v", err) - } - if _, err := os.Stat(filepath.Join(state.RuntimeDir, runnerScriptFileName)); err != nil { - t.Fatalf("expected runner script to exist: %v", err) - } -} - -func TestEnsureInstalledUsesSystemNodeAndSkipsBundledDownload(t *testing.T) { - t.Parallel() - - nodeExecPath := lookupNodeExecutable(t) - playwrightArchive, playwrightSHA := buildTestPlayablePlaywrightTGZ(t, "1.59.0") - - var nodeRequests atomic.Int32 - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/v22.15.1/") { - nodeRequests.Add(1) - http.NotFound(w, r) - return - } - - switch r.URL.Path { - case "/playwright-core/1.59.0": - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "dist": map[string]any{ - "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", - "shasum": playwrightSHA, - }, - }) - case "/tarballs/playwright-core-1.59.0.tgz": - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write(playwrightArchive) - default: - http.NotFound(w, r) - } - }) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "22.15.1" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - NodeDistBaseURL: server.URL, - NPMRegistryBaseURL: server.URL, - TargetOS: goruntime.GOOS, - TargetArch: goruntime.GOARCH, - }) - - if err := manager.EnsureInstalled(context.Background()); err != nil { - t.Fatalf("EnsureInstalled returned error: %v", err) - } - - state := manager.CurrentState() - if !state.Installed || !state.Ready { - t.Fatalf("runtime should be ready after install, got %+v", state) - } - if state.NodeSource != config.AutomationNodeSourceSystem { - t.Fatalf("expected system node source, got %q", state.NodeSource) - } - if filepath.Clean(state.NodePath) != filepath.Clean(nodeExecPath) { - t.Fatalf("expected system node path %q, got %q", nodeExecPath, state.NodePath) - } - if nodeRequests.Load() != 0 { - t.Fatalf("expected bundled node download to be skipped, got %d node requests", nodeRequests.Load()) - } - if _, err := os.Stat(filepath.Join(state.RuntimeDir, "node_modules", "playwright-core", "package.json")); err != nil { - t.Fatalf("expected playwright-core package.json to exist: %v", err) - } - if _, err := os.Stat(filepath.Join(state.RuntimeDir, runnerScriptFileName)); err != nil { - t.Fatalf("expected runner script to exist: %v", err) - } - if _, err := os.Stat(manager.nodeExecutablePath(state.RuntimeDir)); !os.IsNotExist(err) { - t.Fatalf("expected bundled node to be absent, got err=%v", err) - } -} - -func TestProbeSystemNodeUsesExplicitPath(t *testing.T) { - t.Parallel() - - nodeExecPath := lookupNodeExecutable(t) - manager := NewManager(t.TempDir(), config.DefaultConfig(), nil, Options{}) - - result, err := manager.ProbeSystemNode(context.Background(), nodeExecPath) - if err != nil { - t.Fatalf("ProbeSystemNode returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected probe result to be ok, got %+v", result) - } - if filepath.Clean(result.Path) != filepath.Clean(nodeExecPath) { - t.Fatalf("expected probe path %q, got %q", nodeExecPath, result.Path) - } - if strings.TrimSpace(result.Version) == "" { - t.Fatalf("expected probe version to be set, got %+v", result) - } -} - -func TestProbeSystemNodeMissingReturnsError(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - - manager := NewManager(t.TempDir(), config.DefaultConfig(), nil, Options{}) - _, err := manager.ProbeSystemNode(context.Background(), filepath.Join(t.TempDir(), "missing-node.exe")) - if err == nil { - t.Fatalf("expected ProbeSystemNode to fail for missing node path") - } -} - -func TestCurrentStateReportsBundledFallbackReasonWhenSystemNodeMissing(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceAuto - cfg.Automation.SystemNodePath = filepath.Join(t.TempDir(), "missing-node.exe") - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - TargetOS: "windows", - TargetArch: "amd64", - }) - - state := manager.CurrentState() - if state.NodeSource != config.AutomationNodeSourceBundled { - t.Fatalf("expected bundled node source, got %q", state.NodeSource) - } - if !strings.Contains(state.NodeResolution, "回退") { - t.Fatalf("expected fallback resolution message, got %q", state.NodeResolution) - } - if strings.TrimSpace(state.SystemNodeError) == "" { - t.Fatalf("expected system node error to be set, got %+v", state) - } -} - -func TestCurrentStateReportsSystemResolutionWhenExplicitNodeSucceeds(t *testing.T) { - t.Parallel() - - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceAuto - cfg.Automation.SystemNodePath = nodeExecPath - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - TargetOS: goruntime.GOOS, - TargetArch: goruntime.GOARCH, - }) - - state := manager.CurrentState() - if state.NodeSource != config.AutomationNodeSourceSystem { - t.Fatalf("expected system node source, got %q", state.NodeSource) - } - if !strings.Contains(state.NodeResolution, "配置的系统 Node 路径") { - t.Fatalf("expected explicit system node resolution, got %q", state.NodeResolution) - } -} - -func TestEnsureInstalledRepairsBrokenReadyAutoRuntime(t *testing.T) { - t.Parallel() - - nodeExecPath := lookupNodeExecutable(t) - playwrightArchive, playwrightSHA := buildTestPlayablePlaywrightTGZ(t, "1.59.0") - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/playwright-core/1.59.0": - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "dist": map[string]any{ - "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", - "shasum": playwrightSHA, - }, - }) - case "/tarballs/playwright-core-1.59.0.tgz": - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write(playwrightArchive) - default: - http.NotFound(w, r) - } - }) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceAuto - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "22.15.1" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - NPMRegistryBaseURL: server.URL, - TargetOS: goruntime.GOOS, - TargetArch: goruntime.GOARCH, - }) - - initialState := manager.CurrentState() - if err := writeRunnerScript(initialState.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeBrokenPlaywrightModule(initialState.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { - t.Fatalf("write broken playwright module failed: %v", err) - } - - readyState := manager.CurrentState() - if !readyState.Ready { - t.Fatalf("expected broken runtime to appear ready before verification, got %+v", readyState) - } - - if err := manager.EnsureInstalled(context.Background()); err != nil { - t.Fatalf("EnsureInstalled returned error: %v", err) - } - - check, err := manager.SelfCheck(context.Background()) - if err != nil { - t.Fatalf("SelfCheck returned error after repair: %v", err) - } - if !check.OK { - t.Fatalf("expected repaired runtime to pass self-check, got %+v", check) - } -} - -func TestEnsureInstalledAutoFallsBackToBundledWhenSystemNodeMissing(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - - nodeArchive, nodeSHA := buildTestNodeZip(t) - playwrightArchive, playwrightSHA := buildTestPlaywrightTGZ(t) - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v22.15.1/SHASUMS256.txt": - _, _ = w.Write([]byte(nodeSHA + " node-v22.15.1-win-x64.zip\n")) - case "/v22.15.1/node-v22.15.1-win-x64.zip": - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(nodeArchive) - case "/playwright-core/1.59.0": - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "dist": map[string]any{ - "tarball": server.URL + "/tarballs/playwright-core-1.59.0.tgz", - "shasum": playwrightSHA, - }, - }) - case "/tarballs/playwright-core-1.59.0.tgz": - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write(playwrightArchive) - default: - http.NotFound(w, r) - } - }) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceAuto - cfg.Automation.SystemNodePath = filepath.Join(t.TempDir(), "missing-node.exe") - cfg.Automation.NodeVersion = "22.15.1" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - NodeDistBaseURL: server.URL, - NPMRegistryBaseURL: server.URL, - TargetOS: "windows", - TargetArch: "amd64", - }) - - if err := manager.EnsureInstalled(context.Background()); err != nil { - t.Fatalf("EnsureInstalled returned error: %v", err) - } - - state := manager.CurrentState() - if !state.Installed || !state.Ready { - t.Fatalf("runtime should be ready after install, got %+v", state) - } - if state.NodeSource != config.AutomationNodeSourceBundled { - t.Fatalf("expected bundled node source after fallback, got %q", state.NodeSource) - } -} - -func TestEnsureInstalledSystemSourceFailsWhenSystemNodeMissing(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = filepath.Join(t.TempDir(), "missing-node.exe") - cfg.Automation.NodeVersion = "22.15.1" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - TargetOS: "windows", - TargetArch: "amd64", - }) - - err := manager.EnsureInstalled(context.Background()) - if err == nil { - t.Fatalf("expected EnsureInstalled to fail when system node is missing") - } - if !strings.Contains(err.Error(), "系统 Node 不可用") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestEnsureInstalledRefreshesExistingRunnerScript(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceBundled - cfg.Automation.NodeVersion = "22.15.1" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = config.DefaultAutomationRuntimeVersion(cfg.Automation.NodeVersion, cfg.Automation.PlaywrightCoreVersion) - - manager := NewManager(t.TempDir(), cfg, nil, Options{ - TargetOS: "windows", - TargetArch: "amd64", - }) - - state := manager.CurrentState() - if err := os.MkdirAll(filepath.Dir(state.NodePath), 0o755); err != nil { - t.Fatalf("create node dir failed: %v", err) - } - if err := os.WriteFile(state.NodePath, []byte("fake-node-runtime"), 0o755); err != nil { - t.Fatalf("write fake node failed: %v", err) - } - playwrightPkgPath := filepath.Join(state.RuntimeDir, "node_modules", "playwright-core", "package.json") - if err := os.MkdirAll(filepath.Dir(playwrightPkgPath), 0o755); err != nil { - t.Fatalf("create playwright dir failed: %v", err) - } - if err := os.WriteFile(playwrightPkgPath, []byte(`{"name":"playwright-core","version":"1.59.0"}`), 0o644); err != nil { - t.Fatalf("write fake playwright package.json failed: %v", err) - } - if err := os.WriteFile(state.RunnerPath, []byte("old-runner"), 0o755); err != nil { - t.Fatalf("write stale runner failed: %v", err) - } - - if err := manager.EnsureInstalled(context.Background()); err != nil { - t.Fatalf("EnsureInstalled returned error: %v", err) - } - - runnerData, err := os.ReadFile(state.RunnerPath) - if err != nil { - t.Fatalf("read refreshed runner failed: %v", err) - } - if string(runnerData) != string(runnerScriptContent) { - t.Fatalf("expected runner script to be refreshed") - } -} diff --git a/backend/internal/automation/script_run_store_test.go b/backend/internal/automation/script_run_store_test.go deleted file mode 100644 index de330489..00000000 --- a/backend/internal/automation/script_run_store_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package automation - -import ( - "path/filepath" - "testing" -) - -func TestScriptRunStoreSaveAndList(t *testing.T) { - store := NewScriptRunStore(filepath.Join(t.TempDir(), "data", "automation", "runs")) - - first, err := store.Save(ScriptRunRecord{ - ID: "run-1", - ScriptID: "script-1", - ScriptName: "脚本 1", - Status: "success", - Summary: "ok", - LogText: "2026-04-02T09:00:00Z 打开页面", - StartedAt: "2026-04-02T09:00:00Z", - FinishedAt: "2026-04-02T09:00:01Z", - DurationMs: 1000, - }) - if err != nil { - t.Fatalf("Save first returned error: %v", err) - } - if first.ID != "run-1" { - t.Fatalf("expected run id run-1, got %q", first.ID) - } - if first.LogText != "2026-04-02T09:00:00Z 打开页面" { - t.Fatalf("expected run log text to be persisted, got %q", first.LogText) - } - - if _, err := store.Save(ScriptRunRecord{ - ID: "run-2", - ScriptID: "script-2", - ScriptName: "脚本 2", - Status: "failed", - Summary: "bad", - StartedAt: "2026-04-02T10:00:00Z", - FinishedAt: "2026-04-02T10:00:02Z", - DurationMs: 2000, - }); err != nil { - t.Fatalf("Save second returned error: %v", err) - } - - items, err := store.List(10) - if err != nil { - t.Fatalf("List returned error: %v", err) - } - if len(items) != 2 { - t.Fatalf("expected two runs, got %d", len(items)) - } - if items[0].ID != "run-2" { - t.Fatalf("expected latest run first, got %q", items[0].ID) - } -} diff --git a/backend/internal/automation/task_runner_launch_test.go b/backend/internal/automation/task_runner_launch_test.go deleted file mode 100644 index 04dfa2b5..00000000 --- a/backend/internal/automation/task_runner_launch_test.go +++ /dev/null @@ -1,596 +0,0 @@ -package automation - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "ant-chrome/backend/internal/config" -) - -func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - type launchRequestPayload struct { - ProxyID string `json:"proxyId"` - ProxyConfig string `json:"proxyConfig"` - SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` - } - - receivedBody := launchRequestPayload{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("unexpected method: %s", r.Method) - } - if r.URL.Path != "/api/launch" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&receivedBody); err != nil { - t.Fatalf("decode launch request body failed: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-script", - "debugPort": 9333, - "cdpUrl": "http://127.0.0.1:9333", - }) - })) - defer server.Close() - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-launch-proxy.cjs") - scriptSource := `module.exports.run = async ({ launch }) => { - await launch({ - proxyId: 'proxy-picked', - proxyConfig: 'socks5://127.0.0.1:1080', - skipDefaultStartUrls: true, - }) - - return { - ok: true, - summary: '脚本执行成功', - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:launch-proxy", - ScriptPath: scriptPath, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if receivedBody.ProxyID != "proxy-picked" { - t.Fatalf("expected proxyId to be forwarded, got %+v", receivedBody) - } - if receivedBody.ProxyConfig != "socks5://127.0.0.1:1080" { - t.Fatalf("expected proxyConfig to be forwarded, got %+v", receivedBody) - } - if !receivedBody.SkipDefaultStartURLs { - t.Fatalf("expected skipDefaultStartUrls to stay true, got %+v", receivedBody) - } -} - -func TestRunScriptTaskLaunchSkipsDefaultStartUrlsByDefault(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 { - SkipDefaultStartURLs *bool `json:"skipDefaultStartUrls"` - } - receivedBody := launchRequestPayload{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/launch" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if err := json.NewDecoder(r.Body).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-default-skip.cjs") - scriptSource := `module.exports.run = async ({ launch, selector }) => { - await launch({ selector }) - 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-default-skip", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if receivedBody.SkipDefaultStartURLs == nil || *receivedBody.SkipDefaultStartURLs != true { - t.Fatalf("expected skipDefaultStartUrls default true, got %+v", receivedBody) - } -} - -func TestRunScriptTaskUseBrowserWithoutURLDoesNotCreateBlankPage(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) - } - - markerPath := filepath.Join(t.TempDir(), "new-page-count.txt") - if err := writeMockPlaywrightModuleCountingNewPages(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, markerPath); 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-no-blank", - "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-no-blank.cjs") - scriptSource := `module.exports.run = async ({ useBrowser, selector }) => { - const runtime = await useBrowser({ selector }) - return { ok: true, summary: runtime.page ? runtime.page.url() : 'no-page' } -}` - 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:no-blank", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if result.Summary != "no-page" { - t.Fatalf("summary = %q, want no-page", result.Summary) - } - if _, err := os.Stat(markerPath); !os.IsNotExist(err) { - if err != nil { - t.Fatalf("stat marker failed: %v", err) - } - data, _ := os.ReadFile(markerPath) - t.Fatalf("context.newPage should not be called, marker=%q", string(data)) - } -} - -func TestRunScriptTaskOpenPageWithoutURLDoesNotCreateBlankPage(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) - } - - markerPath := filepath.Join(t.TempDir(), "new-page-count.txt") - if err := writeMockPlaywrightModuleCountingNewPages(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, markerPath); 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-open-no-url", - "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-no-url.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, openPage, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session) - const opened = await openPage(connection, { reuseCurrentPage: true }) - return { ok: true, summary: opened.page ? opened.page.url() : 'no-page' } -}` - 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-no-url", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if result.Summary != "no-page" { - t.Fatalf("summary = %q, want no-page", result.Summary) - } - if _, err := os.Stat(markerPath); !os.IsNotExist(err) { - if err != nil { - t.Fatalf("stat marker failed: %v", err) - } - data, _ := os.ReadFile(markerPath) - t.Fatalf("context.newPage should not be called, marker=%q", string(data)) - } -} - -func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("unexpected method: %s", r.Method) - } - if r.URL.Path != "/api/launch" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-script", - "debugPort": 0, - "debugReady": false, - "cdpUrl": "http://127.0.0.1:0", - }) - })) - defer server.Close() - - if err := writeMockPlaywrightModuleWithExpectedEndpoint(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, server.URL); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-fallback.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session) - - return { - ok: true, - summary: '脚本已通过 Launch 地址回退连接', - connectedEndpoint: connection.session.cdpUrl, - profileId: session.profileId, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:fallback", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if result.Summary != "脚本已通过 Launch 地址回退连接" { - t.Fatalf("unexpected summary: %s", result.Summary) - } - if !strings.Contains(result.ResultText, `"connectedEndpoint":"`+server.URL+`"`) { - t.Fatalf("expected result text to contain fallback endpoint, got %s", result.ResultText) - } -} - -func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModuleWithPersistentConnection(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, ""); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-script-close", - "debugPort": 9333, - "cdpUrl": "http://127.0.0.1:9333", - }) - })) - defer server.Close() - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-close.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session) - - return { - ok: true, - summary: '脚本执行成功', - connectedEndpoint: connection.session.cdpUrl, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - result, err := manager.RunScriptTask(ctx, ScriptTaskRequest{ - TaskKey: "script:close", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } -} - -func TestRunScriptTaskConnectHonorsPerCallTimeout(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModuleWithExpectedConnectTimeout(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, 47000); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "profileId": "profile-timeout", - "debugPort": 9333, - "cdpUrl": "http://127.0.0.1:9333", - }) - })) - defer server.Close() - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-connect-timeout.cjs") - scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { - const session = await launch({ selector }) - const connection = await connect(session, { timeoutMs: 47000 }) - - return { - ok: true, - summary: '脚本执行成功', - connectedEndpoint: connection.session.cdpUrl, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:connect-timeout", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - LaunchBaseURL: server.URL, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } -} - -func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) { - nodeExecPath := lookupNodeExecutable(t) - - cfg := config.DefaultConfig() - cfg.Automation.Enabled = true - cfg.Automation.NodeSource = config.AutomationNodeSourceSystem - cfg.Automation.SystemNodePath = nodeExecPath - cfg.Automation.NodeVersion = "test-node" - cfg.Automation.PlaywrightCoreVersion = "1.59.0" - cfg.Automation.RuntimeVersion = "test-runtime" - - manager := NewManager(t.TempDir(), cfg, nil, Options{}) - - state := manager.CurrentState() - if err := writeRunnerScript(state.RunnerPath); err != nil { - t.Fatalf("write runner script failed: %v", err) - } - if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { - t.Fatalf("write mock playwright module failed: %v", err) - } - - scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatalf("create script dir failed: %v", err) - } - scriptPath := filepath.Join(scriptDir, "script-timeout.cjs") - scriptSource := `module.exports.run = async () => { - await new Promise(() => setInterval(() => {}, 1000)) -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - startedAt := time.Now() - _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:timeout", - ScriptPath: scriptPath, - LaunchBaseURL: "http://127.0.0.1", - Timeout: 150 * time.Millisecond, - }) - elapsed := time.Since(startedAt) - if err == nil { - t.Fatalf("expected RunScriptTask to fail on timeout") - } - if !strings.Contains(err.Error(), "超时") { - t.Fatalf("expected timeout error, got %v", err) - } - if elapsed > 3*time.Second { - t.Fatalf("expected timeout to terminate quickly, took %s", elapsed) - } - - manager.mu.Lock() - activeTaskCount := len(manager.activeTasks) - profileTaskCount := len(manager.profileTask) - manager.mu.Unlock() - if activeTaskCount != 0 || profileTaskCount != 0 { - t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount) - } -} diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go deleted file mode 100644 index b635bc16..00000000 --- a/backend/internal/automation/task_runner_test.go +++ /dev/null @@ -1,491 +0,0 @@ -package automation - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "ant-chrome/backend/internal/config" -) - -func TestFormatTaskRunnerLogs(t *testing.T) { - logText := formatTaskRunnerLogs([]taskRunnerLogEntry{ - {Time: "2026-04-02T09:00:00Z", Values: []any{"打开页面", map[string]any{"url": "https://example.test"}}}, - {Time: "2026-04-02T09:00:01Z", Values: []any{"点击按钮"}}, - }) - - if !strings.Contains(logText, "2026-04-02T09:00:00Z 打开页面") { - t.Fatalf("expected first log line, got %q", logText) - } - if !strings.Contains(logText, `{"url":"https://example.test"}`) { - t.Fatalf("expected structured log value, got %q", logText) - } - if !strings.Contains(logText, "2026-04-02T09:00:01Z 点击按钮") { - t.Fatalf("expected second log line, got %q", logText) - } -} - -func TestRunScriptTaskExecutesCustomRunner(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) - } - - receivedBody := map[string]any{} - 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) - } - if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil { - t.Fatalf("decode 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.cjs") - scriptSource := `const fs = require('fs'); - -module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { - const session = await launch({ - selector, - startUrls: params.startUrls, - skipDefaultStartUrls: true, - }) - - const { browser } = await connect(session) - const context = browser.contexts()[0] - const page = context.pages()[0] || await context.newPage() - await page.goto(params.url, { waitUntil: 'domcontentloaded', timeout: params.timeoutMs || 30000 }) - - const filePath = artifact('script-output.txt') - fs.writeFileSync(filePath, 'artifact-ready') - log('profile', session.profileId) - - return { - ok: true, - summary: '脚本执行成功', - profileId: session.profileId, - url: page.url(), - artifactPath: filePath, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - artifactDir := filepath.Join(t.TempDir(), "artifacts") - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:test", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "BUYER_001"}, - Params: map[string]any{"url": "https://example.com/script", "startUrls": []string{"https://example.com/script"}}, - LaunchBaseURL: server.URL, - ArtifactDir: artifactDir, - }) - if err != nil { - t.Fatalf("RunScriptTask returned error: %v", err) - } - - if !result.OK { - t.Fatalf("expected script task to succeed, got %+v", result) - } - if result.Summary != "脚本执行成功" { - t.Fatalf("unexpected summary: %s", result.Summary) - } - if result.Error != "" { - t.Fatalf("unexpected error: %s", result.Error) - } - if !strings.Contains(result.ResultText, `"profileId":"profile-script"`) { - t.Fatalf("expected result text to contain profileId, got %s", result.ResultText) - } - if !strings.Contains(result.ResultText, `"artifactPath":"`) { - t.Fatalf("expected result text to contain artifact path, got %s", result.ResultText) - } - - if selector, ok := receivedBody["selector"].(map[string]any); !ok || selector["code"] != "BUYER_001" { - t.Fatalf("unexpected selector payload: %+v", receivedBody) - } - - artifactData, err := os.ReadFile(filepath.Join(artifactDir, "script-output.txt")) - if err != nil { - t.Fatalf("read script artifact failed: %v", err) - } - if string(artifactData) != "artifact-ready" { - t.Fatalf("unexpected script artifact payload: %s", string(artifactData)) - } -} - -func TestRunScriptTaskOpenPageReusesInitialBlankPageAndGrantsPermissions(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"] != true { - t.Fatalf("expected reusedPage to be true, 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) - - 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 { - Code string `json:"code"` - Key string `json:"key"` - ProfileID string `json:"profileId"` - ProfileName string `json:"profileName"` - Keyword string `json:"keyword"` - Keywords []string `json:"keywords"` - Tag string `json:"tag"` - 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"` - 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-filter.cjs") - scriptSource := `module.exports.run = async ({ launch, selector, params }) => { - const session = await launch({ - selector, - startUrls: params.startUrls, - skipDefaultStartUrls: true, - }) - - return { - ok: true, - summary: '脚本执行成功', - profileId: session.profileId, - } -}` - if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { - t.Fatalf("write script failed: %v", err) - } - - result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ - TaskKey: "script:launch-filter", - ScriptPath: scriptPath, - Selector: map[string]any{"code": "DEMO_READY"}, - Params: map[string]any{"url": "https://www.baidu.com", "keyword": "OpenAI", "captureScreenshot": true, "waitAfterSearchMs": 1500, "startUrls": []string{"https://www.baidu.com"}}, - 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.Selector["code"] != "DEMO_READY" { - t.Fatalf("unexpected selector payload: %+v", receivedBody) - } - if len(receivedBody.StartURLs) != 1 || receivedBody.StartURLs[0] != "https://www.baidu.com" { - t.Fatalf("unexpected startUrls payload: %+v", receivedBody.StartURLs) - } - if !receivedBody.SkipDefaultStartURLs { - t.Fatalf("expected skipDefaultStartUrls to be true") - } - 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) - } -} diff --git a/backend/internal/automation/task_runner_test_helpers_test.go b/backend/internal/automation/task_runner_test_helpers_test.go deleted file mode 100644 index d34a8d11..00000000 --- a/backend/internal/automation/task_runner_test_helpers_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package automation - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func lookupNodeExecutable(t *testing.T) string { - t.Helper() - - nodePath, err := exec.LookPath("node") - if err != nil { - t.Skipf("node is not available: %v", err) - } - - cmd := exec.Command(nodePath, "-p", "process.execPath") - output, err := cmd.Output() - if err != nil { - return nodePath - } - - resolved := strings.TrimSpace(string(output)) - if resolved == "" { - return nodePath - } - return resolved -} - -func writeMockPlaywrightModule(runtimeDir, version string) error { - return writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, "") -} - -func writeMockPlaywrightModuleWithExpectedEndpoint(runtimeDir, version, expectedEndpoint string) error { - return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, false) -} - -func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expectedEndpoint string) error { - return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true) -} - -func writeMockPlaywrightModuleCountingNewPages(runtimeDir, version, markerPath string) 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 - } - - markerPathJSON, err := json.Marshal(markerPath) - if err != nil { - return err - } - - indexJS := fmt.Sprintf(`const fs = require('fs'); - -const markerPath = %s; -let newPageCount = 0; - -const context = { - async newPage() { - newPageCount += 1; - fs.writeFileSync(markerPath, String(newPageCount)); - return { - async bringToFront() {}, - async goto() {}, - async waitForLoadState() {}, - async waitForTimeout() {}, - isClosed() { - return false; - }, - url() { - return 'about:blank'; - }, - }; - }, - pages() { - return []; - }, -}; - -exports.chromium = { - async connectOverCDP() { - return { - contexts() { - return [context]; - }, - async close() {}, - }; - }, -}; -`, string(markerPathJSON)) - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) -} - -func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error { - moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(moduleDir, 0o755); err != nil { - return err - } - - packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) - if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { - return err - } - - indexJS := fmt.Sprintf(`const expectedConnectTimeout = %d; - -const context = { - async grantPermissions() {}, - async newPage() { - return { - async goto() {}, - async bringToFront() {}, - async waitForLoadState() {}, - async waitForTimeout() {}, - async close() {}, - isClosed() { - return false; - }, - async title() { - return 'Mock Page Title'; - }, - url() { - return 'about:blank'; - }, - }; - }, - pages() { - return []; - }, -}; - -exports.chromium = { - async connectOverCDP(endpoint, options = {}) { - if (options.timeout !== expectedConnectTimeout) { - throw new Error('unexpected connect timeout: ' + String(options.timeout)); - } - return { - contexts() { - return [context]; - }, - async close() {}, - }; - }, -}; -`, expectedConnectTimeout) - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) -} - -func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error { - moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") - if err := os.MkdirAll(moduleDir, 0o755); err != nil { - return err - } - - packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) - if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { - return err - } - - expectedEndpointJSON, err := json.Marshal(expectedEndpoint) - if err != nil { - return err - } - persistentConnectionJSON, err := json.Marshal(persistentConnection) - if err != nil { - return err - } - - indexJS := fmt.Sprintf(`const fs = require('fs'); - -const expectedEndpoint = %s; -const persistentConnection = %s; - -function createPage() { - let currentURL = 'about:blank'; - return { - async goto(url) { - currentURL = url; - }, - async bringToFront() {}, - async waitForLoadState() {}, - async waitForTimeout() {}, - async screenshot(options) { - fs.writeFileSync(options.path, 'mock-screenshot'); - }, - async evaluate(fn, arg) { - const previousFetch = global.fetch; - global.fetch = async (url, init = {}) => { - return { - ok: String(init.method || 'GET').toUpperCase() !== 'DELETE', - status: String(init.method || 'GET').toUpperCase() === 'POST' ? 201 : 200, - statusText: String(init.method || 'GET').toUpperCase() === 'DELETE' ? 'Forbidden' : 'OK', - url: String(url), - headers: { - forEach(callback) { - callback('application/json', 'content-type'); - }, - }, - async text() { - return JSON.stringify({ - ok: true, - url: String(url), - method: String(init.method || 'GET').toUpperCase(), - credentials: init.credentials || '', - headers: init.headers || {}, - body: init.body || '', - }); - }, - }; - }; - try { - return await fn(arg); - } finally { - global.fetch = previousFetch; - } - }, - async title() { - return 'Mock Page Title'; - }, - url() { - return currentURL; - }, - isClosed() { - return false; - }, - async close() {}, - }; -} - -const initialPage = createPage(); - -const context = { - async grantPermissions() {}, - async newPage() { - return createPage(); - }, - pages() { - return [initialPage]; - }, -}; - -exports.chromium = { - async connectOverCDP(endpoint) { - if (String(endpoint).includes(':0')) { - throw new Error('invalid cdp endpoint'); - } - if (expectedEndpoint && endpoint !== expectedEndpoint) { - throw new Error('unexpected cdp endpoint: ' + endpoint); - } - const hold = persistentConnection ? setInterval(() => {}, 1000) : null; - return { - contexts() { - return [context]; - }, - async close() { - if (hold) { - clearInterval(hold); - } - }, - }; - }, -}; -`, string(expectedEndpointJSON), string(persistentConnectionJSON)) - return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) -} diff --git a/backend/internal/backup/spec_test.go b/backend/internal/backup/spec_test.go deleted file mode 100644 index 9fbc683f..00000000 --- a/backend/internal/backup/spec_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package backup - -import ( - "ant-chrome/backend/internal/config" - "path/filepath" - "testing" - "time" -) - -func TestBuildScope_DefaultConfigKeepsCoreEntries(t *testing.T) { - tempDir := t.TempDir() - cfg := config.DefaultConfig() - - scope, err := BuildScope(BuildOptions{ - AppRoot: tempDir, - Config: cfg, - }) - if err != nil { - t.Fatalf("BuildScope 返回错误: %v", err) - } - - if scope.Format != PackageFormat { - t.Fatalf("format 不正确: %s", scope.Format) - } - if scope.ManifestVersion != ManifestVersion { - t.Fatalf("manifestVersion 不正确: %d", scope.ManifestVersion) - } - - ids := make(map[string]ScopeEntry) - for _, e := range scope.Entries { - ids[e.ID] = e - } - - assertEntry(t, ids, "system_config_main") - assertEntry(t, ids, "system_config_proxies") - assertEntry(t, ids, "app_data_root") - assertEntry(t, ids, "browser_core_root") - - if _, ok := ids["database_sqlite_main"]; ok { - t.Fatalf("默认配置下 database_sqlite_main 应被 app_data_root 覆盖,不应单独出现") - } - if _, ok := ids["browser_user_data_root"]; ok { - t.Fatalf("默认配置下 browser_user_data_root 与 app_data_root 重合,不应重复出现") - } -} - -func TestBuildScope_CustomPathsIncludeNonOverlappingEntries(t *testing.T) { - tempDir := t.TempDir() - cfg := config.DefaultConfig() - - cfg.Browser.UserDataRoot = "profiles" - cfg.Database.SQLite.Path = "db/main.db" - cfg.Logging.FilePath = "runtime/logs/app.log" - cfg.Browser.Cores = []config.BrowserCore{ - { - CoreId: "core-external-a", - CoreName: "External Core A", - CorePath: "external-core-a", - }, - } - - scope, err := BuildScope(BuildOptions{ - AppRoot: tempDir, - Config: cfg, - }) - if err != nil { - t.Fatalf("BuildScope 返回错误: %v", err) - } - - ids := make(map[string]ScopeEntry) - for _, e := range scope.Entries { - ids[e.ID] = e - } - - assertEntry(t, ids, "browser_user_data_root") - assertEntry(t, ids, "database_sqlite_main") - assertEntry(t, ids, "database_sqlite_wal") - assertEntry(t, ids, "database_sqlite_shm") - assertEntry(t, ids, "logs_root") - assertEntry(t, ids, "browser_core_external_external-01") - - dbEntry := ids["database_sqlite_main"] - expectedDB := filepath.Join(tempDir, "db", "main.db") - if dbEntry.SourcePath != expectedDB { - t.Fatalf("database source path 不匹配: got=%s want=%s", dbEntry.SourcePath, expectedDB) - } -} - -func TestBuildManifest_StripsSourcePath(t *testing.T) { - tempDir := t.TempDir() - scope, err := BuildScope(BuildOptions{ - AppRoot: tempDir, - Config: config.DefaultConfig(), - }) - if err != nil { - t.Fatalf("BuildScope 返回错误: %v", err) - } - - at := time.Date(2026, 3, 2, 12, 0, 0, 0, time.UTC) - manifest := BuildManifest(scope, "Ant Browser", "1.1.0", at) - - if manifest.CreatedAt != "2026-03-02T12:00:00Z" { - t.Fatalf("CreatedAt 不匹配: %s", manifest.CreatedAt) - } - if manifest.App.Name != "Ant Browser" { - t.Fatalf("manifest app name 不正确: %s", manifest.App.Name) - } - if manifest.App.Version != "1.1.0" { - t.Fatalf("manifest app version 不正确: %s", manifest.App.Version) - } - - for _, item := range manifest.Entries { - if item.ArchivePath == "" { - t.Fatalf("manifest entry 缺少 archivePath: %+v", item) - } - } -} - -func assertEntry(t *testing.T, entries map[string]ScopeEntry, id string) { - t.Helper() - if _, ok := entries[id]; !ok { - t.Fatalf("缺少 scope entry: %s", id) - } -} diff --git a/backend/internal/browser/bookmarks_test.go b/backend/internal/browser/bookmarks_test.go deleted file mode 100644 index edf50d11..00000000 --- a/backend/internal/browser/bookmarks_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestEnsureDefaultBookmarksOnlyAppendsMissingItems(t *testing.T) { - t.Parallel() - - userDataDir := t.TempDir() - profileDir := filepath.Join(userDataDir, "Default") - if err := os.MkdirAll(profileDir, 0o755); err != nil { - t.Fatalf("create profile dir: %v", err) - } - - root := newEmptyBookmarkRoot("0") - roots := root["roots"].(map[string]interface{}) - bar := roots["bookmark_bar"].(map[string]interface{}) - bar["children"] = []interface{}{ - map[string]interface{}{ - "id": "4", - "name": "用户自己的书签", - "type": "url", - "url": "https://user.example/", - }, - } - other := roots["other"].(map[string]interface{}) - other["children"] = []interface{}{ - map[string]interface{}{ - "id": "5", - "name": "其他文件夹已有默认书签", - "type": "url", - "url": "https://existing.example/", - }, - } - writeBookmarkRoot(t, profileDir, root) - - err := EnsureDefaultBookmarks(userDataDir, []config.BrowserBookmark{ - {Name: "已存在默认书签", URL: "https://existing.example/"}, - {Name: "新增默认书签", URL: "https://new.example/"}, - {Name: "", URL: "https://ignored-name.example/"}, - {Name: "忽略空 URL", URL: ""}, - }) - if err != nil { - t.Fatalf("EnsureDefaultBookmarks returned error: %v", err) - } - - updated := readBookmarkRoot(t, profileDir) - if got := countBookmarkURL(updated, "https://user.example/"); got != 1 { - t.Fatalf("用户自己的书签不应被改动: count=%d", got) - } - if got := countBookmarkURL(updated, "https://existing.example/"); got != 1 { - t.Fatalf("已存在 URL 不应跨文件夹重复添加: count=%d", got) - } - if got := countBookmarkURL(updated, "https://new.example/"); got != 1 { - t.Fatalf("新增默认书签应追加一次: count=%d", got) - } - if got := countBookmarkURL(updated, "https://ignored-name.example/"); got != 0 { - t.Fatalf("空名称书签不应写入: count=%d", got) - } - if !bookmarkBarHasURL(updated, "https://user.example/") { - t.Fatalf("用户自己的书签应保留在书签栏") - } - if !bookmarkBarHasURL(updated, "https://new.example/") { - t.Fatalf("新增默认书签应追加到书签栏") - } -} - -func TestEnsureDefaultBookmarksDoesNotRewriteWhenNothingMissing(t *testing.T) { - t.Parallel() - - userDataDir := t.TempDir() - profileDir := filepath.Join(userDataDir, "Default") - if err := os.MkdirAll(profileDir, 0o755); err != nil { - t.Fatalf("create profile dir: %v", err) - } - - root := newEmptyBookmarkRoot("0") - roots := root["roots"].(map[string]interface{}) - bar := roots["bookmark_bar"].(map[string]interface{}) - bar["date_modified"] = "unchanged" - bar["children"] = []interface{}{ - map[string]interface{}{ - "id": "4", - "name": "已有默认书签", - "type": "url", - "url": "https://existing.example/", - }, - } - writeBookmarkRoot(t, profileDir, root) - before, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks")) - if err != nil { - t.Fatalf("read before: %v", err) - } - - err = EnsureDefaultBookmarks(userDataDir, []config.BrowserBookmark{ - {Name: "已有默认书签", URL: "https://existing.example/"}, - }) - if err != nil { - t.Fatalf("EnsureDefaultBookmarks returned error: %v", err) - } - after, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks")) - if err != nil { - t.Fatalf("read after: %v", err) - } - if string(after) != string(before) { - t.Fatalf("没有新增项时不应重写用户书签文件") - } -} - -func writeBookmarkRoot(t *testing.T, profileDir string, root map[string]interface{}) { - t.Helper() - data, err := json.MarshalIndent(root, "", " ") - if err != nil { - t.Fatalf("marshal bookmarks: %v", err) - } - if err := os.WriteFile(filepath.Join(profileDir, "Bookmarks"), data, 0o644); err != nil { - t.Fatalf("write bookmarks: %v", err) - } -} - -func readBookmarkRoot(t *testing.T, profileDir string) map[string]interface{} { - t.Helper() - data, err := os.ReadFile(filepath.Join(profileDir, "Bookmarks")) - if err != nil { - t.Fatalf("read bookmarks: %v", err) - } - var root map[string]interface{} - if err := json.Unmarshal(data, &root); err != nil { - t.Fatalf("unmarshal bookmarks: %v", err) - } - return root -} - -func countBookmarkURL(root map[string]interface{}, url string) int { - count := 0 - roots, ok := root["roots"].(map[string]interface{}) - if !ok { - return count - } - for _, item := range roots { - folder, ok := item.(map[string]interface{}) - if !ok { - continue - } - if children, ok := folder["children"].([]interface{}); ok { - count += countURLInNodes(children, url) - } - } - return count -} - -func countURLInNodes(nodes []interface{}, url string) int { - count := 0 - for _, item := range nodes { - node, ok := item.(map[string]interface{}) - if !ok { - continue - } - if node["type"] == "url" && node["url"] == url { - count++ - } - if children, ok := node["children"].([]interface{}); ok { - count += countURLInNodes(children, url) - } - } - return count -} - -func bookmarkBarHasURL(root map[string]interface{}, url string) bool { - roots, ok := root["roots"].(map[string]interface{}) - if !ok { - return false - } - bar, ok := roots["bookmark_bar"].(map[string]interface{}) - if !ok { - return false - } - children, ok := bar["children"].([]interface{}) - return ok && countURLInNodes(children, url) > 0 -} diff --git a/backend/internal/browser/connector_test.go b/backend/internal/browser/connector_test.go deleted file mode 100644 index 8f41b618..00000000 --- a/backend/internal/browser/connector_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package browser - -import ( - "reflect" - "testing" -) - -func TestBuildLaunchArgsAppendsDefaultVerificationURLs(t *testing.T) { - t.Parallel() - - baseArgs := []string{"--disable-sync"} - got := BuildLaunchArgs(append([]string{}, baseArgs...), []string{}) - want := []string{ - "--disable-sync", - } - - if !reflect.DeepEqual(got, want) { - t.Fatalf("BuildLaunchArgs 结果错误:\n got=%v\nwant=%v", got, want) - } -} diff --git a/backend/internal/browser/core_test.go b/backend/internal/browser/core_test.go deleted file mode 100644 index 4bbcb290..00000000 --- a/backend/internal/browser/core_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "os" - "path/filepath" - "runtime" - "testing" -) - -type coreDAOStub struct { - list []Core - err error -} - -func (s *coreDAOStub) List() ([]Core, error) { - if s.err != nil { - return nil, s.err - } - return append([]Core{}, s.list...), nil -} - -func (s *coreDAOStub) Upsert(Core) error { return nil } -func (s *coreDAOStub) Delete(string) error { return nil } -func (s *coreDAOStub) SetDefault(string) error { return nil } - -func TestResolveChromeBinaryUsesDefaultCoreFromDAO(t *testing.T) { - t.Parallel() - - root := t.TempDir() - coreDir := filepath.Join(root, "chrome142") - if err := os.MkdirAll(coreDir, 0o755); err != nil { - t.Fatalf("创建内核目录失败: %v", err) - } - - exePath := filepath.Join(coreDir, filepath.FromSlash(CoreExecutableCandidates()[0])) - if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { - t.Fatalf("创建可执行文件目录失败: %v", err) - } - if err := os.WriteFile(exePath, []byte("stub"), 0o755); err != nil { - t.Fatalf("写入可执行文件失败: %v", err) - } - - cfg := config.DefaultConfig() - cfg.Browser.Cores = nil // 模拟 ReloadConfig 后被 config.yaml 清空的场景 - - mgr := NewManager(cfg, root) - mgr.CoreDAO = &coreDAOStub{ - list: []Core{ - { - CoreId: "core-142", - CoreName: "Chrome 142", - CorePath: "chrome142", - IsDefault: true, - }, - }, - } - - got, err := mgr.ResolveChromeBinary(&Profile{CoreId: ""}) - if err != nil { - t.Fatalf("ResolveChromeBinary 返回错误: %v", err) - } - if got != exePath { - t.Fatalf("ResolveChromeBinary 路径错误: got=%q want=%q", got, exePath) - } -} - -func TestResolveChromeBinaryNormalizesWindowsStyleRelativeCorePath(t *testing.T) { - t.Parallel() - - root := t.TempDir() - coreDir := filepath.Join(root, "chrome", "Chrom-144") - if err := os.MkdirAll(coreDir, 0o755); err != nil { - t.Fatalf("创建内核目录失败: %v", err) - } - - exePath := filepath.Join(coreDir, filepath.FromSlash(CoreExecutableCandidates()[0])) - if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { - t.Fatalf("创建可执行文件目录失败: %v", err) - } - if err := os.WriteFile(exePath, []byte("stub"), 0o755); err != nil { - t.Fatalf("写入可执行文件失败: %v", err) - } - - cfg := config.DefaultConfig() - mgr := NewManager(cfg, root) - mgr.CoreDAO = &coreDAOStub{ - list: []Core{ - { - CoreId: "core-144", - CoreName: "Chrome 144", - CorePath: `chrome\Chrom-144`, - IsDefault: true, - }, - }, - } - - got, err := mgr.ResolveChromeBinary(&Profile{}) - if err != nil { - t.Fatalf("ResolveChromeBinary 返回错误: %v", err) - } - if got != exePath { - t.Fatalf("ResolveChromeBinary 路径错误: got=%q want=%q", got, exePath) - } -} - -func TestResolveChromeBinaryAcceptsDirectExecutablePath(t *testing.T) { - t.Parallel() - - root := t.TempDir() - coreDir := filepath.Join(root, "chrome-direct") - if err := os.MkdirAll(coreDir, 0o755); err != nil { - t.Fatalf("创建内核目录失败: %v", err) - } - - exePath := filepath.Join(coreDir, filepath.FromSlash(CoreExecutableCandidates()[0])) - if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { - t.Fatalf("创建可执行文件目录失败: %v", err) - } - if err := os.WriteFile(exePath, []byte("stub"), 0o755); err != nil { - t.Fatalf("写入可执行文件失败: %v", err) - } - - cfg := config.DefaultConfig() - mgr := NewManager(cfg, root) - mgr.CoreDAO = &coreDAOStub{ - list: []Core{ - { - CoreId: "core-direct", - CoreName: "Chrome Direct", - CorePath: exePath, - IsDefault: true, - }, - }, - } - - got, err := mgr.ResolveChromeBinary(&Profile{}) - if err != nil { - t.Fatalf("ResolveChromeBinary 返回错误: %v", err) - } - if got != exePath { - t.Fatalf("ResolveChromeBinary 路径错误: got=%q want=%q", got, exePath) - } -} - -func TestResolveChromeBinaryAcceptsDarwinAppBundlePath(t *testing.T) { - t.Parallel() - - if runtime.GOOS != "darwin" { - t.Skip("仅验证 darwin .app 路径解析") - } - - root := t.TempDir() - appDir := filepath.Join(root, "Chromium.app") - exePath := filepath.Join(appDir, "Contents", "MacOS", "Chromium") - if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { - t.Fatalf("创建可执行文件目录失败: %v", err) - } - if err := os.WriteFile(exePath, []byte("stub"), 0o755); err != nil { - t.Fatalf("写入可执行文件失败: %v", err) - } - - cfg := config.DefaultConfig() - mgr := NewManager(cfg, root) - mgr.CoreDAO = &coreDAOStub{ - list: []Core{ - { - CoreId: "core-app", - CoreName: "Chromium App", - CorePath: appDir, - IsDefault: true, - }, - }, - } - - got, err := mgr.ResolveChromeBinary(&Profile{}) - if err != nil { - t.Fatalf("ResolveChromeBinary 返回错误: %v", err) - } - if got != exePath { - t.Fatalf("ResolveChromeBinary 路径错误: got=%q want=%q", got, exePath) - } -} - -func TestCountInstancesByCoreTreatsLegacyDefaultReferenceAsDefault(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - cfg.Browser.Profiles = []config.BrowserProfileConfig{ - {ProfileId: "p-empty", CoreId: ""}, - {ProfileId: "p-legacy", CoreId: "default"}, - {ProfileId: "p-explicit", CoreId: "core-142"}, - } - - mgr := NewManager(cfg, "") - mgr.CoreDAO = &coreDAOStub{ - list: []Core{ - { - CoreId: "core-142", - CoreName: "Chrome 142", - CorePath: "chrome142", - IsDefault: true, - }, - }, - } - - if got := mgr.CountInstancesByCore("core-142"); got != 3 { - t.Fatalf("默认内核实例计数错误: got=%d want=3", got) - } -} diff --git a/backend/internal/browser/dashboard_test.go b/backend/internal/browser/dashboard_test.go deleted file mode 100644 index b0f4be9f..00000000 --- a/backend/internal/browser/dashboard_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "testing" -) - -func TestBuildDashboardStats(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.Proxies = []config.BrowserProxy{{ProxyId: "p1"}, {ProxyId: "p2"}} - cfg.Browser.Cores = []config.BrowserCore{{CoreId: "c1"}} - cfg.App.MaxProfileLimit = 7 - - stats := BuildDashboardStats([]Profile{ - {ProfileId: "a", Running: true}, - {ProfileId: "b", Running: false}, - {ProfileId: "c", Running: true}, - }, cfg) - - if stats.TotalInstances != 3 || stats.RunningInstances != 2 { - t.Fatalf("instance stats = %#v", stats) - } - if stats.ProxyCount != 2 || stats.CoreCount != 1 || stats.MaxProfileLimit != 7 { - t.Fatalf("config stats = %#v", stats) - } -} - -func TestBuildDashboardStatsDefaultsWithoutConfig(t *testing.T) { - stats := BuildDashboardStats(nil, nil) - if stats.MaxProfileLimit != DefaultMaxProfileLimit { - t.Fatalf("max profile limit = %d", stats.MaxProfileLimit) - } -} - -func TestRunningProfiles(t *testing.T) { - profiles := RunningProfiles([]Profile{ - {ProfileId: "a", Running: true}, - {ProfileId: "b"}, - }) - if len(profiles) != 1 || profiles[0].ProfileId != "a" { - t.Fatalf("profiles = %#v", profiles) - } -} diff --git a/backend/internal/browser/environment_test.go b/backend/internal/browser/environment_test.go deleted file mode 100644 index a793116f..00000000 --- a/backend/internal/browser/environment_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "errors" - "testing" -) - -type proxyDAOStub struct { - list []Proxy - err error -} - -func (s *proxyDAOStub) List() ([]Proxy, error) { - if s.err != nil { - return nil, s.err - } - return append([]Proxy{}, s.list...), nil -} - -func (s *proxyDAOStub) ListByGroup(string) ([]Proxy, error) { return nil, nil } -func (s *proxyDAOStub) ListGroups() ([]string, error) { return nil, nil } -func (s *proxyDAOStub) Upsert(Proxy) error { return nil } -func (s *proxyDAOStub) Delete(string) error { return nil } -func (s *proxyDAOStub) DeleteAll() error { return nil } -func (s *proxyDAOStub) UpdateSpeedResult(string, bool, int64, string) error { - return nil -} -func (s *proxyDAOStub) UpdateIPHealthResult(string, string) error { return nil } - -func TestGetProxyConfigByIdPreferDAO(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "pool-1", ProxyConfig: "http://127.0.0.1:9999"}, - } - - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: "pool-1", ProxyConfig: "socks5://127.0.0.1:1080"}, - }, - } - - got, ok := mgr.GetProxyConfigById("pool-1") - if !ok { - t.Fatalf("expected proxy to be found") - } - if got != "socks5://127.0.0.1:1080" { - t.Fatalf("expected dao proxy config, got=%q", got) - } -} - -func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.Proxies = []config.BrowserProxy{ - {ProxyId: "pool-2", ProxyConfig: "http://proxy.invalid:8080"}, - } - - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{err: errors.New("dao unavailable")} - - got, ok := mgr.GetProxyConfigById("pool-2") - if !ok { - t.Fatalf("expected proxy to be found in config fallback") - } - if got != "http://proxy.invalid:8080" { - t.Fatalf("unexpected proxy config: %q", got) - } -} diff --git a/backend/internal/browser/extension_manager_test.go b/backend/internal/browser/extension_manager_test.go deleted file mode 100644 index 873be367..00000000 --- a/backend/internal/browser/extension_manager_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package browser - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "ant-chrome/backend/internal/database" -) - -func newExtensionTestDAO(t *testing.T) *SQLiteExtensionDAO { - t.Helper() - dbPath := filepath.Join(t.TempDir(), "extensions.db") - db, err := database.NewDB(dbPath) - if err != nil { - t.Fatalf("创建测试数据库失败: %v", err) - } - if err := db.Migrate(); err != nil { - t.Fatalf("迁移测试数据库失败: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - return NewSQLiteExtensionDAO(db.GetConn()) -} - -func TestNormalizeExtensionID(t *testing.T) { - validID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - cases := []struct { - name string - in string - want string - }{ - {name: "raw id", in: validID, want: validID}, - {name: "upper id", in: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", want: validID}, - {name: "store url", in: "https://chromewebstore.google.com/detail/example/" + validID, want: validID}, - {name: "invalid", in: "not-an-extension", want: ""}, - {name: "wrong alphabet", in: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", want: ""}, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - if got := NormalizeExtensionID(tt.in); got != tt.want { - t.Fatalf("NormalizeExtensionID() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestProfileExtensionSettingsRoundTrip(t *testing.T) { - dao := newExtensionTestDAO(t) - ids := []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - - settings, err := dao.SetProfileSettings("profile-1", ids, true) - if err != nil { - t.Fatalf("SetProfileSettings failed: %v", err) - } - wantIDs := []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} - if !settings.Configured || !reflect.DeepEqual(settings.ExtensionIDs, wantIDs) { - t.Fatalf("settings = %+v, want configured with %v", settings, wantIDs) - } - - loaded, err := dao.GetProfileSettings("profile-1") - if err != nil { - t.Fatalf("GetProfileSettings failed: %v", err) - } - if !loaded.Configured || !reflect.DeepEqual(loaded.ExtensionIDs, wantIDs) { - t.Fatalf("loaded = %+v, want configured with %v", loaded, wantIDs) - } -} - -func TestEnabledExtensionDirsForProfile(t *testing.T) { - dao := newExtensionTestDAO(t) - root := t.TempDir() - firstDir := writeExtensionManifest(t, root, "one") - secondDir := writeExtensionManifest(t, root, "two") - disabledDir := writeExtensionManifest(t, root, "disabled") - - manager := NewManager(nil, root) - manager.ExtensionDAO = dao - - items := []Extension{ - {ExtensionID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "one", Version: "1.0.0", InstallDir: firstDir, Enabled: true}, - {ExtensionID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "two", Version: "1.0.0", InstallDir: secondDir, Enabled: true}, - {ExtensionID: "cccccccccccccccccccccccccccccccc", Name: "disabled", Version: "1.0.0", InstallDir: disabledDir, Enabled: false}, - } - for _, item := range items { - if err := dao.Upsert(item); err != nil { - t.Fatalf("Upsert(%s) failed: %v", item.ExtensionID, err) - } - } - - if got := manager.EnabledExtensionDirsForProfile("profile-inherit"); !reflect.DeepEqual(got, []string{firstDir, secondDir}) { - t.Fatalf("inherit dirs = %v", got) - } - - _, err := dao.SetProfileSettings("profile-custom", []string{ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "cccccccccccccccccccccccccccccccc", - }, true) - if err != nil { - t.Fatalf("SetProfileSettings failed: %v", err) - } - if got := manager.EnabledExtensionDirsForProfile("profile-custom"); !reflect.DeepEqual(got, []string{firstDir}) { - t.Fatalf("custom dirs = %v, want only enabled selected dir", got) - } -} - -func writeExtensionManifest(t *testing.T, root string, name string) string { - t.Helper() - dir := filepath.Join(root, name) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("创建插件目录失败: %v", err) - } - manifest := []byte(`{"manifest_version":3,"name":"` + name + `","version":"1.0.0"}`) - if err := os.WriteFile(filepath.Join(dir, "manifest.json"), manifest, 0o644); err != nil { - t.Fatalf("写入 manifest 失败: %v", err) - } - return dir -} diff --git a/backend/internal/browser/profile_copy_test.go b/backend/internal/browser/profile_copy_test.go deleted file mode 100644 index d33a3471..00000000 --- a/backend/internal/browser/profile_copy_test.go +++ /dev/null @@ -1,211 +0,0 @@ -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 -} diff --git a/backend/internal/browser/profile_defaults_test.go b/backend/internal/browser/profile_defaults_test.go deleted file mode 100644 index 96187d06..00000000 --- a/backend/internal/browser/profile_defaults_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "testing" -) - -func TestApplyDefaultsDoesNotFallbackToDirectAfterPoolBindByProxyConfig(t *testing.T) { - cfg := config.DefaultConfig() - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "pool-1", ProxyName: "节点-01", ProxyConfig: "socks5://127.0.0.1:1080"}, - }, - } - - profile := &Profile{ - ProfileId: "pf-apply-defaults-1", - ProxyId: "", - ProxyConfig: "socks5://127.0.0.1:1080", - } - - changed := mgr.ApplyDefaults(profile) - if !changed { - t.Fatalf("expected proxy binding to change") - } - if profile.ProxyId != "pool-1" { - t.Fatalf("expected proxyId to bind to pool-1, got=%q", profile.ProxyId) - } - if profile.ProxyId == directProxyID { - t.Fatalf("expected not to fallback to direct proxy") - } -} - -func TestApplyDefaultsKeepsCustomProxyConfigWhenNotInPool(t *testing.T) { - cfg := config.DefaultConfig() - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "pool-2", ProxyName: "日本-01", ProxyConfig: "socks5://127.0.0.1:2080"}, - }, - } - - profile := &Profile{ - ProfileId: "pf-apply-defaults-2", - ProxyId: "", - ProxyConfig: "http://127.0.0.1:9090", - } - - _ = mgr.ApplyDefaults(profile) - if profile.ProxyId != "" { - t.Fatalf("expected proxyId to stay empty for custom proxyConfig, got=%q", profile.ProxyId) - } - if profile.ProxyConfig != "http://127.0.0.1:9090" { - t.Fatalf("expected proxyConfig to be preserved, got=%q", profile.ProxyConfig) - } -} - -func TestApplyDefaultsClearsMissingProxyIdButPreservesProxyConfig(t *testing.T) { - cfg := config.DefaultConfig() - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - }, - } - - profile := &Profile{ - ProfileId: "pf-apply-defaults-3", - ProxyId: "missing-proxy-id", - ProxyConfig: "http://127.0.0.1:9090", - } - - changed := mgr.ApplyDefaults(profile) - if !changed { - t.Fatalf("expected proxy binding to change when clearing missing proxyId") - } - if profile.ProxyId != "" { - t.Fatalf("expected missing proxyId to be cleared, got=%q", profile.ProxyId) - } - if profile.ProxyConfig != "http://127.0.0.1:9090" { - t.Fatalf("expected proxyConfig to be preserved, got=%q", profile.ProxyConfig) - } - if profile.ProxyId == directProxyID { - t.Fatalf("expected not to fallback to direct proxy when proxyConfig is present") - } -} - -func TestApplyDefaultsFallsBackToDirectWhenProxyMissing(t *testing.T) { - cfg := config.DefaultConfig() - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - }, - } - - profile := &Profile{ - ProfileId: "pf-apply-defaults-4", - ProxyId: "", - ProxyConfig: "", - } - - changed := mgr.ApplyDefaults(profile) - if !changed { - t.Fatalf("expected direct proxy fallback to change profile") - } - if profile.ProxyId != directProxyID { - t.Fatalf("expected fallback to direct proxy id, got=%q", profile.ProxyId) - } - if profile.ProxyConfig != "direct://" { - t.Fatalf("expected fallback proxy config to be direct://, got=%q", profile.ProxyConfig) - } -} diff --git a/backend/internal/browser/profile_delete_test.go b/backend/internal/browser/profile_delete_test.go deleted file mode 100644 index b4877a0c..00000000 --- a/backend/internal/browser/profile_delete_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "os" - "path/filepath" - "testing" - "time" -) - -func TestDeleteKeepsProfileUserDataDirDuringTrashRetention(t *testing.T) { - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = "data" - mgr := NewManager(cfg, appRoot) - profile := &Profile{ProfileId: "profile-1", UserDataDir: "profile-1"} - mgr.Profiles[profile.ProfileId] = profile - - profileDir := filepath.Join(appRoot, "data", "profile-1") - if err := os.MkdirAll(profileDir, 0o755); err != nil { - t.Fatalf("MkdirAll failed: %v", err) - } - - if err := mgr.Delete(profile.ProfileId); err != nil { - t.Fatalf("Delete failed: %v", err) - } - if _, err := os.Stat(profileDir); err != nil { - t.Fatalf("expected profile dir kept during trash retention, stat err=%v", err) - } -} - -func TestDeleteKeepsUserDataRootWhenProfileDirIsRoot(t *testing.T) { - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = "data" - mgr := NewManager(cfg, appRoot) - profile := &Profile{ProfileId: "profile-root", UserDataDir: ""} - mgr.Profiles[profile.ProfileId] = profile - - rootDir := filepath.Join(appRoot, "data") - if err := os.MkdirAll(rootDir, 0o755); err != nil { - t.Fatalf("MkdirAll failed: %v", err) - } - profile.UserDataDir = rootDir - - if err := mgr.Delete(profile.ProfileId); err != nil { - t.Fatalf("Delete failed: %v", err) - } - if _, err := os.Stat(rootDir); err != nil { - t.Fatalf("expected data root kept, stat err=%v", err) - } -} - -func TestCleanupExpiredTrashRemovesProfileDataAndSnapshots(t *testing.T) { - appRoot := t.TempDir() - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = "data" - mgr := NewManager(cfg, appRoot) - profile := &Profile{ - ProfileId: "profile-expired", - UserDataDir: "profile-expired", - DeletedAt: time.Now().Add(-profileTrashRetention - time.Hour).Format(time.RFC3339), - } - - profileDir := filepath.Join(appRoot, "data", "profile-expired") - snapshotDir := filepath.Join(appRoot, "data", "snapshots", "profile-expired") - if err := os.MkdirAll(profileDir, 0o755); err != nil { - t.Fatalf("MkdirAll profile dir failed: %v", err) - } - if err := os.MkdirAll(snapshotDir, 0o755); err != nil { - t.Fatalf("MkdirAll snapshot dir failed: %v", err) - } - - mgr.ProfileDAO = &memoryExpiredProfileDAO{expired: []*Profile{profile}} - if err := mgr.CleanupExpiredTrash(); err != nil { - t.Fatalf("CleanupExpiredTrash failed: %v", err) - } - if _, err := os.Stat(profileDir); !os.IsNotExist(err) { - t.Fatalf("expected profile dir removed, stat err=%v", err) - } - if _, err := os.Stat(snapshotDir); !os.IsNotExist(err) { - t.Fatalf("expected snapshot dir removed, stat err=%v", err) - } -} - -type memoryExpiredProfileDAO struct { - expired []*Profile -} - -func (d *memoryExpiredProfileDAO) List() ([]*Profile, error) { return nil, nil } -func (d *memoryExpiredProfileDAO) ListDeleted() ([]*Profile, error) { return nil, nil } -func (d *memoryExpiredProfileDAO) GetById(profileId string) (*Profile, error) { return nil, nil } -func (d *memoryExpiredProfileDAO) Upsert(profile *Profile) error { return nil } -func (d *memoryExpiredProfileDAO) Delete(profileId string) error { return nil } -func (d *memoryExpiredProfileDAO) SoftDelete(profileId string, deletedAt string) error { return nil } -func (d *memoryExpiredProfileDAO) Restore(profileId string) error { return nil } -func (d *memoryExpiredProfileDAO) ListExpiredDeleted(expiredBefore string) ([]*Profile, error) { - return d.expired, nil -} diff --git a/backend/internal/browser/profile_proxy_input_test.go b/backend/internal/browser/profile_proxy_input_test.go deleted file mode 100644 index 10235ee5..00000000 --- a/backend/internal/browser/profile_proxy_input_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "strings" - "testing" -) - -func newProfileProxyInputTestManager(t *testing.T) *Manager { - t.Helper() - cfg := config.DefaultConfig() - mgr := NewManager(cfg, t.TempDir()) - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: directProxyID, ProxyName: "直连(不走代理)", ProxyConfig: "direct://"}, - {ProxyId: "proxy-us", ProxyName: "US", ProxyConfig: "socks5://127.0.0.1:1080"}, - }, - } - return mgr -} - -func TestCreateProfileRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { - mgr := newProfileProxyInputTestManager(t) - _, err := mgr.Create(ProfileInput{ - ProfileName: "buyer-1", - ProxyId: "missing-id", - }) - if err == nil { - t.Fatalf("expected create to fail for missing proxy id without proxyConfig") - } - if !strings.Contains(strings.ToLower(err.Error()), "proxy id not found") { - t.Fatalf("unexpected error: %v", err) - } - if len(mgr.Profiles) != 0 { - t.Fatalf("profile should not be created on proxy validation failure") - } -} - -func TestCreateProfileFallsBackToCustomProxyConfigWhenProxyIDMissing(t *testing.T) { - mgr := newProfileProxyInputTestManager(t) - profile, err := mgr.Create(ProfileInput{ - ProfileName: "buyer-2", - ProxyId: "missing-id", - ProxyConfig: "http://127.0.0.1:18080", - }) - if err != nil { - t.Fatalf("create failed: %v", err) - } - if profile.ProxyId != "" { - t.Fatalf("expected proxyId to be cleared, got=%q", profile.ProxyId) - } - if profile.ProxyConfig != "http://127.0.0.1:18080" { - t.Fatalf("expected proxyConfig to be preserved, got=%q", profile.ProxyConfig) - } -} - -func TestCreateProfileFallsBackToDirectWhenProxyInputEmpty(t *testing.T) { - mgr := newProfileProxyInputTestManager(t) - profile, err := mgr.Create(ProfileInput{ - ProfileName: "buyer-3", - }) - if err != nil { - t.Fatalf("create failed: %v", err) - } - if profile.ProxyId != directProxyID { - t.Fatalf("expected direct proxy id, got=%q", profile.ProxyId) - } - if profile.ProxyConfig != "direct://" { - t.Fatalf("expected direct proxy config, got=%q", profile.ProxyConfig) - } -} - -func TestUpdateProfileRejectsMissingProxyIDWithoutProxyConfig(t *testing.T) { - mgr := newProfileProxyInputTestManager(t) - profile, err := mgr.Create(ProfileInput{ - ProfileName: "buyer-old", - ProxyId: "proxy-us", - }) - if err != nil { - t.Fatalf("create failed: %v", err) - } - beforeName := profile.ProfileName - beforeProxyID := profile.ProxyId - beforeProxyConfig := profile.ProxyConfig - - _, err = mgr.Update(profile.ProfileId, ProfileInput{ - ProfileName: "buyer-new", - ProxyId: "missing-id", - }) - if err == nil { - t.Fatalf("expected update to fail for missing proxy id without proxyConfig") - } - current := mgr.Profiles[profile.ProfileId] - if current.ProfileName != beforeName { - t.Fatalf("profile name should stay unchanged on failure, got=%q", current.ProfileName) - } - if current.ProxyId != beforeProxyID || current.ProxyConfig != beforeProxyConfig { - t.Fatalf("proxy fields should stay unchanged on failure, got=%q/%q", current.ProxyId, current.ProxyConfig) - } -} - -func TestUpdateProfileFallsBackToCustomProxyConfigWhenProxyIDMissing(t *testing.T) { - mgr := newProfileProxyInputTestManager(t) - profile, err := mgr.Create(ProfileInput{ - ProfileName: "buyer-old", - ProxyId: "proxy-us", - }) - if err != nil { - t.Fatalf("create failed: %v", err) - } - - updated, err := mgr.Update(profile.ProfileId, ProfileInput{ - ProfileName: "buyer-new", - ProxyId: "missing-id", - ProxyConfig: "http://127.0.0.1:19090", - }) - if err != nil { - t.Fatalf("update failed: %v", err) - } - if updated.ProfileName != "buyer-new" { - t.Fatalf("expected updated name, got=%q", updated.ProfileName) - } - if updated.ProxyId != "" { - t.Fatalf("expected proxyId to be cleared, got=%q", updated.ProxyId) - } - if updated.ProxyConfig != "http://127.0.0.1:19090" { - t.Fatalf("expected proxyConfig to be updated, got=%q", updated.ProxyConfig) - } -} diff --git a/backend/internal/browser/proxy_binding_test.go b/backend/internal/browser/proxy_binding_test.go deleted file mode 100644 index 45e859db..00000000 --- a/backend/internal/browser/proxy_binding_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package browser - -import ( - "ant-chrome/backend/internal/config" - "testing" -) - -func TestResolveProfileProxyBindingBySourceAndName(t *testing.T) { - cfg := config.DefaultConfig() - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - { - ProxyId: "new-p1", - ProxyName: "节点-01", - ProxyConfig: "socks5://127.0.0.1:1080", - SourceID: "src-hk", - SourceURL: "https://example.com/sub", - }, - }, - } - - profile := &Profile{ - ProfileId: "pf-1", - ProxyId: "old-missing-id", - ProxyConfig: "socks5://127.0.0.1:2080", - ProxyBindSourceID: "src-hk", - ProxyBindName: "节点-01", - } - - changed, boundInPool, mode := mgr.ResolveProfileProxyBinding(profile) - if !changed { - t.Fatalf("expected profile binding to change") - } - if !boundInPool { - t.Fatalf("expected profile to be rebound in pool") - } - if mode != "source_id+name" && mode != "proxy_id" { - t.Fatalf("unexpected bind mode: %s", mode) - } - if profile.ProxyId != "new-p1" { - t.Fatalf("unexpected rebound proxy id: %s", profile.ProxyId) - } - if profile.ProxyConfig != "socks5://127.0.0.1:1080" { - t.Fatalf("unexpected rebound proxy config: %s", profile.ProxyConfig) - } - if profile.ProxyBindUpdatedAt == "" { - t.Fatalf("expected bind updated time to be set") - } -} - -func TestResolveProfileProxyBindingAmbiguousNameNoRebind(t *testing.T) { - cfg := config.DefaultConfig() - mgr := NewManager(cfg, "") - mgr.ProxyDAO = &proxyDAOStub{ - list: []Proxy{ - {ProxyId: "p1", ProxyName: "重复节点", ProxyConfig: "socks5://127.0.0.1:1080", SourceID: "src-a"}, - {ProxyId: "p2", ProxyName: "重复节点", ProxyConfig: "socks5://127.0.0.1:2080", SourceID: "src-b"}, - }, - } - - profile := &Profile{ - ProfileId: "pf-2", - ProxyId: "old-missing-id", - ProxyBindName: "重复节点", - } - - changed, boundInPool, mode := mgr.ResolveProfileProxyBinding(profile) - if changed { - t.Fatalf("did not expect binding to change") - } - if boundInPool { - t.Fatalf("did not expect ambiguous name to bind") - } - if mode != "" { - t.Fatalf("expected empty mode, got=%s", mode) - } - if profile.ProxyId != "old-missing-id" { - t.Fatalf("proxy id should remain unchanged, got=%s", profile.ProxyId) - } -} diff --git a/backend/internal/browser/proxy_queries_test.go b/backend/internal/browser/proxy_queries_test.go deleted file mode 100644 index c09f4946..00000000 --- a/backend/internal/browser/proxy_queries_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package browser - -import ( - "errors" - "testing" -) - -type proxyQueryTestDAO struct { - list []Proxy - groups []string - groupList []Proxy - listErr error - groupsErr error - groupListErr error -} - -func (d proxyQueryTestDAO) List() ([]Proxy, error) { return d.list, d.listErr } -func (d proxyQueryTestDAO) ListByGroup(string) ([]Proxy, error) { return d.groupList, d.groupListErr } -func (d proxyQueryTestDAO) ListGroups() ([]string, error) { return d.groups, d.groupsErr } -func (d proxyQueryTestDAO) Upsert(Proxy) error { return nil } -func (d proxyQueryTestDAO) Delete(string) error { return nil } -func (d proxyQueryTestDAO) DeleteAll() error { return nil } -func (d proxyQueryTestDAO) UpdateSpeedResult(string, bool, int64, string) error { return nil } -func (d proxyQueryTestDAO) UpdateIPHealthResult(string, string) error { return nil } - -func TestListProxiesWithFallbackUsesDAO(t *testing.T) { - fallback := []Proxy{{ProxyId: "fallback"}} - list := ListProxiesWithFallback(proxyQueryTestDAO{list: []Proxy{{ProxyId: "dao"}}}, fallback) - if len(list) != 1 || list[0].ProxyId != "dao" { - t.Fatalf("list = %#v", list) - } -} - -func TestListProxiesWithFallbackCopiesFallback(t *testing.T) { - fallback := []Proxy{{ProxyId: "fallback"}} - list := ListProxiesWithFallback(proxyQueryTestDAO{listErr: errors.New("failed")}, fallback) - list[0].ProxyId = "changed" - if fallback[0].ProxyId != "fallback" { - t.Fatalf("fallback was mutated") - } -} - -func TestListProxiesByGroupWithFallbackFiltersFallback(t *testing.T) { - list := ListProxiesByGroupWithFallback(nil, "group-a", []Proxy{ - {ProxyId: "a", GroupName: "group-a"}, - {ProxyId: "b", GroupName: "group-b"}, - }) - if len(list) != 1 || list[0].ProxyId != "a" { - t.Fatalf("list = %#v", list) - } -} - -func TestLatestProxiesWithFallbackKeepsFallbackForEmptyDAOList(t *testing.T) { - fallback := []Proxy{{ProxyId: "fallback"}} - list := LatestProxiesWithFallback(proxyQueryTestDAO{}, fallback) - if len(list) != 1 || list[0].ProxyId != "fallback" { - t.Fatalf("list = %#v", list) - } -} diff --git a/backend/internal/browser/session_restore_test.go b/backend/internal/browser/session_restore_test.go deleted file mode 100644 index 27b2bcf9..00000000 --- a/backend/internal/browser/session_restore_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package browser - -import ( - "os" - "path/filepath" - "testing" -) - -func TestClearSessionRestoreDataRemovesSessionArtifactsOnly(t *testing.T) { - t.Parallel() - - userDataDir := t.TempDir() - profileDir := filepath.Join(userDataDir, "Default") - sessionsDir := filepath.Join(profileDir, "Sessions") - if err := os.MkdirAll(sessionsDir, 0o755); err != nil { - t.Fatalf("创建 Sessions 目录失败: %v", err) - } - - filesToCreate := []string{ - filepath.Join(sessionsDir, "Session_1"), - filepath.Join(sessionsDir, "Tabs_1"), - filepath.Join(profileDir, "Last Session"), - filepath.Join(profileDir, "Current Tabs"), - filepath.Join(profileDir, "Preferences"), - } - for _, path := range filesToCreate { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("创建目录失败: %v", err) - } - if err := os.WriteFile(path, []byte("stub"), 0o644); err != nil { - t.Fatalf("写入测试文件失败: %v", err) - } - } - - if err := ClearSessionRestoreData(userDataDir); err != nil { - t.Fatalf("ClearSessionRestoreData 返回错误: %v", err) - } - - if entries, err := os.ReadDir(sessionsDir); err != nil { - t.Fatalf("读取 Sessions 目录失败: %v", err) - } else if len(entries) != 0 { - t.Fatalf("Sessions 目录应为空: got=%d", len(entries)) - } - - for _, name := range []string{"Last Session", "Current Tabs"} { - if _, err := os.Stat(filepath.Join(profileDir, name)); !os.IsNotExist(err) { - t.Fatalf("%s 应已删除: err=%v", name, err) - } - } - - if _, err := os.Stat(filepath.Join(profileDir, "Preferences")); err != nil { - 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) - } -} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go deleted file mode 100644 index 9a0b2d29..00000000 --- a/backend/internal/config/config_test.go +++ /dev/null @@ -1,362 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "runtime" - "testing" -) - -func TestLoadBackfillsLegacyConfig(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - legacyConfig := ` -app: - used_cd_keys: - - GITHUB_STAR_REWARD -logging: {} -browser: {} -` - if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - cfg, err := Load(configPath) - if err != nil { - t.Fatalf("加载配置失败: %v", err) - } - - if cfg.Database.Type != "sqlite" { - t.Fatalf("Database.Type 未补齐: got=%q", cfg.Database.Type) - } - if cfg.Database.SQLite.Path != "data/app.db" { - t.Fatalf("Database.SQLite.Path 未补齐: got=%q", cfg.Database.SQLite.Path) - } - if cfg.App.Name != "Ant Browser" { - t.Fatalf("App.Name 未补齐: got=%q", cfg.App.Name) - } - if cfg.App.MaxProfileLimit != GithubStarProfileTotal { - t.Fatalf("MaxProfileLimit 计算错误: got=%d want=%d", cfg.App.MaxProfileLimit, GithubStarProfileTotal) - } - if cfg.Runtime.MaxMemoryMB != 0 || cfg.Runtime.GCPercent != 100 { - t.Fatalf("Runtime 未补齐: got=%+v", cfg.Runtime) - } - if cfg.Logging.Level != "info" || cfg.Logging.FilePath != "data/logs/app.log" { - t.Fatalf("Logging 基础字段未补齐: got=%+v", cfg.Logging) - } - if !cfg.Logging.Interceptor.Enabled || !cfg.Logging.Interceptor.LogParameters || !cfg.Logging.Interceptor.LogResults { - t.Fatalf("Interceptor 默认值未补齐: got=%+v", cfg.Logging.Interceptor) - } - if len(cfg.Logging.Interceptor.SensitiveFields) == 0 { - t.Fatalf("Interceptor.SensitiveFields 未补齐") - } - if cfg.Browser.UserDataRoot != "data" { - t.Fatalf("Browser.UserDataRoot 未补齐: got=%q", cfg.Browser.UserDataRoot) - } - if len(cfg.Browser.DefaultFingerprintArgs) == 0 || len(cfg.Browser.DefaultLaunchArgs) == 0 { - t.Fatalf("Browser 默认启动参数未补齐") - } - if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 { - t.Fatalf("Browser 默认启动页面应初始化为空切片: got=%v", cfg.Browser.DefaultStartURLs) - } - if cfg.Browser.RestoreLastSession { - t.Fatalf("Browser.RestoreLastSession 默认应为 false") - } - if cfg.Browser.Cores == nil || cfg.Browser.Proxies == nil || cfg.Browser.Profiles == nil { - t.Fatalf("Browser 列表字段应初始化为空切片") - } - if cfg.LaunchServer.Port != DefaultLaunchServerPort { - t.Fatalf("LaunchServer.Port 未补齐: got=%d", cfg.LaunchServer.Port) - } - if cfg.LaunchServer.Auth.Enabled { - t.Fatalf("LaunchServer.Auth.Enabled 默认应为 false: got=%v", cfg.LaunchServer.Auth.Enabled) - } - if cfg.LaunchServer.Auth.APIKey != "" { - t.Fatalf("LaunchServer.Auth.APIKey 默认应为空: got=%q", cfg.LaunchServer.Auth.APIKey) - } - if cfg.LaunchServer.Auth.Header != DefaultLaunchServerAPIKeyHeader { - t.Fatalf("LaunchServer.Auth.Header 未补齐: got=%q", cfg.LaunchServer.Auth.Header) - } - if cfg.Automation.InstallPolicy != DefaultAutomationInstallPolicy { - t.Fatalf("Automation.InstallPolicy 未补齐: got=%q", cfg.Automation.InstallPolicy) - } - if cfg.Automation.RuntimeVersion != DefaultAutomationRuntimeVersion(DefaultAutomationNodeVersion, DefaultAutomationPWVersion) { - t.Fatalf("Automation.RuntimeVersion 未补齐: got=%q", cfg.Automation.RuntimeVersion) - } - if !cfg.Automation.KeepRuntimeOnDisable { - t.Fatalf("Automation.KeepRuntimeOnDisable 默认应为 true") - } - if cfg.Automation.NodeVersion != DefaultAutomationNodeVersion { - t.Fatalf("Automation.NodeVersion 未补齐: got=%q", cfg.Automation.NodeVersion) - } - if cfg.Automation.NodeSource != DefaultAutomationNodeSource { - t.Fatalf("Automation.NodeSource 未补齐: got=%q", cfg.Automation.NodeSource) - } - if cfg.Automation.SystemNodePath != "" { - t.Fatalf("Automation.SystemNodePath 默认应为空: got=%q", cfg.Automation.SystemNodePath) - } - if cfg.Automation.PlaywrightCoreVersion != DefaultAutomationPWVersion { - t.Fatalf("Automation.PlaywrightCoreVersion 未补齐: got=%q", cfg.Automation.PlaywrightCoreVersion) - } - if cfg.Automation.AllowTypeScriptBuild { - t.Fatalf("Automation.AllowTypeScriptBuild 默认应为 false") - } -} - -func TestDefaultFingerprintArgsForOS(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "windows": "--fingerprint-platform=windows", - "linux": "--fingerprint-platform=linux", - "darwin": "--fingerprint-platform=mac", - "freebsd": "--fingerprint-platform=windows", - } - - for goos, want := range tests { - got := defaultFingerprintArgsForOS(goos) - if len(got) != 2 { - t.Fatalf("%s: unexpected args length: got=%v", goos, got) - } - if got[1] != want { - t.Fatalf("%s: platform arg mismatch: got=%q want=%q", goos, got[1], want) - } - } -} - -func TestDefaultConfigUsesCurrentOSFingerprintPlatform(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - want := defaultFingerprintArgsForOS(runtime.GOOS) - if len(cfg.Browser.DefaultFingerprintArgs) != len(want) { - t.Fatalf("默认指纹参数数量不符: got=%v want=%v", cfg.Browser.DefaultFingerprintArgs, want) - } - for i := range want { - if cfg.Browser.DefaultFingerprintArgs[i] != want[i] { - t.Fatalf("默认指纹参数不符: got=%v want=%v", cfg.Browser.DefaultFingerprintArgs, want) - } - } -} - -func TestNormalizeBrowserConnectorTypeAliases(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "": BrowserConnectorXray, - "xray": BrowserConnectorXray, - "sing-box": BrowserConnectorXray, - "singbox": BrowserConnectorXray, - "sing_box": BrowserConnectorXray, - "mihomo": BrowserConnectorMihomo, - "clash": BrowserConnectorMihomo, - "clash-meta": BrowserConnectorMihomo, - } - for input, want := range tests { - if got := NormalizeBrowserConnectorType(input); got != want { - t.Fatalf("NormalizeBrowserConnectorType(%q) = %q, want %q", input, got, want) - } - } -} - -func TestSingBoxAliasStaysInsideXrayConnectorStack(t *testing.T) { - t.Parallel() - - if got := NormalizeBrowserConnectorType("sing-box"); got != BrowserConnectorXrayStack { - t.Fatalf("sing-box alias = %q, want xray connector stack", got) - } -} - -func TestLoadClearsLegacyVerificationStartURLs(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - legacyConfig := ` -browser: - default_start_urls: - - https://ippure.com/ - - https://iplark.com/ - - https://ping0.cc/ -` - if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - cfg, err := Load(configPath) - if err != nil { - t.Fatalf("加载配置失败: %v", err) - } - if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 { - t.Fatalf("旧默认检测页应迁移为空: got=%v", cfg.Browser.DefaultStartURLs) - } -} - -func TestLoadPreservesExplicitConfig(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - customConfig := ` -database: - type: sqlite - sqlite: - path: custom/app.db -app: - name: Custom App - window: - width: 1400 - height: 800 - min_width: 900 - min_height: 600 - max_profile_limit: 20 - used_cd_keys: [] -runtime: - max_memory_mb: 2048 - gc_percent: 80 -logging: - level: debug - file_enabled: true - file_path: custom.log - format: json - buffer_size: 8 - async_queue_size: 2000 - flush_interval_ms: 500 - rotation: - enabled: true - max_size_mb: 10 - max_age: 3 - max_backups: 2 - time_interval: hourly - interceptor: - enabled: false - log_parameters: false - log_results: false - sensitive_fields: [] -browser: - user_data_root: custom_data - default_fingerprint_args: - - --fingerprint-brand=Edge - default_launch_args: - - --start-maximized - default_start_urls: [] - restore_last_session: true - default_proxy: direct:// - default_bookmarks: [] - cores: [] - proxies: [] - profiles: [] -launch_server: - port: 30000 - auth: - enabled: true - api_key: secret-key - header: X-Custom-Ant-Key -automation: - enabled: true - install_policy: on_demand - runtime_version: custom-runtime - 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 - playwright_core_version: 1.59.0 -` - if err := os.WriteFile(configPath, []byte(customConfig), 0o644); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - cfg, err := Load(configPath) - if err != nil { - t.Fatalf("加载配置失败: %v", err) - } - - if cfg.App.Name != "Custom App" || cfg.App.MaxProfileLimit != 20 { - t.Fatalf("App 显式配置被覆盖: got=%+v", cfg.App) - } - if cfg.Database.SQLite.Path != "custom/app.db" { - t.Fatalf("Database.SQLite.Path 显式配置被覆盖: got=%q", cfg.Database.SQLite.Path) - } - if cfg.Runtime.MaxMemoryMB != 2048 || cfg.Runtime.GCPercent != 80 { - t.Fatalf("Runtime 显式配置被覆盖: got=%+v", cfg.Runtime) - } - if cfg.Logging.Level != "debug" || cfg.Logging.Format != "json" || !cfg.Logging.FileEnabled { - t.Fatalf("Logging 显式配置被覆盖: got=%+v", cfg.Logging) - } - if cfg.Logging.Interceptor.Enabled { - t.Fatalf("Interceptor.Enabled 显式 false 被覆盖") - } - if len(cfg.Browser.DefaultFingerprintArgs) != 1 || cfg.Browser.DefaultFingerprintArgs[0] != "--fingerprint-brand=Edge" { - t.Fatalf("Browser.DefaultFingerprintArgs 显式配置被覆盖: got=%v", cfg.Browser.DefaultFingerprintArgs) - } - if cfg.Browser.DefaultStartURLs == nil || len(cfg.Browser.DefaultStartURLs) != 0 { - t.Fatalf("Browser.DefaultStartURLs 显式空配置被覆盖: got=%v", cfg.Browser.DefaultStartURLs) - } - if !cfg.Browser.RestoreLastSession { - t.Fatalf("Browser.RestoreLastSession 显式 true 被覆盖") - } - if cfg.Browser.UserDataRoot != "custom_data" { - t.Fatalf("Browser 显式配置被覆盖: got=%+v", cfg.Browser) - } - if cfg.LaunchServer.Port != 30000 { - t.Fatalf("LaunchServer.Port 显式配置被覆盖: got=%d", cfg.LaunchServer.Port) - } - if !cfg.LaunchServer.Auth.Enabled { - t.Fatalf("LaunchServer.Auth.Enabled 显式配置被覆盖") - } - if cfg.LaunchServer.Auth.APIKey != "secret-key" { - t.Fatalf("LaunchServer.Auth.APIKey 显式配置被覆盖: got=%q", cfg.LaunchServer.Auth.APIKey) - } - if cfg.LaunchServer.Auth.Header != "X-Custom-Ant-Key" { - t.Fatalf("LaunchServer.Auth.Header 显式配置被覆盖: got=%q", cfg.LaunchServer.Auth.Header) - } - if !cfg.Automation.Enabled || !cfg.Automation.HeadlessDefault { - t.Fatalf("Automation 显式配置被覆盖: got=%+v", cfg.Automation) - } - if cfg.Automation.RuntimeVersion != "custom-runtime" { - t.Fatalf("Automation.RuntimeVersion 显式配置被覆盖: got=%q", cfg.Automation.RuntimeVersion) - } - if cfg.Automation.KeepRuntimeOnDisable { - t.Fatalf("Automation.KeepRuntimeOnDisable 显式 false 被覆盖") - } - if cfg.Automation.NodeSource != AutomationNodeSourceSystem { - t.Fatalf("Automation.NodeSource 显式配置被覆盖: got=%q", cfg.Automation.NodeSource) - } - if cfg.Automation.SystemNodePath != "C:/tools/node/node.exe" { - t.Fatalf("Automation.SystemNodePath 显式配置被覆盖: got=%q", cfg.Automation.SystemNodePath) - } - 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) { - t.Parallel() - - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - legacyConfig := ` -logging: - file_path: logs/app.log -` - if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - cfg, err := Load(configPath) - if err != nil { - t.Fatalf("加载配置失败: %v", err) - } - - if cfg.Logging.FilePath != "data/logs/app.log" { - t.Fatalf("legacy 根目录日志路径未迁移: got=%q", cfg.Logging.FilePath) - } -} diff --git a/backend/internal/fsutil/path_test.go b/backend/internal/fsutil/path_test.go deleted file mode 100644 index 861b1e5e..00000000 --- a/backend/internal/fsutil/path_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package fsutil - -import ( - "os" - "path/filepath" - goruntime "runtime" - "testing" -) - -func TestNormalizePathInputConvertsWindowsSeparators(t *testing.T) { - t.Parallel() - - got := NormalizePathInput(`chrome\Chrom-144\chrome.exe`) - want := filepath.Join("chrome", "Chrom-144", "chrome.exe") - if got != want { - t.Fatalf("NormalizePathInput() = %q, want %q", got, want) - } -} - -func TestResolveUserDataDir(t *testing.T) { - t.Parallel() - - root := t.TempDir() - got, err := ResolveUserDataDir(func(path string) string { - return filepath.Join(root, path) - }, "profiles", "profile-a") - if err != nil { - t.Fatalf("ResolveUserDataDir() 返回错误: %v", err) - } - want := filepath.Join(root, "profiles", "profile-a") - if got != want { - t.Fatalf("ResolveUserDataDir() = %q, want %q", got, want) - } -} - -func TestResolveUserDataDirUsesDefaultRoot(t *testing.T) { - t.Parallel() - - got, err := ResolveUserDataDir(func(path string) string { return filepath.Join("app", path) }, "", "profile-a") - if err != nil { - t.Fatalf("ResolveUserDataDir() 返回错误: %v", err) - } - want := filepath.Join("app", "data", "profile-a") - if got != want { - t.Fatalf("ResolveUserDataDir() = %q, want %q", got, want) - } -} - -func TestResolveExistingPathUsesResolverForRelativePath(t *testing.T) { - t.Parallel() - - got, err := ResolveExistingPath(func(path string) string { return filepath.Join("app", path) }, "chrome/core", "不能为空") - if err != nil { - t.Fatalf("ResolveExistingPath() 返回错误: %v", err) - } - want := filepath.Join("app", "chrome/core") - if got != want { - t.Fatalf("ResolveExistingPath() = %q, want %q", got, want) - } -} - -func TestEnsureExecutableRepairsMissingExecBitsOnUnix(t *testing.T) { - t.Parallel() - - if goruntime.GOOS == "windows" { - t.Skip("Windows does not use POSIX execute bits") - } - - path := filepath.Join(t.TempDir(), "tool") - if err := os.WriteFile(path, []byte("stub"), 0o644); err != nil { - t.Fatalf("写入测试文件失败: %v", err) - } - - if err := EnsureExecutable(path); err != nil { - t.Fatalf("EnsureExecutable() 返回错误: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("读取测试文件状态失败: %v", err) - } - if info.Mode()&0o111 == 0 { - t.Fatalf("EnsureExecutable() 未补充执行权限: mode=%#o", info.Mode().Perm()) - } -} diff --git a/backend/internal/launchcode/server_auth_internal_test.go b/backend/internal/launchcode/server_auth_internal_test.go deleted file mode 100644 index 59507f8f..00000000 --- a/backend/internal/launchcode/server_auth_internal_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package launchcode - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestBuildHandlerRejectsNonLocalRequestBeforeAPIAuth(t *testing.T) { - srv := NewLaunchServer(NewLaunchCodeService(NewMemoryLaunchCodeDAO()), nil, nil, 0) - srv.SetAPIAuthConfig(APIAuthConfig{ - Enabled: true, - APIKey: "secret-key", - Header: "X-Test-Api-Key", - }) - - req := httptest.NewRequest(http.MethodGet, "/api/health", nil) - req.RemoteAddr = "10.0.0.8:3456" - w := httptest.NewRecorder() - srv.buildHandler(true).ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("非 localhost 请求应优先返回 403: got=%d body=%s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "forbidden: only localhost is allowed") { - t.Fatalf("错误信息不正确: %s", w.Body.String()) - } -} diff --git a/backend/internal/launchcode/server_start_test.go b/backend/internal/launchcode/server_start_test.go deleted file mode 100644 index 8c555799..00000000 --- a/backend/internal/launchcode/server_start_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package launchcode_test - -import ( - "fmt" - "net" - "net/http" - "testing" - - "ant-chrome/backend/internal/launchcode" -) - -func TestLaunchServerStartWithAutoPort(t *testing.T) { - svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - srv := launchcode.NewLaunchServer(svc, nil, nil, 0) - - if err := srv.Start(); err != nil { - t.Fatalf("Start 失败: %v", err) - } - defer func() { - _ = srv.Stop() - }() - - port := srv.Port() - if port <= 0 { - t.Fatalf("自动端口分配失败: got=%d", port) - } - - resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port)) - if err != nil { - t.Fatalf("健康检查请求失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Fatalf("健康检查状态码错误: got=%d", resp.StatusCode) - } -} - -func TestLaunchServerReturnsErrorWhenPreferredPortIsBusy(t *testing.T) { - occupied, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("占用端口失败: %v", err) - } - defer occupied.Close() - - busyPort := occupied.Addr().(*net.TCPAddr).Port - svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) - srv := launchcode.NewLaunchServer(svc, nil, nil, busyPort) - - if err := srv.Start(); err == nil { - defer func() { - _ = srv.Stop() - }() - t.Fatalf("期望固定端口被占用时返回错误,但启动成功了: %d", busyPort) - } -} diff --git a/backend/internal/logger/formatter_test.go b/backend/internal/logger/formatter_test.go deleted file mode 100644 index 25487205..00000000 --- a/backend/internal/logger/formatter_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package logger - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" -) - -// genLevel 生成随机日志级别 -func genLevel() gopter.Gen { - return gen.IntRange(0, 3).Map(func(i int) Level { - return Level(i) - }) -} - -// genLogEntry 生成随机 LogEntry -func genLogEntry() gopter.Gen { - return gopter.CombineGens( - genLevel(), - gen.AlphaString(), - gen.AlphaString(), - gen.AlphaString(), - gen.AlphaString(), - gen.Int64Range(0, 10000), - ).Map(func(values []interface{}) *LogEntry { - level := values[0].(Level) - component := values[1].(string) - message := values[2].(string) - requestID := values[3].(string) - method := values[4].(string) - duration := values[5].(int64) - - entry := &LogEntry{ - Timestamp: time.Now(), - Level: level, - Component: component, - Message: message, - RequestID: requestID, - Method: method, - Duration: duration, - } - return entry - }) -} - -// TestProperty13_JSONFormatValidity 属性测试:JSON 格式有效性 -// **Property 13: JSON Format Validity** -// **Validates: Requirements 6.2** -// *For any* log entry when JSON format is configured, the output SHALL be valid JSON -// that can be parsed without error. -func TestProperty13_JSONFormatValidity(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - formatter := NewJSONFormatter() - - properties.Property("JSON output is always valid JSON", prop.ForAll( - func(entry *LogEntry) bool { - // Format the entry - data, err := formatter.Format(entry) - if err != nil { - return false - } - - // Verify it's valid JSON by attempting to unmarshal - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return false - } - - return true - }, - genLogEntry(), - )) - - properties.TestingRun(t) -} - -// TestProperty12_StructuredLogFieldCompleteness 属性测试:结构化日志字段完整性 -// **Property 12: Structured Log Field Completeness** -// **Validates: Requirements 6.1** -// *For any* log entry, the output SHALL contain: timestamp (ISO 8601), level -// (DEBUG/INFO/WARN/ERROR), component name, and message. -func TestProperty12_StructuredLogFieldCompleteness(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - textFormatter := NewTextFormatter() - jsonFormatter := NewJSONFormatter() - - // Test TextFormatter field completeness - properties.Property("TextFormatter output contains all required fields", prop.ForAll( - func(entry *LogEntry) bool { - data, err := textFormatter.Format(entry) - if err != nil { - return false - } - - output := string(data) - - // Check timestamp format (YYYY-MM-DD HH:MM:SS.mmm) - if !strings.Contains(output, "[") || !strings.Contains(output, "]") { - return false - } - - // Check level is present (DEBUG/INFO/WARN/ERROR) - levelStr := entry.Level.String() - if !strings.Contains(output, "["+levelStr+"]") { - return false - } - - // Check component is present (or "-" if empty) - if entry.Component != "" { - if !strings.Contains(output, "["+entry.Component+"]") { - return false - } - } else { - if !strings.Contains(output, "[-]") { - return false - } - } - - // Check message is present - if !strings.Contains(output, entry.Message) { - return false - } - - return true - }, - genLogEntry(), - )) - - // Test JSONFormatter field completeness - properties.Property("JSONFormatter output contains all required fields", prop.ForAll( - func(entry *LogEntry) bool { - data, err := jsonFormatter.Format(entry) - if err != nil { - return false - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return false - } - - // Check timestamp exists and is in ISO 8601 format - timestamp, ok := result["timestamp"].(string) - if !ok || timestamp == "" { - return false - } - // Verify timestamp can be parsed as ISO 8601 - _, err = time.Parse("2006-01-02T15:04:05.000Z07:00", timestamp) - if err != nil { - return false - } - - // Check level exists and is valid - level, ok := result["level"].(string) - if !ok { - return false - } - validLevels := map[string]bool{"DEBUG": true, "INFO": true, "WARN": true, "ERROR": true} - if !validLevels[level] { - return false - } - - // Check component exists - if _, ok := result["component"]; !ok { - return false - } - - // Check message exists - if _, ok := result["message"]; !ok { - return false - } - - return true - }, - genLogEntry(), - )) - - properties.TestingRun(t) -} - -// TestTextFormatterBasic 基础单元测试:TextFormatter -func TestTextFormatterBasic(t *testing.T) { - formatter := NewTextFormatter() - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - - entry := &LogEntry{ - Timestamp: testTime, - Level: INFO, - Component: "TestComponent", - Message: "Test message", - } - - data, err := formatter.Format(entry) - if err != nil { - t.Fatalf("Format failed: %v", err) - } - - output := string(data) - - // Verify basic structure - if !strings.Contains(output, "[2024-01-15 10:30:00.000]") { - t.Errorf("Timestamp not found in output: %s", output) - } - if !strings.Contains(output, "[INFO]") { - t.Errorf("Level not found in output: %s", output) - } - if !strings.Contains(output, "[TestComponent]") { - t.Errorf("Component not found in output: %s", output) - } - if !strings.Contains(output, "Test message") { - t.Errorf("Message not found in output: %s", output) - } -} - -// TestJSONFormatterBasic 基础单元测试:JSONFormatter -func TestJSONFormatterBasic(t *testing.T) { - formatter := NewJSONFormatter() - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - - entry := &LogEntry{ - Timestamp: testTime, - Level: INFO, - Component: "TestComponent", - Message: "Test message", - RequestID: "req-123", - Method: "TestMethod", - Duration: 150, - } - - data, err := formatter.Format(entry) - if err != nil { - t.Fatalf("Format failed: %v", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("JSON unmarshal failed: %v", err) - } - - // Verify fields - if result["level"] != "INFO" { - t.Errorf("Level should be 'INFO', got %v", result["level"]) - } - if result["component"] != "TestComponent" { - t.Errorf("Component should be 'TestComponent', got %v", result["component"]) - } - if result["message"] != "Test message" { - t.Errorf("Message should be 'Test message', got %v", result["message"]) - } - if result["request_id"] != "req-123" { - t.Errorf("RequestID should be 'req-123', got %v", result["request_id"]) - } -} - -// TestFormatterNilEntry 测试 nil entry 处理 -func TestFormatterNilEntry(t *testing.T) { - textFormatter := NewTextFormatter() - jsonFormatter := NewJSONFormatter() - - _, err := textFormatter.Format(nil) - if err == nil { - t.Error("TextFormatter should return error for nil entry") - } - - _, err = jsonFormatter.Format(nil) - if err == nil { - t.Error("JSONFormatter should return error for nil entry") - } -} diff --git a/backend/internal/logger/interceptor_test.go b/backend/internal/logger/interceptor_test.go deleted file mode 100644 index 050d4a7d..00000000 --- a/backend/internal/logger/interceptor_test.go +++ /dev/null @@ -1,466 +0,0 @@ -package logger - -import ( - "errors" - "sync" - "testing" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" -) - -// TestProperty3_RequestIDUniqueness 属性测试:请求 ID 唯一性 -// **Property 3: Request ID Uniqueness** -// **Validates: Requirements 2.4** -// *For any* sequence of N method calls through the interceptor, all N generated -// request IDs SHALL be unique (no duplicates). -func TestProperty3_RequestIDUniqueness(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("All generated request IDs are unique", prop.ForAll( - func(n int) bool { - if n <= 0 { - return true - } - - ids := make(map[string]bool) - for i := 0; i < n; i++ { - id := GenerateRequestID() - if ids[id] { - // Duplicate found - return false - } - ids[id] = true - } - return true - }, - gen.IntRange(1, 1000), - )) - - properties.TestingRun(t) -} - -// TestProperty3_RequestIDUniqueness_Concurrent 并发场景下的请求 ID 唯一性 -func TestProperty3_RequestIDUniqueness_Concurrent(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Concurrent request ID generation produces unique IDs", prop.ForAll( - func(goroutines int, idsPerGoroutine int) bool { - if goroutines <= 0 || idsPerGoroutine <= 0 { - return true - } - - var mu sync.Mutex - ids := make(map[string]bool) - var wg sync.WaitGroup - - for g := 0; g < goroutines; g++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < idsPerGoroutine; i++ { - id := GenerateRequestID() - mu.Lock() - if ids[id] { - mu.Unlock() - return - } - ids[id] = true - mu.Unlock() - } - }() - } - - wg.Wait() - - // Verify total count matches expected - expectedCount := goroutines * idsPerGoroutine - return len(ids) == expectedCount - }, - gen.IntRange(1, 10), - gen.IntRange(1, 100), - )) - - properties.TestingRun(t) -} - -// TestProperty4_SensitiveFieldMasking 属性测试:敏感字段脱敏 -// **Property 4: Sensitive Field Masking** -// **Validates: Requirements 2.5** -// *For any* log entry containing fields configured as sensitive, the logged value -// SHALL be masked (e.g., "***") and SHALL NOT contain the original value. -func TestProperty4_SensitiveFieldMasking(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - // Test map masking - properties.Property("Sensitive fields in maps are masked", prop.ForAll( - func(sensitiveField string, sensitiveValue string, normalField string, normalValue string) bool { - // Skip empty field names - if sensitiveField == "" || normalField == "" { - return true - } - // Ensure fields are different - if sensitiveField == normalField { - return true - } - - interceptor := NewMethodInterceptor(nil, InterceptorConfig{ - Enabled: true, - LogParameters: true, - SensitiveFields: []string{sensitiveField}, - }) - - input := map[string]interface{}{ - sensitiveField: sensitiveValue, - normalField: normalValue, - } - - masked := interceptor.maskValue(input) - maskedMap, ok := masked.(map[string]interface{}) - if !ok { - return false - } - - // Sensitive field should be masked - if maskedMap[sensitiveField] != "***" { - return false - } - - // Normal field should not be masked - if maskedMap[normalField] != normalValue { - return false - } - - return true - }, - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }), - gen.AlphaString(), - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }), - gen.AlphaString(), - )) - - properties.TestingRun(t) -} - -// TestProperty4_SensitiveFieldMasking_CaseInsensitive 测试大小写不敏感 -func TestProperty4_SensitiveFieldMasking_CaseInsensitive(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Sensitive field matching is case-insensitive", prop.ForAll( - func(fieldName string, value string) bool { - if fieldName == "" { - return true - } - - // Configure with lowercase - interceptor := NewMethodInterceptor(nil, InterceptorConfig{ - Enabled: true, - LogParameters: true, - SensitiveFields: []string{fieldName}, - }) - - // Test with various case variations - variations := []string{ - fieldName, - toUpperCase(fieldName), - toLowerCase(fieldName), - mixedCase(fieldName), - } - - for _, variant := range variations { - input := map[string]interface{}{ - variant: value, - } - - masked := interceptor.maskValue(input) - maskedMap, ok := masked.(map[string]interface{}) - if !ok { - return false - } - - // All variations should be masked - if maskedMap[variant] != "***" { - return false - } - } - - return true - }, - gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }), - gen.AlphaString(), - )) - - properties.TestingRun(t) -} - -// Helper functions for case conversion -func toUpperCase(s string) string { - result := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'a' && c <= 'z' { - result[i] = c - 32 - } else { - result[i] = c - } - } - return string(result) -} - -func toLowerCase(s string) string { - result := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - result[i] = c + 32 - } else { - result[i] = c - } - } - return string(result) -} - -func mixedCase(s string) string { - result := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if i%2 == 0 { - if c >= 'a' && c <= 'z' { - result[i] = c - 32 - } else { - result[i] = c - } - } else { - if c >= 'A' && c <= 'Z' { - result[i] = c + 32 - } else { - result[i] = c - } - } - } - return string(result) -} - -// TestProperty4_SensitiveFieldMasking_NestedStructures 测试嵌套结构脱敏 -func TestProperty4_SensitiveFieldMasking_NestedStructures(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Sensitive fields in nested maps are masked", prop.ForAll( - func(sensitiveValue string) bool { - interceptor := NewMethodInterceptor(nil, InterceptorConfig{ - Enabled: true, - LogParameters: true, - SensitiveFields: []string{"password", "token", "secret"}, - }) - - // Create nested structure - input := map[string]interface{}{ - "user": map[string]interface{}{ - "name": "testuser", - "password": sensitiveValue, - }, - "auth": map[string]interface{}{ - "token": sensitiveValue, - }, - } - - masked := interceptor.maskValue(input) - maskedMap, ok := masked.(map[string]interface{}) - if !ok { - return false - } - - // Check nested password is masked - userMap, ok := maskedMap["user"].(map[string]interface{}) - if !ok { - return false - } - if userMap["password"] != "***" { - return false - } - if userMap["name"] != "testuser" { - return false - } - - // Check nested token is masked - authMap, ok := maskedMap["auth"].(map[string]interface{}) - if !ok { - return false - } - if authMap["token"] != "***" { - return false - } - - return true - }, - gen.AlphaString(), - )) - - properties.TestingRun(t) -} - -// TestProperty11_LoggerFaultIsolation 属性测试:日志系统错误隔离 -// **Property 11: Logger Fault Isolation** -// **Validates: Requirements 5.3** -// *For any* error occurring in the logging system (file write failure, formatter -// error, etc.), the wrapped business method SHALL still execute and return normally. -func TestProperty11_LoggerFaultIsolation(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Business method executes normally even when logger is nil", prop.ForAll( - func(input int) bool { - // Create interceptor with nil logger (simulates logger failure) - interceptor := NewMethodInterceptor(nil, InterceptorConfig{ - Enabled: true, - LogParameters: true, - LogResults: true, - }) - - // Wrap a simple function - expectedResult := input * 2 - wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} { - return input * 2 - }) - - // Execute wrapped function - result := wrappedFn() - - // Verify business logic executed correctly - return result == expectedResult - }, - gen.Int(), - )) - - properties.TestingRun(t) -} - -// TestProperty11_LoggerFaultIsolation_WithError 测试返回错误的方法 -func TestProperty11_LoggerFaultIsolation_WithError(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Error-returning method works with nil logger", prop.ForAll( - func(shouldError bool) bool { - interceptor := NewMethodInterceptor(nil, InterceptorConfig{ - Enabled: true, - LogParameters: true, - LogResults: true, - }) - - var expectedErr error - if shouldError { - expectedErr = errors.New("test error") - } - - wrappedFn := interceptor.WrapFuncWithError("TestMethod", func() error { - return expectedErr - }) - - // Execute wrapped function - resultErr := wrappedFn() - - // Verify error is returned correctly - if shouldError { - return resultErr != nil && resultErr.Error() == "test error" - } - return resultErr == nil - }, - gen.Bool(), - )) - - properties.TestingRun(t) -} - -// faultyWriter 模拟故障的写入器 -type faultyWriter struct { - shouldPanic bool -} - -func (w *faultyWriter) Write(entry *LogEntry) error { - if w.shouldPanic { - panic("simulated writer panic") - } - return errors.New("simulated write error") -} - -func (w *faultyWriter) Close() error { - return nil -} - -// TestProperty11_LoggerFaultIsolation_FaultyWriter 测试故障写入器 -func TestProperty11_LoggerFaultIsolation_FaultyWriter(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Business method executes normally with faulty writer", prop.ForAll( - func(input string) bool { - // Create a logger with faulty writer - logger := &Logger{ - level: INFO, - writers: []Writer{&faultyWriter{shouldPanic: false}}, - } - - interceptor := NewMethodInterceptor(logger, InterceptorConfig{ - Enabled: true, - LogParameters: true, - LogResults: true, - }) - - // Wrap a simple function - expectedResult := "processed: " + input - wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} { - return "processed: " + input - }) - - // Execute wrapped function - result := wrappedFn() - - // Verify business logic executed correctly - return result == expectedResult - }, - gen.AlphaString(), - )) - - properties.TestingRun(t) -} - -// TestProperty11_LoggerFaultIsolation_DisabledInterceptor 测试禁用的拦截器 -func TestProperty11_LoggerFaultIsolation_DisabledInterceptor(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - properties.Property("Disabled interceptor passes through without modification", prop.ForAll( - func(input int) bool { - interceptor := NewMethodInterceptor(nil, InterceptorConfig{ - Enabled: false, - }) - - expectedResult := input * 3 - wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} { - return input * 3 - }) - - result := wrappedFn() - return result == expectedResult - }, - gen.Int(), - )) - - properties.TestingRun(t) -} diff --git a/backend/internal/logger/interfaces_test.go b/backend/internal/logger/interfaces_test.go deleted file mode 100644 index 9c50138b..00000000 --- a/backend/internal/logger/interfaces_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package logger - -import ( - "encoding/json" - "testing" - "time" -) - -// TestLogEntryJSONSerialization 测试 LogEntry JSON 序列化 -func TestLogEntryJSONSerialization(t *testing.T) { - // 创建测试时间 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - - // 创建完整的 LogEntry - entry := &LogEntry{ - Timestamp: testTime, - Level: INFO, - Component: "TestComponent", - Message: "Test message", - Fields: map[string]interface{}{"key1": "value1", "key2": 123}, - RequestID: "req-12345", - Method: "TestMethod", - Duration: 150, - CallerFile: "test.go", - CallerLine: 42, - Error: "", - } - - // 序列化 - data, err := entry.ToJSON() - if err != nil { - t.Fatalf("ToJSON failed: %v", err) - } - - // 验证是有效的 JSON - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("JSON unmarshal failed: %v", err) - } - - // 验证必需字段存在 - requiredFields := []string{"timestamp", "level", "component", "message"} - for _, field := range requiredFields { - if _, ok := result[field]; !ok { - t.Errorf("Required field %q missing from JSON output", field) - } - } - - // 验证 Level 以字符串形式输出 - if level, ok := result["level"].(string); !ok || level != "INFO" { - t.Errorf("Level should be string 'INFO', got %v", result["level"]) - } - - // 验证 Component - if component, ok := result["component"].(string); !ok || component != "TestComponent" { - t.Errorf("Component should be 'TestComponent', got %v", result["component"]) - } - - // 验证 Message - if message, ok := result["message"].(string); !ok || message != "Test message" { - t.Errorf("Message should be 'Test message', got %v", result["message"]) - } - - // 验证 RequestID - if requestID, ok := result["request_id"].(string); !ok || requestID != "req-12345" { - t.Errorf("RequestID should be 'req-12345', got %v", result["request_id"]) - } - - // 验证 Method - if method, ok := result["method"].(string); !ok || method != "TestMethod" { - t.Errorf("Method should be 'TestMethod', got %v", result["method"]) - } - - // 验证 Duration - if duration, ok := result["duration_ms"].(float64); !ok || duration != 150 { - t.Errorf("Duration should be 150, got %v", result["duration_ms"]) - } -} - -// TestLogEntryJSONSerializationAllLevels 测试所有日志级别的序列化 -func TestLogEntryJSONSerializationAllLevels(t *testing.T) { - levels := []struct { - level Level - expected string - }{ - {DEBUG, "DEBUG"}, - {INFO, "INFO"}, - {WARN, "WARN"}, - {ERROR, "ERROR"}, - } - - for _, tc := range levels { - t.Run(tc.expected, func(t *testing.T) { - entry := NewLogEntry(tc.level, "TestComponent", "Test message") - data, err := entry.ToJSON() - if err != nil { - t.Fatalf("ToJSON failed: %v", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("JSON unmarshal failed: %v", err) - } - - if level, ok := result["level"].(string); !ok || level != tc.expected { - t.Errorf("Level should be %q, got %v", tc.expected, result["level"]) - } - }) - } -} - -// TestLogEntryOmitEmptyFields 测试空字段不输出 -func TestLogEntryOmitEmptyFields(t *testing.T) { - // 创建只有必需字段的 LogEntry - entry := NewLogEntry(INFO, "TestComponent", "Test message") - - data, err := entry.ToJSON() - if err != nil { - t.Fatalf("ToJSON failed: %v", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("JSON unmarshal failed: %v", err) - } - - // 验证可选字段不存在(omitempty) - optionalFields := []string{"fields", "request_id", "method", "error"} - for _, field := range optionalFields { - if val, ok := result[field]; ok && val != "" { - t.Errorf("Optional field %q should be omitted when empty, got %v", field, val) - } - } -} - -// TestLogEntryWithMethods 测试链式方法 -func TestLogEntryWithMethods(t *testing.T) { - entry := NewLogEntry(INFO, "TestComponent", "Test message"). - WithRequestID("req-123"). - WithMethod("TestMethod"). - WithDuration(100). - WithCaller("test.go", 10). - WithFields(map[string]interface{}{"key": "value"}) - - if entry.RequestID != "req-123" { - t.Errorf("RequestID should be 'req-123', got %q", entry.RequestID) - } - if entry.Method != "TestMethod" { - t.Errorf("Method should be 'TestMethod', got %q", entry.Method) - } - if entry.Duration != 100 { - t.Errorf("Duration should be 100, got %d", entry.Duration) - } - if entry.CallerFile != "test.go" { - t.Errorf("CallerFile should be 'test.go', got %q", entry.CallerFile) - } - if entry.CallerLine != 10 { - t.Errorf("CallerLine should be 10, got %d", entry.CallerLine) - } - if entry.Fields["key"] != "value" { - t.Errorf("Fields['key'] should be 'value', got %v", entry.Fields["key"]) - } -} diff --git a/backend/internal/logger/logger_test.go b/backend/internal/logger/logger_test.go deleted file mode 100644 index 81413a50..00000000 --- a/backend/internal/logger/logger_test.go +++ /dev/null @@ -1,439 +0,0 @@ -package logger - -import ( - "bytes" - "context" - "sync" - "testing" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" -) - -// MockWriter 用于测试的模拟写入器 -type MockWriter struct { - entries []*LogEntry - mu sync.Mutex -} - -func NewMockWriter() *MockWriter { - return &MockWriter{ - entries: make([]*LogEntry, 0), - } -} - -func (w *MockWriter) Write(entry *LogEntry) error { - w.mu.Lock() - defer w.mu.Unlock() - w.entries = append(w.entries, entry) - return nil -} - -func (w *MockWriter) Close() error { - return nil -} - -func (w *MockWriter) GetEntries() []*LogEntry { - w.mu.Lock() - defer w.mu.Unlock() - result := make([]*LogEntry, len(w.entries)) - copy(result, w.entries) - return result -} - -func (w *MockWriter) Clear() { - w.mu.Lock() - defer w.mu.Unlock() - w.entries = make([]*LogEntry, 0) -} - -// createTestLogger 创建用于测试的 Logger -func createTestLogger(level Level, writer Writer) *Logger { - return &Logger{ - level: level, - component: "test", - writers: []Writer{writer}, - consoleWriter: writer, - } -} - -// TestProperty5_LogLevelFiltering 属性测试:日志级别过滤 -// Property 5: Log Level Filtering -// *For any* configured log level L, all log entries with level below L SHALL NOT be written -// to any output, and all entries with level >= L SHALL be written. -// **Validates: Requirements 3.2** -func TestProperty5_LogLevelFiltering(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - // 生成日志级别 (0-3: DEBUG, INFO, WARN, ERROR) - levelGen := gen.IntRange(0, 3).Map(func(i int) Level { - return Level(i) - }) - - // Property: 低于配置级别的日志不应被写入 - properties.Property("logs below configured level are not written", prop.ForAll( - func(configuredLevel Level, entryLevel Level) bool { - mockWriter := NewMockWriter() - logger := createTestLogger(configuredLevel, mockWriter) - - // 根据 entryLevel 调用相应的日志方法 - switch entryLevel { - case DEBUG: - logger.Debug("test message") - case INFO: - logger.Info("test message") - case WARN: - logger.Warn("test message") - case ERROR: - logger.Error("test message") - } - - entries := mockWriter.GetEntries() - - // 如果 entryLevel < configuredLevel,不应该有日志写入 - if entryLevel < configuredLevel { - return len(entries) == 0 - } - // 如果 entryLevel >= configuredLevel,应该有日志写入 - return len(entries) == 1 && entries[0].Level == entryLevel - }, - levelGen, - levelGen, - )) - - // Property: 等于或高于配置级别的日志应被写入 - properties.Property("logs at or above configured level are written", prop.ForAll( - func(configuredLevel Level) bool { - mockWriter := NewMockWriter() - logger := createTestLogger(configuredLevel, mockWriter) - - // 写入所有级别的日志 - logger.Debug("debug message") - logger.Info("info message") - logger.Warn("warn message") - logger.Error("error message") - - entries := mockWriter.GetEntries() - - // 计算应该写入的日志数量 - expectedCount := 0 - for level := DEBUG; level <= ERROR; level++ { - if level >= configuredLevel { - expectedCount++ - } - } - - if len(entries) != expectedCount { - return false - } - - // 验证所有写入的日志级别都 >= configuredLevel - for _, entry := range entries { - if entry.Level < configuredLevel { - return false - } - } - - return true - }, - levelGen, - )) - - // Property: 动态修改级别后过滤行为正确 - properties.Property("dynamic level change affects filtering correctly", prop.ForAll( - func(initialLevel Level, newLevel Level) bool { - mockWriter := NewMockWriter() - logger := createTestLogger(initialLevel, mockWriter) - - // 使用初始级别写入日志 - logger.Info("initial info") - initialEntries := mockWriter.GetEntries() - - // 验证初始级别过滤 - initialExpected := INFO >= initialLevel - if initialExpected && len(initialEntries) != 1 { - return false - } - if !initialExpected && len(initialEntries) != 0 { - return false - } - - // 动态修改级别 - mockWriter.Clear() - logger.SetLevel(newLevel) - - // 使用新级别写入日志 - logger.Info("new info") - newEntries := mockWriter.GetEntries() - - // 验证新级别过滤 - newExpected := INFO >= newLevel - if newExpected && len(newEntries) != 1 { - return false - } - if !newExpected && len(newEntries) != 0 { - return false - } - - return true - }, - levelGen, - levelGen, - )) - - properties.TestingRun(t) -} - -// TestLoggerBasic 基础功能测试 -func TestLoggerBasic(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(DEBUG, mockWriter) - - logger.Debug("debug message") - logger.Info("info message") - logger.Warn("warn message") - logger.Error("error message") - - entries := mockWriter.GetEntries() - if len(entries) != 4 { - t.Errorf("expected 4 entries, got %d", len(entries)) - } -} - -// TestLoggerLevelFiltering 级别过滤测试 -func TestLoggerLevelFiltering(t *testing.T) { - tests := []struct { - name string - configLevel Level - expectedCount int - }{ - {"DEBUG level logs all", DEBUG, 4}, - {"INFO level filters DEBUG", INFO, 3}, - {"WARN level filters DEBUG and INFO", WARN, 2}, - {"ERROR level filters all except ERROR", ERROR, 1}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(tt.configLevel, mockWriter) - - logger.Debug("debug") - logger.Info("info") - logger.Warn("warn") - logger.Error("error") - - entries := mockWriter.GetEntries() - if len(entries) != tt.expectedCount { - t.Errorf("expected %d entries, got %d", tt.expectedCount, len(entries)) - } - }) - } -} - -// TestLoggerSetLevel 动态级别修改测试 -func TestLoggerSetLevel(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(DEBUG, mockWriter) - - // 初始级别为 DEBUG,所有日志都应该写入 - logger.Debug("debug1") - if len(mockWriter.GetEntries()) != 1 { - t.Error("DEBUG log should be written at DEBUG level") - } - - // 修改级别为 ERROR - mockWriter.Clear() - logger.SetLevel(ERROR) - - logger.Debug("debug2") - logger.Info("info2") - logger.Warn("warn2") - logger.Error("error2") - - entries := mockWriter.GetEntries() - if len(entries) != 1 { - t.Errorf("expected 1 entry at ERROR level, got %d", len(entries)) - } - if entries[0].Level != ERROR { - t.Errorf("expected ERROR level, got %s", entries[0].Level.String()) - } -} - -// TestLoggerGetLevel 获取级别测试 -func TestLoggerGetLevel(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(WARN, mockWriter) - - if logger.GetLevel() != WARN { - t.Errorf("expected WARN level, got %s", logger.GetLevel().String()) - } - - logger.SetLevel(DEBUG) - if logger.GetLevel() != DEBUG { - t.Errorf("expected DEBUG level after SetLevel, got %s", logger.GetLevel().String()) - } -} - -// TestLoggerShouldLog 检查是否应该记录测试 -func TestLoggerShouldLog(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(INFO, mockWriter) - - if logger.ShouldLog(DEBUG) { - t.Error("DEBUG should not be logged at INFO level") - } - if !logger.ShouldLog(INFO) { - t.Error("INFO should be logged at INFO level") - } - if !logger.ShouldLog(WARN) { - t.Error("WARN should be logged at INFO level") - } - if !logger.ShouldLog(ERROR) { - t.Error("ERROR should be logged at INFO level") - } -} - -// TestLoggerWithFields 带字段的日志测试 -func TestLoggerWithFields(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(DEBUG, mockWriter) - - logger.Info("test message", F("key1", "value1"), F("key2", 123)) - - entries := mockWriter.GetEntries() - if len(entries) != 1 { - t.Fatalf("expected 1 entry, got %d", len(entries)) - } - - entry := entries[0] - if entry.Fields == nil { - t.Fatal("expected fields to be set") - } - if entry.Fields["key1"] != "value1" { - t.Errorf("expected key1=value1, got %v", entry.Fields["key1"]) - } - if entry.Fields["key2"] != 123 { - t.Errorf("expected key2=123, got %v", entry.Fields["key2"]) - } -} - -// TestParseLevel 级别解析测试 -func TestParseLevel(t *testing.T) { - tests := []struct { - input string - expected Level - }{ - {"debug", DEBUG}, - {"DEBUG", DEBUG}, - {"info", INFO}, - {"INFO", INFO}, - {"warn", WARN}, - {"WARN", WARN}, - {"warning", WARN}, - {"error", ERROR}, - {"ERROR", ERROR}, - {"invalid", INFO}, // 默认为 INFO - {"", INFO}, // 空字符串默认为 INFO - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := ParseLevel(tt.input) - if result != tt.expected { - t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, result, tt.expected) - } - }) - } -} - -// TestLoggerInit 初始化测试 -func TestLoggerInit(t *testing.T) { - // 保存原始全局 logger - originalLogger := globalLogger - defer func() { - globalLogger = originalLogger - }() - - ctx := context.Background() - Init(ctx, "debug") - - logger := New("test-component") - if logger.GetLevel() != DEBUG { - t.Errorf("expected DEBUG level, got %s", logger.GetLevel().String()) - } - if logger.component != "test-component" { - t.Errorf("expected component 'test-component', got %s", logger.component) - } -} - -// TestLoggerConcurrency 并发安全测试 -func TestLoggerConcurrency(t *testing.T) { - mockWriter := NewMockWriter() - logger := createTestLogger(DEBUG, mockWriter) - - var wg sync.WaitGroup - iterations := 100 - - // 并发写入日志 - for i := 0; i < iterations; i++ { - wg.Add(1) - go func(n int) { - defer wg.Done() - logger.Info("concurrent message", F("iteration", n)) - }(i) - } - - // 并发修改级别 - for i := 0; i < 10; i++ { - wg.Add(1) - go func(n int) { - defer wg.Done() - level := Level(n % 4) - logger.SetLevel(level) - }(i) - } - - wg.Wait() - - // 验证没有 panic 发生,日志数量可能因级别变化而不同 - entries := mockWriter.GetEntries() - t.Logf("Concurrent test wrote %d entries", len(entries)) -} - -// BufferWriter 用于捕获输出的写入器 -type BufferWriter struct { - buffer *bytes.Buffer - mu sync.Mutex -} - -func NewBufferWriter() *BufferWriter { - return &BufferWriter{ - buffer: new(bytes.Buffer), - } -} - -func (w *BufferWriter) Write(entry *LogEntry) error { - w.mu.Lock() - defer w.mu.Unlock() - formatter := NewTextFormatter() - data, err := formatter.Format(entry) - if err != nil { - return err - } - w.buffer.Write(data) - return nil -} - -func (w *BufferWriter) Close() error { - return nil -} - -func (w *BufferWriter) String() string { - w.mu.Lock() - defer w.mu.Unlock() - return w.buffer.String() -} diff --git a/backend/internal/logger/rotation_test.go b/backend/internal/logger/rotation_test.go deleted file mode 100644 index 3237e986..00000000 --- a/backend/internal/logger/rotation_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package logger - -import ( - "os" - "testing" - "time" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" -) - -// mockFileInfo 模拟文件信息用于测试 -type mockFileInfo struct { - name string - size int64 - mode os.FileMode - modTime time.Time - isDir bool -} - -func (m *mockFileInfo) Name() string { return m.name } -func (m *mockFileInfo) Size() int64 { return m.size } -func (m *mockFileInfo) Mode() os.FileMode { return m.mode } -func (m *mockFileInfo) ModTime() time.Time { return m.modTime } -func (m *mockFileInfo) IsDir() bool { return m.isDir } -func (m *mockFileInfo) Sys() interface{} { return nil } - -// TestProperty6_SizeBasedRotationTrigger 属性测试:大小分片触发 -// **Property 6: Size-Based Rotation Trigger** -// **Validates: Requirements 4.2** -// *For any* configured max file size S, when the current log file size exceeds S, -// a new log file SHALL be created before writing the next entry. -func TestProperty6_SizeBasedRotationTrigger(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - // 生成随机的最大文件大小 (1KB - 100MB) - maxSizeGen := gen.Int64Range(1024, 100*1024*1024) - // 生成随机的当前文件大小 (0 - 200MB) - currentSizeGen := gen.Int64Range(0, 200*1024*1024) - - properties.Property("size rotation triggers when file size >= maxSize", prop.ForAll( - func(maxSize, currentSize int64) bool { - policy := NewSizeRotationPolicy(maxSize) - - fileInfo := &mockFileInfo{ - name: "test.log", - size: currentSize, - modTime: time.Now(), - } - - entry := NewLogEntry(INFO, "test", "test message") - shouldRotate := policy.ShouldRotate(fileInfo, entry) - - // 当文件大小 >= 最大大小时,应该触发分片 - expected := currentSize >= maxSize - return shouldRotate == expected - }, - maxSizeGen, - currentSizeGen, - )) - - properties.TestingRun(t) -} - -// TestProperty8_HistoryFileLimit 属性测试:历史文件数量限制 -// **Property 8: History File Limit** -// **Validates: Requirements 4.5** -// *For any* configured max backup count N, the number of rotated log files -// SHALL never exceed N, with oldest files deleted first. -func TestProperty8_HistoryFileLimit(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - // 生成随机的最大备份数 (1-20) - maxBackupsGen := gen.IntRange(1, 20) - // 生成随机的初始文件数 (0-30) - initialFilesGen := gen.IntRange(0, 30) - - properties.Property("history files never exceed maxBackups after cleanup", prop.ForAll( - func(maxBackups, initialFiles int) bool { - // 创建临时目录 - tempDir, err := os.MkdirTemp("", "rotation_test_*") - if err != nil { - t.Logf("Failed to create temp dir: %v", err) - return false - } - defer os.RemoveAll(tempDir) - - basePath := tempDir + "/app.log" - - // 创建初始的分片文件 - baseTime := time.Now().AddDate(0, 0, -initialFiles) - for i := 0; i < initialFiles; i++ { - fileTime := baseTime.AddDate(0, 0, i) - fileName := tempDir + "/app." + fileTime.Format("2006-01-02") + ".log" - f, err := os.Create(fileName) - if err != nil { - t.Logf("Failed to create file: %v", err) - return false - } - f.Close() - // 设置文件修改时间以便排序 - os.Chtimes(fileName, fileTime, fileTime) - } - - // 创建 RotationManager 并执行清理 - manager := NewRotationManager(RotationManagerConfig{ - BasePath: basePath, - MaxBackups: maxBackups, - }) - - // 执行清理 - err = manager.cleanupOldFiles() - if err != nil { - t.Logf("Cleanup failed: %v", err) - return false - } - - // 检查剩余文件数 - count, err := manager.GetRotatedFileCount() - if err != nil { - t.Logf("Failed to get file count: %v", err) - return false - } - - // 文件数应该不超过 maxBackups - return count <= maxBackups - }, - maxBackupsGen, - initialFilesGen, - )) - - properties.TestingRun(t) -} - -// TestProperty9_RotatedFileNamingFormat 属性测试:分片文件命名格式 -// **Property 9: Rotated File Naming Format** -// **Validates: Requirements 4.6** -// *For any* rotated log file, the filename SHALL match the pattern -// `{basename}.{timestamp}[.{sequence}].log` where timestamp is in ISO date format. -func TestProperty9_RotatedFileNamingFormat(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - properties := gopter.NewProperties(parameters) - - // 生成随机的基础文件名(使用字母数字字符) - baseNameGen := gen.AlphaString().Map(func(s string) string { - if s == "" || len(s) == 0 { - return "app" - } - if len(s) > 20 { - return s[:20] - } - return s - }) - - // 生成随机时间戳 (过去一年内) - timestampGen := gen.Int64Range(0, 365*24).Map(func(hours int64) time.Time { - return time.Now().Add(-time.Duration(hours) * time.Hour) - }) - - // 生成随机的时间间隔类型 - intervalGen := gen.OneConstOf(Daily, Hourly) - - properties.Property("time rotation generates valid file names", prop.ForAll( - func(baseName string, timestamp time.Time, interval TimeInterval) bool { - policy := NewTimeRotationPolicy(interval) - fileName := policy.GetRotatedFileName(baseName+".log", timestamp) - - // 验证文件名格式 - return ValidateRotatedFileName(fileName) - }, - baseNameGen, - timestampGen, - intervalGen, - )) - - // 测试大小分片的文件命名 - properties.Property("size rotation generates valid file names", prop.ForAll( - func(baseName string, timestamp time.Time, maxSize int64) bool { - policy := NewSizeRotationPolicy(maxSize) - fileName := policy.GetRotatedFileName(baseName+".log", timestamp) - - // 验证文件名格式 - return ValidateRotatedFileName(fileName) - }, - baseNameGen, - timestampGen, - gen.Int64Range(1024, 100*1024*1024), - )) - - properties.TestingRun(t) -} diff --git a/backend/internal/logger/writer_test.go b/backend/internal/logger/writer_test.go deleted file mode 100644 index 6ad3e127..00000000 --- a/backend/internal/logger/writer_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package logger - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/leanovate/gopter" - "github.com/leanovate/gopter/gen" - "github.com/leanovate/gopter/prop" -) - -// TestConsoleWriter_Write tests basic console writer functionality -func TestConsoleWriter_Write(t *testing.T) { - formatter := NewTextFormatter() - writer := NewConsoleWriter(formatter) - defer writer.Close() - - entry := NewLogEntry(INFO, "test", "test message") - err := writer.Write(entry) - if err != nil { - t.Errorf("ConsoleWriter.Write() error = %v", err) - } -} - -// TestFileWriter_Write tests basic file writer functionality -func TestFileWriter_Write(t *testing.T) { - // Create temp directory - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") - - config := DefaultFileWriterConfig(logPath) - formatter := NewTextFormatter() - - writer, err := NewFileWriter(config, formatter) - if err != nil { - t.Fatalf("NewFileWriter() error = %v", err) - } - defer writer.Close() - - entry := NewLogEntry(INFO, "test", "test message") - err = writer.Write(entry) - if err != nil { - t.Errorf("FileWriter.Write() error = %v", err) - } - - // Flush and verify file exists - writer.Flush() - - if _, err := os.Stat(logPath); os.IsNotExist(err) { - t.Error("Log file was not created") - } -} - -// TestFileWriter_CreateDirectory tests automatic directory creation -func TestFileWriter_CreateDirectory(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log") - - config := DefaultFileWriterConfig(logPath) - formatter := NewTextFormatter() - - writer, err := NewFileWriter(config, formatter) - if err != nil { - t.Fatalf("NewFileWriter() error = %v", err) - } - defer writer.Close() - - // Verify directory was created - dir := filepath.Dir(logPath) - if _, err := os.Stat(dir); os.IsNotExist(err) { - t.Error("Directory was not created automatically") - } -} - -// TestAsyncFileWriter_NonBlocking tests that async writes are non-blocking -func TestAsyncFileWriter_NonBlocking(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "async_test.log") - - config := FileWriterConfig{ - FilePath: logPath, - BufferSize: 4 * 1024, - FlushInterval: 100 * time.Millisecond, - AsyncQueueSize: 100, - } - formatter := NewTextFormatter() - - writer, err := NewAsyncFileWriter(config, formatter) - if err != nil { - t.Fatalf("NewAsyncFileWriter() error = %v", err) - } - defer writer.Close() - - // Write should return quickly - entry := NewLogEntry(INFO, "test", "test message") - start := time.Now() - err = writer.Write(entry) - elapsed := time.Since(start) - - if err != nil { - t.Errorf("AsyncFileWriter.Write() error = %v", err) - } - - // Should complete in less than 1ms (non-blocking) - if elapsed > time.Millisecond { - t.Errorf("Async write took too long: %v", elapsed) - } -} - -// Property 10: Async Write Non-Blocking -// *For any* log write operation, the call SHALL return within a bounded time (< 1ms typical) -// regardless of file I/O latency. -// **Validates: Requirements 5.2** -func TestProperty10_AsyncWriteNonBlocking(t *testing.T) { - parameters := gopter.DefaultTestParameters() - parameters.MinSuccessfulTests = 100 - parameters.MaxSize = 50 - - properties := gopter.NewProperties(parameters) - - properties.Property("async write returns within bounded time", prop.ForAll( - func(level int, component string, message string) bool { - // Create temp file for each test - tmpDir := os.TempDir() - logPath := filepath.Join(tmpDir, "pbt_async_test.log") - defer os.Remove(logPath) - - config := FileWriterConfig{ - FilePath: logPath, - BufferSize: 4 * 1024, - FlushInterval: time.Second, - AsyncQueueSize: 1000, - } - formatter := NewTextFormatter() - - writer, err := NewAsyncFileWriter(config, formatter) - if err != nil { - return false - } - defer writer.Close() - - // Create log entry from generated data - logLevel := Level(level % 4) // Ensure valid level 0-3 - entry := NewLogEntry(logLevel, component, message) - - // Measure write time - start := time.Now() - _ = writer.Write(entry) - elapsed := time.Since(start) - - // Property: write should complete within 1ms (non-blocking) - // Using 5ms as upper bound to account for system variance - return elapsed < 5*time.Millisecond - }, - gen.IntRange(0, 3), - gen.AlphaString(), - gen.AlphaString(), - )) - - properties.TestingRun(t) -} - -// TestMultiWriter tests writing to multiple destinations -func TestMultiWriter(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "multi_test.log") - - // Create console and file writers - consoleWriter := NewConsoleWriter(NewTextFormatter()) - fileConfig := DefaultFileWriterConfig(logPath) - fileWriter, err := NewFileWriter(fileConfig, NewTextFormatter()) - if err != nil { - t.Fatalf("NewFileWriter() error = %v", err) - } - - multiWriter := NewMultiWriter(consoleWriter, fileWriter) - defer multiWriter.Close() - - entry := NewLogEntry(INFO, "test", "multi writer test") - err = multiWriter.Write(entry) - if err != nil { - t.Errorf("MultiWriter.Write() error = %v", err) - } - - // Flush file writer - fileWriter.Flush() - - // Verify file was written - if _, err := os.Stat(logPath); os.IsNotExist(err) { - t.Error("Log file was not created by MultiWriter") - } -} diff --git a/backend/internal/proxy/check_config_test.go b/backend/internal/proxy/check_config_test.go deleted file mode 100644 index e9663c8b..00000000 --- a/backend/internal/proxy/check_config_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package proxy - -import ( - "ant-chrome/backend/internal/config" - "testing" - "time" -) - -func TestNormalizeCheckSettingsDefaultsAndSelectsTargets(t *testing.T) { - settings := NormalizeCheckSettings(config.ProxyCheckConfig{ - Targets: []config.ProxyCheckTarget{ - {ID: " speed-main ", URL: " https://speed.example.com ", Type: " speed ", TimeoutMs: 0}, - {ID: "health-main", URL: "https://health.example.com", Type: "ip_health", Parser: " ipqualityscore ", TimeoutMs: 1500}, - }, - }) - - if settings.BridgeStartTimeoutMs != defaultBridgeStartTimeoutMs { - t.Fatalf("bridge timeout = %d", settings.BridgeStartTimeoutMs) - } - if settings.BridgeStartTimeoutMs != 15000 { - t.Fatalf("bridge timeout = %d, want 15000", settings.BridgeStartTimeoutMs) - } - if settings.SpeedTargetID != "speed-main" { - t.Fatalf("speed target id = %q", settings.SpeedTargetID) - } - if settings.IPHealthTargetID != "health-main" { - t.Fatalf("ip health target id = %q", settings.IPHealthTargetID) - } - if settings.Targets[0].TimeoutMs != defaultSpeedTargetTimeoutMs { - t.Fatalf("speed target timeout = %d", settings.Targets[0].TimeoutMs) - } - if settings.Targets[1].TimeoutMs != 1500 { - t.Fatalf("ip health target timeout = %d", settings.Targets[1].TimeoutMs) - } -} - -func TestNormalizeCheckTargetsUsesLongerDefaultForIPHealth(t *testing.T) { - targets := NormalizeCheckTargets([]config.ProxyCheckTarget{ - {ID: "speed", URL: "https://speed.example.com", Type: "speed"}, - {ID: "health", URL: "https://health.example.com", Type: "ip_health"}, - }) - - if targets[0].TimeoutMs != defaultSpeedTargetTimeoutMs { - t.Fatalf("speed timeout = %d", targets[0].TimeoutMs) - } - if targets[1].TimeoutMs != defaultIPHealthTargetTimeoutMs { - t.Fatalf("ip health timeout = %d", targets[1].TimeoutMs) - } -} - -func TestNormalizeCheckTargetsDropsInvalidAndDuplicateTargets(t *testing.T) { - targets := NormalizeCheckTargets([]config.ProxyCheckTarget{ - {ID: "main", URL: "https://example.com"}, - {ID: " MAIN ", URL: "https://duplicate.example.com"}, - {ID: "missing-url"}, - }) - - if len(targets) != 1 { - t.Fatalf("len = %d", len(targets)) - } - if targets[0].Name != "main" || targets[0].Type != "speed" { - t.Fatalf("target defaults were not applied: %#v", targets[0]) - } -} - -func TestBuildProxyCheckConfigs(t *testing.T) { - settings := config.ProxyCheckConfig{ - BridgeStartTimeoutMs: 15000, - SpeedTargetID: "speed-main", - IPHealthTargetID: "health-main", - Targets: []config.ProxyCheckTarget{ - {ID: "speed-main", Type: "speed", URL: "https://speed.example.com", TimeoutMs: 1200, ExpectedStatus: []int{204}}, - {ID: "health-main", Type: "ip_health", URL: "https://health.example.com", Parser: "ipqualityscore", TimeoutMs: 2300}, - }, - } - - speed := BuildSpeedTestConfig(settings) - if len(speed.URLs) != 1 || speed.URLs[0] != "https://speed.example.com" || speed.Timeout != 1200*time.Millisecond { - t.Fatalf("speed config = %#v", speed) - } - if len(speed.ExpectedStatus) != 1 || speed.ExpectedStatus[0] != 204 { - t.Fatalf("speed expected status = %#v", speed.ExpectedStatus) - } - if speed.TCPTimeout != 15000*time.Millisecond { - t.Fatalf("speed tcp timeout = %s", speed.TCPTimeout) - } - - health := BuildIPHealthConfig(settings) - if health.URL != "https://health.example.com" || health.Source != "health-main" || health.Parser != "ipqualityscore" || health.Timeout != 2300*time.Millisecond { - t.Fatalf("health config = %#v", health) - } -} diff --git a/backend/internal/proxy/diagnostics_test.go b/backend/internal/proxy/diagnostics_test.go deleted file mode 100644 index c3a5c9db..00000000 --- a/backend/internal/proxy/diagnostics_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package proxy - -import ( - "ant-chrome/backend/internal/config" - "path/filepath" - "strings" - "testing" -) - -func TestBuildProxyDiagnosticAuthenticatedSocks5UsesXrayBridge(t *testing.T) { - cfg := &config.Config{} - cfg.Browser.UserDataRoot = t.TempDir() - manager := NewXrayManager(cfg, "") - t.Cleanup(manager.StopAll) - - proxies := []config.BrowserProxy{{ - ProxyId: "p1", - ProxyName: "auth-socks", - ProxyConfig: "socks5://user:secret@127.0.0.1:1080", - DnsServers: "1.1.1.1", - }} - - diag := BuildProxyDiagnostic("", proxies, "p1", BuildDiagnosticOptions{XrayMgr: manager}) - if !diag.Ok { - t.Fatalf("expected diagnostic ok, errors=%v", diag.Errors) - } - if diag.Engine != "xray" { - t.Fatalf("engine = %s, want xray", diag.Engine) - } - if diag.NodeKey == "" { - t.Fatal("expected node key") - } - if !strings.Contains(diag.RawConfigMasked, "***") || strings.Contains(diag.RawConfigMasked, "secret") { - t.Fatalf("raw config was not masked: %s", diag.RawConfigMasked) - } - if len(diag.Outbounds) != 1 || len(diag.Routes) != 1 { - t.Fatalf("unexpected bridge plan: outbounds=%d routes=%d", len(diag.Outbounds), len(diag.Routes)) - } - if diag.Runtime.WorkDir == "" || diag.Runtime.ConfigPath != filepath.Join(diag.Runtime.WorkDir, "xray-config.json") { - t.Fatalf("unexpected runtime paths: %+v", diag.Runtime) - } - if !diag.DnsSummary.HasConfig || diag.DnsSummary.XrayServerCount != 1 { - t.Fatalf("unexpected dns summary: %+v", diag.DnsSummary) - } -} - -func TestBuildProxyDiagnosticSingBoxMasksSecrets(t *testing.T) { - cfg := &config.Config{} - cfg.Browser.UserDataRoot = t.TempDir() - manager := NewSingBoxManager(cfg, "") - src := "hysteria2://pass123@example.com:443?sni=example.com&obfs-password=obfs-secret&insecure=1" - - diag := BuildProxyDiagnostic(src, nil, "", BuildDiagnosticOptions{SingBoxMgr: manager}) - if !diag.Ok { - t.Fatalf("expected diagnostic ok, errors=%v", diag.Errors) - } - if diag.Engine != "sing-box" { - t.Fatalf("engine = %s, want sing-box", diag.Engine) - } - if strings.Contains(diag.RawConfigMasked, "obfs-secret") { - t.Fatalf("query secret was not masked: %s", diag.RawConfigMasked) - } - obfs, _ := diag.Outbound["obfs"].(map[string]interface{}) - if diag.Outbound["password"] != "***" || obfs["password"] != "***" { - t.Fatalf("outbound secrets were not masked: %+v", diag.Outbound) - } - if diag.Runtime.ConfigPath != filepath.Join(diag.Runtime.WorkDir, "singbox-config.json") { - t.Fatalf("unexpected runtime paths: %+v", diag.Runtime) - } -} - -func TestBuildProxyDiagnosticStandardProxyDoesNotBridge(t *testing.T) { - diag := BuildProxyDiagnostic("http://127.0.0.1:8080", nil, "", BuildDiagnosticOptions{}) - if !diag.Ok { - t.Fatalf("expected diagnostic ok, errors=%v", diag.Errors) - } - if diag.Engine != "none" { - t.Fatalf("engine = %s, want none", diag.Engine) - } - if diag.StandardProxy != "http://127.0.0.1:8080" { - t.Fatalf("standardProxy = %s", diag.StandardProxy) - } - if len(diag.Outbounds) != 0 || diag.Runtime.WorkDir != "" { - t.Fatalf("standard proxy should not have bridge plan: %+v", diag) - } -} - -func TestBuildProxyDiagnosticXrayShowsBrowserTuning(t *testing.T) { - src := ` -name: vmess-ws -type: vmess -server: edge.example.com -port: 443 -uuid: 00000000-0000-0000-0000-000000000009 -cipher: auto -network: ws -browser-mux: true -` - diag := BuildProxyDiagnostic(src, nil, "", BuildDiagnosticOptions{}) - if !diag.Ok { - t.Fatalf("expected diagnostic ok, errors=%v", diag.Errors) - } - if diag.Inbound == nil { - t.Fatalf("expected xray inbound diagnostic") - } - sniffing := diag.Inbound["sniffing"].(map[string]interface{}) - if sniffing["enabled"] != true { - t.Fatalf("sniffing.enabled = %v, want true", sniffing["enabled"]) - } - outbound := diag.Outbounds[0].(map[string]interface{}) - mux := outbound["mux"].(map[string]interface{}) - if mux["enabled"] != true { - t.Fatalf("mux.enabled = %v, want true", mux["enabled"]) - } -} - -func TestBuildProxyDiagnosticMissingProxy(t *testing.T) { - diag := BuildProxyDiagnostic("", []config.BrowserProxy{{ProxyId: "p1", ProxyConfig: "direct://"}}, "missing", BuildDiagnosticOptions{}) - if diag.Ok { - t.Fatal("expected missing proxy diagnostic to fail") - } - if diag.Found { - t.Fatal("expected Found=false") - } - if len(diag.Errors) != 1 || !strings.Contains(diag.Errors[0], "不存在") { - t.Fatalf("unexpected errors: %v", diag.Errors) - } -} diff --git a/backend/internal/proxy/iphealth_test.go b/backend/internal/proxy/iphealth_test.go deleted file mode 100644 index fbd23ddb..00000000 --- a/backend/internal/proxy/iphealth_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package proxy - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "ant-chrome/backend/internal/config" -) - -func TestFetchIPHealthInfoReturnsSourceMetadataOnParseError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("not-json")) - })) - defer server.Close() - - data, err := FetchIPHealthInfo( - "proxy-1", - []config.BrowserProxy{{ProxyId: "proxy-1", ProxyConfig: "direct://"}}, - nil, - nil, - nil, - config.BrowserConnectorXray, - &IPHealthConfig{ - URL: server.URL, - Source: "json", - Parser: "json", - }, - ) - if err == nil { - t.Fatalf("expected parse error") - } - if !strings.Contains(err.Error(), "source=json") { - t.Fatalf("expected source in error, got %v", err) - } - if !strings.Contains(err.Error(), "parser=json") { - t.Fatalf("expected parser in error, got %v", err) - } - if got := mapString(data, "_source"); got != "json" { - t.Fatalf("source metadata = %q, want json", got) - } - if got := mapString(data, "_targetUrl"); got != server.URL { - t.Fatalf("target url metadata = %q, want %q", got, server.URL) - } - if got := mapString(data, "_parser"); got != "json" { - t.Fatalf("parser metadata = %q, want json", got) - } - if got := mapString(data, "_bodySnippet"); got != "not-json" { - t.Fatalf("body snippet = %q, want not-json", got) - } -} diff --git a/backend/internal/proxy/page_probe_test.go b/backend/internal/proxy/page_probe_test.go deleted file mode 100644 index e03f593c..00000000 --- a/backend/internal/proxy/page_probe_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package proxy - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestRunBrowserPageProbeCollectsConcurrentMetrics(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("ok")) - })) - defer server.Close() - - result := runBrowserPageProbe("node-a", server.Client(), BrowserPageProbeConfig{ - URLs: []string{server.URL}, - Timeout: time.Second, - Concurrency: 4, - }) - if !result.Ok { - t.Fatalf("probe Ok = false, error=%q", result.Error) - } - if result.Completed != 4 || result.Failed != 0 || result.Concurrency != 4 { - t.Fatalf("unexpected counts: completed=%d failed=%d concurrency=%d", result.Completed, result.Failed, result.Concurrency) - } - if result.Bytes != 8 { - t.Fatalf("bytes = %d, want 8", result.Bytes) - } - if result.P95Ms < 0 || result.AverageMs < 0 || result.TotalMs < 0 { - t.Fatalf("unexpected latency metrics: %#v", result) - } -} - -func TestRunBrowserPageProbeReportsFailures(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "bad", http.StatusBadGateway) - })) - defer server.Close() - - result := runBrowserPageProbe("node-a", server.Client(), BrowserPageProbeConfig{ - URLs: []string{server.URL}, - Timeout: time.Second, - Concurrency: 3, - }) - if result.Ok { - t.Fatalf("probe Ok = true, want false") - } - if result.Completed != 0 || result.Failed != 3 { - t.Fatalf("unexpected counts: completed=%d failed=%d", result.Completed, result.Failed) - } - if result.Error != fmt.Sprintf("%d %s", http.StatusBadGateway, http.StatusText(http.StatusBadGateway)) { - t.Fatalf("error = %q", result.Error) - } -} - -func TestPercentileLatency(t *testing.T) { - t.Parallel() - - latencies := []int64{10, 20, 30, 40, 50} - if got := percentileLatency(latencies, 0.95); got != 50 { - t.Fatalf("p95 = %d, want 50", got) - } - if got := percentileLatency(latencies, 0.50); got != 30 { - t.Fatalf("p50 = %d, want 30", got) - } -} diff --git a/backend/internal/proxy/runtime_config_test.go b/backend/internal/proxy/runtime_config_test.go deleted file mode 100644 index 5c932a45..00000000 --- a/backend/internal/proxy/runtime_config_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package proxy - -import ( - "encoding/json" - "os" - "testing" - - "ant-chrome/backend/internal/config" -) - -func TestXrayRuntimeConfigUsesWarningLogLevel(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = t.TempDir() - manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()} - - cfgPath, err := manager.buildRuntimeConfigWithRoute( - "log-level-test", - []interface{}{map[string]interface{}{"protocol": "freedom", "tag": "proxy-out"}}, - []interface{}{}, - 19092, - "", - ) - if err != nil { - t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) - } - - runtimeConfig := readRuntimeConfigMap(t, cfgPath) - logConfig := runtimeConfig["log"].(map[string]interface{}) - if got := logConfig["loglevel"]; got != "warning" { - t.Fatalf("xray loglevel = %v, want warning", got) - } -} - -func TestXrayRuntimeConfigEnablesBrowserSniffing(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = t.TempDir() - manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()} - - cfgPath, err := manager.buildRuntimeConfigWithRoute( - "sniffing-test", - []interface{}{map[string]interface{}{"protocol": "freedom", "tag": "proxy-out"}}, - []interface{}{}, - 19094, - "", - ) - if err != nil { - t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) - } - - runtimeConfig := readRuntimeConfigMap(t, cfgPath) - inbounds := runtimeConfig["inbounds"].([]interface{}) - inbound := inbounds[0].(map[string]interface{}) - sniffing := inbound["sniffing"].(map[string]interface{}) - if sniffing["enabled"] != true { - t.Fatalf("sniffing.enabled = %v, want true", sniffing["enabled"]) - } - destOverride := sniffing["destOverride"].([]interface{}) - if len(destOverride) != 3 || destOverride[0] != "http" || destOverride[1] != "tls" || destOverride[2] != "quic" { - t.Fatalf("sniffing.destOverride = %#v, want [http tls quic]", destOverride) - } -} - -func TestXrayRuntimeConfigRemovesDeprecatedAllowInsecure(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = t.TempDir() - manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()} - - cfgPath, err := manager.buildRuntimeConfigWithRoute( - "deprecated-field-test", - []interface{}{ - map[string]interface{}{ - "protocol": "trojan", - "tag": "proxy-out", - "streamSettings": map[string]interface{}{ - "security": "tls", - "tlsSettings": map[string]interface{}{ - "serverName": "example.com", - "allowInsecure": true, - }, - }, - }, - }, - []interface{}{}, - 19095, - "", - ) - if err != nil { - t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) - } - - runtimeConfig := readRuntimeConfigMap(t, cfgPath) - outbounds := runtimeConfig["outbounds"].([]interface{}) - outbound := outbounds[0].(map[string]interface{}) - stream := outbound["streamSettings"].(map[string]interface{}) - tlsSettings := stream["tlsSettings"].(map[string]interface{}) - if _, ok := tlsSettings["allowInsecure"]; ok { - t.Fatalf("runtime config must not include deprecated allowInsecure: %#v", tlsSettings) - } -} - -func TestXrayRuntimeConfigKeepsTrojanServersArray(t *testing.T) { - node := "trojan://password@example.com:443?peer=sni.example.com&sni=sni.example.com&type=tcp" - _, outbound, err := ParseProxyNode(node) - if err != nil { - t.Fatalf("ParseProxyNode returned error: %v", err) - } - - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = t.TempDir() - manager := &XrayManager{Config: cfg, AppRoot: t.TempDir()} - cfgPath, err := manager.buildRuntimeConfigWithRoute( - "trojan-shape-test", - []interface{}{outbound}, - []interface{}{}, - 19096, - "", - ) - if err != nil { - t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) - } - - runtimeConfig := readRuntimeConfigMap(t, cfgPath) - outbounds := runtimeConfig["outbounds"].([]interface{}) - outboundConfig := outbounds[0].(map[string]interface{}) - settings := outboundConfig["settings"].(map[string]interface{}) - servers, ok := settings["servers"].([]interface{}) - if !ok || len(servers) != 1 { - t.Fatalf("trojan settings.servers invalid: %#v", settings["servers"]) - } - server := servers[0].(map[string]interface{}) - if server["address"] != "example.com" || server["port"] != float64(443) || server["password"] != "password" { - t.Fatalf("trojan server invalid: %#v", server) - } - if _, ok := settings["address"]; ok { - t.Fatalf("legacy flat trojan settings should not be present: %#v", settings) - } -} - -func TestSummarizeXrayErrorReturnsShortConfigReason(t *testing.T) { - raw := `Xray 26.6.20 (Xray, Penetrates Everything.) -Failed to start: main: failed to load config files: [xray-config.json] > infra/conf: failed to build outbound config with tag proxy-out > infra/conf: Failed to build stream settings for outbound detour. > infra/conf: Failed to build TLS config. > The feature "allowInsecure" has been removed and migrated to "certificate". Please update your config(s) according to release note and documentation before removal.` - - got := summarizeXrayError(raw) - want := "字段 allowInsecure 已被当前 Xray 移除" - if got != want { - t.Fatalf("summary = %q, want %q", got, want) - } -} - -func TestSingBoxRuntimeConfigUsesWarnLogLevel(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Browser.UserDataRoot = t.TempDir() - manager := &SingBoxManager{Config: cfg, AppRoot: t.TempDir()} - - cfgPath, err := manager.buildConfig("log-level-test", map[string]interface{}{"type": "direct", "tag": "proxy-out"}, 19093) - if err != nil { - t.Fatalf("buildConfig returned error: %v", err) - } - - runtimeConfig := readRuntimeConfigMap(t, cfgPath) - logConfig := runtimeConfig["log"].(map[string]interface{}) - if got := logConfig["level"]; got != "warn" { - t.Fatalf("sing-box log level = %v, want warn", got) - } -} - -func readRuntimeConfigMap(t *testing.T, path string) map[string]interface{} { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read runtime config failed: %v", err) - } - var runtimeConfig map[string]interface{} - if err := json.Unmarshal(data, &runtimeConfig); err != nil { - t.Fatalf("unmarshal runtime config failed: %v", err) - } - return runtimeConfig -} diff --git a/backend/internal/proxy/singbox_launch_lock_test.go b/backend/internal/proxy/singbox_launch_lock_test.go deleted file mode 100644 index 3b514bc2..00000000 --- a/backend/internal/proxy/singbox_launch_lock_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package proxy - -import ( - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestSingBoxLaunchLockSerializesSameKey(t *testing.T) { - manager := NewSingBoxManager(nil, "") - defer manager.StopAll() - - const workers = 8 - var active int32 - var maxActive int32 - var wg sync.WaitGroup - start := make(chan struct{}) - for i := 0; i < workers; i++ { - wg.Add(1) - go func() { - defer wg.Done() - <-start - unlock := manager.lockLaunchForKey("same-node") - current := atomic.AddInt32(&active, 1) - for { - old := atomic.LoadInt32(&maxActive) - if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) { - break - } - } - time.Sleep(5 * time.Millisecond) - atomic.AddInt32(&active, -1) - unlock() - }() - } - close(start) - wg.Wait() - - if maxActive != 1 { - t.Fatalf("same-key launch lock allowed %d concurrent entries", maxActive) - } -} diff --git a/backend/internal/proxy/singbox_runtime_helpers_test.go b/backend/internal/proxy/singbox_runtime_helpers_test.go deleted file mode 100644 index df1a886b..00000000 --- a/backend/internal/proxy/singbox_runtime_helpers_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package proxy - -import ( - "ant-chrome/backend/internal/config" - "encoding/json" - "os" - "testing" -) - -func TestDefaultSingBoxDNSConfigUsesPublicIPv4Servers(t *testing.T) { - dns := defaultSingBoxDNSConfig() - if dns["final"] != "public-dns" { - t.Fatalf("dns.final = %v, want public-dns", dns["final"]) - } - if dns["strategy"] != "ipv4_only" { - t.Fatalf("dns.strategy = %v, want ipv4_only", dns["strategy"]) - } - servers, ok := dns["servers"].([]interface{}) - if !ok || len(servers) < 2 { - t.Fatalf("dns.servers = %#v, want at least two servers", dns["servers"]) - } - first, ok := servers[0].(map[string]interface{}) - if !ok { - t.Fatalf("first dns server is %T, want map", servers[0]) - } - if first["type"] != "udp" || first["server"] != "223.5.5.5" { - t.Fatalf("first dns server = %#v", first) - } -} - -func TestDefaultXrayDNSConfigUsesPublicServers(t *testing.T) { - dns := defaultXrayDNSConfig() - servers, ok := dns["servers"].([]interface{}) - if !ok || len(servers) != 2 { - t.Fatalf("dns.servers = %#v, want two servers", dns["servers"]) - } - if servers[0] != "223.5.5.5" || servers[1] != "119.29.29.29" { - t.Fatalf("dns.servers = %#v", servers) - } -} - -func TestSingBoxRouteUsesDefaultDomainResolver(t *testing.T) { - appConfig := config.DefaultConfig() - appConfig.Browser.UserDataRoot = t.TempDir() - m := &SingBoxManager{Config: appConfig, AppRoot: t.TempDir()} - cfgPath, err := m.buildConfig("dns-route-test", map[string]interface{}{"type": "direct", "tag": "proxy-out"}, 12345) - if err != nil { - t.Fatalf("buildConfig returned error: %v", err) - } - var generatedConfig map[string]interface{} - data, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read config failed: %v", err) - } - if err := json.Unmarshal(data, &generatedConfig); err != nil { - t.Fatalf("decode config failed: %v", err) - } - route, ok := generatedConfig["route"].(map[string]interface{}) - if !ok { - t.Fatalf("route is %T, want map", generatedConfig["route"]) - } - if route["default_domain_resolver"] != "public-dns" { - t.Fatalf("default_domain_resolver = %v", route["default_domain_resolver"]) - } -} diff --git a/backend/internal/proxy/speedtest_test.go b/backend/internal/proxy/speedtest_test.go deleted file mode 100644 index de773054..00000000 --- a/backend/internal/proxy/speedtest_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package proxy - -import ( - "bufio" - "encoding/base64" - "fmt" - "net" - "strings" - "sync/atomic" - "testing" - "time" - - "ant-chrome/backend/internal/config" -) - -func TestProxyConfigToMappingStandardProxy(t *testing.T) { - t.Parallel() - - mapping, err := proxyConfigToMapping("http://user:pass@example.com:8080/path") - if err != nil { - t.Fatalf("proxyConfigToMapping returned error: %v", err) - } - - if got := mapping["type"]; got != "http" { - t.Fatalf("type = %v, want http", got) - } - if got := mapping["server"]; got != "example.com" { - t.Fatalf("server = %v, want example.com", got) - } - if got := mapping["port"]; got != 8080 { - t.Fatalf("port = %v, want 8080", got) - } - if got := mapping["username"]; got != "user" { - t.Fatalf("username = %v, want user", got) - } - if got := mapping["password"]; got != "pass" { - t.Fatalf("password = %v, want pass", got) - } -} - -func TestProxyConfigToMappingEscapedCredentials(t *testing.T) { - t.Parallel() - - mapping, err := proxyConfigToMapping("http://user%40mail:p%40ss%3Aword@example.com:8080") - if err != nil { - t.Fatalf("proxyConfigToMapping returned error: %v", err) - } - if got := mapping["username"]; got != "user@mail" { - t.Fatalf("username = %v, want user@mail", got) - } - if got := mapping["password"]; got != "p@ss:word" { - t.Fatalf("password = %v, want p@ss:word", got) - } -} - -func TestProxyEndpointDropsCredentials(t *testing.T) { - t.Parallel() - - endpoint, err := proxyEndpoint("http://user:pass@example.com:8080") - if err != nil { - t.Fatalf("proxyEndpoint returned error: %v", err) - } - if endpoint != "example.com:8080" { - t.Fatalf("endpoint = %q, want example.com:8080", endpoint) - } -} - -func TestProxyConfigToMappingClashYAML(t *testing.T) { - t.Parallel() - - src := "proxies:\n - type: vmess\n server: test.example.com\n port: 443\n" - mapping, err := proxyConfigToMapping(src) - if err != nil { - t.Fatalf("proxyConfigToMapping returned error: %v", err) - } - - if got := mapping["type"]; got != "vmess" { - t.Fatalf("type = %v, want vmess", got) - } - if got := mapping["server"]; got != "test.example.com" { - t.Fatalf("server = %v, want test.example.com", got) - } - if got := mapping["port"]; got != 443 { - t.Fatalf("port = %v, want 443", got) - } - if got := mapping["name"]; got != "speedtest-proxy" { - t.Fatalf("name = %v, want speedtest-proxy", got) - } -} - -func TestProxyConfigToMappingSSURI(t *testing.T) { - t.Parallel() - - userinfo := base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:secret")) - mapping, err := proxyConfigToMapping("ss://" + userinfo + "@ptxlv6-1.hxx.top:43001#node") - if err != nil { - t.Fatalf("proxyConfigToMapping returned error: %v", err) - } - if got := mapping["type"]; got != "ss" { - t.Fatalf("type = %v, want ss", got) - } - if got := mapping["server"]; got != "ptxlv6-1.hxx.top" { - t.Fatalf("server = %v, want ptxlv6-1.hxx.top", got) - } - if got := mapping["port"]; got != 43001 { - t.Fatalf("port = %v, want 43001", got) - } - if got := mapping["cipher"]; got != "aes-128-gcm" { - t.Fatalf("cipher = %v, want aes-128-gcm", got) - } - if got := mapping["password"]; got != "secret" { - t.Fatalf("password = %v, want secret", got) - } -} - -func TestProxyEndpointSSURIIPv6(t *testing.T) { - t.Parallel() - - raw := base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:secret@[2001:db8::1]:43001")) - endpoint, err := proxyEndpoint("ss://" + raw) - if err != nil { - t.Fatalf("proxyEndpoint returned error: %v", err) - } - if endpoint != "[2001:db8::1]:43001" { - t.Fatalf("endpoint = %q, want [2001:db8::1]:43001", endpoint) - } -} - -func TestProxyConfigToMappingUnsupportedURI(t *testing.T) { - t.Parallel() - - if _, err := proxyConfigToMapping("vmess://example"); err == nil { - t.Fatal("expected unsupported URI error") - } -} - -func TestDefaultProxyCheckURLsAreConfigured(t *testing.T) { - t.Parallel() - - if strings.TrimSpace(DefaultSpeedTestURL) == "" { - t.Fatalf("DefaultSpeedTestURL must not be empty") - } - if strings.TrimSpace(DefaultIPHealthURL) == "" { - t.Fatalf("DefaultIPHealthURL must not be empty") - } -} - -func TestDefaultSpeedTestTimeoutsAreShort(t *testing.T) { - t.Parallel() - - if DefaultSpeedTestConfig.Timeout != 3*time.Second { - t.Fatalf("speed timeout = %s, want 3s", DefaultSpeedTestConfig.Timeout) - } - if DefaultSpeedTestConfig.TCPTimeout != 3*time.Second { - t.Fatalf("speed tcp timeout = %s, want 3s", DefaultSpeedTestConfig.TCPTimeout) - } -} - -func TestSpeedTestDefaultsToXrayLightHTTPDelay(t *testing.T) { - var requests atomic.Int32 - const responseDelay = 120 * time.Millisecond - proxyURL, closeProxy := startDelayedConnectProxy(t, responseDelay, &requests) - t.Cleanup(closeProxy) - - proxyID := "delayed-http-proxy" - result := SpeedTest( - proxyID, - []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: proxyURL}}, - nil, - nil, - &SpeedTestConfig{Timeout: 2 * time.Second, URLs: []string{"http://latency.test/generate_204"}}, - ) - if !result.Ok { - t.Fatalf("SpeedTest failed: %+v", result) - } - if requests.Load() == 0 { - t.Fatal("test proxy did not receive any speed-test request") - } - if requests.Load() != 2 { - t.Fatalf("requests = %d, want unified delay to perform two HEAD requests", requests.Load()) - } - if result.Engine != "native" { - t.Fatalf("engine = %q, want native", result.Engine) - } - if result.LatencyMs <= 0 || result.LatencyMs >= int64(responseDelay/time.Millisecond) { - t.Fatalf("latency = %dms, want second unified-delay probe below first-connection delay", result.LatencyMs) - } -} - -func TestSpeedTestFallsBackAcrossTargets(t *testing.T) { - var requests atomic.Int32 - proxyURL, closeProxy := startDelayedConnectProxy(t, 10*time.Millisecond, &requests) - t.Cleanup(closeProxy) - - proxyID := "fallback-http-proxy" - result := SpeedTestWithConnector( - proxyID, - []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: proxyURL}}, - nil, - nil, - nil, - config.BrowserConnectorXray, - &SpeedTestConfig{Timeout: 2 * time.Second, URLs: []string{"http://latency.test/fail", "http://latency.test/generate_204"}}, - ) - if !result.Ok { - t.Fatalf("SpeedTestWithConnector should fallback to second target: %+v", result) - } - if requests.Load() != 4 { - t.Fatalf("requests = %d, want fallback to perform unified-delay HEAD pair per target", requests.Load()) - } -} - -func TestSpeedTestTargetsDoNotIncludeRealConnectivityFallbacks(t *testing.T) { - t.Parallel() - - targets := speedTestTargetURLs(&SpeedTestConfig{}) - if len(targets) != 1 { - t.Fatalf("targets = %#v, want only default speed test URL", targets) - } - if targets[0] != DefaultSpeedTestURL { - t.Fatalf("target = %q, want %q", targets[0], DefaultSpeedTestURL) - } - for _, target := range targets { - if strings.Contains(target, "cloudflare") || strings.Contains(target, "msftconnecttest") { - t.Fatalf("speed test target unexpectedly includes real-connectivity URL: %#v", targets) - } - } -} - -func TestSpeedTestUsesSingBoxProtocolWhenXrayConnectorSelected(t *testing.T) { - proxyID := "hy2-proxy" - result := SpeedTestWithConnector( - proxyID, - []config.BrowserProxy{{ProxyId: proxyID, ProxyConfig: "hysteria2://pass@example.com:443?sni=example.com"}}, - nil, - nil, - nil, - config.BrowserConnectorXray, - &SpeedTestConfig{Timeout: 10 * time.Millisecond, URLs: []string{"http://latency.test/generate_204"}}, - ) - if result.Ok { - t.Fatalf("speed test should fail without sing-box manager, got success: %+v", result) - } - if result.Engine != "sing-box" { - t.Fatalf("engine = %q, want sing-box; result=%+v", result.Engine, result) - } - if !strings.Contains(result.Error, "sing-box 管理器未初始化") { - t.Fatalf("error = %q, want sing-box manager guidance", result.Error) - } -} - -func startDelayedConnectProxy(t *testing.T, delay time.Duration, requests *atomic.Int32) (string, func()) { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - done := make(chan struct{}) - go func() { - defer close(done) - for { - conn, err := listener.Accept() - if err != nil { - return - } - go handleDelayedConnectProxyConn(conn, delay, requests) - } - }() - return "http://" + listener.Addr().String(), func() { - _ = listener.Close() - <-done - } -} - -func handleDelayedConnectProxyConn(conn net.Conn, delay time.Duration, requests *atomic.Int32) { - defer conn.Close() - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - return - } - for { - header, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(header) == "" { - break - } - } - if strings.HasPrefix(line, "CONNECT ") { - _, _ = fmt.Fprint(conn, "HTTP/1.1 200 Connection Established\r\n\r\n") - line, err = reader.ReadString('\n') - if err != nil { - return - } - for { - header, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(header) == "" { - break - } - } - } - for strings.HasPrefix(line, "HEAD ") { - requestCount := requests.Add(1) - if requestCount == 1 { - time.Sleep(delay) - } else { - time.Sleep(10 * time.Millisecond) - } - statusLine := "HTTP/1.1 204 No Content" - if strings.Contains(line, "/fail") { - statusLine = "HTTP/1.1 500 Internal Server Error" - } - _, _ = fmt.Fprintf(conn, "%s\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n", statusLine) - line, err = reader.ReadString('\n') - if err != nil { - return - } - for { - header, err := reader.ReadString('\n') - if err != nil || strings.TrimSpace(header) == "" { - break - } - } - } -} diff --git a/backend/internal/proxy/xray_launch_lock_test.go b/backend/internal/proxy/xray_launch_lock_test.go deleted file mode 100644 index 2f2dc9bb..00000000 --- a/backend/internal/proxy/xray_launch_lock_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package proxy - -import ( - "sync/atomic" - "testing" - "time" -) - -func TestXrayLaunchLockSerializesSameKey(t *testing.T) { - t.Parallel() - - manager := &XrayManager{} - unlockFirst := manager.lockLaunchForKey("node-a") - acquiredSecond := make(chan struct{}) - go func() { - unlockSecond := manager.lockLaunchForKey("node-a") - defer unlockSecond() - close(acquiredSecond) - }() - - select { - case <-acquiredSecond: - t.Fatalf("same-key launch lock was not serialized") - case <-time.After(30 * time.Millisecond): - } - - unlockFirst() - select { - case <-acquiredSecond: - case <-time.After(time.Second): - t.Fatalf("same-key launch lock did not release") - } -} - -func TestXrayLaunchLockAllowsDifferentKeys(t *testing.T) { - t.Parallel() - - manager := &XrayManager{} - unlockFirst := manager.lockLaunchForKey("node-a") - defer unlockFirst() - var acquired int32 - done := make(chan struct{}) - go func() { - unlockSecond := manager.lockLaunchForKey("node-b") - defer unlockSecond() - atomic.StoreInt32(&acquired, 1) - close(done) - }() - - select { - case <-done: - case <-time.After(time.Second): - t.Fatalf("different-key launch lock was unexpectedly blocked") - } - if atomic.LoadInt32(&acquired) != 1 { - t.Fatalf("different-key launch lock was not acquired") - } -} diff --git a/backend/internal/proxy/xray_runtime_helpers_test.go b/backend/internal/proxy/xray_runtime_helpers_test.go deleted file mode 100644 index 8be331ef..00000000 --- a/backend/internal/proxy/xray_runtime_helpers_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package proxy - -import ( - "io" - "net" - "reflect" - "testing" - "time" -) - -func TestParseDnsConfigFromClashYAML(t *testing.T) { - t.Parallel() - - raw := ` -dns: - enable: true - nameserver: - - 8.8.8.8 - - tls://1.1.1.1 - fallback: - - https://dns.google/dns-query -` - - got := parseDnsConfig(raw) - want := map[string]interface{}{ - "servers": []interface{}{"8.8.8.8", "https://dns.google/dns-query"}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("parseDnsConfig() = %#v, want %#v", got, want) - } -} - -func TestBuildDnsDiagnosticSummaryFromClashYAML(t *testing.T) { - t.Parallel() - - raw := ` -dns: - enable: true - enhanced-mode: fake-ip - nameserver: - - 8.8.8.8 - - tls://1.1.1.1 - fallback: - - https://dns.google/dns-query -` - - got := buildDnsDiagnosticSummary(raw) - if !got.HasConfig || got.SourceFormat != "clash" || got.EnhancedMode != "fake-ip" { - t.Fatalf("unexpected summary identity: %+v", got) - } - if got.NameserverCount != 2 || got.FallbackCount != 1 || got.XrayServerCount != 2 { - t.Fatalf("unexpected summary counts: %+v", got) - } - if len(got.Unsupported) != 1 || got.Unsupported[0] != "tls://1.1.1.1" { - t.Fatalf("unsupported = %#v, want tls://1.1.1.1", got.Unsupported) - } -} - -func TestBuildDnsDiagnosticSummaryFromCommaList(t *testing.T) { - t.Parallel() - - got := buildDnsDiagnosticSummary("8.8.8.8, tls://1.1.1.1, https://dns.google/dns-query") - if !got.HasConfig || got.SourceFormat != "list" { - t.Fatalf("unexpected summary identity: %+v", got) - } - if got.NameserverCount != 3 || got.XrayServerCount != 2 || len(got.Unsupported) != 1 { - t.Fatalf("unexpected summary counts: %+v", got) - } -} - -func TestParseDnsConfigFromCommaList(t *testing.T) { - t.Parallel() - - got := parseDnsConfig("8.8.8.8, tls://1.1.1.1, 127.0.0.1:53") - want := map[string]interface{}{ - "servers": []interface{}{"8.8.8.8", "127.0.0.1:53"}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("parseDnsConfig() = %#v, want %#v", got, want) - } -} - -func TestNormalizeNodeScheme(t *testing.T) { - t.Parallel() - - if got := normalizeNodeScheme("hysteria://example"); got != "hysteria2://example" { - t.Fatalf("normalizeNodeScheme() = %q", got) - } - if got := normalizeNodeScheme("vmess://example"); got != "vmess://example" { - t.Fatalf("normalizeNodeScheme() unexpectedly changed vmess: %q", got) - } -} - -func TestWaitPortReadyIncludesLastDialError(t *testing.T) { - t.Parallel() - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - port := listener.Addr().(*net.TCPAddr).Port - listener.Close() - - err = waitPortReady("127.0.0.1", port, 50*time.Millisecond) - if err == nil { - t.Fatalf("expected waitPortReady error") - } - if got := err.Error(); got == "" || got == "端口 0 不可用" { - t.Fatalf("expected detailed port error, got %q", got) - } -} - -func TestWaitSocks5ReadyRequiresHandshake(t *testing.T) { - t.Parallel() - - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - defer listener.Close() - port := listener.Addr().(*net.TCPAddr).Port - done := make(chan struct{}) - go func() { - defer close(done) - conn, err := listener.Accept() - if err != nil { - return - } - defer conn.Close() - buf := make([]byte, 3) - _, _ = io.ReadFull(conn, buf) - _, _ = conn.Write([]byte{0x05, 0x00}) - }() - - if err := waitSocks5Ready("127.0.0.1", port, time.Second); err != nil { - t.Fatalf("waitSocks5Ready() error = %v", err) - } - <-done -} diff --git a/backend/internal/snapshot/archive_test.go b/backend/internal/snapshot/archive_test.go deleted file mode 100644 index 03218152..00000000 --- a/backend/internal/snapshot/archive_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package snapshot - -import ( - "os" - "path/filepath" - "testing" -) - -func TestZipDirAndUnzipTo(t *testing.T) { - t.Parallel() - - root := t.TempDir() - src := filepath.Join(root, "src") - dstZip := filepath.Join(root, "archive.zip") - dstDir := filepath.Join(root, "dst") - - if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil { - t.Fatalf("mkdir src: %v", err) - } - if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil { - t.Fatalf("write source file: %v", err) - } - - if err := ZipDir(src, dstZip); err != nil { - t.Fatalf("ZipDir failed: %v", err) - } - if err := UnzipTo(dstZip, dstDir); err != nil { - t.Fatalf("UnzipTo failed: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dstDir, "nested", "file.txt")) - if err != nil { - t.Fatalf("read extracted file: %v", err) - } - if string(data) != "hello" { - t.Fatalf("extracted content = %q, want hello", string(data)) - } -} diff --git a/backend/internal/snapshot/files_test.go b/backend/internal/snapshot/files_test.go deleted file mode 100644 index 30d15935..00000000 --- a/backend/internal/snapshot/files_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package snapshot - -import ( - "os" - "path/filepath" - "testing" -) - -func TestFindFiles(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - metaPath := filepath.Join(dir, "snap-1_demo.meta.json") - zipPath := filepath.Join(dir, "snap-1_demo.zip") - - if err := os.WriteFile(metaPath, []byte("{}"), 0o644); err != nil { - t.Fatalf("write meta: %v", err) - } - if err := os.WriteFile(zipPath, []byte("zip"), 0o644); err != nil { - t.Fatalf("write zip: %v", err) - } - - gotMeta, gotZip, err := FindFiles(dir, "snap-1") - if err != nil { - t.Fatalf("FindFiles failed: %v", err) - } - if gotMeta != metaPath { - t.Fatalf("meta path = %q, want %q", gotMeta, metaPath) - } - if gotZip != zipPath { - t.Fatalf("zip path = %q, want %q", gotZip, zipPath) - } -} - -func TestEnsureDir(t *testing.T) { - t.Parallel() - - root := t.TempDir() - dir, err := EnsureDir(root, "profile-1") - if err != nil { - t.Fatalf("EnsureDir failed: %v", err) - } - expected := filepath.Join(root, "snapshots", "profile-1") - if dir != expected { - t.Fatalf("dir = %q, want %q", dir, expected) - } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - t.Fatalf("dir was not created: info=%v err=%v", info, err) - } -} diff --git a/backend/license_state_test.go b/backend/license_state_test.go deleted file mode 100644 index 10aaedc3..00000000 --- a/backend/license_state_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package backend - -import ( - appconfig "ant-chrome/backend/internal/config" - "path/filepath" - "testing" -) - -func TestLoadConfigRestoresLocalLicenseState(t *testing.T) { - root := t.TempDir() - configPath := filepath.Join(root, "config.yaml") - - cfg := appconfig.DefaultConfig() - if err := cfg.Save(configPath); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - if err := saveLocalLicenseState(configPath, &localLicenseState{ - MaxProfileLimit: appconfig.GithubStarProfileTotal + appconfig.StandardCDKeyProfileBonus, - UsedCDKeys: []string{"GITHUB_STAR_REWARD", "ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE"}, - }); err != nil { - t.Fatalf("写入本机额度状态失败: %v", err) - } - - loaded, err := LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig 失败: %v", err) - } - - if loaded.App.MaxProfileLimit != appconfig.GithubStarProfileTotal+appconfig.StandardCDKeyProfileBonus { - t.Fatalf("本机额度状态未恢复: got=%d", loaded.App.MaxProfileLimit) - } - if len(loaded.App.UsedCDKeys) != 2 { - t.Fatalf("兑换记录未恢复: %+v", loaded.App.UsedCDKeys) - } -} - -func TestLoadConfigSeedsLocalLicenseStateFromConfig(t *testing.T) { - root := t.TempDir() - configPath := filepath.Join(root, "config.yaml") - - cfg := appconfig.DefaultConfig() - cfg.App.MaxProfileLimit = appconfig.GithubStarProfileTotal - cfg.App.UsedCDKeys = []string{"GITHUB_STAR_REWARD"} - if err := cfg.Save(configPath); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - loaded, err := LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig 失败: %v", err) - } - if loaded.App.MaxProfileLimit != appconfig.GithubStarProfileTotal { - t.Fatalf("LoadConfig 读取额度失败: got=%d", loaded.App.MaxProfileLimit) - } - - state, exists, err := loadLocalLicenseState(configPath) - if err != nil { - t.Fatalf("读取本机额度状态失败: %v", err) - } - if !exists { - t.Fatalf("应当从现有配置补建本机额度状态") - } - if state.MaxProfileLimit != appconfig.GithubStarProfileTotal { - t.Fatalf("本机额度状态未补建: got=%d", state.MaxProfileLimit) - } - if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" { - t.Fatalf("本机兑换记录未补建: %+v", state.UsedCDKeys) - } -} - -func TestRedeemGithubStarPersistsLocalLicenseState(t *testing.T) { - root := t.TempDir() - configPath := filepath.Join(root, "config.yaml") - - cfg := appconfig.DefaultConfig() - if err := cfg.Save(configPath); err != nil { - t.Fatalf("写入测试配置失败: %v", err) - } - - app := NewApp(root) - app.config = cfg - - if err := app.RedeemGithubStar(); err != nil { - t.Fatalf("RedeemGithubStar 失败: %v", err) - } - - state, exists, err := loadLocalLicenseState(configPath) - if err != nil { - t.Fatalf("读取本机额度状态失败: %v", err) - } - if !exists { - t.Fatalf("兑换后应写入本机额度状态") - } - if state.MaxProfileLimit != appconfig.GithubStarProfileTotal { - t.Fatalf("兑换后本机额度状态错误: got=%d", state.MaxProfileLimit) - } - if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" { - t.Fatalf("兑换后本机兑换记录错误: %+v", state.UsedCDKeys) - } -} diff --git a/window_sizing_test.go b/window_sizing_test.go deleted file mode 100644 index 6c8dce91..00000000 --- a/window_sizing_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import "testing" - -func TestFitStartupWindowBoundsUsesThreeQuartersOfDesktop(t *testing.T) { - bounds := fitStartupWindowBounds( - startupWindowBounds{Width: 1750, Height: 1000, MinWidth: 1200, MinHeight: 700}, - desktopWorkArea{Width: 1366, Height: 768}, - true, - ) - - if bounds.Width != 1024 || bounds.Height != 576 { - t.Fatalf("expected 1024x576, got %dx%d", bounds.Width, bounds.Height) - } -} - -func TestFitStartupWindowBoundsKeepsSmallerConfiguredMinimum(t *testing.T) { - bounds := fitStartupWindowBounds( - startupWindowBounds{Width: 1750, Height: 1000, MinWidth: 800, MinHeight: 420}, - desktopWorkArea{Width: 1366, Height: 768}, - true, - ) - - if bounds.MinWidth != 800 || bounds.MinHeight != 420 { - t.Fatalf("expected configured min size 800x420, got %dx%d", bounds.MinWidth, bounds.MinHeight) - } -} - -func TestFitStartupWindowBoundsRelaxesOversizedMinimum(t *testing.T) { - bounds := fitStartupWindowBounds( - startupWindowBounds{Width: 1750, Height: 1000, MinWidth: 1200, MinHeight: 700}, - desktopWorkArea{Width: 1366, Height: 768}, - true, - ) - - if bounds.MinWidth != 768 { - t.Fatalf("expected min width to relax to 768, got %d", bounds.MinWidth) - } - if bounds.MinHeight != 432 { - t.Fatalf("expected min height to relax to 432, got %d", bounds.MinHeight) - } -} - -func TestFitStartupWindowBoundsKeepsConfigWhenDesktopUnavailable(t *testing.T) { - bounds := fitStartupWindowBounds( - startupWindowBounds{Width: 1750, Height: 1000, MinWidth: 1200, MinHeight: 700}, - desktopWorkArea{}, - false, - ) - - if bounds.Width != 1750 || bounds.Height != 1000 { - t.Fatalf("expected configured size 1750x1000, got %dx%d", bounds.Width, bounds.Height) - } - if bounds.MinWidth != 1200 || bounds.MinHeight != 700 { - t.Fatalf("expected configured min size 1200x700, got %dx%d", bounds.MinWidth, bounds.MinHeight) - } -}