dsh-browser-verify 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ verification is just tool calls.
18
18
  ## Quick start
19
19
 
20
20
  ```sh
21
- dsh plugin --profile web add dsh-browser-verify@0.1.2
21
+ dsh plugin --profile web add dsh-browser-verify@0.1.4
22
22
  ```
23
23
 
24
24
  1. **Install** with the command above (or see [Install](#install)).
@@ -44,7 +44,7 @@ state?"; add `browser_screenshot` when you need to see the layout, or
44
44
 
45
45
  | Tool | Purpose |
46
46
  |---|---|
47
- | `browser_open` | Open a URL in a fresh scenario (headless Chromium, default viewport 390×844 @2x) and report title / HTTP status / visible-text summary / console errors. Optional `waitSelector` waits for a key element before returning, and optional inline `mocks` intercept APIs **before** the first navigation — for pages that boot against mocked data. |
47
+ | `browser_open` | Open a URL in a fresh scenario (headless Chromium, default viewport 390×844 @2x) and report title / HTTP status / visible-text summary / console errors. Without `waitSelector` it waits for the page to render-settle (two identical consecutive visible-text samples, capped at ~3s) before snapshotting, so it never returns the boot/skeleton frame; loading-state noise (`加载中...` etc.) is filtered out of the summary. Optional `waitSelector` waits for a key element before returning, and optional inline `mocks` intercept APIs **before** the first navigation — for pages that boot against mocked data. |
48
48
  | `browser_mock` | Register a playwright-glob route (`**/api/*.do*`) returning your JSON, then auto-reload the page to show the mocked state — the quickest way to verify empty / error / abnormal states without touching the backend. Duplicate patterns are rejected with a hint. |
49
49
  | `browser_assert` | The cheapest and most precise check: wait for a CSS selector, verify its count and contained text, return `{pass, count, actualText, elapsedMs}`. A mismatch is `pass:false` (with the diff), never a throw — so failure is a first-class result, not an error you debug. |
50
50
  | `browser_screenshot` | Capture the current page (viewport or full page) and **auto-project the image block into the model context** — the model sees the layout without any file handling. Reports dimensions, sha256, and `identicalToPrevious:true` when the shot is byte-identical to the previous one (page probably not refreshed). |
@@ -52,6 +52,22 @@ state?"; add `browser_screenshot` when you need to see the layout, or
52
52
  Use `browser_assert` before `browser_screenshot`: an assertion is cheaper, and
53
53
  a screenshot is for when the rendering itself must be judged.
54
54
 
55
+ ## Real model experience
56
+
57
+ > **"Pleasant to use, cleanly layered."** — a real model's verdict, after
58
+ > putting the four tools to work on a real business page.
59
+
60
+ - **A three-state verification in 4–5 tool calls, zero environment setup** —
61
+ the same job used to take ~20 manual script steps.
62
+ - **Mock, then it reloads itself** — no more "same URL, screenshot unchanged"
63
+ traps.
64
+ - **Screenshots land right in the model's context** — no file paths to chase.
65
+ - **Structured asserts** (count / text / elapsed) that even warn you when the
66
+ page didn't actually refresh.
67
+ - **Every open resets the world** — states never bleed into each other.
68
+
69
+ Verification should be a few tool calls, not script choreography.
70
+
55
71
  ### Worked example — two states, six calls
56
72
 
57
73
  The typical verification (empty state + normal state) is 6 calls:
@@ -69,7 +85,7 @@ browser_screenshot
69
85
  ## Install
70
86
 
71
87
  ```sh
72
- dsh plugin --profile web add dsh-browser-verify@0.1.2
88
+ dsh plugin --profile web add dsh-browser-verify@0.1.4
73
89
  ```
74
90
 
75
91
  The version is pinned on purpose: pnpm 11 holds back packages published in the
@@ -79,18 +95,23 @@ of this deployment — use your own profile name if it differs.
79
95
 
80
96
  Requires **dsh ≥ 0.1.2-alpha.1**.
81
97
 
82
- ### Browser prerequisite (read this)
98
+ ### Browser prerequisite (usually none)
99
+
100
+ The plugin does **not** download Chromium — it finds a browser on your machine:
101
+ the playwright cache (any installed version), then system Chrome / Chromium /
102
+ Edge on common install paths and `$PATH`. Most machines — anything that ever
103
+ ran a playwright project, or has Chrome installed — work with zero setup.
83
104
 
84
- The plugin does **not** download Chromium — it finds a browser on your machine
85
- instead. Install it once with:
105
+ Only when neither exists, install the browser once:
86
106
 
87
107
  ```sh
88
- npx playwright install chromium # needs playwright-core@1.62.0
108
+ npx playwright install chromium
89
109
  ```
90
110
 
91
111
  or point the plugin at an existing binary via `DSH_BROWSER_VERIFY_CHROMIUM`
92
- (see [Environment variables](#environment-variables)). Without either, the
93
- first `browser_open` fails with an actionable install hint.
112
+ (see [Environment variables](#environment-variables)). A system browser is
113
+ used as-is with an "unverified version" hint; without any browser, the first
114
+ `browser_open` fails with an actionable install hint.
94
115
 
95
116
  ## Environment variables
96
117
 
@@ -120,7 +141,7 @@ first `browser_open` fails with an actionable install hint.
120
141
 
121
142
  ## Testing status
122
143
 
123
- 39 unit tests (fully offline — no browser needed), strict typecheck, and a
144
+ 41 unit tests (fully offline — no browser needed), strict typecheck, and a
124
145
  per-file ≥90% statement coverage gate. Verified **end-to-end in the real DSH
125
146
  web GUI** on dsh 0.1.2-alpha.4: a two-state loop (empty + normal) against a
126
147
  live uni-app H5 (hhhweb) in 6 tool calls, with screenshots auto-projected and
package/README.zh.md CHANGED
@@ -12,7 +12,7 @@
12
12
  ## 快速上手
13
13
 
14
14
  ```sh
15
- dsh plugin --profile web add dsh-browser-verify@0.1.2
15
+ dsh plugin --profile web add dsh-browser-verify@0.1.4
16
16
  ```
17
17
 
18
18
  1. **安装**(更多方式见 [安装](#安装))。
@@ -34,16 +34,29 @@ browser_assert selector=".empty-wrap" text="暂无可用缴费服务"
34
34
 
35
35
  ## 它做什么
36
36
 
37
- | 工具 | 用途 |
38
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
- | `browser_open` | 在全新场景中打开 URL(无头 Chromium,默认视口 390×844 @2x),返回 title / HTTP 状态 / 可见文本摘要 / console 错误。可选`waitSelector` 等待关键元素出现后再返回;可选内联 `mocks` 在**首次导航前**拦截接口——用于一启动就依赖 mock 数据的页面。 |
40
- | `browser_mock` | 注册 playwright glob 路由(如`**/api/*.do*`)返回指定 JSON,并自动 reload 页面展示 mock 状态——不碰后端最快验证空态/异常态。重复 pattern 会提示报错。 |
41
- | `browser_assert` | 最省 token 也最精确的验证:等待 CSS 选择器出现,校验数量与包含文本,返回`{pass, count, actualText, elapsedMs}`。不满足时返回 `pass:false`(附差异),**绝不抛错**——失败是一等公民的结果,而不是要调试的异常。 |
42
- | `browser_screenshot` | 截取当前页面(视口或整页),**自动以 image block 投影到模型上下文**——模型直接"看见"版式,无需任何文件处理。返回尺寸、sha256;与上一张完全一致时 `identicalToPrevious:true`(疑似页面未刷新)。 |
37
+ | 工具 | 用途 |
38
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `browser_open` | 在全新场景中打开 URL(无头 Chromium,默认视口 390×844 @2x),返回 title / HTTP 状态 / 可见文本摘要 / console 错误。不传`waitSelector` 时默认等待页面**渲染稳定**后再采样(连续两次相同可见文本即稳定,上限约 3s),不会采到启动骨架帧;`加载中...` 等加载态文案自动过滤。可选 `waitSelector` 等待关键元素出现后再返回;可选内联 `mocks` 在**首次导航前**拦截接口——用于一启动就依赖 mock 数据的页面。 |
40
+ | `browser_mock` | 注册 playwright glob 路由(如`**/api/*.do*`)返回指定 JSON,并自动 reload 页面展示 mock 状态——不碰后端最快验证空态/异常态。重复 pattern 会提示报错。 |
41
+ | `browser_assert` | 最省 token 也最精确的验证:等待 CSS 选择器出现,校验数量与包含文本,返回`{pass, count, actualText, elapsedMs}`。不满足时返回 `pass:false`(附差异),**绝不抛错**——失败是一等公民的结果,而不是要调试的异常。 |
42
+ | `browser_screenshot` | 截取当前页面(视口或整页),**自动以 image block 投影到模型上下文**——模型直接"看见"版式,无需任何文件处理。返回尺寸、sha256;与上一张完全一致时 `identicalToPrevious:true`(疑似页面未刷新)。 |
43
43
 
44
44
  先 `browser_assert` 再 `browser_screenshot`:断言更便宜,截图留给必须判断
45
45
  渲染效果的时刻。
46
46
 
47
+ ## 真实模型使用体验
48
+
49
+ > **"好用,层级分明。"** —— 一位真实使用过的大模型的评价:把四件套拿真实的
50
+ > 业务页面轮了一遍。
51
+
52
+ - **三态验证 4–5 次工具调用跑完,零环境搭建**——以前同样的事要 ~20 步手动脚本;
53
+ - **mock 后自动 reload**——不会再踩"同 URL、截图不变"的坑;
54
+ - **截图直接进模型上下文**——不用追文件路径;
55
+ - **断言结构化**(count / text / 耗时),页面没真刷新还会主动提醒你;
56
+ - **每次打开场景全新**——三态之间互不串扰。
57
+
58
+ 验证就该是几次工具调用,而不是脚本编排。
59
+
47
60
  ### 完整示例——两态六次调用
48
61
 
49
62
  典型验证(空态 + 正常态)只需 6 次调用:
@@ -61,7 +74,7 @@ browser_screenshot
61
74
  ## 安装
62
75
 
63
76
  ```sh
64
- dsh plugin --profile web add dsh-browser-verify@0.1.2
77
+ dsh plugin --profile web add dsh-browser-verify@0.1.4
65
78
  ```
66
79
 
67
80
  版本故意钉死:pnpm 11 会暂缓 24 小时内新发布的包,裸写 `add dsh-browser-verify`(latest)会在发布当天装到上一个版本。`--profile web`
@@ -69,16 +82,21 @@ dsh plugin --profile web add dsh-browser-verify@0.1.2
69
82
 
70
83
  要求 **dsh ≥ 0.1.2-alpha.1**。
71
84
 
72
- ### 浏览器前置(务必读)
85
+ ### 浏览器前置(通常无需)
86
+
87
+ 本插件**不自带 Chromium**——它在你机器上找浏览器:playwright 缓存(任意安装版本),
88
+ 然后是系统 Chrome / Chromium / Edge 的常见安装路径与 `$PATH`。多数机器——装过任何
89
+ playwright 项目,或装有 Chrome——零配置直接可用。
73
90
 
74
- 本插件**不自带 Chromium**——它在你机器上找浏览器。先装一次:
91
+ 两者都没有时才需要装一次:
75
92
 
76
93
  ```sh
77
- npx playwright install chromium # 需要 playwright-core@1.62.0
94
+ npx playwright install chromium
78
95
  ```
79
96
 
80
97
  或通过 `DSH_BROWSER_VERIFY_CHROMIUM` 指向已有二进制(见
81
- [环境变量](#环境变量))。两者都没有时,第一次 `browser_open` 会给出可操作的安装提示。
98
+ [环境变量](#环境变量))。系统浏览器直接使用(附"未认证版本"提示);一个浏览器
99
+ 都没有时,第一次 `browser_open` 会给出可操作的安装提示。
82
100
 
83
101
  ## 环境变量
84
102
 
@@ -101,7 +119,7 @@ npx playwright install chromium # 需要 playwright-core@1.62.0
101
119
 
102
120
  ## 测试状态
103
121
 
104
- 39 个单测(完全离线,无需浏览器)、严格 typecheck、每文件 ≥90% 语句覆盖率闸门。已在 **dsh 0.1.2-alpha.4 真实 GUI 端到端验证**:对一个真实 uni-app H5(hhhweb),空态+正常态两态闭环共 6 次调用,截图自动投影,无临时目录残留、无僵尸进程。
122
+ 41 个单测(完全离线,无需浏览器)、严格 typecheck、每文件 ≥90% 语句覆盖率闸门。已在 **dsh 0.1.2-alpha.4 真实 GUI 端到端验证**:对一个真实 uni-app H5(hhhweb),空态+正常态两态闭环共 6 次调用,截图自动投影,无临时目录残留、无僵尸进程。
105
123
 
106
124
  ## 已知限制
107
125
 
package/lib/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as BrowserDriver } from "./driver-DxMldTYf.js";
1
+ import { t as BrowserDriver } from "./driver-POR2-Czc.js";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
@@ -23,6 +23,48 @@ const LIST_PREFIXES = [{
23
23
  kind: "chromium",
24
24
  prefix: "chromium-"
25
25
  }];
26
+ /** PATH command names probed as a last-resort system-browser fallback. */
27
+ const SYSTEM_COMMANDS = [
28
+ "google-chrome",
29
+ "google-chrome-stable",
30
+ "chromium",
31
+ "chromium-browser",
32
+ "chrome",
33
+ "chrome.exe"
34
+ ];
35
+ /** Common install locations for system Chromium-based browsers, per platform. */
36
+ function systemBrowserCandidates(platform = process.platform, home = homedir()) {
37
+ if (platform === "win32") return [
38
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
39
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
40
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
41
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
42
+ ];
43
+ if (platform === "linux") return [
44
+ "/usr/bin/google-chrome",
45
+ "/usr/bin/google-chrome-stable",
46
+ "/usr/bin/chromium",
47
+ "/usr/bin/chromium-browser",
48
+ "/usr/bin/chrome",
49
+ "/snap/bin/chromium",
50
+ "/snap/bin/google-chrome"
51
+ ];
52
+ return [
53
+ "Google Chrome.app/Contents/MacOS/Google Chrome",
54
+ "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
55
+ "Chromium.app/Contents/MacOS/Chromium"
56
+ ].flatMap((app) => [`/Applications/${app}`, join(home, "Applications", app)]);
57
+ }
58
+ /** Resolve a command name on $PATH using the injected exists probe (no exec). */
59
+ function resolveCommandOnPath(command, pathEnv, exists, sep = process.platform === "win32" ? ";" : ":") {
60
+ if (pathEnv === void 0 || pathEnv === "") return null;
61
+ for (const dir of pathEnv.split(sep)) {
62
+ if (dir === "") continue;
63
+ const candidate = join(dir, command);
64
+ if (exists(candidate)) return candidate;
65
+ }
66
+ return null;
67
+ }
26
68
  /** Default cache location on macOS. */
27
69
  function defaultCacheDir() {
28
70
  return join(homedir(), "Library", "Caches", "ms-playwright");
@@ -62,8 +104,7 @@ function discoverBrowser(opts = {}) {
62
104
  } catch {
63
105
  list = null;
64
106
  }
65
- if (list === null) throw new Error(`browser-verify: 未找到浏览器缓存目录 ${cacheDir}。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`);
66
- for (const { kind, prefix } of LIST_PREFIXES) {
107
+ if (list !== null) for (const { kind, prefix } of LIST_PREFIXES) {
67
108
  const revision = maxRevision(list, prefix);
68
109
  if (revision === null) continue;
69
110
  const executablePath = join(cacheDir, `${kind === "headless-shell" ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind]);
@@ -77,7 +118,24 @@ function discoverBrowser(opts = {}) {
77
118
  versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join("/")});若协议异常,请安装匹配版本`
78
119
  };
79
120
  }
80
- throw new Error(`browser-verify: 未找到浏览器二进制。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`);
121
+ const system = findSystemBrowser(opts, exists);
122
+ if (system !== null) return system;
123
+ throw new Error(`browser-verify: 未找到可用的浏览器(已探测:playwright 缓存 ${cacheDir} 与系统 Chrome/Chromium/Edge 常见路径)。请执行一次 npx playwright install chromium 后重试,或用 DSH_BROWSER_VERIFY_CHROMIUM 指定已有二进制。`);
124
+ }
125
+ /** First existing system-browser candidate, or null. */
126
+ function findSystemBrowser(opts, exists) {
127
+ const candidates = opts.systemCandidates ?? [...systemBrowserCandidates(), ...SYSTEM_COMMANDS.map((cmd) => resolveCommandOnPath(cmd, opts.pathEnv ?? process.env.PATH, exists)).filter((p) => p !== null)];
128
+ for (const candidate of candidates) {
129
+ if (!exists(candidate)) continue;
130
+ return {
131
+ executablePath: candidate,
132
+ kind: "system",
133
+ revision: 0,
134
+ known: false,
135
+ versionHint: `系统浏览器(未与插件认证版本 ${Object.keys(KNOWN_REVISIONS).join("/")} 对齐);若渲染异常,请执行 npx playwright install chromium 安装匹配版本`
136
+ };
137
+ }
138
+ return null;
81
139
  }
82
140
  //#endregion
83
141
  //#region src/browser/scenario.ts
@@ -91,6 +149,19 @@ const MAX_VISIBLE = 8;
91
149
  const MAX_VISIBLE_LEN = 40;
92
150
  const MAX_ERRORS = 5;
93
151
  const MAX_ERROR_LEN = 120;
152
+ /** Poll interval and cap for the default render-settled wait (no waitSelector). */
153
+ const SETTLE_INTERVAL_MS = 250;
154
+ const SETTLE_CAP_MS = 3e3;
155
+ /**
156
+ * Loading-state noise (uni-app showLoading / boot toasts) filtered out of the
157
+ * visible summary. Anchored full-string patterns only, so business states like
158
+ * 加载失败 / 加载更多 are never affected.
159
+ */
160
+ const NOISE_TEXT_PATTERN = /^(加载中|正在加载|请稍候|loading)[.…]{0,3}$/i;
161
+ /** True for transient loading-state text that should not pollute the summary. */
162
+ function isNoiseText(text) {
163
+ return NOISE_TEXT_PATTERN.test(text);
164
+ }
94
165
  function assertNoMockConflict(patterns, next) {
95
166
  if (patterns.includes(next)) throw new Error(`browser-verify: 拦截 pattern 已存在: ${next}(已有: ${patterns.join(", ")})。请先 browser_open 重开场景或用不同的 urlPattern。`);
96
167
  }
@@ -110,7 +181,7 @@ function summarizeVisibleText(texts) {
110
181
  const out = [];
111
182
  for (const raw of texts) {
112
183
  const trimmed = raw.trim();
113
- if (trimmed === "") continue;
184
+ if (trimmed === "" || isNoiseText(trimmed)) continue;
114
185
  const reduced = trimmed.length > MAX_VISIBLE_LEN ? trimmed.slice(0, MAX_VISIBLE_LEN) : trimmed;
115
186
  if (seen.has(reduced)) continue;
116
187
  seen.add(reduced);
@@ -159,6 +230,7 @@ var Scenario = class {
159
230
  timeout
160
231
  });
161
232
  if (opts.waitSelector !== void 0) await this.page.waitForSelector(opts.waitSelector, { timeout });
233
+ else await this.waitUntilRendered(timeout);
162
234
  const texts = await this.page.evaluate(VISIBLE_TEXT_SCRIPT);
163
235
  return {
164
236
  title: await this.page.title(),
@@ -169,6 +241,26 @@ var Scenario = class {
169
241
  elapsedMs: Date.now() - started
170
242
  };
171
243
  }
244
+ /**
245
+ * Default settle wait: two consecutive identical non-empty visible-text
246
+ * samples (250ms apart) mean the render stopped changing. Bounded by
247
+ * min(timeoutMs, 3s) — pages that never settle (polling/animations) fall
248
+ * through to the current snapshot instead of burning the whole budget.
249
+ */
250
+ async waitUntilRendered(timeoutMs) {
251
+ const cap = Math.min(timeoutMs, SETTLE_CAP_MS);
252
+ const deadline = Date.now() + cap;
253
+ let prev = null;
254
+ for (;;) {
255
+ const sig = (await this.page.evaluate(VISIBLE_TEXT_SCRIPT)).join("\0");
256
+ if (sig !== "") {
257
+ if (prev !== null && sig === prev) return;
258
+ prev = sig;
259
+ }
260
+ if (Date.now() >= deadline) return;
261
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_INTERVAL_MS));
262
+ }
263
+ }
172
264
  async addMock(rule) {
173
265
  assertNoMockConflict([...this.mocks.keys()], rule.urlPattern);
174
266
  const status = rule.status ?? 200;
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as BrowserDriver } from "./driver-DxMldTYf.js";
1
+ import { t as BrowserDriver } from "./driver-POR2-Czc.js";
2
2
  import { exec } from "node:child_process";
3
3
  import { readdir, rm, stat } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
@@ -111,7 +111,7 @@ function registerBrowserTools(ctx) {
111
111
  });
112
112
  ctx.tools.register(defineTool({
113
113
  name: "browser_open",
114
- description: "在无头浏览器中打开一个页面并返回页面状态(标题/状态码/可见文本摘要/console 错误)用于验证前端页面;可选 waitSelector 等待关键元素出现,默认视口 390×844 @2x(移动端形态)。可传 mocks 在打开时拦截接口(用于启动即依赖接口数据的页面)。验证顺序:先 browser_assert 做 DOM 断言,确需看版式再 browser_screenshot。",
114
+ description: "在无头浏览器中打开一个页面并返回页面状态(标题/状态码/可见文本摘要/console 错误)用于验证前端页面;可选 waitSelector 等待关键元素出现(不传时自动等待页面渲染稳定后返回,加载态文案如「加载中...」自动过滤),默认视口 390×844 @2x(移动端形态)。可传 mocks 在打开时拦截接口(用于启动即依赖接口数据的页面)。验证顺序:先 browser_assert 做 DOM 断言,确需看版式再 browser_screenshot。",
115
115
  parameters: {
116
116
  url: {
117
117
  type: "string",
@@ -165,7 +165,7 @@ function registerBrowserTools(ctx) {
165
165
  },
166
166
  waitSelector: {
167
167
  type: "string",
168
- description: "可选:等待该选择器出现后再返回(优先于固定等待)"
168
+ description: "可选:等待该选择器出现后再返回;不传则自动等待页面渲染稳定后返回(最多数秒)"
169
169
  },
170
170
  timeoutMs: {
171
171
  type: "number",
@@ -3,7 +3,7 @@
3
3
  * filesystem probing is injected so every branch is unit-testable.
4
4
  * @module dsh-browser-verify/browser/discover
5
5
  */
6
- export type BrowserKind = 'headless-shell' | 'chromium' | 'custom';
6
+ export type BrowserKind = 'headless-shell' | 'chromium' | 'custom' | 'system';
7
7
  /** Revision numbers verified against the matching playwright-core browsers.json. */
8
8
  export declare const KNOWN_REVISIONS: Readonly<Record<number, string>>;
9
9
  export interface DiscoveredBrowser {
@@ -19,7 +19,14 @@ export interface DiscoverOptions {
19
19
  exists?: (path: string) => boolean;
20
20
  /** Directory listing of cacheDir; defaults to readdirSync(cacheDir) (throws → treated as missing). */
21
21
  entries?: string[];
22
+ /** System-browser candidate paths; defaults to common install locations + PATH commands. */
23
+ systemCandidates?: string[];
24
+ pathEnv?: string;
22
25
  }
26
+ /** Common install locations for system Chromium-based browsers, per platform. */
27
+ export declare function systemBrowserCandidates(platform?: string, home?: string): string[];
28
+ /** Resolve a command name on $PATH using the injected exists probe (no exec). */
29
+ export declare function resolveCommandOnPath(command: string, pathEnv: string | undefined, exists: (path: string) => boolean, sep?: string): string | null;
23
30
  /** Default cache location on macOS. */
24
31
  export declare function defaultCacheDir(): string;
25
32
  /**
@@ -23,6 +23,8 @@ export interface AssertResult {
23
23
  actualText: string | null;
24
24
  elapsedMs: number;
25
25
  }
26
+ /** True for transient loading-state text that should not pollute the summary. */
27
+ export declare function isNoiseText(text: string): boolean;
26
28
  export declare function assertNoMockConflict(patterns: string[], next: string): void;
27
29
  export declare function normalizeCountSpec(count: number | {
28
30
  min: number;
@@ -48,6 +50,13 @@ export declare class Scenario {
48
50
  waitSelector?: string;
49
51
  timeoutMs?: number;
50
52
  }): Promise<OpenResult>;
53
+ /**
54
+ * Default settle wait: two consecutive identical non-empty visible-text
55
+ * samples (250ms apart) mean the render stopped changing. Bounded by
56
+ * min(timeoutMs, 3s) — pages that never settle (polling/animations) fall
57
+ * through to the current snapshot instead of burning the whole budget.
58
+ */
59
+ private waitUntilRendered;
51
60
  addMock(rule: {
52
61
  urlPattern: string;
53
62
  json: unknown;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-browser-verify",
3
3
  "description": "Read-only browser verification tools for the DeepSeek Harness web GUI: browser_open / browser_mock / browser_assert / browser_screenshot — verify a page (H5/desktop) in ≤4 tool calls with mock interception, DOM assertions, and screenshots that auto-project into the model context.",
4
- "version": "0.1.2",
4
+ "version": "0.1.4",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -8,7 +8,7 @@ import { existsSync, readdirSync } from 'node:fs'
8
8
  import { homedir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
 
11
- export type BrowserKind = 'headless-shell' | 'chromium' | 'custom'
11
+ export type BrowserKind = 'headless-shell' | 'chromium' | 'custom' | 'system'
12
12
 
13
13
  /** Revision numbers verified against the matching playwright-core browsers.json. */
14
14
  export const KNOWN_REVISIONS: Readonly<Record<number, string>> = {
@@ -29,6 +29,9 @@ export interface DiscoverOptions {
29
29
  exists?: (path: string) => boolean
30
30
  /** Directory listing of cacheDir; defaults to readdirSync(cacheDir) (throws → treated as missing). */
31
31
  entries?: string[]
32
+ /** System-browser candidate paths; defaults to common install locations + PATH commands. */
33
+ systemCandidates?: string[]
34
+ pathEnv?: string
32
35
  }
33
36
 
34
37
  const SUBDIRS: Readonly<Record<'headless-shell' | 'chromium', string>> = {
@@ -41,6 +44,55 @@ const LIST_PREFIXES: Readonly<Array<{ kind: 'headless-shell' | 'chromium'; prefi
41
44
  { kind: 'chromium', prefix: 'chromium-' },
42
45
  ]
43
46
 
47
+ /** PATH command names probed as a last-resort system-browser fallback. */
48
+ const SYSTEM_COMMANDS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome', 'chrome.exe']
49
+
50
+ /** Common install locations for system Chromium-based browsers, per platform. */
51
+ export function systemBrowserCandidates(platform: string = process.platform, home: string = homedir()): string[] {
52
+ if (platform === 'win32') {
53
+ return [
54
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
55
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
56
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
57
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
58
+ ]
59
+ }
60
+ if (platform === 'linux') {
61
+ return [
62
+ '/usr/bin/google-chrome',
63
+ '/usr/bin/google-chrome-stable',
64
+ '/usr/bin/chromium',
65
+ '/usr/bin/chromium-browser',
66
+ '/usr/bin/chrome',
67
+ '/snap/bin/chromium',
68
+ '/snap/bin/google-chrome',
69
+ ]
70
+ }
71
+ // darwin (and anything else unix-like): app bundles in system + user Applications.
72
+ const apps = [
73
+ 'Google Chrome.app/Contents/MacOS/Google Chrome',
74
+ 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
75
+ 'Chromium.app/Contents/MacOS/Chromium',
76
+ ]
77
+ return apps.flatMap(app => [`/Applications/${app}`, join(home, 'Applications', app)])
78
+ }
79
+
80
+ /** Resolve a command name on $PATH using the injected exists probe (no exec). */
81
+ export function resolveCommandOnPath(
82
+ command: string,
83
+ pathEnv: string | undefined,
84
+ exists: (path: string) => boolean,
85
+ sep: string = process.platform === 'win32' ? ';' : ':',
86
+ ): string | null {
87
+ if (pathEnv === undefined || pathEnv === '') return null
88
+ for (const dir of pathEnv.split(sep)) {
89
+ if (dir === '') continue
90
+ const candidate = join(dir, command)
91
+ if (exists(candidate)) return candidate
92
+ }
93
+ return null
94
+ }
95
+
44
96
  /** Default cache location on macOS. */
45
97
  export function defaultCacheDir(): string {
46
98
  return join(homedir(), 'Library', 'Caches', 'ms-playwright')
@@ -81,24 +133,45 @@ export function discoverBrowser(opts: DiscoverOptions = {}): DiscoveredBrowser {
81
133
  list = null
82
134
  }
83
135
  }
84
- if (list === null) {
85
- throw new Error(`browser-verify: 未找到浏览器缓存目录 ${cacheDir}。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`)
86
- }
87
- for (const { kind, prefix } of LIST_PREFIXES) {
88
- const revision = maxRevision(list, prefix)
89
- if (revision === null) continue
90
- const executablePath = join(cacheDir, `${kind === 'headless-shell' ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind])
91
- if (!exists(executablePath)) {
92
- throw new Error(`browser-verify: 缓存目录存在 ${prefix}${revision} 但可执行文件缺失(${cacheDir})。请删除该目录后重新执行 npx playwright install chromium。`)
136
+ if (list !== null) {
137
+ for (const { kind, prefix } of LIST_PREFIXES) {
138
+ const revision = maxRevision(list, prefix)
139
+ if (revision === null) continue
140
+ const executablePath = join(cacheDir, `${kind === 'headless-shell' ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind])
141
+ if (!exists(executablePath)) {
142
+ throw new Error(`browser-verify: 缓存目录存在 ${prefix}${revision} 但可执行文件缺失(${cacheDir})。请删除该目录后重新执行 npx playwright install chromium。`)
143
+ }
144
+ const known = KNOWN_REVISIONS[revision] !== undefined
145
+ return {
146
+ executablePath,
147
+ kind,
148
+ revision,
149
+ known,
150
+ versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join('/')});若协议异常,请安装匹配版本`,
151
+ }
93
152
  }
94
- const known = KNOWN_REVISIONS[revision] !== undefined
153
+ }
154
+ // Cache had nothing usable → last-resort system browser (zero download).
155
+ const system = findSystemBrowser(opts, exists)
156
+ if (system !== null) return system
157
+ throw new Error(`browser-verify: 未找到可用的浏览器(已探测:playwright 缓存 ${cacheDir} 与系统 Chrome/Chromium/Edge 常见路径)。请执行一次 npx playwright install chromium 后重试,或用 DSH_BROWSER_VERIFY_CHROMIUM 指定已有二进制。`)
158
+ }
159
+
160
+ /** First existing system-browser candidate, or null. */
161
+ function findSystemBrowser(opts: DiscoverOptions, exists: (path: string) => boolean): DiscoveredBrowser | null {
162
+ const candidates = opts.systemCandidates ?? [
163
+ ...systemBrowserCandidates(),
164
+ ...SYSTEM_COMMANDS.map(cmd => resolveCommandOnPath(cmd, opts.pathEnv ?? process.env.PATH, exists)).filter((p): p is string => p !== null),
165
+ ]
166
+ for (const candidate of candidates) {
167
+ if (!exists(candidate)) continue
95
168
  return {
96
- executablePath,
97
- kind,
98
- revision,
99
- known,
100
- versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join('/')});若协议异常,请安装匹配版本`,
169
+ executablePath: candidate,
170
+ kind: 'system',
171
+ revision: 0,
172
+ known: false,
173
+ versionHint: `系统浏览器(未与插件认证版本 ${Object.keys(KNOWN_REVISIONS).join('/')} 对齐);若渲染异常,请执行 npx playwright install chromium 安装匹配版本`,
101
174
  }
102
175
  }
103
- throw new Error(`browser-verify: 未找到浏览器二进制。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`)
176
+ return null
104
177
  }
@@ -34,6 +34,20 @@ const MAX_VISIBLE_LEN = 40
34
34
  const MAX_ERRORS = 5
35
35
  const MAX_ERROR_LEN = 120
36
36
  const MAX_DIFF_LEN = 120
37
+ /** Poll interval and cap for the default render-settled wait (no waitSelector). */
38
+ const SETTLE_INTERVAL_MS = 250
39
+ const SETTLE_CAP_MS = 3000
40
+ /**
41
+ * Loading-state noise (uni-app showLoading / boot toasts) filtered out of the
42
+ * visible summary. Anchored full-string patterns only, so business states like
43
+ * 加载失败 / 加载更多 are never affected.
44
+ */
45
+ const NOISE_TEXT_PATTERN = /^(加载中|正在加载|请稍候|loading)[.…]{0,3}$/i
46
+
47
+ /** True for transient loading-state text that should not pollute the summary. */
48
+ export function isNoiseText(text: string): boolean {
49
+ return NOISE_TEXT_PATTERN.test(text)
50
+ }
37
51
 
38
52
  export function assertNoMockConflict(patterns: string[], next: string): void {
39
53
  if (patterns.includes(next)) {
@@ -52,7 +66,7 @@ export function summarizeVisibleText(texts: string[]): string[] {
52
66
  const out: string[] = []
53
67
  for (const raw of texts) {
54
68
  const trimmed = raw.trim()
55
- if (trimmed === '') continue
69
+ if (trimmed === '' || isNoiseText(trimmed)) continue
56
70
  const reduced = trimmed.length > MAX_VISIBLE_LEN ? trimmed.slice(0, MAX_VISIBLE_LEN) : trimmed
57
71
  if (seen.has(reduced)) continue
58
72
  seen.add(reduced)
@@ -106,6 +120,11 @@ export class Scenario {
106
120
  const response = await this.page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout })
107
121
  if (opts.waitSelector !== undefined) {
108
122
  await this.page.waitForSelector(opts.waitSelector, { timeout })
123
+ } else {
124
+ // No explicit selector: wait for the page to render-settle instead of
125
+ // snapshotting the boot frame (SPAs like uni-app show skeleton/loading
126
+ // right after domcontentloaded — a 500ms snapshot reads empty).
127
+ await this.waitUntilRendered(timeout)
109
128
  }
110
129
  const texts = await this.page.evaluate(VISIBLE_TEXT_SCRIPT) as string[]
111
130
  return {
@@ -118,6 +137,27 @@ export class Scenario {
118
137
  }
119
138
  }
120
139
 
140
+ /**
141
+ * Default settle wait: two consecutive identical non-empty visible-text
142
+ * samples (250ms apart) mean the render stopped changing. Bounded by
143
+ * min(timeoutMs, 3s) — pages that never settle (polling/animations) fall
144
+ * through to the current snapshot instead of burning the whole budget.
145
+ */
146
+ private async waitUntilRendered(timeoutMs: number): Promise<void> {
147
+ const cap = Math.min(timeoutMs, SETTLE_CAP_MS)
148
+ const deadline = Date.now() + cap
149
+ let prev: string | null = null
150
+ for (;;) {
151
+ const sig = (await this.page.evaluate(VISIBLE_TEXT_SCRIPT) as string[]).join('\u0000')
152
+ if (sig !== '') {
153
+ if (prev !== null && sig === prev) return
154
+ prev = sig
155
+ }
156
+ if (Date.now() >= deadline) return
157
+ await new Promise(resolve => setTimeout(resolve, SETTLE_INTERVAL_MS))
158
+ }
159
+ }
160
+
121
161
  async addMock(rule: { urlPattern: string; json: unknown; status?: number; reload?: boolean; timeoutMs?: number }): Promise<string[]> {
122
162
  assertNoMockConflict([...this.mocks.keys()], rule.urlPattern)
123
163
  const status = rule.status ?? 200
@@ -24,7 +24,7 @@ export function registerBrowserTools(ctx: Context): void {
24
24
 
25
25
  ctx.tools.register(defineTool({
26
26
  name: 'browser_open',
27
- description: '在无头浏览器中打开一个页面并返回页面状态(标题/状态码/可见文本摘要/console 错误)用于验证前端页面;可选 waitSelector 等待关键元素出现,默认视口 390×844 @2x(移动端形态)。可传 mocks 在打开时拦截接口(用于启动即依赖接口数据的页面)。验证顺序:先 browser_assert 做 DOM 断言,确需看版式再 browser_screenshot。',
27
+ description: '在无头浏览器中打开一个页面并返回页面状态(标题/状态码/可见文本摘要/console 错误)用于验证前端页面;可选 waitSelector 等待关键元素出现(不传时自动等待页面渲染稳定后返回,加载态文案如「加载中...」自动过滤),默认视口 390×844 @2x(移动端形态)。可传 mocks 在打开时拦截接口(用于启动即依赖接口数据的页面)。验证顺序:先 browser_assert 做 DOM 断言,确需看版式再 browser_screenshot。',
28
28
  parameters: {
29
29
  url: { type: 'string', required: true, description: '页面地址,如 http://localhost:5173/hweb/pages/...' },
30
30
  viewport: {
@@ -50,7 +50,7 @@ export function registerBrowserTools(ctx: Context): void {
50
50
  },
51
51
  description: '可选:页面启动前注册的接口拦截(glob urlPattern + json,如 [{urlPattern: "**/api/*.do*", json: {...}}])',
52
52
  },
53
- waitSelector: { type: 'string', description: '可选:等待该选择器出现后再返回(优先于固定等待)' },
53
+ waitSelector: { type: 'string', description: '可选:等待该选择器出现后再返回;不传则自动等待页面渲染稳定后返回(最多数秒)' },
54
54
  timeoutMs: { type: 'number', description: `加载超时,默认 ${envTimeoutMs()}ms` },
55
55
  },
56
56
  output: {