test: trim low-value unit tests

This commit is contained in:
ant-black
2026-06-23 17:32:41 +08:00
parent f010f2c3d3
commit 0a5d387ff9
66 changed files with 0 additions and 10353 deletions
-128
View File
@@ -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))
}
}
-169
View File
@@ -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
}
-85
View File
@@ -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")
}
}
-89
View File
@@ -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")
}
}
-216
View File
@@ -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)
}
}
-36
View File
@@ -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")
}
}
-138
View File
@@ -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")
}
}
-121
View File
@@ -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, "<html><body>client not supported</body></html>")
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)
}
}
-38
View File
@@ -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")
}
}
-60
View File
@@ -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)
}
}
-60
View File
@@ -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)
}
}
-108
View File
@@ -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")
}
}
-124
View File
@@ -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)
}
}
@@ -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)
}
}
}
-472
View File
@@ -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)
}
}
-195
View File
@@ -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])
}
}
@@ -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)
}
}
-321
View File
@@ -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")
}
}
-170
View File
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
-149
View File
@@ -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 Supportgot=%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)
}
}
@@ -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)
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
-124
View File
@@ -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)
}
}
-185
View File
@@ -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
}
@@ -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)
}
}
-210
View File
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
-362
View File
@@ -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)
}
}
-85
View File
@@ -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())
}
}
@@ -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())
}
}
@@ -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)
}
}
-280
View File
@@ -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")
}
}
-466
View File
@@ -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)
}
-163
View File
@@ -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"])
}
}
-439
View File
@@ -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()
}
-197
View File
@@ -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)
}
-194
View File
@@ -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")
}
}
@@ -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)
}
}
-128
View File
@@ -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)
}
}
-53
View File
@@ -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)
}
}
-72
View File
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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"])
}
}
-324
View File
@@ -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
}
}
}
}
@@ -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")
}
}
@@ -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
}
-38
View File
@@ -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))
}
}
-50
View File
@@ -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)
}
}
-100
View File
@@ -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)
}
}
-57
View File
@@ -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)
}
}