Files
Cyrene_v2/internal/capability/browsercfg/probe_test.go
T
AskaEth 29181245ce feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)
按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json
(0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、
视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium
可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用);
profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。

本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。
2026-10-06 09:03:34 +08:00

282 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package browsercfg
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// TestResolveNoSource 钉住「都没有 ⇒ 不假装可用」:本机无浏览器、无 CDP 端点 ⇒
// Usable=false、Reason=no_source(design/28 §8 B1、§7 验收判据 1)。
func TestResolveNoSource(t *testing.T) {
got := Resolve(context.Background(), nil, noBrowserProber())
if got.Usable {
t.Fatalf("应判不可用,got=%+v", got)
}
if got.Reason != ReasonNoSource {
t.Fatalf("Reason=%q,want %q", got.Reason, ReasonNoSource)
}
if got.Source != SourceNone {
t.Fatalf("不可用时应无来源,got=%q", got.Source)
}
}
// TestResolveLocalBrowserFromPath 钉住优先级第 1 档:PATH 上的 Chrome/Chromium 命中即用本机。
func TestResolveLocalBrowserFromPath(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(name string) (string, error) {
if name == "google-chrome" {
return "/usr/bin/google-chrome", nil
}
return "", errors.New("not found")
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/usr/bin/google-chrome" {
t.Fatalf("应命中本机浏览器,got=%+v", got)
}
if got.Reason != ReasonUsable {
t.Fatalf("可用时 Reason 应为 usable,got=%q", got.Reason)
}
}
// TestResolveLocalBrowserFromAbsolutePath 钉住 PATH 未命中时会把固定安装位置当候选。
func TestResolveLocalBrowserFromAbsolutePath(t *testing.T) {
p := noBrowserProber()
p.Stat = func(path string) (os.FileInfo, error) {
if path == "/usr/bin/chromium" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Executable != "/usr/bin/chromium" {
t.Fatalf("应命中固定安装位置 /usr/bin/chromium,got=%+v", got)
}
}
// TestResolveLocalBrowserSkipsDirectory 钉住目录不算可执行文件。
func TestResolveLocalBrowserSkipsDirectory(t *testing.T) {
p := noBrowserProber()
p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{dir: true, mode: 0o755}, nil }
if got := Resolve(context.Background(), nil, p); got.Usable {
t.Fatalf("目录不该被当浏览器,got=%+v", got)
}
}
// TestResolveLocalBrowserNeedsExecBit 钉住非 Windows 上无执行位不算可执行(Windows 跳过)。
func TestResolveLocalBrowserNeedsExecBit(t *testing.T) {
p := noBrowserProber()
p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{mode: 0o644}, nil }
if got := Resolve(context.Background(), nil, p); got.Usable {
t.Fatalf("无执行位不该算可执行,got=%+v", got)
}
p.GOOS = "windows"
p.Getenv = func(key string) string {
if key == "ProgramFiles" {
return `C:\PF`
}
return ""
}
if got := Resolve(context.Background(), nil, p); !got.Usable {
t.Fatalf("Windows 不看执行位,应判可用,got=%+v", got)
}
}
// TestResolveCDPWhenNoLocalBrowser 钉住优先级第 2 档:本机没有时看已在跑的 CDP 端点。
func TestResolveCDPWhenNoLocalBrowser(t *testing.T) {
p := noBrowserProber()
p.CheckCDP = func(_ context.Context, endpoint string) error {
if endpoint == DefaultCDPEndpoint {
return nil
}
return errors.New("connection refused")
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Source != SourceCDP || got.Endpoint != DefaultCDPEndpoint {
t.Fatalf("应命中 CDP 端点,got=%+v", got)
}
}
// TestResolveLocalPreferredOverCDP 钉住优先级顺序(design/28 §8 B1:本机 → 容器)。
func TestResolveLocalPreferredOverCDP(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
p.CheckCDP = func(context.Context, string) error { return nil }
got := Resolve(context.Background(), nil, p)
if got.Source != SourceLocalChrome {
t.Fatalf("本机应优先于 CDP,got=%+v", got)
}
}
// TestResolveConfiguredExecutable 钉住面板显式指定的可执行文件:存在即可用、缺失即分类报错。
func TestResolveConfiguredExecutable(t *testing.T) {
ok := noBrowserProber()
ok.Stat = func(path string) (os.FileInfo, error) {
if path == "/opt/chrome" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
got := Resolve(context.Background(), &Config{Executable: "/opt/chrome"}, ok)
if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/opt/chrome" {
t.Fatalf("配置的可执行文件应被采纳,got=%+v", got)
}
got = Resolve(context.Background(), &Config{Executable: "/opt/missing"}, noBrowserProber())
if got.Usable || got.Reason != ReasonExecutableMissing {
t.Fatalf("缺失应报 %q,got=%+v", ReasonExecutableMissing, got)
}
}
// TestResolveConfiguredEndpoint 钉住面板显式指定的 CDP 端点:可达即可用、不可达分类报错。
func TestResolveConfiguredEndpoint(t *testing.T) {
p := noBrowserProber()
p.CheckCDP = func(_ context.Context, endpoint string) error {
if endpoint == "http://127.0.0.1:9333" {
return nil
}
return errors.New("connection refused")
}
got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9333"}, p)
if !got.Usable || got.Source != SourceCDP || got.Endpoint != "http://127.0.0.1:9333" {
t.Fatalf("配置的 CDP 端点应被采纳,got=%+v", got)
}
got = Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p)
if got.Usable || got.Reason != ReasonCDPUnreachable {
t.Fatalf("不可达应报 %q,got=%+v", ReasonCDPUnreachable, got)
}
}
// TestResolveConfiguredEndpointDoesNotFallBack 是「不许静默回退」的守门测试:面板配了端点、
// 端点连不上时,**即使本机明明有浏览器**也必须如实报 cdp_unreachable,不许改走本机。
func TestResolveConfiguredEndpointDoesNotFallBack(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p)
if got.Usable || got.Source != SourceNone || got.Reason != ReasonCDPUnreachable {
t.Fatalf("配了端点并连不上时必须如实报错、不得回退本机,got=%+v", got)
}
}
// TestResolveConfiguredExecutableWinsOverEndpoint 钉住显式来源的优先序:executable 先于
// cdpEndpoint(同一个「本机 → 容器」的口径),命中 executable 时不该再去探测端点。
func TestResolveConfiguredExecutableWinsOverEndpoint(t *testing.T) {
p := noBrowserProber()
p.Stat = func(path string) (os.FileInfo, error) {
if path == "/opt/chrome" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
probedEndpoint := false
p.CheckCDP = func(context.Context, string) error { probedEndpoint = true; return nil }
got := Resolve(context.Background(), &Config{Executable: "/opt/chrome", CDPEndpoint: "http://127.0.0.1:9333"}, p)
if got.Source != SourceLocalChrome {
t.Fatalf("executable 应优先,got=%+v", got)
}
if probedEndpoint {
t.Fatal("命中 executable 时不该再探测 CDP 端点")
}
}
// TestResolveHeadlessFromConfig 钉住档位来自配置(默认有头,显式 true 才无头)。
func TestResolveHeadlessFromConfig(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
if got := Resolve(context.Background(), &Config{}, p); got.Headless {
t.Fatalf("默认档应为有头,got=%+v", got)
}
if got := Resolve(context.Background(), &Config{Headless: true}, p); !got.Headless {
t.Fatalf("显式无头应被采纳,got=%+v", got)
}
if got := Resolve(context.Background(), nil, p); got.Headless {
t.Fatalf("无配置时应为有头默认,got=%+v", got)
}
}
// TestProberWithDefaults 钉住零值 Prober 也被补上真实现(零值 = 全默认)。
func TestProberWithDefaults(t *testing.T) {
p := (&Prober{}).withDefaults()
if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" {
t.Fatalf("withDefaults 未补齐字段:%+v", p)
}
}
// TestNewProber 钉住 NewProber 返回的也是补齐过的实现(生产路径取它)。
func TestNewProber(t *testing.T) {
p := NewProber()
if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" {
t.Fatalf("NewProber 未补齐字段:%+v", p)
}
}
// TestCheckCDP 用本机 httptest 服务钉住默认探测实现(生产路径,不能不测):
// `/json/version` 返回 2xx 即可达;非 2xx、连不上都算不可达;尾部斜杠容错。
func TestCheckCDP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/json/version" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"Browser":"Chrome/120"}`))
}))
defer srv.Close()
if err := checkCDP(context.Background(), srv.URL); err != nil {
t.Fatalf("可达端点应返回 nil,got=%v", err)
}
if err := checkCDP(context.Background(), srv.URL+"/"); err != nil {
t.Fatalf("尾部斜杠应被容错,got=%v", err)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer bad.Close()
if err := checkCDP(context.Background(), bad.URL); err == nil {
t.Fatal("非 2xx 应判不可达")
}
if err := checkCDP(context.Background(), "http://127.0.0.1:1"); err == nil {
t.Fatal("连不上应报错")
}
}
// TestCandidateTables 钉住三平台的候选表都非空(避免某个平台分支写成空表 ⇒ 该平台永远
// 探测不到浏览器),以及 Windows 候选完全由安装目录环境变量驱动。
func TestCandidateTables(t *testing.T) {
for _, goos := range []string{"windows", "darwin", "linux"} {
if len(lookPathNames(goos)) == 0 {
t.Fatalf("%s 的 PATH 候选为空", goos)
}
}
env := map[string]string{"ProgramFiles": `C:\PF`, "LOCALAPPDATA": `C:\LA`}
getenv := func(k string) string { return env[k] }
for _, goos := range []string{"windows", "darwin", "linux"} {
if got := absoluteCandidates(goos, getenv); len(got) == 0 {
t.Fatalf("%s 的绝对路径候选为空", goos)
}
}
if got := absoluteCandidates("windows", func(string) string { return "" }); len(got) != 0 {
t.Fatalf("无安装目录环境变量时 windows 候选应为空,got=%v", got)
}
}
// TestResolveNilProberDoesNotPanic 钉住 p=nil 不 panic(不连真实端点:用一个必失败的
// 上下文超时让它立刻返回)。
func TestResolveNilProberDoesNotPanic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
defer func() {
if r := recover(); r != nil {
t.Fatalf("Resolve(nil prober) panic: %v", r)
}
}()
_ = Resolve(ctx, nil, nil)
}