mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
channel: master version: 1.1.0 source-ref: master published-at-utc: 2026-03-29T11:32:04Z
580 lines
16 KiB
Go
580 lines
16 KiB
Go
package backend
|
|
|
|
import (
|
|
"ant-chrome/backend/internal/browser"
|
|
"ant-chrome/backend/internal/config"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
goruntime "runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestEnsureNewWindowLaunchArgAddsFlagOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := ensureNewWindowLaunchArg([]string{"--lang=en-US"})
|
|
want := []string{"--lang=en-US", "--new-window"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ensureNewWindowLaunchArg 结果错误: got=%v want=%v", got, want)
|
|
}
|
|
|
|
got = ensureNewWindowLaunchArg([]string{"--new-window", "--lang=en-US"})
|
|
want = []string{"--new-window", "--lang=en-US"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ensureNewWindowLaunchArg 不应重复追加: got=%v want=%v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestIsBrowserProfileLive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ln := mustListenLoopback(t)
|
|
defer ln.Close()
|
|
|
|
profile := &BrowserProfile{
|
|
Running: true,
|
|
DebugPort: listenerPort(t, ln),
|
|
}
|
|
if !isBrowserProfileLive(profile, nil) {
|
|
t.Fatal("期望存活中的调试端口被识别为运行中实例")
|
|
}
|
|
|
|
if isBrowserProfileLive(&BrowserProfile{Running: true, DebugPort: 0}, nil) {
|
|
t.Fatal("debugPort=0 不应被识别为运行中实例")
|
|
}
|
|
}
|
|
|
|
func TestIsBrowserProfileLiveKeepsPendingDebugProcessAlive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cmd := longLivedCommand(2 * time.Second)
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("启动长生命周期测试进程失败: %v", err)
|
|
}
|
|
defer func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}
|
|
}()
|
|
|
|
profile := &BrowserProfile{
|
|
Running: true,
|
|
Pid: cmd.Process.Pid,
|
|
DebugPort: 0,
|
|
DebugReady: false,
|
|
}
|
|
if !isBrowserProfileLive(profile, cmd) {
|
|
t.Fatal("期望调试接口未就绪但进程仍存活时识别为运行中实例")
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableKeepsListeningPort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/json/version":
|
|
_, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
|
|
case "/json/list":
|
|
_, _ = w.Write([]byte(`[{"id":"page-1"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
if _, err := waitBrowserDebugPortStable(server.port, "", time.Second, 250*time.Millisecond, nil); err != nil {
|
|
t.Fatalf("waitBrowserDebugPortStable 返回错误: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableRejectsEphemeralPort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/json/version":
|
|
_, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
|
|
case "/json/list":
|
|
_, _ = w.Write([]byte(`[{"id":"page-1"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
port := server.port
|
|
time.AfterFunc(120*time.Millisecond, func() {
|
|
_ = server.Close()
|
|
})
|
|
|
|
_, err := waitBrowserDebugPortStable(port, "", time.Second, 400*time.Millisecond, nil)
|
|
if err == nil {
|
|
t.Fatal("期望短暂就绪后关闭的端口被判定为失败")
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableRejectsPlainTCPPort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ln := mustListenLoopback(t)
|
|
defer ln.Close()
|
|
|
|
_, err := waitBrowserDebugPortStable(listenerPort(t, ln), "", 700*time.Millisecond, 250*time.Millisecond, nil)
|
|
if err == nil {
|
|
t.Fatal("期望仅开放 TCP 端口但无 DevTools HTTP 时启动失败")
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableDiscoversPortFromStderr(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/json/version":
|
|
_, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
|
|
case "/json/list":
|
|
_, _ = w.Write([]byte(`[{"id":"page-1"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cmd := stderrPortCommand(server.port, 2*time.Second)
|
|
monitor, err := newBrowserProcessMonitor(cmd)
|
|
if err != nil {
|
|
t.Fatalf("初始化浏览器进程监控失败: %v", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("启动测试命令失败: %v", err)
|
|
}
|
|
monitor.Start()
|
|
|
|
debugPort, err := waitBrowserDebugPortStable(0, "", 2*time.Second, 250*time.Millisecond, monitor)
|
|
if err != nil {
|
|
t.Fatalf("期望从 stderr 自动发现调试端口,实际错误: %v", err)
|
|
}
|
|
if debugPort != server.port {
|
|
t.Fatalf("期望发现调试端口 %d,实际=%d", server.port, debugPort)
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableDiscoversPortFromDevToolsFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := startDevToolsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/json/version":
|
|
_, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
|
|
case "/json/list":
|
|
_, _ = w.Write([]byte(`[{"id":"page-1"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
userDataDir := t.TempDir()
|
|
writeDevToolsActivePortFile(t, userDataDir, server.port)
|
|
|
|
debugPort, err := waitBrowserDebugPortStable(0, userDataDir, time.Second, 250*time.Millisecond, nil)
|
|
if err != nil {
|
|
t.Fatalf("期望从 DevToolsActivePort 自动发现调试端口,实际错误: %v", err)
|
|
}
|
|
if debugPort != server.port {
|
|
t.Fatalf("期望发现调试端口 %d,实际=%d", server.port, debugPort)
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableReturnsProcessExitDetail(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cmd := stderrFailingCommand("missing libEGL.dll")
|
|
monitor, err := newBrowserProcessMonitor(cmd)
|
|
if err != nil {
|
|
t.Fatalf("初始化浏览器进程监控失败: %v", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("启动测试命令失败: %v", err)
|
|
}
|
|
monitor.Start()
|
|
|
|
startedAt := time.Now()
|
|
_, err = waitBrowserDebugPortStable(0, "", 2*time.Second, 250*time.Millisecond, monitor)
|
|
if err == nil {
|
|
t.Fatal("期望启动前退出被判定为失败")
|
|
}
|
|
if time.Since(startedAt) >= 2*time.Second {
|
|
t.Fatalf("期望在超时前返回进程退出错误,实际耗时=%s", time.Since(startedAt))
|
|
}
|
|
|
|
var exitErr *browserStartupExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("期望 browserStartupExitError,实际=%T %v", err, err)
|
|
}
|
|
if !strings.Contains(exitErr.Detail(), "missing libEGL.dll") {
|
|
t.Fatalf("期望 stderr 细节被捕获,实际=%q", exitErr.Detail())
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserDebugPortStableAllowsDebugPortAfterLauncherExit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
port := freeLoopbackPort(t)
|
|
cmd := shortLivedCommand()
|
|
monitor, err := newBrowserProcessMonitor(cmd)
|
|
if err != nil {
|
|
t.Fatalf("初始化浏览器进程监控失败: %v", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("启动短命测试命令失败: %v", err)
|
|
}
|
|
monitor.Start()
|
|
|
|
serverReady := make(chan *devToolsTestServer, 1)
|
|
go func() {
|
|
time.Sleep(300 * time.Millisecond)
|
|
serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/json/version":
|
|
_, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
|
|
case "/json/list":
|
|
_, _ = w.Write([]byte(`[{"id":"page-1"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
}()
|
|
|
|
debugPort, err := waitBrowserDebugPortStable(port, "", 100*time.Millisecond, 250*time.Millisecond, monitor)
|
|
server := <-serverReady
|
|
defer server.Close()
|
|
|
|
if err != nil {
|
|
t.Fatalf("期望启动器退出后仍能等待到调试端口就绪,实际错误: %v", err)
|
|
}
|
|
if debugPort != port {
|
|
t.Fatalf("期望发现调试端口 %d,实际=%d", port, debugPort)
|
|
}
|
|
}
|
|
|
|
func TestWaitBrowserProcessKeepsRunningWhileDebugPortAlive(t *testing.T) {
|
|
ln := mustListenLoopback(t)
|
|
port := listenerPort(t, ln)
|
|
|
|
app := NewApp("")
|
|
app.browserMgr = browser.NewManager(config.DefaultConfig(), "")
|
|
app.browserMgr.Profiles = map[string]*BrowserProfile{
|
|
"profile-detached": {
|
|
ProfileId: "profile-detached",
|
|
ProfileName: "Detached Browser",
|
|
Running: true,
|
|
DebugPort: port,
|
|
DebugReady: true,
|
|
Pid: 12345,
|
|
},
|
|
}
|
|
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
|
|
|
|
cmd := shortLivedCommand()
|
|
monitor, err := newBrowserProcessMonitor(cmd)
|
|
if err != nil {
|
|
t.Fatalf("初始化测试进程监控失败: %v", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("启动短命测试进程失败: %v", err)
|
|
}
|
|
monitor.Start()
|
|
app.browserMgr.BrowserProcesses["profile-detached"] = cmd
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
app.waitBrowserProcess("profile-detached", monitor)
|
|
close(done)
|
|
}()
|
|
|
|
waitForCondition(t, 3*time.Second, func() bool {
|
|
app.browserMgr.Mutex.Lock()
|
|
defer app.browserMgr.Mutex.Unlock()
|
|
|
|
profile := app.browserMgr.Profiles["profile-detached"]
|
|
_, tracked := app.browserMgr.BrowserProcesses["profile-detached"]
|
|
return profile != nil && profile.Running && !tracked
|
|
})
|
|
|
|
_ = ln.Close()
|
|
|
|
waitForCondition(t, 4*time.Second, func() bool {
|
|
app.browserMgr.Mutex.Lock()
|
|
defer app.browserMgr.Mutex.Unlock()
|
|
|
|
profile := app.browserMgr.Profiles["profile-detached"]
|
|
return profile != nil && !profile.Running && profile.DebugPort == 0 && profile.Pid == 0
|
|
})
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(4 * time.Second):
|
|
t.Fatal("waitBrowserProcess 未在调试端口关闭后结束")
|
|
}
|
|
}
|
|
|
|
func TestWaitForBrowserDebugReadyMarksProfileReady(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
port := freeLoopbackPort(t)
|
|
app := NewApp("")
|
|
app.browserMgr = browser.NewManager(config.DefaultConfig(), "")
|
|
app.browserMgr.Profiles = map[string]*BrowserProfile{
|
|
"profile-ready": {
|
|
ProfileId: "profile-ready",
|
|
ProfileName: "Ready Browser",
|
|
Running: true,
|
|
DebugPort: port,
|
|
DebugReady: false,
|
|
RuntimeWarning: "pending",
|
|
LastStartAt: time.Now().Format(time.RFC3339),
|
|
},
|
|
}
|
|
app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
|
|
|
|
serverReady := make(chan *devToolsTestServer, 1)
|
|
go func() {
|
|
time.Sleep(200 * time.Millisecond)
|
|
serverReady <- startDevToolsServerOnPort(t, port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/json/version":
|
|
_, _ = w.Write([]byte(`{"Browser":"Chrome/142.0","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser"}`))
|
|
case "/json/list":
|
|
_, _ = w.Write([]byte(`[{"id":"page-1"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
}()
|
|
|
|
snapshot, changed := app.waitForBrowserDebugReady("profile-ready", port, 2*time.Second)
|
|
server := <-serverReady
|
|
defer server.Close()
|
|
|
|
if snapshot == nil {
|
|
t.Fatal("期望等待到调试接口就绪")
|
|
}
|
|
if !changed {
|
|
t.Fatal("期望调试接口就绪后标记实例状态变更")
|
|
}
|
|
if !snapshot.DebugReady {
|
|
t.Fatal("期望实例被标记为调试接口已就绪")
|
|
}
|
|
if snapshot.RuntimeWarning != "" {
|
|
t.Fatalf("期望调试接口就绪后清空警告,实际=%q", snapshot.RuntimeWarning)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeManagedLaunchArgsRemovesSystemManagedFlags(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, removed := sanitizeManagedLaunchArgs([]string{
|
|
"--lang=en-US",
|
|
"--remote-debugging-port=9222",
|
|
"--user-data-dir", "D:\\profiles\\demo",
|
|
"--proxy-server", "http://127.0.0.1:9000",
|
|
"--remote-debugging-pipe",
|
|
"https://example.com",
|
|
})
|
|
|
|
wantArgs := []string{"--lang=en-US", "https://example.com"}
|
|
if !reflect.DeepEqual(got, wantArgs) {
|
|
t.Fatalf("sanitizeManagedLaunchArgs args mismatch: got=%v want=%v", got, wantArgs)
|
|
}
|
|
|
|
wantRemoved := []string{
|
|
"--remote-debugging-port",
|
|
"--user-data-dir",
|
|
"--proxy-server",
|
|
"--remote-debugging-pipe",
|
|
}
|
|
if !reflect.DeepEqual(removed, wantRemoved) {
|
|
t.Fatalf("sanitizeManagedLaunchArgs removed mismatch: got=%v want=%v", removed, wantRemoved)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []string{"--lang=en-US", "--disable-sync", "https://example.com"}
|
|
got, removed := sanitizeManagedLaunchArgs(input)
|
|
if !reflect.DeepEqual(got, input) {
|
|
t.Fatalf("sanitizeManagedLaunchArgs should preserve unmanaged args: got=%v want=%v", got, input)
|
|
}
|
|
if len(removed) != 0 {
|
|
t.Fatalf("sanitizeManagedLaunchArgs should not report managed args, got=%v", removed)
|
|
}
|
|
}
|
|
|
|
func mustListenLoopback(t *testing.T) net.Listener {
|
|
t.Helper()
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("监听测试端口失败: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = conn.Close()
|
|
}
|
|
}()
|
|
|
|
return ln
|
|
}
|
|
|
|
func listenerPort(t *testing.T, ln net.Listener) int {
|
|
t.Helper()
|
|
|
|
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
t.Fatalf("解析监听地址失败: %T", ln.Addr())
|
|
}
|
|
return tcpAddr.Port
|
|
}
|
|
|
|
func shortLivedCommand() *exec.Cmd {
|
|
if goruntime.GOOS == "windows" {
|
|
return exec.Command("cmd", "/c", "exit", "0")
|
|
}
|
|
return exec.Command("sh", "-c", "exit 0")
|
|
}
|
|
|
|
func longLivedCommand(duration time.Duration) *exec.Cmd {
|
|
if goruntime.GOOS == "windows" {
|
|
seconds := int(duration / time.Second)
|
|
if seconds < 1 {
|
|
seconds = 1
|
|
}
|
|
return exec.Command("cmd", "/c", fmt.Sprintf("ping -n %d 127.0.0.1 >nul", seconds+1))
|
|
}
|
|
return exec.Command("sh", "-c", fmt.Sprintf("sleep %.1f", duration.Seconds()))
|
|
}
|
|
|
|
func stderrFailingCommand(message string) *exec.Cmd {
|
|
if goruntime.GOOS == "windows" {
|
|
return exec.Command("cmd", "/c", fmt.Sprintf("echo %s 1>&2 & exit 5", message))
|
|
}
|
|
return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' 1>&2; exit 5", message))
|
|
}
|
|
|
|
func stderrPortCommand(port int, holdFor time.Duration) *exec.Cmd {
|
|
if goruntime.GOOS == "windows" {
|
|
seconds := int(holdFor / time.Second)
|
|
if seconds < 1 {
|
|
seconds = 1
|
|
}
|
|
// ping -n N waits roughly N-1 seconds on Windows.
|
|
return exec.Command("cmd", "/c", fmt.Sprintf("echo DevTools listening on ws://127.0.0.1:%d/devtools/browser/test 1>&2 & ping -n %d 127.0.0.1 >nul", port, seconds+1))
|
|
}
|
|
return exec.Command("sh", "-c", fmt.Sprintf("echo 'DevTools listening on ws://127.0.0.1:%d/devtools/browser/test' 1>&2; sleep %.1f", port, holdFor.Seconds()))
|
|
}
|
|
|
|
func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if check() {
|
|
return
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
t.Fatal("等待条件成立超时")
|
|
}
|
|
|
|
func freeLoopbackPort(t *testing.T) int {
|
|
t.Helper()
|
|
|
|
ln := mustListenLoopback(t)
|
|
port := listenerPort(t, ln)
|
|
_ = ln.Close()
|
|
return port
|
|
}
|
|
|
|
type devToolsTestServer struct {
|
|
port int
|
|
server *http.Server
|
|
done chan struct{}
|
|
}
|
|
|
|
func startDevToolsServer(t *testing.T, handler http.Handler) *devToolsTestServer {
|
|
t.Helper()
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("启动 DevTools 测试服务失败: %v", err)
|
|
}
|
|
|
|
srv := &http.Server{Handler: handler}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_ = srv.Serve(ln)
|
|
}()
|
|
|
|
return &devToolsTestServer{
|
|
port: listenerPort(t, ln),
|
|
server: srv,
|
|
done: done,
|
|
}
|
|
}
|
|
|
|
func startDevToolsServerOnPort(t *testing.T, port int, handler http.Handler) *devToolsTestServer {
|
|
t.Helper()
|
|
|
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
|
if err != nil {
|
|
t.Fatalf("在指定端口启动 DevTools 测试服务失败: %v", err)
|
|
}
|
|
|
|
srv := &http.Server{Handler: handler}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_ = srv.Serve(ln)
|
|
}()
|
|
|
|
return &devToolsTestServer{
|
|
port: port,
|
|
server: srv,
|
|
done: done,
|
|
}
|
|
}
|
|
|
|
func (s *devToolsTestServer) Close() error {
|
|
if s == nil || s.server == nil {
|
|
return nil
|
|
}
|
|
err := s.server.Close()
|
|
<-s.done
|
|
return err
|
|
}
|
|
|
|
func writeDevToolsActivePortFile(t *testing.T, userDataDir string, port int) {
|
|
t.Helper()
|
|
|
|
content := fmt.Sprintf("%d\n/devtools/browser/test\n", port)
|
|
if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0644); err != nil {
|
|
t.Fatalf("写入 DevToolsActivePort 失败: %v", err)
|
|
}
|
|
}
|