Files
Cyrene_v2/internal/capability/browser/render_test.go
T
AskaEth 378faf212e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
Z5(c2e413e)把 web-fetch 的降级链与注入点做完了,但「取正文」入口缺失 ⇒
组合根拿不到 BodyRenderer、这一档实际关着(当时如实登记)。本片补上:

- internal/capability/browser/render.go:新增 Manager.RenderText(ctx,url)
  (导出的进程内能力)—— 先过 GuardURL(与全仓同一份 SSRF 判定源)、复用
  withSession/session、等 readyState=complete 之后才取 body.innerText;
  渲染失败 = 分类错误,渲染成功但正文为空 = 空串+无错(不冒充成功/失败)。
- cmd/cyrene/wire_body_fallback.go:适配器 + 挂载点,把 Manager 适配成
  io.BodyRenderer 并用 WithBrowserFallback 挂上;web_runtime 里把浏览器
  接线**上移到 io 工具集之前**(顺序颠倒会静默关死这一档)。
- plugins/tools/browser/tool.go:新增 Tool.Manager(),供组合根取驱动句柄。

开关口径(裁决,依据 design/28 §1 M5 / §7 判据 4 / §8 B5):**有可用浏览器
就挂,没有就不挂**,不新增开关 ——
① 设计把 M5/B5 写成要落地生效的降级档,不是永远关着的空壳;
② 设计 §8 的可调项清单里没有这条降级档的开关,`.env` 已冻结 ⇒ 自造开关属
   「自行发挥」;
③ io 库层「不传 = 默认关」的语义一字未改,本片只把组合根从「永远关」改成
   「没来源就关」,并留带来源的 WARN(reason=no-usable-browser)。
⚠️ 若日后要「有浏览器也需显式开启」,得先给设计定开关落点,代码侧一行改回。

验收(主会话复跑 + 亲自变异):go build ./... + 三包 vet 绿;browser/io 两包
测试绿(cmd/cyrene 由子代理跑过全绿);我用 -overlay 把 GuardURL 的判定对象
换成常量 URL(绕过被拒目标)⇒ TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess
变红,证明判定确实**在起任何进程之前**跑。

⚠️ 生产端到端未验:按红线没重启后端、没起真 Chrome ⇒「真页面 → browser 取到
正文」这条链只有假 CDP + 组合根接线级证据。
⚠️ design/28 有 6 处表述与代码现状冲突(归属 web-fetch vs web-search、extract
动作归属、B5 排期、状态过期等),已登记待我同步文档。
2026-10-07 09:59:56 +08:00

188 lines
7.4 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 browser
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// renderTestManager 造一个「导航目标已过白名单」的管理器:白名单里有 example.com,
// 于是 RenderText 的 SSRF 判定走白名单短路(不依赖外网 DNS,离线可跑)。
func renderTestManager(t *testing.T, avail browsercfg.Availability) *Manager {
t.Helper()
m := NewManager(Options{
Availability: avail,
Config: browsercfg.Defaults(t.TempDir()),
DataDir: t.TempDir(),
AllowedHosts: []string{"example.com"},
})
t.Cleanup(func() { _ = m.Close() })
return m
}
// okBrowserCDP 是「导航得通、正文取得回」的假 CDP:它按方法名应答,并把页面自报的
// state(url/title/readyState)与正文 text 分别交给 pageState 与 innerText 两次求值。
// 两个求值靠表达式区分(一个读 readyState,一个读 body.innerText)。
func okBrowserCDP(t *testing.T, text string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/final", "title": "示例页", "readyState": "complete",
}}}, nil
}
if !strings.Contains(expr, "innerText") {
return nil, &cdpError{Code: -32601, Message: "unexpected expression " + expr}
}
return map[string]any{"result": map[string]any{"value": text}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
}
// cdpEndpointAvail 把假 CDP 的地址包装成「已配 CDP 端点」的探测结论(走 remoteCDP,
// 不起任何本机进程)。
func cdpEndpointAvail(f *fakeCDP) browsercfg.Availability {
return browsercfg.Availability{
Usable: true, Source: browsercfg.SourceCDP, Endpoint: f.base, Detail: "假 CDP 端点 " + f.base,
}
}
// ① 渲染成功 ⇒ 取回渲染后的可见正文(innerText,不是 HTML 源码),并如实带出来源与
// 页面自报的地址/标题。
func TestManager_RenderTextReturnsVisibleText(t *testing.T) {
t.Parallel()
f := okBrowserCDP(t, "渲染后的正文:Hello 世界")
m := renderTestManager(t, cdpEndpointAvail(f))
got, err := m.RenderText(context.Background(), "https://example.com/")
if err != nil {
t.Fatalf("RenderText: %v", err)
}
if got.Text != "渲染后的正文:Hello 世界" {
t.Errorf("Text = %q", got.Text)
}
if got.URL != "https://example.com/final" || got.Title != "示例页" {
t.Errorf("URL/Title 应取页面自报值,实得 %q / %q", got.URL, got.Title)
}
if !strings.Contains(got.Source, "假 CDP 端点") {
t.Errorf("Source = %q,必须如实标注来源", got.Source)
}
}
// ② 渲染**成功但正文为空** ⇒ 空串 + nil error:它是读数不是失败,由上层按自己的口径
// 判(io 的降级档把空正文按 ErrEmptyRenderedBody 处理)。这里钉住本层不许把它变成 error,
// 也不许拿空串冒充「取到了失败」。
func TestManager_RenderTextEmptyBodyIsNotAnError(t *testing.T) {
t.Parallel()
f := okBrowserCDP(t, " \n ")
m := renderTestManager(t, cdpEndpointAvail(f))
got, err := m.RenderText(context.Background(), "https://example.com/")
if err != nil {
t.Fatalf("正文为空不该是错误(它是读数),实得 %v", err)
}
if strings.TrimSpace(got.Text) != "" {
t.Errorf("Text = %q, want 空/空白", got.Text)
}
}
// ③ 导航失败(CDP 的 errorText)⇒ 如实报错,**不返回空串冒充成功**:判据是
// errors.Is(err, ErrNavigate) 且正文那一次求值根本没发生过。
func TestManager_RenderTextNavigateFailureIsHonestError(t *testing.T) {
t.Parallel()
f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
switch method {
case "Page.enable":
return map[string]any{}, nil
case "Page.navigate":
return map[string]any{"errorText": "net::ERR_NAME_NOT_RESOLVED"}, nil
}
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
})
m := renderTestManager(t, cdpEndpointAvail(f))
got, err := m.RenderText(context.Background(), "https://example.com/")
if err == nil {
t.Fatal("导航失败必须报错,不许返回空正文冒充成功")
}
if !errors.Is(err, ErrNavigate) {
t.Errorf("err = %v, want ErrNavigate", err)
}
if !strings.Contains(err.Error(), "ERR_NAME_NOT_RESOLVED") {
t.Errorf("错误里应带上 CDP 的原始原因: %v", err)
}
if got.Text != "" || got.Source != "" {
t.Errorf("失败时不该交出半个结论: %+v", got)
}
}
// ④ 页面求值异常(正文那一次)⇒ 归类为 ErrEvaluate,同样不许冒充「取到了空正文」。
func TestManager_RenderTextEvaluateFailureIsHonestError(t *testing.T) {
t.Parallel()
f := newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/", "title": "t", "readyState": "complete",
}}}, nil
}
return map[string]any{"exceptionDetails": map[string]any{"text": "TypeError: body is gone"}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
m := renderTestManager(t, cdpEndpointAvail(f))
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
}
// ⑤ SSRF 守卫在**起浏览器之前**跑:云元数据地址被拒,且没有任何 CDP 请求发生
// (假 CDP 只被 /json/version 碰过的话就不是这条路径 —— 这里直接用不可达的可执行文件,
// 若顺序颠倒会拿到启动错误而不是守卫错误)。
func TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess(t *testing.T) {
t.Parallel()
m := renderTestManager(t, browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
})
_, err := m.RenderText(context.Background(), "http://169.254.169.254/latest/meta-data/")
if !errors.Is(err, ErrBlockedURL) {
t.Fatalf("err = %v, want ErrBlockedURL", err)
}
if !strings.Contains(err.Error(), "安全守卫") {
t.Errorf("错误应来自 URL 守卫: %v", err)
}
}
// ⑥ 没有可用来源时不假装可用:ErrUnavailable(组合根据此**不挂**这条降级档)。
func TestManager_RenderTextUnavailableDoesNotPretend(t *testing.T) {
t.Parallel()
m := renderTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"})
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
}