dsh-browser-playwright-codex 0.6.5 → 0.6.6
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/CHANGELOG.md +28 -0
- package/NOTICE.md +5 -6
- package/README.md +103 -44
- package/SECURITY.md +18 -11
- package/lib/challenge-signatures.d.ts +9 -0
- package/lib/challenge-signatures.js +7 -4
- package/lib/challenge.d.ts +20 -0
- package/lib/challenge.js +106 -30
- package/lib/page-actions.d.ts +20 -0
- package/lib/page-actions.js +49 -17
- package/lib/page-diagnostics.d.ts +5 -0
- package/lib/page-diagnostics.js +37 -1
- package/lib/page-probes.d.ts +12 -0
- package/lib/page-probes.js +33 -0
- package/lib/page-snapshot.js +2 -2
- package/lib/tool-render.js +37 -2
- package/lib/tool-types.d.ts +6 -0
- package/package.json +5 -5
- package/scripts/dsh-browser-verify.mjs +24 -12
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
Bilingual by intent: the version headings and the summary of each release are given in both
|
|
4
4
|
languages, the individual entries follow the language the surrounding code and docs use.
|
|
5
5
|
|
|
6
|
+
## 0.6.6 — 2026-10-17
|
|
7
|
+
|
|
8
|
+
一句话摘要:**四份独立审查(文档漂移 / 代码对抗 / 发行打包 / 中性冷读)查出的问题集中修掉**——两条安全缺陷、几处"假成功/假失败"、随包自检的假承诺、发布门禁漏洞与一批文档漂移。
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **点击白名单可被祖先 `<a>` 绕过**:`browser_click` 的 `ref` / `text` / `precise` 三条路径只校验**被点元素自身**的 href,外层 `<a href="http://外站/">` 包住它时点击会导航到站外并返回成功。现在改为「自身 → 最近祖先 `<a href>`(穿 shadow host)」,与裸坐标路径同层,统一交给 URL 策略。两处独立审查各自用本地 fixture 复现过。
|
|
13
|
+
- **密码/验证码/卡号曾进入 prompt**:`browser_extract` 把 `pageData()` 拼进辅助模型的 prompt,而 `pageData()` 采集输入框**当前值**——登录页的明文密码会随请求发出去(实测 prompt 里出现 `hunter2`)。现在 `type=password` 与 `autocomplete` 为 `current-password` / `new-password` / `one-time-code` / `cc-number` / `cc-csc` / `cc-exp` 的字段一律置空值,**字段本身保留**(模型仍知道"这里有个码要填")。
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **填表核对两头错**:上一版把回读改成严格相等后,页面自动改写格式的输入框(全大写、千分位 `1,000`)被误判成"没写进去",而 `browser_fill` 的描述恰恰把 input-mask / currency 列为预期场景;更早的版本则相反,`ABCD→ZZZZ`、`John→Jane` 会被判成功。现在的归一化只放过"页面改写、不是我们写错":NFKC + 去大小写 + 去掉非字母数字;`1000≡1,000`、`abc≡ABC` 通过,`ABCD↔ZZZZ`、`1234↔12345` 仍然失败。contenteditable 也不再假失败(改读 `textContent`/`innerText`)。
|
|
18
|
+
- **挑战页识别的两处回归**(本轮自查引入的):给控件层加"页面得够空"的门控后,**正常登录页里嵌的真 reCAPTCHA / hCaptcha / Turnstile 完全不再识别**(登录页正是主要场景),已移除该门控;`CAPTCHA_ROLES` 曾删掉 `iframe`,导致名字就叫「验证码」的非厂商 iframe 不被识别,已恢复。
|
|
19
|
+
- **`[nothing here]` 提示三类误报**:静默页扫描深度 4 → 8 层(正文藏得深的正常页曾被判"几乎没有文字",与同一结果树里的正文自相矛盾);无标题的裸文档(`about:blank`、直接打开图片)不再提示;有挂起弹窗时不出这条(否则与 `[dialog]` 指令冲突)。
|
|
20
|
+
- **提示互斥**:已判定为拦截页时不再叠 `[http NNN]`;已识别为挑战时不再叠行为兜底提示。
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **测试 159 → 220**(+61):挑战识别 50 条(含真实页面测得的门控数值与反例:讲 hCaptcha 的文章、`验证码说明` 帮助链接、`获取验证码` 按钮、百度搜索页标题)、真浏览器端到端 6 条(真实 `cf-mitigated` 头、无签名拦截面被行为兜底抓住、`interactiveOnly` 下 iframe 仍识别、单页应用改 URL 后旧响应头不复用、真实 404 显示状态码、真实访客门显示静默页提示)、安全回归 5 条(外链祖先点击被拒、密码/OTP 不进 prompt、contenteditable 成功、同长度不同值失败)、本机路径守卫。
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **随包自检不再在 npm 安装形态崩溃**:`scripts/dsh-browser-verify.mjs` 的「`lib/` 与 `src/` 同步」一组在没有 `src/` 时**跳过**并打印说明,退出码 0 —— 安装形态显示「全部通过 (56 项,跳过 1 项)」,仓库形态仍是「(57 项)」。README / CHANGELOG 此前承诺的"装上即可跑"现在才真的成立。
|
|
29
|
+
- **依赖不再全是 `*`**:`@deepseek-ai/schemastery` 固定 `^3.18.4`,peer `cordis >=4.0.4`、`dsh-llm` / `dsh-tools >=0.2.0-rc.1`。修前空项目安装会解析到 `dsh-tools 0.0.1-rc.1`、`dsh-llm 0.0.1-rc.5`(远旧于测试基线);修后解析到 `0.2.0-rc.2`。代价:不同版本元组的预发布版(如 `0.2.1-rc.*`)会收到 **peer 警告**(不是失败,pnpm 默认不严格)。
|
|
30
|
+
- **发布门禁补齐**:CI 增加 `npm run typecheck` 与 `npm run preflight`(与 `prepublishOnly` 对齐);CI 与 publish 都新增「`npm run build` + `git diff --exit-code -- lib`」守卫——以前先 build 再比 mtime,`src` 改完忘重建也能全绿,而 `dsh plugin add github:…` 的安装路径会拿到旧 `lib/`。
|
|
31
|
+
- **文档漂移**:独立审查列出 13 条硬错误 / 11 条过期 / 8 条矛盾 / 8 条缺失,全部修掉——随包文档死链由 12 条清零(改 GitHub 绝对链接)、测试计数与成本数字按实际命令重取、`npm test:live` 与 `browser-plugin/dev.cordis.yml` 这类跑不通/不存在的引用纠正、URL 白名单不再声称覆盖 `hover`(实际不覆盖)、补写 `snapshot.engine` 说明,并在 `中文说明.md` 新增 **§4.14 挑战页识别**(四层判定与两条结果提示,明确"产出提示不是阻断,认不出来就停下来问人")。许可表述收敛为:代码与说明文档 MIT,CC BY-NC-SA 仅限 `FOCUS-STEALING.md` / `MODE-TRADE-OFFS.md` 两篇分析长文。
|
|
32
|
+
- **`.gitignore` 补登录态**:`dsh-storage-state.json` / `browser-profiles/` / `profile/`。默认写在 `~/.dsh/browser-profiles/`(仓库外),但只要有人把 `launch.profileDir` 指进 checkout,那个文件里就是明文 cookie。
|
|
33
|
+
|
|
6
34
|
## 0.6.5 — 2026-10-16
|
|
7
35
|
|
|
8
36
|
一句话摘要:**挑战页识别不再靠一份关键词表** —— 判定改成「厂商自有的机器信号优先,认不出来就问一句『页面变了吗』」,所以新出现的验证方式也能被接住;顺带清掉漏网的 4 处本机路径,并把 npm 包里 12 个用不上的开发脚本收成 2 个。
|
package/NOTICE.md
CHANGED
|
@@ -6,26 +6,25 @@ This repository mixes work from three different origins. They are **not** under
|
|
|
6
6
|
|
|
7
7
|
## 1. The plugin — MIT (`./LICENSE`)
|
|
8
8
|
|
|
9
|
-
Covers everything that is part of the plugin: `src/`, `tests/`, `scripts/`, `cordis.patch.yml`, `tsconfig.json`, `package.json`, `CHANGELOG.md`, and the plugin's own documentation (`PLUGIN-README.md`, `DIALOG-POLICY.md`, `SAFETY-RULES.md`, `SNAPSHOT-RULES.md`, `codex-rules.md`).
|
|
9
|
+
Covers everything that is part of the plugin: `src/`, `tests/`, `scripts/`, `cordis.patch.yml`, `tsconfig.json`, `package.json`, `CHANGELOG.md`, and the plugin's own documentation (`README.md`, `SECURITY.md`, `NOTICE.md`, `PLUGIN-README.md`, `DIALOG-POLICY.md`, `SAFETY-RULES.md`, `SNAPSHOT-RULES.md`, `codex-rules.md`, `中文说明.md`, `对比与优势.md`, and the articles under `docs/articles/`).
|
|
10
10
|
|
|
11
11
|
| Origin | What | Licence |
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| [ChenyuHeee/dsh-browser-playwright](https://github.com/ChenyuHeee/dsh-browser-playwright) v0.1.1 | The accessibility-snapshot engine (`src/injected.ts`, `src/snapshot-render.ts`), the tool family (`src/tool.ts`), the service/provider seams (`src/service.ts`, `src/playwright.ts`), the URL policy, and the test-suite shape. Copyright (c) 2026 dsh-browser-playwright contributors. | **MIT** |
|
|
14
|
-
| This fork's merged layer | Geometric click (`browser_click` with `precise: true`) with landing-element reporting, the CDP policy layer (`src/cdp-policy.ts`), the persistent profile + login-state export (`src/
|
|
14
|
+
| This fork's merged layer | Geometric click (`browser_click` with `precise: true`) with landing-element reporting, the CDP policy layer (`src/cdp-policy.ts`), the persistent profile + login-state export (`src/provider.ts`, `persistState`/`restoreState`), dialog parking, the console/network diagnostics tools, the contract/compat modules, and the fixes listed in [`CHANGELOG.md`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/CHANGELOG.md) | **MIT** |
|
|
15
15
|
|
|
16
16
|
**Why the plugin cannot be non-commercial:** it is a derivative of MIT-licensed work. MIT grants everyone the right to use, modify and redistribute the software **including commercially**, and a redistributor cannot add a non-commercial restriction to it. Any non-commercial claim over `src/` would be an unauthorised restriction — worse for downstream users than leaving it MIT. The MIT text in `./LICENSE` (including the upstream copyright notice) is unchanged.
|
|
17
17
|
|
|
18
18
|
## 2. This repository's analysis prose — CC BY-NC-SA 4.0 (`./LICENSE-DOCS`)
|
|
19
19
|
|
|
20
|
-
Covers only the
|
|
20
|
+
Covers only the two analysis documents produced *for this repository*:
|
|
21
21
|
|
|
22
|
-
- `README.md` (this repository's landing page)
|
|
23
22
|
- `FOCUS-STEALING.md`
|
|
24
23
|
- `MODE-TRADE-OFFS.md`
|
|
25
24
|
|
|
26
|
-
Share and adapt for **non-commercial** purposes with attribution and share-alike; commercial use is not permitted. Legal code: [`LICENSE-DOCS`](LICENSE-DOCS).
|
|
25
|
+
Share and adapt for **non-commercial** purposes with attribution and share-alike; commercial use is not permitted. Legal code: [`LICENSE-DOCS`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/LICENSE-DOCS) (the licence text lives in the repository; it is not part of the npm tarball).
|
|
27
26
|
|
|
28
|
-
Note the deliberate boundary:
|
|
27
|
+
Note the deliberate boundary: these two documents are **not shipped in the npm package** — only `README.md`, `CHANGELOG.md`, `SECURITY.md` and `NOTICE.md` travel with the plugin, and all four are MIT (§1). So everything an npm installer actually receives stays redistributable on MIT terms.
|
|
29
28
|
|
|
30
29
|
## 3. Third-party content that is **not** redistributed here
|
|
31
30
|
|
package/README.md
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> [](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/) [](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/)
|
|
4
4
|
|
|
5
|
-
**一句话**:让 AI 用你**已经登录的账号**在真实网页上干活 ——
|
|
5
|
+
**一句话**:让 AI 用你**已经登录的账号**在真实网页上干活 —— 填表、抓取列表、多标签查后台;浏览器窗口就在你桌面上、你随时看得见。原生弹框会被挂起、**绝不替你点「确定」**;验证码/登录页会被识别出来并把情况报给你、请你本人接手(识别不出来时也会停下来问人,而不是硬闯)。
|
|
6
6
|
|
|
7
7
|
**In one line**: the agent drives a **visible, profile-backed browser window** with the logins you
|
|
8
8
|
already have — filling in forms, scraping lists and working across tabs in real pages — through
|
|
9
|
-
accessibility snapshots with stable element refs
|
|
10
|
-
|
|
9
|
+
accessibility snapshots with stable element refs. Native dialogs are parked and reported instead of
|
|
10
|
+
auto-accepted; a login, 2FA or challenge page is handed back to you rather than guessed at. No
|
|
11
|
+
CSS-selector guessing, no full-DOM dumps.
|
|
11
12
|
|
|
12
13
|
```sh
|
|
13
14
|
# from npm
|
|
@@ -22,14 +23,16 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
|
|
|
22
23
|
- **登录一次,一直用** — a profile-backed window plus an exported session state, so a closed window or
|
|
23
24
|
a restarted harness comes back logged in.
|
|
24
25
|
- **全程看得见,也不抢你的焦点** — a real window you can watch, minimise or take over; the provider
|
|
25
|
-
keeps working while it is minimised ([FOCUS-STEALING.md](FOCUS-STEALING.md)).
|
|
26
|
+
keeps working while it is minimised ([FOCUS-STEALING.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/FOCUS-STEALING.md)).
|
|
26
27
|
- **遇事停下来叫人** — `alert` / `confirm` / `prompt` are parked and reported, answered only by an
|
|
27
|
-
explicit `browser_dialog` call
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
from wording; if none of those match,
|
|
31
|
-
possible wall instead of being retried
|
|
32
|
-
|
|
28
|
+
explicit `browser_dialog` call ([DIALOG-POLICY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/DIALOG-POLICY.md)).
|
|
29
|
+
A challenge page is recognised from the interceptor's own response headers first
|
|
30
|
+
(`cf-mitigated: challenge` and friends; `x-iinfo` / `x-datadome*` additionally need a blocking
|
|
31
|
+
status), then from the vendor widget's accessible name, then from wording; if none of those match,
|
|
32
|
+
a page that stops changing after an action is reported as a possible wall instead of being retried.
|
|
33
|
+
Recognition adds a note that tells the agent to stop and hand the window back to you — that note is
|
|
34
|
+
advice to the model, not a hard block; the mechanism-level guarantee exists for native dialogs only
|
|
35
|
+
([docs/articles/dialogs-and-captcha.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/dialogs-and-captcha.md)).
|
|
33
36
|
- **ref 失效会明说** — per-document nonce refs; a stale ref fails fast with an explanation instead of
|
|
34
37
|
silently clicking a different element.
|
|
35
38
|
- **强力工具默认关闭** — `browser_evaluate` / `browser_cdp` are **not registered at all** unless your
|
|
@@ -37,9 +40,11 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
|
|
|
37
40
|
|
|
38
41
|
**安全一览 / Security at a glance** — dsh.so's scanner flags this repository's **one Critical finding**, and that finding **is** the
|
|
39
42
|
implementation of `browser_evaluate` (its dynamic evaluation), which this package ships switched off.
|
|
40
|
-
[SECURITY.md](SECURITY.md)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
[SECURITY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/SECURITY.md) says what each
|
|
44
|
+
finding actually is, whether a capability gate makes it reachable (two of the warnings are not gate-controlled:
|
|
45
|
+
the login-state write and the local test server in the acceptance script), and explains what the plugin can and
|
|
46
|
+
cannot do with your logins (URL policy, CDP allow-list, dialog policy, no telemetry, and it never types
|
|
47
|
+
passwords or 2FA codes). Counts move as the scanner and the code change — their own report is the
|
|
43
48
|
live number.
|
|
44
49
|
|
|
45
50
|
## Install
|
|
@@ -59,9 +64,15 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
|
|
|
59
64
|
dsh plugin --profile <name> add link:<absolute path to this directory>
|
|
60
65
|
```
|
|
61
66
|
|
|
62
|
-
**Compatibility.** Built and tested against DSH `0.2.0-rc.1
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
**Compatibility.** Built and tested against DSH `0.2.0-rc.1`. The dependency ranges are **floors, not
|
|
68
|
+
wildcards**: peers `@deepseek-ai/cordis` `>=4.0.4`, `@deepseek-ai/dsh-llm` `>=0.2.0-rc.1`,
|
|
69
|
+
`@deepseek-ai/dsh-tools` `>=0.2.0-rc.1`, and the direct dependency `@deepseek-ai/schemastery` `^3.18.4`. The
|
|
70
|
+
floors exist so an empty project cannot resolve a very old `0.0.1-rc.*` build, while a newer harness still
|
|
71
|
+
installs. One cost of npm's prerelease rules: a prerelease whose `[major, minor, patch]` tuple differs from
|
|
72
|
+
`0.2.0` (for example `0.2.1-rc.*`) is outside a `>=0.2.0-rc.1` range, so you may see a **peer warning** — a
|
|
73
|
+
warning, not an install failure (pnpm does not enforce peer ranges strictly by default). After a harness
|
|
74
|
+
upgrade, `npm run doctor` re-checks that every named export this plugin imports still exists — that is the
|
|
75
|
+
fast way to tell "the harness moved" from "the plugin broke".
|
|
65
76
|
|
|
66
77
|
`lib/` ships in the repository (and there is deliberately no `prepare` hook), so a fresh clone loads
|
|
67
78
|
without a build step — `npm run build` is only needed after editing `src/`.
|
|
@@ -80,8 +91,9 @@ Install it and ask for something that leaves a visible result — there is nothi
|
|
|
80
91
|
|
|
81
92
|
What you should see: a real browser window on your desktop doing the clicking, and the page coming back to
|
|
82
93
|
the model as a `ref`-numbered list. The first time it meets a sign-in, a 2FA prompt or a verification page
|
|
83
|
-
it **
|
|
84
|
-
|
|
94
|
+
it **reports the page back to you** — take the window over by hand and it continues from where it left off.
|
|
95
|
+
Native dialogs are the one case that is enforced at the interface: they are parked and never auto-accepted.
|
|
96
|
+
The logins already in that profile are what make the rest work.
|
|
85
97
|
|
|
86
98
|
Two habits worth having on day one:
|
|
87
99
|
|
|
@@ -104,7 +116,7 @@ Or pin a binary: `launch.executablePath` / `launch.channel` in the plugin config
|
|
|
104
116
|
|
|
105
117
|
## Tools
|
|
106
118
|
|
|
107
|
-
The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, so refs always come from the latest result. The registered set is capability-gated: `browser_evaluate`, `browser_cdp` and `browser_extract` appear only when their capability is switched on (their schemas are resident in the system prompt on every turn, so a tool that could only answer "disabled" would be pure prompt cost). Count the live surface with `npm run cost` — the shipped `cordis.patch.yml` keeps **both power gates off** (since 0.4.7), so a profile mounting this bundle sees **18 tools**; 19 or 20 once your deployment opts into `evaluate` / `cdp`, 21 once `extract` is configured. How to opt in:
|
|
119
|
+
The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, so refs always come from the latest result. The registered set is capability-gated: `browser_evaluate`, `browser_cdp` and `browser_extract` appear only when their capability is switched on (their schemas are resident in the system prompt on every turn, so a tool that could only answer "disabled" would be pure prompt cost). Count the live surface with `npm run cost` — the shipped `cordis.patch.yml` keeps **both power gates off** (since 0.4.7), so a profile mounting this bundle sees **18 tools**; 19 or 20 once your deployment opts into `evaluate` / `cdp`, 21 once `extract` is configured. How to opt in: paste the snippet under Known Limitations below, or use the settings card in `dsh-browser-toggle`.
|
|
108
120
|
|
|
109
121
|
| Tool | Purpose |
|
|
110
122
|
|---|---|
|
|
@@ -118,10 +130,10 @@ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, s
|
|
|
118
130
|
| `browser_wait` | Wait a bounded duration for lazy content |
|
|
119
131
|
| `browser_tabs` / `browser_open_tab` / `browser_switch_tab` / `browser_close_tab` | Tab management; `browser_tabs` marks the `[active]` tab the other tools act on |
|
|
120
132
|
| `browser_screenshot` | PNG capture stored as a durable image attachment the model can view |
|
|
121
|
-
| `browser_extract` | Structured extraction: natural-language instruction → one parsed JSON value
|
|
122
|
-
| `browser_evaluate` | Run one JavaScript expression in the page (disabled by default)
|
|
133
|
+
| `browser_extract` | Structured extraction: natural-language instruction → one parsed JSON value; **only registered when its capability is enabled** |
|
|
134
|
+
| `browser_evaluate` | Run one JavaScript expression in the page (disabled by default); **only registered when its capability is enabled** |
|
|
123
135
|
| `browser_dialog` | Answer the native dialog blocking the page: `{ accept: true }` confirms, `false` cancels; errors with `NO_DIALOG` when nothing is pending |
|
|
124
|
-
| `browser_cdp` | Send one raw CDP command (DOM inspection / Input simulation allow-list only; disabled by default)
|
|
136
|
+
| `browser_cdp` | Send one raw CDP command (DOM inspection / Input simulation allow-list only; disabled by default); **only registered when its capability is enabled** |
|
|
125
137
|
| `browser_close` | Close the session's browser window; the profile keeps login state, so the next call reopens signed in |
|
|
126
138
|
| `browser_console_messages` | Read the page console messages captured for the current page (levels, text, source) |
|
|
127
139
|
| `browser_network_requests` | Read the network requests captured for the current page (method, URL, status, resource type) |
|
|
@@ -129,7 +141,8 @@ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, s
|
|
|
129
141
|
### Parameter signatures
|
|
130
142
|
|
|
131
143
|
Every registered tool, with the parameters its schema actually declares (`*` = required, `?` = optional).
|
|
132
|
-
|
|
144
|
+
Hand-maintained and pinned by `tests/tool-schema.test.ts` in both directions: a missing tool, a renamed
|
|
145
|
+
parameter or a wrong required flag fails the test, so it cannot drift from the code.
|
|
133
146
|
|
|
134
147
|
- `browser_back` — _none_
|
|
135
148
|
- `browser_cdp` — `method`*, `params`? _(gated: registered only when enabled)_
|
|
@@ -154,7 +167,7 @@ Generated from the live tool surface and pinned by `tests/tool-schema.test.ts`,
|
|
|
154
167
|
- `browser_wait` — `ms`*
|
|
155
168
|
|
|
156
169
|
> **使用铁律**: 状态检查 / 无效不盲试 / 页面内容不可信 / 登录红线 / 风控分类等 Codex 文档纪律,
|
|
157
|
-
> 见 [`codex-rules.md`](codex-rules.md)(A 层已内嵌进各工具 description; B 层供粘贴进 DSH profile / 系统提示)。
|
|
170
|
+
> 见 [`codex-rules.md`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/codex-rules.md)(A 层已内嵌进各工具 description; B 层供粘贴进 DSH profile / 系统提示)。
|
|
158
171
|
|
|
159
172
|
### Snapshot example
|
|
160
173
|
|
|
@@ -172,6 +185,25 @@ Refs: 12
|
|
|
172
185
|
|
|
173
186
|
The model then calls `browser_click` with `ref: e2`. Invisible content is excluded, node and text caps bound the token cost, and truncation is flagged in the result.
|
|
174
187
|
|
|
188
|
+
#### Result notes
|
|
189
|
+
|
|
190
|
+
A result can open with one or more notes before the tree. Each says what the plugin saw; none of them
|
|
191
|
+
claims to *prevent* anything:
|
|
192
|
+
|
|
193
|
+
- `[human verification]` — a challenge page was recognised; the line above the note names the tier
|
|
194
|
+
(response header / widget / wording) and the evidence, and the note tells the agent to stop and ask
|
|
195
|
+
you to finish it in the open window instead of retrying.
|
|
196
|
+
- `[http NNN]` — the main document answered a non-2xx status and no challenge was identified, so the
|
|
197
|
+
tree may be an error page or an interception page rather than the content that was asked for.
|
|
198
|
+
- `[nothing here]` — the page has no clickable elements and almost no text: a gate or visitor check, a
|
|
199
|
+
page that is still loading, or one that needs a person. The note says to take one more snapshot,
|
|
200
|
+
then ask you rather than re-sending the same navigation.
|
|
201
|
+
- `[no visible change]` — the action left the page identical (after one action on a nearly empty page,
|
|
202
|
+
or after two identical results on a page with content). It is worded as a guess on purpose: it is
|
|
203
|
+
what an unrecognised wall looks like.
|
|
204
|
+
- A landing note (which element received the click), a parked-dialog note, and an engine-fallback note
|
|
205
|
+
can also appear.
|
|
206
|
+
|
|
175
207
|
An actionable element with no text of its own is named from its descendants — `<a href="/set"><span>搜索设置</span></a>` renders as `link "搜索设置" [ref=e7]`, and `<a href="/b"><img alt="图标链接"></a>` as `link "图标链接" [ref=e8]` — so a visible label and the ref that carries it always appear on the same line. A non-actionable child that merely repeats an ancestor name is folded away, so each label appears once, on the line that carries the ref; container roles are never named from their content (a `<select>` shows its chosen option, the way a text input shows its value).
|
|
176
208
|
|
|
177
209
|
## Configuration
|
|
@@ -194,9 +226,10 @@ All tunables are patchable through the profile's `cordis.patch.yml` (later layer
|
|
|
194
226
|
navigationTimeoutMs: 30000
|
|
195
227
|
ignoreHTTPSErrors: false
|
|
196
228
|
allowedDomains: [] # e.g. ['github.com'] restricts navigation
|
|
197
|
-
idleTimeoutMs:
|
|
229
|
+
idleTimeoutMs: 0 # shipped value: never auto-close (schema default 600000 = 10 min)
|
|
198
230
|
maxSessions: 8 # LRU eviction (persistent mode: one window)
|
|
199
231
|
snapshot:
|
|
232
|
+
engine: legacy # 'legacy' (default) or 'aria' (Playwright ariaSnapshot, refs aligned back)
|
|
200
233
|
maxNodes: 500 # token budget per snapshot tree
|
|
201
234
|
maxNameLength: 120
|
|
202
235
|
maxTextLength: 300
|
|
@@ -217,7 +250,12 @@ All tunables are patchable through the profile's `cordis.patch.yml` (later layer
|
|
|
217
250
|
maxOutputTokens: 2000
|
|
218
251
|
```
|
|
219
252
|
|
|
220
|
-
`
|
|
253
|
+
`snapshot.engine` picks the page-reading engine: `'legacy'` (the default) walks the injected DOM,
|
|
254
|
+
`'aria'` uses Playwright's official `ariaSnapshot({ mode: 'ai' })` and aligns the result back onto the
|
|
255
|
+
same `ref` ids; an aria tree that cannot be parsed falls back to legacy rather than handing the model an
|
|
256
|
+
empty tree. It is a `snapshot` key, next to `maxNodes` / `maxNameLength` / `maxTextLength` / `diff`.
|
|
257
|
+
|
|
258
|
+
`browser_extract` needs an auxiliary LLM route (`extract.provider` + `extract.model`); without one it fails with an actionable error. With a route configured, `extract.maxInputChars` bounds the page text in that prompt, and the prompt names the cap when it truncates. A gated tool is **not registered at all** while its capability is off, so its schema never reaches the system prompt; set `registerDisabledTools: true` to keep the whole surface discoverable (the tool then answers with the error naming the switch). The shipped layer in [`cordis.patch.yml`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/cordis.patch.yml) keeps `allowEvaluate` and `allowCdp` **off**, matching the schema defaults and this sample: that file travels inside the package, so anything it enabled would be enabled for everyone who installs it. Opt in per deployment, in that deployment's own patch layer. `browser_evaluate` executes arbitrary page JavaScript; `browser_cdp` stays off until `allowCdp: true`, and even then only the allow-list in `lib/cdp-policy.js` passes — network, storage, cookie, security and arbitrary-JS (`Runtime.evaluate`) commands are rejected with `CDP_DENIED`.
|
|
221
259
|
|
|
222
260
|
### Two levers that shrink what tools *return*
|
|
223
261
|
|
|
@@ -277,6 +315,8 @@ npm install
|
|
|
277
315
|
npm test # full suite: engine/action integration on real Chrome, policy, rendering,
|
|
278
316
|
# schema assembly, plus the realistic scenario suites below
|
|
279
317
|
npm run verify # naming/folding + click/hover/dialog acceptance; self-contained, prints its own count
|
|
318
|
+
# (57 in a checkout; from an npm install it prints 56 + "1 skipped", because the
|
|
319
|
+
# `lib/`-vs-`src/` check has no `src/` to compare against)
|
|
280
320
|
npm run verify:live # same, plus a live sample of a real page (needs network)
|
|
281
321
|
npm run doctor # does the installed harness still export everything this plugin imports?
|
|
282
322
|
npm run cost # resident prompt cost of the tool schemas, per surface
|
|
@@ -288,6 +328,10 @@ but `lib/` and the harness: `node scripts/dsh-browser-verify.mjs` (the acceptanc
|
|
|
288
328
|
`node scripts/doctor.mjs` (re-checks the harness exports after an upgrade). The rest are repository-only
|
|
289
329
|
and run from a checkout — `measure-prompt-cost.mjs` reads `src/`, so it cannot work from a tarball.
|
|
290
330
|
|
|
331
|
+
One package-manager note: `package.json` declares `packageManager: pnpm@10.31.0`, while the commands above
|
|
332
|
+
(and CI) use **npm** with the committed `package-lock.json` (`npm ci`). Pick one and stay in it — mixing the
|
|
333
|
+
two in a checkout gives you two lockfiles and an ambiguous install.
|
|
334
|
+
|
|
291
335
|
### Test suites
|
|
292
336
|
|
|
293
337
|
| Suite | Command | What it covers |
|
|
@@ -295,15 +339,15 @@ and run from a checkout — `measure-prompt-cost.mjs` reads `src/`, so it cannot
|
|
|
295
339
|
| Unit + engine integration | `npm test` (included) | Snapshot rendering, schema assembly, URL policy, provider actions on real Chrome |
|
|
296
340
|
| Realistic scenarios | `npm run test:scenarios` | Scripted-agent journeys over a realistic local store fixture: guest checkout with coupon and validation, login/session persistence, multi-tab research, lazy content, infinite scroll, modal dialogs, popups, screenshots, policy, owner isolation, idle disposal, snapshot budgets — at both the `BrowserSession` level and the model-facing `ctx.tools.execute` level (with fake attachments/LLM services) |
|
|
297
341
|
| Regression verify | `npm run verify` | The executable spec of snapshot naming/folding plus ref/text click-hover, the pending-dialog protocol and the tool surface (expected names derived from `contract.ts`) |
|
|
298
|
-
| Live smoke | `npm test:live` | Opt-in checks against the real internet (example.com, Wikipedia REST). Skipped unless `DSH_BROWSER_LIVE=1`; not part of CI |
|
|
342
|
+
| Live smoke | `npm run test:live` | Opt-in checks against the real internet (example.com, Wikipedia REST). Skipped unless `DSH_BROWSER_LIVE=1`; not part of CI |
|
|
299
343
|
|
|
300
344
|
The scenario suites are the plugin's most realistic tests: they drive the browser the way an agent does — snapshot, find a `ref`, act, re-snapshot — and therefore double as executable documentation of the model experience.
|
|
301
345
|
|
|
302
|
-
From a DeepSeek Harness source checkout, load this
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
346
|
+
From a DeepSeek Harness source checkout, the bundle layer to load is this repository's
|
|
347
|
+
[`cordis.patch.yml`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/cordis.patch.yml) — the
|
|
348
|
+
same file the package ships. Register the checkout through your profile with
|
|
349
|
+
`dsh plugin --profile <name> add link:<absolute path to this directory>` (above); this repository carries no
|
|
350
|
+
separate dev patch file.
|
|
307
351
|
|
|
308
352
|
## Model Experience
|
|
309
353
|
|
|
@@ -323,7 +367,19 @@ Measured with ``npm run cost`` (per-turn, resident in the system prompt):
|
|
|
323
367
|
| Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 18 | 12,755 | ≈ 3,543 |
|
|
324
368
|
| Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 15,608 | ≈ 4,336 |
|
|
325
369
|
|
|
326
|
-
|
|
370
|
+
This paragraph describes the 0.6.0 change; the table above is today's measurement. Three things changed
|
|
371
|
+
there, and only one of them was about size. (1) The click family was rewritten so each rule is stated once
|
|
372
|
+
instead of three times: -390 characters, with every rule, example and rationale pinned by
|
|
373
|
+
`tests/tool-descriptions.test.ts`, so a trim that eats one fails the build. (2) A gated tool is registered only
|
|
374
|
+
while its capability is live: `browser_extract`'s schema alone is about 456 characters (≈ 127 tokens/turn),
|
|
375
|
+
which a deployment that leaves `extract` unconfigured does not pay — a separate comparison, so it is not part
|
|
376
|
+
of the net below. (3) Four descriptions were corrected to match the code they describe (dialog blocking, the
|
|
377
|
+
gated diagnostics `browser_fill` points at, persistent-mode `browser_close`, and the click family’s landing
|
|
378
|
+
note), which added 211 characters back because the old wording promised behaviour the implementation did not
|
|
379
|
+
have. Net for that release: 18,065 → 17,886 characters with everything registered. The shipped layer's total
|
|
380
|
+
is identical to the schema-defaults row above, because it keeps both power gates off — those schemas are not
|
|
381
|
+
registered at all and cost nothing. What is left is per-tool prose rather than repetition, so a further cut
|
|
382
|
+
trades away real guidance.
|
|
327
383
|
|
|
328
384
|
#### KV Cache effect
|
|
329
385
|
|
|
@@ -335,15 +391,18 @@ The page state itself (DOM, cookies, storage) lives in the browser context and n
|
|
|
335
391
|
|
|
336
392
|
## Further reading
|
|
337
393
|
|
|
338
|
-
Written for people deciding whether to hand an agent their browser
|
|
394
|
+
Written for people deciding whether to hand an agent their browser. These live in the GitHub repository
|
|
395
|
+
(`docs/articles/`); the npm tarball ships only `README.md`, `CHANGELOG.md`, `SECURITY.md` and `NOTICE.md`, so the
|
|
396
|
+
links below go to GitHub rather than to files inside the package:
|
|
339
397
|
|
|
340
|
-
- [让 AI 用你已经登录的账号干活](docs/articles/login-state.md) — why session cookies are the hard part, and
|
|
398
|
+
- [让 AI 用你已经登录的账号干活](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/login-state.md) — why session cookies are the hard part, and
|
|
341
399
|
what the export does to a minimised window.
|
|
342
|
-
- [AI 开着浏览器抢你的焦点:三个根因](docs/articles/focus-stealing.md) — all three traced into source, with a
|
|
343
|
-
|
|
344
|
-
- [遇到验证码和弹窗,为什么它不该替你点「确定」](docs/articles/dialogs-and-captcha.md) — mechanism-level
|
|
345
|
-
dialog parking, and the same philosophy behind
|
|
346
|
-
|
|
400
|
+
- [AI 开着浏览器抢你的焦点:三个根因](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/focus-stealing.md) — all three traced into source, with a
|
|
401
|
+
113-line reproduction script that needs no part of this plugin.
|
|
402
|
+
- [遇到验证码和弹窗,为什么它不该替你点「确定」](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/dialogs-and-captcha.md) — mechanism-level
|
|
403
|
+
dialog parking, what the challenge detector does and does not guarantee, and the same philosophy behind
|
|
404
|
+
stale refs failing loudly.
|
|
405
|
+
- [官方 Discussions 发帖草稿](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/discussions-post.md) — a compatibility report in the format the
|
|
347
406
|
community asks for, ready to paste.
|
|
348
407
|
|
|
349
408
|
## Known Limitations and Deferred Work
|
|
@@ -366,9 +425,9 @@ Written for people deciding whether to hand an agent their browser:
|
|
|
366
425
|
```
|
|
367
426
|
- **Chromium family only** — Firefox/WebKit channels are not probed; providers are swappable if another engine is needed.
|
|
368
427
|
- **Extract needs a dedicated model route** — it does not reuse the main request's route; misconfiguration fails loudly at call time.
|
|
369
|
-
- **JS-driven navigations are not statically checkable** — `allowedDomains` covers `browser_navigate`, `browser_open_tab`, every click
|
|
428
|
+
- **JS-driven navigations are not statically checkable** — `allowedDomains` covers `browser_navigate`, `browser_open_tab`, every navigating click (`browser_click`: `ref`, `text`, `precise` and raw-coordinate modes) and `browser_switch_tab`; `mode: "hover"` reveals content without activating anything, so it is not checked against the URL policy. A button whose handler runs `location.href = …` can still leave the allowed hosts, and so can a `<form>` whose `action` points at another host (pressing its submit button is a navigation this check does not statically see), and a tab that is already sitting on a disallowed host stays open (the agent simply refuses to drive it); deploy an external network guard for hard isolation.
|
|
370
429
|
- **Stability waits are bounded by design** — a perpetually animating element falls back to a forced click on its current rect rather than stalling; a page that never settles is snapshotted after the cap. This is intentional: the agent must never hang.
|
|
371
|
-
- **Challenge detection reads the responses it has actually seen** — the response-header tier applies to a document response this session observed after it attached to the tab. A tab that was already sitting on a challenge page when the session took it over falls through to the widget and wording tiers and, failing those, to the no-progress backstop: it reports a *possible* wall instead of guessing the vendor.
|
|
430
|
+
- **Challenge detection reads the responses it has actually seen** — the response-header tier applies to a document response this session observed after it attached to the tab. A tab that was already sitting on a challenge page when the session took it over falls through to the widget and wording tiers and, failing those, to the no-progress backstop: it reports a *possible* wall instead of guessing the vendor. A page that is not recognised still never goes silent: a non-2xx document carries an `[http NNN]` note, and a page with no clickable elements and almost no text carries a `[nothing here]` note.
|
|
372
431
|
|
|
373
432
|
### Ref and lifecycle safety (fixed)
|
|
374
433
|
|
|
@@ -388,7 +447,7 @@ MIT
|
|
|
388
447
|
<!-- changelog:today -->
|
|
389
448
|
## 更新记录
|
|
390
449
|
|
|
391
|
-
(此列表只记 **minor 里程碑**;每个补丁版本见 [CHANGELOG.md](CHANGELOG.md)。)
|
|
450
|
+
(此列表只记 **minor 里程碑**;每个补丁版本见 [CHANGELOG.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/CHANGELOG.md)。)
|
|
392
451
|
|
|
393
452
|
### 0.6.0(2026-10-16)
|
|
394
453
|
|
|
@@ -419,7 +478,7 @@ MIT
|
|
|
419
478
|
|
|
420
479
|
### 0.3.0(2026-09-26)
|
|
421
480
|
|
|
422
|
-
- **源码化**:插件重新变成有源码的工程 —— `src/` 10 个 TypeScript
|
|
481
|
+
- **源码化**:插件重新变成有源码的工程 —— `src/` 10 个 TypeScript 模块(当时;现在 `src/` 是 32 个 `.ts`,`lib/` 是 32 个 `.js` + 32 个 `.d.ts`)是唯一真源,`lib/` 由 `npm run build` 生成,不再手改产物。
|
|
423
482
|
- **代码规范**:引入 Prettier 配置(`.prettierrc.json`),`npm run format` / `npm run format:check` 统一格式;严格类型检查 0 错误。
|
|
424
483
|
- **新增两只诊断工具**:`browser_console_messages`(控制台报错)与 `browser_network_requests`(网络请求),用于"点了没反应"时快速定位原因。
|
|
425
484
|
- **接口与实现同步**:`BrowserSession` 补齐 9 个实现里已有的方法;`errors.ts` 补齐 12 个实际在用的错误码;`BrowserSnapshot` 补 `dialogNote` / `landingNote`;`TabInfo` 补 `active`;`runtime-state` / `cdp-policy` 新增类型声明。
|
package/SECURITY.md
CHANGED
|
@@ -6,7 +6,7 @@ the thing worth being careful about. This file answers the two questions a reade
|
|
|
6
6
|
1. **What can it do to me?**
|
|
7
7
|
2. **Why does dsh.so's scanner report a Critical finding?**
|
|
8
8
|
|
|
9
|
-
If you only read one line: **the
|
|
9
|
+
If you only read one line: **the Critical finding is the implementation of `browser_evaluate`, a tool
|
|
10
10
|
this package ships switched off** — it is not registered at all until a deployment turns it on.
|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -20,13 +20,13 @@ the agent is supposed to stop and hand the page back to you.
|
|
|
20
20
|
|
|
21
21
|
**It is visible and interruptible.** The window lives on your desktop. You can watch every action,
|
|
22
22
|
minimise it (the provider detects a minimised window and keeps working without raising it — see
|
|
23
|
-
[FOCUS-STEALING.md](FOCUS-STEALING.md)), or close it: login state is exported first and restored on
|
|
23
|
+
[FOCUS-STEALING.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/FOCUS-STEALING.md)), or close it: login state is exported first and restored on
|
|
24
24
|
the next launch.
|
|
25
25
|
|
|
26
26
|
**Native dialogs are never auto-accepted.** `alert` / `confirm` / `prompt` / `beforeunload` are
|
|
27
27
|
parked as a pending state, reported to the model, and answered only by an explicit `browser_dialog`
|
|
28
28
|
call — a click that raises `confirm()` returns the parked dialog instead of accepting it. See
|
|
29
|
-
[DIALOG-POLICY.md](DIALOG-POLICY.md).
|
|
29
|
+
[DIALOG-POLICY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/DIALOG-POLICY.md).
|
|
30
30
|
|
|
31
31
|
**Which hosts it may touch is policy, not goodwill.** `allowedDomains` is enforced on every path that
|
|
32
32
|
can navigate: `browser_navigate`, `browser_open_tab`, every click variant (ref, text, geometric,
|
|
@@ -55,7 +55,8 @@ While a gate is off the tool is **not registered at all**, so it does not exist
|
|
|
55
55
|
costs no prompt tokens. But this file travels inside the package, so if it enabled them, every
|
|
56
56
|
installer would get a backdoor they never asked for; the opt-in belongs to the deployment, and it
|
|
57
57
|
lives in that deployment's own patch layer (the bundled Settings card writes it, or edit your
|
|
58
|
-
profile patch — see the configuration section of the
|
|
58
|
+
profile patch — see the configuration section of the
|
|
59
|
+
[README](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/README.md#configuration)).
|
|
59
60
|
|
|
60
61
|
**No telemetry.** The plugin does not phone home and does not upload page content anywhere. The only
|
|
61
62
|
outbound traffic is what the page itself requests — plus one exception you control: `browser_extract`
|
|
@@ -71,8 +72,10 @@ Nothing else.
|
|
|
71
72
|
## 2. What the scanner reports, and which gate contains it
|
|
72
73
|
|
|
73
74
|
dsh.so runs `dsh-plugin-vet` over the whole repository. Their scan of **0.6.0 (2026-10-16)** reports
|
|
74
|
-
**1 Critical, 3 Warning, 25 Info** across 80 files — and it is **right about the capability**.
|
|
75
|
-
each
|
|
75
|
+
**1 Critical, 3 Warning, 25 Info** across 80 files — and it is **right about the capability**. Below is what
|
|
76
|
+
each reported item actually is, and what decides whether it is reachable. Not every item sits behind a
|
|
77
|
+
capability gate: the login-state write happens whenever login persistence works, and the acceptance script's
|
|
78
|
+
local server exists only while that script runs.
|
|
76
79
|
|
|
77
80
|
> For the record: their v0.4.9 scan — before this plugin was split into responsibility-scoped modules —
|
|
78
81
|
> reported 2 Critical, 5 Warning, 26 Info across 57 files. Counts move as the scanner and the code
|
|
@@ -85,6 +88,9 @@ each one, with the gate that decides whether it is reachable at all.
|
|
|
85
88
|
| Warning | HTTP request to a bare IP (`scripts/dsh-browser-verify.mjs`) | The acceptance suite starts a **local** `http.server` on `127.0.0.1` with a fixture page, so `npm run verify` needs no DSH and no internet. | Only when you run that script. |
|
|
86
89
|
| Info ×25 | `localStorage` reads, `process.env` reads, hardcoded `127.0.0.1`, dev-only probe scripts | Restoring login state; test hooks (`DSH_BROWSER_STATE_FILE`, `PLAYWRIGHT_DOWNLOAD_HOST`); and scripts under `scripts/analysis/` that the scanner itself marks *"Dev-only file — not executed at install/runtime"*. | — |
|
|
87
90
|
|
|
91
|
+
The scanner counts the two `fs.writeFileSync` call sites (the login-state export and the small runtime-state
|
|
92
|
+
file) as **two separate Warning findings**; they share one row above because they are one persistence path.
|
|
93
|
+
|
|
88
94
|
Two honest notes:
|
|
89
95
|
|
|
90
96
|
- These numbers **will not go to zero**, because `browser_evaluate` exists in the code whether or not
|
|
@@ -92,22 +98,23 @@ Two honest notes:
|
|
|
92
98
|
- We did **not** try to hide the finding (moving the call, obfuscating it, or suppressing the rule).
|
|
93
99
|
That would be deceiving the tool that is trying to protect users, and it would make this file a lie.
|
|
94
100
|
- Module names, not line numbers: this file used to cite a line number in `src/playwright.ts`, which the
|
|
95
|
-
0.5.x split made meaningless (the code lives in `src/session.ts` now, and
|
|
96
|
-
|
|
101
|
+
0.5.x split made meaningless (the code lives in `src/session.ts` now, and its entry point
|
|
102
|
+
`src/playwright.ts` is 56 lines). File names survive refactors; line numbers do not.
|
|
97
103
|
|
|
98
104
|
## 3. How to check all of this yourself
|
|
99
105
|
|
|
100
106
|
```sh
|
|
101
|
-
npm test #
|
|
107
|
+
npm test # 219 tests: contracts, tool schemas, scenarios,
|
|
102
108
|
# aria engine, CDP policy, diff, click/tab allow-lists, snapshot rendering,
|
|
103
109
|
# prompt budget, docs-vs-reality consistency
|
|
104
|
-
npm run verify # 57 self-contained acceptance checks
|
|
110
|
+
npm run verify # 57 self-contained acceptance checks in a checkout (56 checks + 1 skipped
|
|
111
|
+
# from an npm install, where there is no src/): no DSH, no network, local fixture server
|
|
105
112
|
npm run doctor # the host packages this plugin imports are still exporting what it uses
|
|
106
113
|
npm run cost # resident prompt cost of the registered tool surface
|
|
107
114
|
```
|
|
108
115
|
|
|
109
116
|
- CI runs the whole suite on **ubuntu-latest and windows-latest** for every push
|
|
110
|
-
([workflow](.github/workflows/ci.yml)).
|
|
117
|
+
([workflow](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/.github/workflows/ci.yml)).
|
|
111
118
|
- The gates really are off in the package:
|
|
112
119
|
`git show v0.4.7:cordis.patch.yml` (0.4.7 is where that changed).
|
|
113
120
|
- Sandbox evidence, produced by someone else: [dsh.so's L4/L5 records](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/)
|
|
@@ -47,6 +47,15 @@ export interface ChallengeSignature {
|
|
|
47
47
|
readonly headers?: readonly HeaderRule[];
|
|
48
48
|
/** Case-insensitive header-name prefixes (e.g. DataDome's `x-datadome*`). */
|
|
49
49
|
readonly headerPrefixes?: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Status codes this signature must be corroborated by. Needed for vendors whose
|
|
52
|
+
* header is *not* challenge-specific: DataDome adds `x-datadome*` to every
|
|
53
|
+
* response (their docs say so, and they return `NA` when detection is off), and
|
|
54
|
+
* Imperva adds `x-iinfo` to everything it fronts. Measured 2026-10-17: a normal
|
|
55
|
+
* DataDome-fronted page answered 200 with `x-datadome: protected`, which an
|
|
56
|
+
* ungated rule would have reported as a wall.
|
|
57
|
+
*/
|
|
58
|
+
readonly statuses?: readonly number[];
|
|
50
59
|
/**
|
|
51
60
|
* Accessible-name tokens only this vendor's widget carries. On a non-iframe
|
|
52
61
|
* node a token must ALSO be corroborated by {@link WIDGET_CORROBORATION}.
|
|
@@ -53,16 +53,19 @@ export const CHALLENGE_SIGNATURES = Object.freeze([
|
|
|
53
53
|
{
|
|
54
54
|
id: 'imperva-incapsula',
|
|
55
55
|
engine: 'Imperva',
|
|
56
|
-
hint: 'Imperva (Incapsula)
|
|
57
|
-
// Presence only: X-IInfo is an opaque per-response trace that Imperva adds
|
|
58
|
-
//
|
|
56
|
+
hint: 'Imperva (Incapsula) blocked this request from its edge',
|
|
57
|
+
// Presence only: X-IInfo is an opaque per-response trace that Imperva adds to
|
|
58
|
+
// everything it fronts, so the header alone says nothing about a block — the
|
|
59
|
+
// status code has to agree.
|
|
59
60
|
headers: [{ name: 'x-iinfo' }],
|
|
61
|
+
statuses: [403, 429, 503],
|
|
60
62
|
},
|
|
61
63
|
{
|
|
62
64
|
id: 'datadome',
|
|
63
65
|
engine: 'DataDome',
|
|
64
|
-
hint: 'DataDome
|
|
66
|
+
hint: 'DataDome blocked this request',
|
|
65
67
|
headerPrefixes: ['x-datadome'],
|
|
68
|
+
statuses: [403, 429, 503],
|
|
66
69
|
},
|
|
67
70
|
{
|
|
68
71
|
id: 'recaptcha',
|
package/lib/challenge.d.ts
CHANGED
|
@@ -58,3 +58,23 @@ export declare function detectNoProgress(input: {
|
|
|
58
58
|
readonly unchangedStreak: number;
|
|
59
59
|
readonly refs: number;
|
|
60
60
|
}): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Appended when a page has nothing to click and almost nothing to read. This is
|
|
63
|
+
* the blind spot the no-progress backstop cannot cover: that tier needs an action
|
|
64
|
+
* to compare before and after, and on a page with zero actionable elements the
|
|
65
|
+
* agent has no action to take — so the emptiest, most wall-like pages used to
|
|
66
|
+
* produce no message at all. Measured 2026-10-17 on 微博's visitor gate: the model
|
|
67
|
+
* received 364 characters, all of them "URL / Title / Refs: 0 / (no visible
|
|
68
|
+
* elements)", and nothing said the page was a gate rather than an empty document.
|
|
69
|
+
*/
|
|
70
|
+
export declare const SILENT_PAGE_NOTE = "[nothing here] this page has no clickable elements and almost no text: it may be a gate or visitor check that resolves by itself, a page that is still loading, or a page that needs a person. Wait a moment and take one more browser_snapshot; if it still looks like this, tell the user what you were trying to open and ask them to look at the open window, instead of re-sending the same navigation.";
|
|
71
|
+
/**
|
|
72
|
+
* Whether the page is a silent one: nothing to click and (almost) nothing to read.
|
|
73
|
+
* @param input - the action ref count and the accessible tree.
|
|
74
|
+
* @returns the advisory note, or null when the page has content worth reading.
|
|
75
|
+
*/
|
|
76
|
+
export declare function detectSilentPage(input: {
|
|
77
|
+
readonly refs: number;
|
|
78
|
+
readonly nodes?: readonly ChallengeNode[] | undefined;
|
|
79
|
+
readonly title?: string | undefined;
|
|
80
|
+
}): string | null;
|
package/lib/challenge.js
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
import { CAPTCHA_ROLES, CAPTCHA_WORD, CHALLENGE_SIGNATURES,
|
|
1
|
+
import { CAPTCHA_ROLES, CAPTCHA_WORD, CHALLENGE_SIGNATURES, WORDING_COMMAND, WORDING_EXCLUDE, WORDING_TITLE, } from "./challenge-signatures.js";
|
|
2
2
|
/** How many accessible names the wording tiers may look at. */
|
|
3
3
|
const MAX_NAMES = 60;
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The widget tier reads deeper than the wording tiers. Measured 2026-10-17 on
|
|
6
|
+
* nopecha.com/demo/hcaptcha: a real hCaptcha iframe named
|
|
7
|
+
* "包含 hCaptcha 安全挑战复选框的小部件" sits below depth 2, so a three-level scan
|
|
8
|
+
* never saw it and the real widget was reported as NO MATCH. Vendor widgets are
|
|
9
|
+
* usually mounted inside a wrapper stack; control labels are not.
|
|
10
|
+
*/
|
|
11
|
+
const WIDGET_DEPTH = 8;
|
|
12
|
+
/** Cap on names the widget tier walks (deeper scan, so a larger budget). */
|
|
13
|
+
const MAX_WIDGET_NAMES = 150;
|
|
6
14
|
/** A control label is short; a sentence that merely mentions captchas is not. */
|
|
7
15
|
const MAX_CONTROL_LABEL = 32;
|
|
16
|
+
/**
|
|
17
|
+
* A title that mentions verification only counts on a page with (almost) nothing
|
|
18
|
+
* to click. Measured on real pages 2026-10-17: 百度's actual 安全验证 page has
|
|
19
|
+
* **0** actionable elements, while a Baidu *search result* page titled
|
|
20
|
+
* "安全验证_百度搜索" has 57 and "滑块验证_百度搜索" has 81. Without this gate the
|
|
21
|
+
* title tier fires on any search for those words — which is exactly the query a
|
|
22
|
+
* person investigating captchas types.
|
|
23
|
+
*/
|
|
24
|
+
const TITLE_WORDING_MAX_REFS = 5;
|
|
8
25
|
/** Cap on names folded into one page-state fingerprint. */
|
|
9
26
|
const MAX_FINGERPRINT_NAMES = 200;
|
|
10
27
|
/**
|
|
@@ -70,11 +87,25 @@ export function detectChallenge(snapshot) {
|
|
|
70
87
|
const url = snapshot.url ?? '';
|
|
71
88
|
const names = [];
|
|
72
89
|
scan(snapshot.nodes, 0, 2, names, MAX_NAMES);
|
|
90
|
+
// The widget tier gets its own, deeper scan (see WIDGET_DEPTH).
|
|
91
|
+
const widgetNames = [];
|
|
92
|
+
scan(snapshot.nodes, 0, WIDGET_DEPTH, widgetNames, MAX_WIDGET_NAMES);
|
|
73
93
|
const headers = snapshot.response?.headers;
|
|
94
|
+
const status = snapshot.response?.status;
|
|
95
|
+
const refs = snapshot.refs;
|
|
96
|
+
/**
|
|
97
|
+
* Some vendors put their header on every response, so the header alone is not a
|
|
98
|
+
* block: DataDome attaches `x-datadome*` to everything (their docs) and Imperva
|
|
99
|
+
* attaches `x-iinfo` to everything. A signature that lists `statuses` must have
|
|
100
|
+
* the status agree, or a perfectly good page is reported as a wall.
|
|
101
|
+
*/
|
|
102
|
+
const statusAgrees = (required) => required === undefined || (status !== undefined && required.includes(status));
|
|
74
103
|
// Tier 1 — the vendor's own response metadata. This is their published API:
|
|
75
104
|
// Cloudflare documents cf-mitigated: challenge as the supported way to detect
|
|
76
105
|
// a challenge page, so a rename breaks every customer integration, not just us.
|
|
77
106
|
for (const signature of CHALLENGE_SIGNATURES) {
|
|
107
|
+
if (!statusAgrees(signature.statuses))
|
|
108
|
+
continue;
|
|
78
109
|
for (const rule of signature.headers ?? []) {
|
|
79
110
|
const evidence = headerEvidence(rule, headers);
|
|
80
111
|
if (evidence !== null) {
|
|
@@ -98,18 +129,23 @@ export function detectChallenge(snapshot) {
|
|
|
98
129
|
const tokens = signature.widgetTokens;
|
|
99
130
|
if (tokens === undefined)
|
|
100
131
|
continue;
|
|
101
|
-
|
|
132
|
+
// No ref-count gate here on purpose. An earlier version required the page to be
|
|
133
|
+
// nearly unusable before a vendor iframe counted, which suppressed a genuine
|
|
134
|
+
// reCAPTCHA / hCaptcha / Turnstile simply because the login page around it had
|
|
135
|
+
// links (measured: a busy login page stopped being recognised at all). Login
|
|
136
|
+
// pages are the main case for this check, so the frame wins: a v3 badge on an
|
|
137
|
+
// ordinary page is reported as "a captcha is embedded here", which is true.
|
|
138
|
+
for (const node of widgetNames) {
|
|
139
|
+
// Frames only. The vendor's own widget is a frame in every real sample measured
|
|
140
|
+
// (reCAPTCHA, hCaptcha, Turnstile, Arkose): the deep scan exists to reach it.
|
|
141
|
+
// Accepting a token on an ordinary node was a regression — an article titled
|
|
142
|
+
// "How hCaptcha works" has h2/p nodes with the vendor word in them, and the
|
|
143
|
+
// wording elsewhere on the page ("verification", "challenge") then corroborated
|
|
144
|
+
// it, so a perfectly ordinary blog post rendered as a captcha wall.
|
|
145
|
+
if (node.role !== 'iframe' && node.role !== 'frame')
|
|
146
|
+
continue;
|
|
102
147
|
if (!tokens.some((token) => token.test(node.name)))
|
|
103
148
|
continue;
|
|
104
|
-
const onFrame = node.role === 'iframe' || node.role === 'frame';
|
|
105
|
-
if (!onFrame) {
|
|
106
|
-
const others = [title, url, ...names.filter((other) => other !== node).map((other) => other.name)]
|
|
107
|
-
.join(' | ')
|
|
108
|
-
.slice(0, MAX_TEXT);
|
|
109
|
-
const corroborated = WIDGET_CORROBORATION.some((token) => token.test(others));
|
|
110
|
-
if (!corroborated)
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
149
|
return {
|
|
114
150
|
engine: signature.engine,
|
|
115
151
|
hint: signature.hint,
|
|
@@ -119,32 +155,41 @@ export function detectChallenge(snapshot) {
|
|
|
119
155
|
}
|
|
120
156
|
}
|
|
121
157
|
// Tier 3 — wording, weakest of the three. A title is short and written by the
|
|
122
|
-
// interceptor, so it stands alone
|
|
123
|
-
//
|
|
124
|
-
//
|
|
158
|
+
// interceptor, so it stands almost alone: the one extra condition is that the
|
|
159
|
+
// page must also be unusable (see TITLE_WORDING_MAX_REFS). A caller that does
|
|
160
|
+
// not report a ref count is treated as "nothing to click", because the
|
|
161
|
+
// alternative is silently switching this tier off.
|
|
162
|
+
// Fail closed: a caller that does not report a ref count gets no title tier at all.
|
|
163
|
+
// Leaving the gate open when refs was missing is how the false positive came back
|
|
164
|
+
// (the tests themselves passed a BrowserSnapshot, which has totalRefs but no refs).
|
|
165
|
+
const unusablePage = refs !== undefined && refs <= TITLE_WORDING_MAX_REFS;
|
|
125
166
|
const head = title + ' ' + url;
|
|
126
|
-
|
|
127
|
-
for (const
|
|
167
|
+
if (unusablePage) {
|
|
168
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
169
|
+
for (const pattern of signature.titleWording ?? []) {
|
|
170
|
+
if (!pattern.test(head))
|
|
171
|
+
continue;
|
|
172
|
+
return {
|
|
173
|
+
engine: signature.engine,
|
|
174
|
+
hint: signature.hint,
|
|
175
|
+
tier: 'wording',
|
|
176
|
+
evidence: 'title: ' + title.slice(0, 60),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const pattern of WORDING_TITLE) {
|
|
128
181
|
if (!pattern.test(head))
|
|
129
182
|
continue;
|
|
130
183
|
return {
|
|
131
|
-
engine:
|
|
132
|
-
hint:
|
|
184
|
+
engine: 'human-verification',
|
|
185
|
+
hint: 'the page is asking to verify a human visitor',
|
|
133
186
|
tier: 'wording',
|
|
134
187
|
evidence: 'title: ' + title.slice(0, 60),
|
|
135
188
|
};
|
|
136
189
|
}
|
|
137
190
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
continue;
|
|
141
|
-
return {
|
|
142
|
-
engine: 'human-verification',
|
|
143
|
-
hint: 'the page is asking to verify a human visitor',
|
|
144
|
-
tier: 'wording',
|
|
145
|
-
evidence: 'title: ' + title.slice(0, 60),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
191
|
+
// Control-level wording is not gated the same way: it needs a short label on a
|
|
192
|
+
// control role, which an ordinary results page does not carry.
|
|
148
193
|
for (const node of names) {
|
|
149
194
|
if (node.name.length > MAX_CONTROL_LABEL)
|
|
150
195
|
continue;
|
|
@@ -223,3 +268,34 @@ export function detectNoProgress(input) {
|
|
|
223
268
|
return null;
|
|
224
269
|
return NO_PROGRESS_NOTE;
|
|
225
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Appended when a page has nothing to click and almost nothing to read. This is
|
|
273
|
+
* the blind spot the no-progress backstop cannot cover: that tier needs an action
|
|
274
|
+
* to compare before and after, and on a page with zero actionable elements the
|
|
275
|
+
* agent has no action to take — so the emptiest, most wall-like pages used to
|
|
276
|
+
* produce no message at all. Measured 2026-10-17 on 微博's visitor gate: the model
|
|
277
|
+
* received 364 characters, all of them "URL / Title / Refs: 0 / (no visible
|
|
278
|
+
* elements)", and nothing said the page was a gate rather than an empty document.
|
|
279
|
+
*/
|
|
280
|
+
export const SILENT_PAGE_NOTE = '[nothing here] this page has no clickable elements and almost no text: it may be a gate or visitor check that resolves by itself, a page that is still loading, or a page that needs a person. Wait a moment and take one more browser_snapshot; if it still looks like this, tell the user what you were trying to open and ask them to look at the open window, instead of re-sending the same navigation.';
|
|
281
|
+
/** A page with no named node at all counts as "almost no text". Strict on purpose:
|
|
282
|
+
* one heading plus one paragraph is a normal page, not a silent one. */
|
|
283
|
+
const SILENT_PAGE_MAX_NAMES = 0;
|
|
284
|
+
/**
|
|
285
|
+
* Whether the page is a silent one: nothing to click and (almost) nothing to read.
|
|
286
|
+
* @param input - the action ref count and the accessible tree.
|
|
287
|
+
* @returns the advisory note, or null when the page has content worth reading.
|
|
288
|
+
*/
|
|
289
|
+
export function detectSilentPage(input) {
|
|
290
|
+
if (input.refs > 0)
|
|
291
|
+
return null;
|
|
292
|
+
// A page with no title at all is a bare document (about:blank, a raw image):
|
|
293
|
+
// calling that "maybe a gate" is noise, not a warning.
|
|
294
|
+
if (input.title !== undefined && input.title.trim() === '')
|
|
295
|
+
return null;
|
|
296
|
+
const names = [];
|
|
297
|
+
// Read as deep as the widget scan: a page whose only text sits six levels down is
|
|
298
|
+
// a normal page, and a depth-4 scan called it "almost no text" (review, P1-3).
|
|
299
|
+
scan(input.nodes, 0, WIDGET_DEPTH, names, SILENT_PAGE_MAX_NAMES + 1);
|
|
300
|
+
return names.length <= SILENT_PAGE_MAX_NAMES ? SILENT_PAGE_NOTE : null;
|
|
301
|
+
}
|
package/lib/page-actions.d.ts
CHANGED
|
@@ -50,6 +50,15 @@ export declare function resolveClickPoint(session: PlaywrightSession, locator: L
|
|
|
50
50
|
* elements shared the label.
|
|
51
51
|
*/
|
|
52
52
|
export declare function clickText(session: PlaywrightSession, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
53
|
+
/**
|
|
54
|
+
* The href a click on this element would navigate to, for the URL policy.
|
|
55
|
+
* Its own href is not enough: a button or span nested inside an off-domain
|
|
56
|
+
* `<a href>` has no href itself yet the click bubbles into that link and
|
|
57
|
+
* navigates. The raw-coordinate path already checks the closest ancestor
|
|
58
|
+
* link; ref and text clicks must apply the same rule (B2 in the audit).
|
|
59
|
+
* Returns null when no link is involved, which the policy lets through.
|
|
60
|
+
*/
|
|
61
|
+
export declare function clickTargetHref(locator: Locator): Promise<string | null>;
|
|
53
62
|
/**
|
|
54
63
|
* Shared click core: enforce the URL policy for link targets, run the
|
|
55
64
|
* Codex-style geometry ladder with the Playwright actionability fallback,
|
|
@@ -60,6 +69,17 @@ export declare function clickLocator(session: PlaywrightSession, locator: Locato
|
|
|
60
69
|
export declare function clickAt(session: PlaywrightSession, x: number, y: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
61
70
|
export declare function clickAtRef(session: PlaywrightSession, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
62
71
|
export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a field value before comparing. Two levels of forgiveness, both of them
|
|
74
|
+
* things a page does to a value *after* we wrote it, not differences in what we wrote:
|
|
75
|
+
* whitespace is collapsed, and everything that is not a letter or a digit is dropped
|
|
76
|
+
* (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
|
|
77
|
+
* currency fields — which `browser_fill`'s own description names — while still failing
|
|
78
|
+
* a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
|
|
79
|
+
* differ once normalized. The old readback accepted a length tolerance and containment,
|
|
80
|
+
* which is how those used to report success (audit B4).
|
|
81
|
+
*/
|
|
82
|
+
export declare function normalizeFillText(value: string): string;
|
|
63
83
|
/** Read the field back and decide whether the fill landed. */
|
|
64
84
|
export declare function verifyFill(_session: PlaywrightSession, locator: Locator, tag: string, text: string): Promise<boolean>;
|
|
65
85
|
export declare function press(session: PlaywrightSession, ref: string, key: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
package/lib/page-actions.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 滚动、前进后退、标签切换,以及承载它们的可中断等待管道(withAbort)。
|
|
4
4
|
* @module dsh-browser-playwright-codex/page-actions
|
|
5
5
|
*/
|
|
6
|
-
import { hitTest } from "./page-probes.js";
|
|
6
|
+
import { clickTargetHrefProbe, hitTest } from "./page-probes.js";
|
|
7
7
|
import { BrowserError } from "./errors.js";
|
|
8
8
|
import { assertAllowedUrl } from "./url-policy.js";
|
|
9
9
|
import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
|
|
@@ -257,6 +257,17 @@ export async function clickText(session, text, signal) {
|
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The href a click on this element would navigate to, for the URL policy.
|
|
262
|
+
* Its own href is not enough: a button or span nested inside an off-domain
|
|
263
|
+
* `<a href>` has no href itself yet the click bubbles into that link and
|
|
264
|
+
* navigates. The raw-coordinate path already checks the closest ancestor
|
|
265
|
+
* link; ref and text clicks must apply the same rule (B2 in the audit).
|
|
266
|
+
* Returns null when no link is involved, which the policy lets through.
|
|
267
|
+
*/
|
|
268
|
+
export async function clickTargetHref(locator) {
|
|
269
|
+
return (await locator.evaluate(clickTargetHrefProbe)) ?? null;
|
|
270
|
+
}
|
|
260
271
|
/**
|
|
261
272
|
* Shared click core: enforce the URL policy for link targets, run the
|
|
262
273
|
* Codex-style geometry ladder with the Playwright actionability fallback,
|
|
@@ -264,7 +275,7 @@ export async function clickText(session, text, signal) {
|
|
|
264
275
|
* in the landing note; shared is how many elements matched it (0 for refs).
|
|
265
276
|
*/
|
|
266
277
|
export async function clickLocator(session, locator, signal, label, shared) {
|
|
267
|
-
session.assertClickTargetAllowed(await locator
|
|
278
|
+
session.assertClickTargetAllowed(await clickTargetHref(locator));
|
|
268
279
|
const page = session.currentPageSync();
|
|
269
280
|
const urlBefore = page.url();
|
|
270
281
|
let point;
|
|
@@ -396,8 +407,9 @@ export async function clickAtRef(session, ref, signal) {
|
|
|
396
407
|
const page = session.currentPageSync();
|
|
397
408
|
const urlBefore = page.url();
|
|
398
409
|
// Ref mode resolves the element before clicking, so the link policy can
|
|
399
|
-
// be enforced up front — the same rule browser_click applies
|
|
400
|
-
|
|
410
|
+
// be enforced up front — the same rule browser_click applies, including the
|
|
411
|
+
// ancestor link a nested control would navigate through.
|
|
412
|
+
session.assertClickTargetAllowed(await clickTargetHref(locator));
|
|
401
413
|
try {
|
|
402
414
|
// Codex-style geometry click with obstruction awareness:
|
|
403
415
|
// scroll (centre → end → start), wait for the bounding rect to
|
|
@@ -492,9 +504,9 @@ export async function fill(session, ref, text, signal) {
|
|
|
492
504
|
lastError = error;
|
|
493
505
|
continue;
|
|
494
506
|
}
|
|
495
|
-
// Post-action verification: read the value back
|
|
496
|
-
//
|
|
497
|
-
//
|
|
507
|
+
// Post-action verification: read the value back and require it to match
|
|
508
|
+
// what was typed (whitespace-normalized only), so a value the page ignored
|
|
509
|
+
// or rewrote gets one retry then a clear diagnostic.
|
|
498
510
|
written = await session.verifyFill(locator, tag, text);
|
|
499
511
|
if (written)
|
|
500
512
|
break;
|
|
@@ -507,24 +519,44 @@ export async function fill(session, ref, text, signal) {
|
|
|
507
519
|
return session.settleAndSnapshot(page, false);
|
|
508
520
|
});
|
|
509
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Normalize a field value before comparing. Two levels of forgiveness, both of them
|
|
524
|
+
* things a page does to a value *after* we wrote it, not differences in what we wrote:
|
|
525
|
+
* whitespace is collapsed, and everything that is not a letter or a digit is dropped
|
|
526
|
+
* (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
|
|
527
|
+
* currency fields — which `browser_fill`'s own description names — while still failing
|
|
528
|
+
* a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
|
|
529
|
+
* differ once normalized. The old readback accepted a length tolerance and containment,
|
|
530
|
+
* which is how those used to report success (audit B4).
|
|
531
|
+
*/
|
|
532
|
+
export function normalizeFillText(value) {
|
|
533
|
+
return String(value)
|
|
534
|
+
.normalize('NFKC')
|
|
535
|
+
.toLowerCase()
|
|
536
|
+
.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
537
|
+
}
|
|
510
538
|
/** Read the field back and decide whether the fill landed. */
|
|
511
539
|
export async function verifyFill(_session, locator, tag, text) {
|
|
540
|
+
const expected = normalizeFillText(text);
|
|
512
541
|
try {
|
|
513
542
|
if (tag === 'select') {
|
|
514
543
|
const label = await locator.evaluate((el) => {
|
|
515
544
|
const o = el.selectedOptions[0];
|
|
516
545
|
return o === undefined ? '' : o.label;
|
|
517
546
|
});
|
|
518
|
-
return label ===
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
547
|
+
return normalizeFillText(label) === expected;
|
|
548
|
+
}
|
|
549
|
+
if (tag === 'input' || tag === 'textarea') {
|
|
550
|
+
return normalizeFillText(await locator.inputValue()) === expected;
|
|
551
|
+
}
|
|
552
|
+
// contenteditable / role=textbox elements have no inputValue(); calling it
|
|
553
|
+
// throws, which used to turn a fill that HAD landed into VALUE_NOT_SET.
|
|
554
|
+
// Judge the text the page would show instead.
|
|
555
|
+
const contents = await locator.evaluate((el) => {
|
|
556
|
+
const node = el;
|
|
557
|
+
return [el.textContent ?? '', typeof node.innerText === 'string' ? node.innerText : ''];
|
|
558
|
+
});
|
|
559
|
+
return contents.some((candidate) => normalizeFillText(candidate) === expected);
|
|
528
560
|
}
|
|
529
561
|
catch {
|
|
530
562
|
return false;
|
|
@@ -62,4 +62,9 @@ export declare function networkRequests(session: PlaywrightSession, args: {
|
|
|
62
62
|
failure: string | null | undefined;
|
|
63
63
|
}[];
|
|
64
64
|
};
|
|
65
|
+
/**
|
|
66
|
+
* Bounded page data for browser_extract. Credential values (password fields and
|
|
67
|
+
* password-autocomplete fields) are blanked inside the page before the payload
|
|
68
|
+
* leaves the browser: the extract prompt is sent to an auxiliary model.
|
|
69
|
+
*/
|
|
65
70
|
export declare function pageData(session: PlaywrightSession): Promise<unknown>;
|
package/lib/page-diagnostics.js
CHANGED
|
@@ -173,6 +173,11 @@ export function networkRequests(session, args) {
|
|
|
173
173
|
})),
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Bounded page data for browser_extract. Credential values (password fields and
|
|
178
|
+
* password-autocomplete fields) are blanked inside the page before the payload
|
|
179
|
+
* leaves the browser: the extract prompt is sent to an auxiliary model.
|
|
180
|
+
*/
|
|
176
181
|
export async function pageData(session) {
|
|
177
182
|
return session.run(async () => {
|
|
178
183
|
session.assertLive();
|
|
@@ -182,7 +187,38 @@ export async function pageData(session) {
|
|
|
182
187
|
const fn = window.__dshPageData;
|
|
183
188
|
if (typeof fn !== 'function')
|
|
184
189
|
return { url: location.href, title: document.title, text: '', truncated: false, links: [], inputs: [] };
|
|
185
|
-
|
|
190
|
+
const data = fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
|
|
191
|
+
// Credentials never leave the page. __dshPageData reads every field's current
|
|
192
|
+
// value, and browser_extract pastes that object into an auxiliary model's
|
|
193
|
+
// prompt, so a login or change-password page put the plaintext password into
|
|
194
|
+
// that prompt (audit B3). The field itself stays — "there is a password box
|
|
195
|
+
// here" is useful page structure — but its value is blanked. The collector and
|
|
196
|
+
// this pass use the same selector over the same document, so the i-th entry is
|
|
197
|
+
// the i-th field.
|
|
198
|
+
if (data !== null && Array.isArray(data.inputs)) {
|
|
199
|
+
const fields = document.querySelectorAll('input, select, textarea');
|
|
200
|
+
for (let index = 0; index < data.inputs.length; index += 1) {
|
|
201
|
+
const entry = data.inputs[index];
|
|
202
|
+
const field = fields[index];
|
|
203
|
+
if (entry === undefined || field === undefined)
|
|
204
|
+
continue;
|
|
205
|
+
const type = (field.getAttribute('type') ?? '').toLowerCase();
|
|
206
|
+
const autocomplete = (field.getAttribute('autocomplete') ?? '').toLowerCase();
|
|
207
|
+
if (type === 'password' ||
|
|
208
|
+
autocomplete.includes('current-password') ||
|
|
209
|
+
autocomplete.includes('new-password') ||
|
|
210
|
+
// One-time codes and card numbers are just as sensitive as passwords: a
|
|
211
|
+
// 2FA code in the prompt can be replayed, and a card number is payment
|
|
212
|
+
// data (independent review, P2-7). The field stays, its value does not.
|
|
213
|
+
autocomplete.includes('one-time-code') ||
|
|
214
|
+
autocomplete.includes('cc-number') ||
|
|
215
|
+
autocomplete.includes('cc-csc') ||
|
|
216
|
+
autocomplete.includes('cc-exp')) {
|
|
217
|
+
entry['value'] = '';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return data;
|
|
186
222
|
};
|
|
187
223
|
let data;
|
|
188
224
|
try {
|
package/lib/page-probes.d.ts
CHANGED
|
@@ -55,6 +55,18 @@ export declare const pageStabilityProbe: (arg: {
|
|
|
55
55
|
frames: number;
|
|
56
56
|
timeoutMs: number;
|
|
57
57
|
}) => Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* The href a click on this element would follow: the element's own href, or the
|
|
60
|
+
* nearest ancestor `<a href>` — crossing shadow-root boundaries, the same way the
|
|
61
|
+
* hit-test walks. Raw-coordinate clicks already check `closest('a[href]')`; ref and
|
|
62
|
+
* text clicks used to look only at the element itself, so a button/span nested in an
|
|
63
|
+
* off-domain `<a>` reached the outside host while every check passed. This probe is
|
|
64
|
+
* that missing layer: locator.evaluate(clickTargetHrefProbe).
|
|
65
|
+
*
|
|
66
|
+
* A policy check, not a navigation: `javascript:` hrefs are returned as-is and the
|
|
67
|
+
* caller's session policy keeps its existing exemption for them.
|
|
68
|
+
*/
|
|
69
|
+
export declare const clickTargetHrefProbe: (el: Element) => string | null;
|
|
58
70
|
/**
|
|
59
71
|
* The click-point hit test, moved out of the session so it lives with the other page-side
|
|
60
72
|
* functions: it is serialized into the page by locator.evaluate(), so the same
|
package/lib/page-probes.js
CHANGED
|
@@ -201,6 +201,39 @@ export const pageStabilityProbe = (arg) => new Promise((resolve) => {
|
|
|
201
201
|
];
|
|
202
202
|
requestAnimationFrame(tick);
|
|
203
203
|
});
|
|
204
|
+
/**
|
|
205
|
+
* The href a click on this element would follow: the element's own href, or the
|
|
206
|
+
* nearest ancestor `<a href>` — crossing shadow-root boundaries, the same way the
|
|
207
|
+
* hit-test walks. Raw-coordinate clicks already check `closest('a[href]')`; ref and
|
|
208
|
+
* text clicks used to look only at the element itself, so a button/span nested in an
|
|
209
|
+
* off-domain `<a>` reached the outside host while every check passed. This probe is
|
|
210
|
+
* that missing layer: locator.evaluate(clickTargetHrefProbe).
|
|
211
|
+
*
|
|
212
|
+
* A policy check, not a navigation: `javascript:` hrefs are returned as-is and the
|
|
213
|
+
* caller's session policy keeps its existing exemption for them.
|
|
214
|
+
*/
|
|
215
|
+
export const clickTargetHrefProbe = (el) => {
|
|
216
|
+
const own = el.getAttribute('href');
|
|
217
|
+
if (own !== null && own !== '')
|
|
218
|
+
return own;
|
|
219
|
+
let node = el;
|
|
220
|
+
// Document order upward, hopping from a shadow root to its host when the
|
|
221
|
+
// element lives inside one. The seen guard makes a malformed cycle terminate.
|
|
222
|
+
const seen = [];
|
|
223
|
+
while (node !== null) {
|
|
224
|
+
if (seen.indexOf(node) !== -1)
|
|
225
|
+
break;
|
|
226
|
+
seen.push(node);
|
|
227
|
+
const anchor = node.closest('a[href]');
|
|
228
|
+
if (anchor !== null)
|
|
229
|
+
return anchor.getAttribute('href');
|
|
230
|
+
const root = node.getRootNode
|
|
231
|
+
? node.getRootNode()
|
|
232
|
+
: null;
|
|
233
|
+
node = root !== null && root !== undefined && root.host !== undefined ? root.host : null;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
};
|
|
204
237
|
export const hitTest = (el) => {
|
|
205
238
|
const r = el.getBoundingClientRect();
|
|
206
239
|
const w = r.width;
|
package/lib/page-snapshot.js
CHANGED
|
@@ -110,9 +110,9 @@ export async function captureAria(session, page, opts) {
|
|
|
110
110
|
const legacy = await session.captureLegacy(page, opts);
|
|
111
111
|
return {
|
|
112
112
|
...legacy,
|
|
113
|
-
engineNote: 'smart mode could not read
|
|
113
|
+
engineNote: 'smart mode could not read this page, so it was rendered with the compatible engine. ' +
|
|
114
114
|
'The refs below belong to the compatible engine. Nothing is wrong with the page; ' +
|
|
115
|
-
'switch the page-reading mode in Settings if you
|
|
115
|
+
'switch the page-reading mode in Settings if you would rather not see this fallback notice.',
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
return {
|
package/lib/tool-render.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CHALLENGE_NOTE, detectChallenge } from "./challenge.js";
|
|
1
|
+
import { CHALLENGE_NOTE, detectChallenge, detectSilentPage } from "./challenge.js";
|
|
2
2
|
import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
|
|
3
3
|
/**
|
|
4
4
|
* Render one diagnostics payload as one line per entry. Diagnostics are read when
|
|
@@ -57,9 +57,32 @@ export function renderDiagnostics(kind, value) {
|
|
|
57
57
|
}
|
|
58
58
|
/** Project a provider snapshot into its canonical tool value. */
|
|
59
59
|
export function snapshotValue(snapshot) {
|
|
60
|
-
|
|
60
|
+
// The ref count travels with the snapshot on purpose: the wording tier needs it
|
|
61
|
+
// to tell a real wall (nothing to click) from a results page whose title merely
|
|
62
|
+
// contains the phrase. Measured on 百度: 0 refs on the challenge, 57/81 on the
|
|
63
|
+
// search pages titled "安全验证_百度搜索" / "滑块验证_百度搜索".
|
|
64
|
+
const challenge = detectChallenge({
|
|
65
|
+
url: snapshot.url,
|
|
66
|
+
title: snapshot.title,
|
|
67
|
+
nodes: snapshot.nodes,
|
|
68
|
+
refs: snapshot.totalRefs,
|
|
69
|
+
...(snapshot.response !== undefined ? { response: snapshot.response } : {}),
|
|
70
|
+
});
|
|
61
71
|
const tree = renderSnapshot(snapshot);
|
|
62
72
|
const notes = [];
|
|
73
|
+
// The HTTP status was captured for challenge detection but never shown, so a
|
|
74
|
+
// 404/500 error page reached the model looking like an ordinary page — measured
|
|
75
|
+
// 2026-10-17 on a real MDN 404: status 404, 35 refs, and nothing in the result
|
|
76
|
+
// said so. Suppressed when a challenge was identified, because that note
|
|
77
|
+
// already names the reason.
|
|
78
|
+
const status = snapshot.response?.status;
|
|
79
|
+
if (challenge === null && status !== undefined && (status < 200 || status >= 300)) {
|
|
80
|
+
notes.push('[http ' +
|
|
81
|
+
String(status) +
|
|
82
|
+
'] the server answered ' +
|
|
83
|
+
String(status) +
|
|
84
|
+
' for this document, so what follows may be an error page or an interception page rather than the content you asked for.');
|
|
85
|
+
}
|
|
63
86
|
const dialog = snapshot.dialogNote;
|
|
64
87
|
if (dialog !== undefined && dialog !== '')
|
|
65
88
|
notes.push(dialog);
|
|
@@ -80,6 +103,18 @@ export function snapshotValue(snapshot) {
|
|
|
80
103
|
const stall = snapshot.stallNote;
|
|
81
104
|
if (challenge === null && stall !== undefined && stall !== '')
|
|
82
105
|
notes.push(stall);
|
|
106
|
+
// The backstop above needs an action to compare before and after; a page with
|
|
107
|
+
// nothing to click never gets one, so the emptiest wall-like pages used to say
|
|
108
|
+
// nothing at all. Only on a successful status: a 4xx/5xx already has its own line.
|
|
109
|
+
const healthyStatus = status === undefined || (status >= 200 && status < 300);
|
|
110
|
+
// A parked dialog means the page is fine, it is the dialog that blocks us: the
|
|
111
|
+
// dialog note already says what to do, and adding "nothing here" contradicts it.
|
|
112
|
+
const dialogPending = snapshot.dialogNote !== undefined && snapshot.dialogNote !== '';
|
|
113
|
+
if (challenge === null && healthyStatus && !dialogPending) {
|
|
114
|
+
const silent = detectSilentPage({ refs: snapshot.totalRefs, nodes: snapshot.nodes, title: snapshot.title });
|
|
115
|
+
if (silent !== null)
|
|
116
|
+
notes.push(silent);
|
|
117
|
+
}
|
|
83
118
|
const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
|
|
84
119
|
// Incremental diff rides along when the provider computed one (snapshot.diff).
|
|
85
120
|
const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
|
package/lib/tool-types.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface ChallengeSnapshot {
|
|
|
53
53
|
readonly title?: string;
|
|
54
54
|
readonly url?: string;
|
|
55
55
|
readonly nodes?: readonly ChallengeNode[];
|
|
56
|
+
/**
|
|
57
|
+
* Actionable-element count. The wording tier uses it to tell a challenge page
|
|
58
|
+
* (nothing to click) from an ordinary page whose *title* merely contains the
|
|
59
|
+
* phrase — searching Baidu for "安全验证" produces exactly that false positive.
|
|
60
|
+
*/
|
|
61
|
+
readonly refs?: number;
|
|
56
62
|
/** Main document response metadata; absent when no navigation response was seen. */
|
|
57
63
|
readonly response?: DocumentResponse;
|
|
58
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"preflight": "node scripts/preflight.mjs ."
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@deepseek-ai/schemastery": "
|
|
60
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
61
61
|
"playwright-core": "~1.62.1"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@deepseek-ai/cordis": "
|
|
65
|
-
"@deepseek-ai/dsh-llm": "
|
|
66
|
-
"@deepseek-ai/dsh-tools": "
|
|
64
|
+
"@deepseek-ai/cordis": ">=4.0.4",
|
|
65
|
+
"@deepseek-ai/dsh-llm": ">=0.2.0-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-tools": ">=0.2.0-rc.1"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@deepseek-ai/cordis": "4.0.4",
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* 读 lib/,但**必须先 build**:npm run verify 会先跑 npm run build,直接
|
|
15
15
|
* node scripts/... 跑则会撞上最后一组检查(lib/ 不旧于 src/)。
|
|
16
|
+
* npm 安装形态没有 src/(files 里不含它):最后一组自动**跳过并说明**,不算失败,
|
|
17
|
+
* 所以从 npm 装上之后这条命令照样能跑通 —— README 的「装到即可跑」必须是真的。
|
|
16
18
|
*
|
|
17
19
|
* 为什么不像测试那样用 tsx 直读 src/:tsx(esbuild keepNames)会把传给
|
|
18
20
|
* page.evaluate 的函数包成 __name(...),而页面里没有这个符号 ——
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
* 失效。lib/ 是编译产物,没有这层包裹,也正好是宿主真正加载的东西。
|
|
21
23
|
*/
|
|
22
24
|
import http from 'node:http'
|
|
23
|
-
import { readdirSync, statSync } from 'node:fs'
|
|
25
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
24
26
|
import os from 'node:os'
|
|
25
27
|
import path from 'node:path'
|
|
26
28
|
import { PlaywrightProvider } from '../lib/provider.js'
|
|
@@ -32,12 +34,13 @@ import { getEffectiveEngine, setSnapshotEngine } from '../lib/runtime-state.js'
|
|
|
32
34
|
// 设置页的「页面识别方式」存在 ~/.dsh/dsh-browser-playwright.state.json,运行时状态层
|
|
33
35
|
// 允许用 DSH_BROWSER_STATE_FILE 把它指向别处(src/runtime-state.ts 里为测试留的开关)。
|
|
34
36
|
// 指向一个不存在的文件 = engine 回到配置默认值 legacy,本脚本的期望值就是按它写的。
|
|
35
|
-
//
|
|
37
|
+
// 不隔离的话:用户把开关拨到「智能模式」,这份验收就会假失败(2026-10-01 实测)。
|
|
36
38
|
// 注意 [1]–[7] 因此都跑在兼容模式下;智能模式由 [8] 显式切过去跑(隔离状态文件里切,
|
|
37
39
|
// 不碰用户的真实选择)。
|
|
38
40
|
process.env.DSH_BROWSER_STATE_FILE = path.join(os.tmpdir(), 'dsh-browser-verify-state-isolated.json')
|
|
39
41
|
|
|
40
42
|
let pass = 0
|
|
43
|
+
let skipped = 0
|
|
41
44
|
const failures = []
|
|
42
45
|
function check(name, ok, detail) {
|
|
43
46
|
if (ok) {
|
|
@@ -455,22 +458,31 @@ await provider.dispose()
|
|
|
455
458
|
server.close()
|
|
456
459
|
|
|
457
460
|
// [7] lib/ 是宿主真正加载的产物:源码改完没 build,跑绿了也是白跑。
|
|
461
|
+
// npm 包里没有 src/(package.json 的 files 只收 lib/),安装形态下这一组无从比较:
|
|
462
|
+
// 跳过并说明原因,不记失败、不影响退出码 —— 否则装上 npm 包之后这条命令必崩,
|
|
463
|
+
// 与 README/CHANGELOG 的「装到即可跑」直接矛盾。
|
|
458
464
|
console.log('[7] lib/ 与 src/ 同步(宿主加载 lib/)')
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
465
|
+
if (!existsSync(new URL('../src', import.meta.url))) {
|
|
466
|
+
skipped += 1
|
|
467
|
+
console.log(' (跳过)没有 src/:这是 npm 安装形态')
|
|
468
|
+
} else {
|
|
469
|
+
const stale = []
|
|
470
|
+
for (const file of readdirSync(new URL('../src', import.meta.url))) {
|
|
471
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts')) continue
|
|
472
|
+
const src = statSync(new URL('../src/' + file, import.meta.url)).mtimeMs
|
|
473
|
+
const built = statSync(new URL('../lib/' + file.replace(/\.ts$/, '.js'), import.meta.url), {
|
|
474
|
+
throwIfNoEntry: false,
|
|
475
|
+
})
|
|
476
|
+
if (built === undefined || built.mtimeMs < src) stale.push(file.replace(/\.ts$/, '.js'))
|
|
477
|
+
}
|
|
478
|
+
check('lib/ 不旧于 src/(旧了就先 npm run build)', stale.length === 0, stale.join(', '))
|
|
467
479
|
}
|
|
468
|
-
check('lib/ 不旧于 src/(旧了就先 npm run build)', stale.length === 0, stale.join(', '))
|
|
469
480
|
|
|
470
481
|
console.log('')
|
|
482
|
+
const skipNote = skipped > 0 ? ',跳过 ' + skipped + ' 项' : ''
|
|
471
483
|
console.log(
|
|
472
484
|
failures.length === 0
|
|
473
|
-
? '全部通过 (' + pass + ' 项)'
|
|
485
|
+
? '全部通过 (' + pass + ' 项' + skipNote + ')'
|
|
474
486
|
: '失败 ' + failures.length + ' 项 / 共 ' + (pass + failures.length) + ' 项: ' + failures.join(', '),
|
|
475
487
|
)
|
|
476
488
|
process.exit(failures.length === 0 ? 0 : 1)
|