dsh-browser-playwright-codex 0.8.1 → 0.8.2
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 +36 -0
- package/README.md +13 -0
- package/SECURITY.md +12 -4
- package/lib/gates-patch.js +60 -0
- package/lib/snapshot-aria.d.ts +0 -20
- package/lib/snapshot-aria.js +68 -3
- package/lib/swarm-intro.js +4 -4
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
## 0.8.2 — 2026-11-12
|
|
2
|
+
|
|
3
|
+
一句话摘要:**发版门禁不再会"假绿",快照引擎第一次在真实网站上被量过**——preflight 过去只读 `ℹ fail`(而且"fail 0"是写死的字符串),被取消的用例看不出、也不报;设置页这个全仓最大的零测试模块补上了走真实 HTTP 的测试,顺手修掉"已有未标记装载行时写出第二行"的坏文件路径;智能模式(aria)的解析器与可交互集对齐后,130 个任务页的降级从 18 页降到 3 页。
|
|
4
|
+
|
|
5
|
+
### Fixed — 门禁与默认值
|
|
6
|
+
|
|
7
|
+
- **preflight 的假绿通道(最要紧)**:旧实现只解析 `ℹ fail N`,摘要里的 `fail 0` 是硬编码字符串,`cancelled` / `todo` 一次都没读。0.7.8 有 7 条用例被打成 `cancelledByParent`,对外仍说"278 通过",实际只跑了 271 条。现在判读抽成 `scripts/preflight-suite.mjs`,读全部六个计数,并把下列情形都判成硬错误:`fail > 0`、`cancelled > 0`、`todo > 0`、恒等式 `tests ≠ pass + fail + cancelled + skipped + todo`、任一计数读不到、命令非零退出、出现 `cancelledByParent > 0`。**生产脚本里不允许任何外部注入测试结果的入口**(第一版加过、被复核否掉,并留了守卫测试防止再加回来)。
|
|
8
|
+
- **`swarm.enabled` 的默认方向反了**:`config.ts` 的 schema 是 `default(false)`,而 `swarm-intro.ts` 的三个入口写成 `?? true` —— 配置整体缺失时会播。改成 `?? false`,并补测试:`config` 为 `undefined` / `{}` / `{ swarm: {} }` 三种形状都不播,另有一条 `enabled: true` 的对照防止测试空转。
|
|
9
|
+
- **设置页写出重复装载行**:补丁层里已有未标记的 `- id: browser` 行时,旧实现会再追加一次模板,同一个 id 被挂两行(与 issue #4 同一类"保存写出坏文件")。现在改为**收编**那一行(保留用户键、外面套标记、只改三个闸门);若那一行属于别的包,**拒绝写入并报错**,文件一个字节不动。
|
|
10
|
+
- **SECURITY.md 与真实行为不符**:旧文写着"页面自己的 JS 仍能离开白名单",实际名单装在浏览器 context 上、请求层就会拒。按真实行为重写,并保留两条诚实的限制(Chromium 自家网络栈里的跳转靠动作后校验最终 URL;已经停在不允许域名上的标签页仍打开、只是不驱动)。
|
|
11
|
+
|
|
12
|
+
### Fixed — 快照引擎(aria / smart 模式)
|
|
13
|
+
|
|
14
|
+
- **解析器读不懂"整行被引号包住"的节点**:Playwright 在名字含双引号时会写成 `- 'tab "Section #1" [ref=e5]'`(可能带尾冒号)。旧解析器把它算格式漂移,整页降级——MiniWoB 的标签页/折叠题共 8 页因此读不了。现在容忍外层引号 + 可选尾冒号,并加了"原来能读的行不许读坏"的守卫测试。
|
|
15
|
+
- **两套"可交互"定义对不齐**:DOM 铸造集与 aria 树的可交互集不一致,会触发对齐守卫、整页回退。对齐了这几处:`tr→row`、`td→cell`、`th→columnheader`、`li→listitem`、`table/list/p/h1-6/img` 的隐式角色;`<option>` 的可见性看它的 `<select>`;`role="presentation"/"none"` 在可聚焦链接上忽略(ARIA 规范,jQuery-UI 标签页就是这样);`<input type=number>` → `spinbutton`。
|
|
16
|
+
- **实测**:MiniWoB++ 130 个任务页里,智能模式降级 **18 → 3**;`ref` 落不到 DOM 元素 **0 页 / 0 个**;130/130 有效、0 失败。真实网站 7 页 5 站(含 GitHub 仓库页、runoob 表格页、w3school 表单页、element-plus 组件页)全部 `unresolved = 0`,点击落点逐个核对。
|
|
17
|
+
- **代价与旋钮**:GitHub 仓库页从此前的"读不了、回退 legacy(81 refs / 8,517 字符)"变成"智能模式可读(134 refs / 20,780 字符)"——结构更全、也更贵。真正决定体积的是 `snapshot.maxNodes`(默认 500),不是 `interactiveOnly`:同一页 `interactiveOnly` 是 18,351 字符 / 239 refs,**只省 12% 且 ref 反而更多**(预算重新分配,不减少总量)。README 与 PLUGIN-README 已写明这个旋钮。
|
|
18
|
+
|
|
19
|
+
### Added — 测试与流程
|
|
20
|
+
|
|
21
|
+
- **设置卡端到端测试**(`tests/settings-card.test.ts`):全仓最大的零测试模块(537 行)补上 10 条,走**真实 HTTP**(`node:http` 起服务、把设置卡注册的 handler 原样接上:方法/请求头/请求体/状态码都走真实栈)。钉住 issue #4(保存后 YAML 可重新解析、无重复键、只有一个装载行)、切开关不抹 `profileDir` 等用户配置、六个路由的 `x-dsh-plugin` 防伪头、被拒请求不动文件。其中"重复装载行"与"拒绝写入"两条做过变异验证(把修复改回去,测试立刻红)。
|
|
22
|
+
- **覆盖率**:`npm run coverage`(`scripts/coverage.mjs`)。用 Node 自带的 `--experimental-test-coverage`,不引入新依赖(本机 `@deepseek-ai/*` 是指向活运行时的软链接,不能 `npm install`)。自带报告只列被加载过的文件,脚本额外把 `src/**` 全清单求差集,**把 0 覆盖模块明着列出来**。首测:43 个模块、41 个被加载、2 个 0 覆盖(纯类型文件)。**不是门禁**。
|
|
23
|
+
- **跨仓库检查显式化**:配套件文档检查移出默认套件(`tests/cross-repo/`),默认套件的数字在任何机器上一致;没设 `DSH_TOGGLE_DIR` 时 preflight 明写**「未验证」**,不再以"跳过"冒充通过。
|
|
24
|
+
- **live 三条接进发版清单**:`docs/RELEASING.md` 新增发版清单,要求发版前手动跑 `DSH_BROWSER_LIVE=1 npm run test:live`,结论写进 Release;没跑就在 CHANGELOG 明写「本次未跑 live」。preflight 每次固定打一行「未验证:live 三条」,不再悄悄消失。
|
|
25
|
+
- **一次性发布文档挪位**:`RELEASE-0.7.0.md` / `MERGE-PROGRESS.md` 移到 `docs/releases/`(**不删**,里面有回退命令),preflight 把整个目录当历史豁免——根目录发版噪声从 5 处降到 3 处。
|
|
26
|
+
|
|
27
|
+
### live 三条:本次跑过,2/3 通过
|
|
28
|
+
|
|
29
|
+
按 `docs/RELEASING.md` 手动跑了一次 `DSH_BROWSER_LIVE=1 npm run test:live`(2026-11-12):
|
|
30
|
+
|
|
31
|
+
- ✔ `example.com renders, snapshots, and screenshots`(1.3s)
|
|
32
|
+
- ✔ `example.com rejects disallowed schemes consistently`(0.03s)
|
|
33
|
+
- ✖ `wikipedia REST summary parses through evaluate` —— **本机网络到不了 wikipedia**:`page.goto` 在 30 秒后 `NAVIGATION_TIMEOUT`(这条不经过插件的新代码,属环境不可达)
|
|
34
|
+
|
|
35
|
+
所以本次 live **未全绿**,原因记录在此;它不在 `prepublishOnly` 里,不影响发布门禁。preflight 的「未验证:live 三条」行同样保留。
|
|
36
|
+
|
|
1
37
|
## 0.8.1 — 2026-11-11
|
|
2
38
|
|
|
3
39
|
一句话摘要:**快照第一次真正"看得见状态"**——填进去的值、勾选状态进快照也进页面指纹,正常的填字和勾选不再被误报成"疑似反爬墙";密码 / 验证码 / 卡号即使有值也绝不进快照。顺带削掉真实站点快照里最贵的一类垃圾(跳转包裹 URL、同一 href 的重复打印),并让 `browser_evaluate` 返回非标量时给出能照着改的错误。**另外:进场动画重新随包分发——作者 Dhvanil Patel 于 2026-11-11 邮件书面许可打包与修改,默认仍然关闭。**
|
package/README.md
CHANGED
|
@@ -378,6 +378,19 @@ The tool schemas join the system-prompt assembly through `ctx.tools.register()`;
|
|
|
378
378
|
|
|
379
379
|
One tool schema block per tool (small, fixed), plus per-call result text proportional to the visible page, capped by ``snapshot.maxNodes`` and name/text limits. Actions return a fresh snapshot, so interactive flows cost roughly one snapshot per step.
|
|
380
380
|
|
|
381
|
+
The merged tools carry their own allowance rather than the default 1,650 characters. `browser_click` (
|
|
382
|
+
which absorbed the old separate point-click and hover tools) measures **2,165 characters** against a
|
|
383
|
+
budget of **2,400** — about **235 characters** of headroom left. `npm run cost` prints that per-tool
|
|
384
|
+
figure on every run, so it is measured, not estimated.
|
|
385
|
+
|
|
386
|
+
**The size knob is `snapshot.maxNodes`, not the reading mode.** The node cap decides how many
|
|
387
|
+
characters a snapshot can spend: at the default 500 the engine stops once 500 nodes are in the tree.
|
|
388
|
+
On a very large page whose snapshot is re-sent at every step, lowering it is the lever — the price is
|
|
389
|
+
fewer elements visible. `interactiveOnly` does **not** shrink the total: it spends the same node
|
|
390
|
+
budget on actionable elements only, so the same page can carry *more* refs that way. Measured on one
|
|
391
|
+
real page (a GitHub repository page, 2026-11-12): full snapshot **20,780 characters / 134 refs**; the
|
|
392
|
+
same page with `interactiveOnly: true` **18,351 characters / 239 refs**.
|
|
393
|
+
|
|
381
394
|
Measured with ``npm run cost`` (per-turn, resident in the system prompt):
|
|
382
395
|
|
|
383
396
|
| Surface | Tools | Schema characters | ≈ tokens/turn |
|
package/SECURITY.md
CHANGED
|
@@ -37,10 +37,18 @@ call — a click that raises `confirm()` returns the parked dialog instead of ac
|
|
|
37
37
|
|
|
38
38
|
**Which hosts it may touch is policy, not goodwill.** `allowedDomains` is enforced on every path that
|
|
39
39
|
can navigate: `browser_navigate`, `browser_open_tab`, every click variant (ref, text, geometric,
|
|
40
|
-
raw coordinates) and `browser_switch_tab`.
|
|
41
|
-
(`
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
raw coordinates) and `browser_switch_tab`. **The list is also installed on the browser context itself**
|
|
41
|
+
(`src/navigation-guard.ts`), so the browser — not just the tool layer — refuses a document request to a
|
|
42
|
+
host outside it. A page's own JavaScript (`location.href = …`), a `meta refresh`, a `<form>` posting
|
|
43
|
+
off-list and a page-opened popup all stop at the request; `fetch` / `xhr` / `EventSource` to an
|
|
44
|
+
off-list host are refused too, while rendering assets are left alone so a page does not look broken.
|
|
45
|
+
|
|
46
|
+
Two limits remain, and they are the honest part of this section. (1) Chromium follows a redirect inside
|
|
47
|
+
its own network stack, where request interception never sees the hop: after every action the page's
|
|
48
|
+
final URL is checked, an off-list landing is refused with `URL_NOT_ALLOWED`, and the tab is reset to
|
|
49
|
+
`about:blank`. (2) A tab that was **already** sitting on a disallowed host when this session took it over
|
|
50
|
+
(a profile written by an older version) stays open — the agent simply refuses to drive it. For hard
|
|
51
|
+
isolation from the whole host stack, still put an external network guard around it.
|
|
44
52
|
|
|
45
53
|
**`browser_cdp` is CDP with an allow-list.** Nineteen methods pass, matched by **exact name with no
|
|
46
54
|
family prefixes**: DOM reads (geometry, node lookup, attributes, markup), one focus action, the four
|
package/lib/gates-patch.js
CHANGED
|
@@ -174,6 +174,41 @@ function findBlock(original) {
|
|
|
174
174
|
}
|
|
175
175
|
return undefined;
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* A **top-level** loader row for `id: browser` that is not inside a managed block.
|
|
179
|
+
*
|
|
180
|
+
* Why this exists (2026-11-12, found while writing the settings-card tests): when the
|
|
181
|
+
* user's patch layer already carried an unmarked `- id: browser` row (hand-edited, or
|
|
182
|
+
* written by a version before the markers existed), `mergeBrowserBlock` used to append
|
|
183
|
+
* the full template — producing **two loader rows for the same id**. The loader then
|
|
184
|
+
* sees the package twice, which is the same class of failure as issue #4 (a save writes
|
|
185
|
+
* a patch that breaks the next start). Adopting the row instead keeps the user's keys
|
|
186
|
+
* and leaves exactly one row.
|
|
187
|
+
*
|
|
188
|
+
* @param original - the whole patch file
|
|
189
|
+
* @returns the row's bounds, or undefined when there is none
|
|
190
|
+
*/
|
|
191
|
+
function findUnmarkedRow(original) {
|
|
192
|
+
const head = /^([ \t]*)-[ \t]+id:[ \t]*browser[ \t]*(?:#.*)?$/m.exec(original);
|
|
193
|
+
if (head === null)
|
|
194
|
+
return undefined;
|
|
195
|
+
const start = head.index;
|
|
196
|
+
const indent = (head[1] ?? '').length;
|
|
197
|
+
const bodyStart = start + head[0].length;
|
|
198
|
+
// The row ends at the next sequence item at the same (or a shallower) indent.
|
|
199
|
+
let end = original.length;
|
|
200
|
+
const item = /^([ \t]*)-[ \t]+/gm;
|
|
201
|
+
item.lastIndex = bodyStart;
|
|
202
|
+
for (let found = item.exec(original); found !== null; found = item.exec(original)) {
|
|
203
|
+
if ((found[1] ?? '').length <= indent) {
|
|
204
|
+
end = found.index;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const body = original.slice(start, end);
|
|
209
|
+
const namesUs = /['"]dsh-browser-playwright-codex['"]/.test(body);
|
|
210
|
+
return { start, end, namesUs, indent };
|
|
211
|
+
}
|
|
177
212
|
/**
|
|
178
213
|
* Merge the gate values into `original`.
|
|
179
214
|
*
|
|
@@ -189,6 +224,31 @@ export function mergeBrowserBlock(original, allowEvaluate, allowCdp, diagnostics
|
|
|
189
224
|
const gates = { allowEvaluate, allowCdp, diagnostics };
|
|
190
225
|
const found = findBlock(original);
|
|
191
226
|
if (found === undefined) {
|
|
227
|
+
const row = findUnmarkedRow(original);
|
|
228
|
+
if (row !== undefined) {
|
|
229
|
+
// An unmarked row already mounts this package. Wrapping it (instead of appending a
|
|
230
|
+
// second one) is what keeps "one loader row per package" true — the same invariant
|
|
231
|
+
// the 0.7.0 merge had to satisfy.
|
|
232
|
+
if (!row.namesUs) {
|
|
233
|
+
// It is somebody else's `- id: browser` row. Refuse rather than write a file that
|
|
234
|
+
// mounts two different packages under one id: a clear error now beats a fatal start.
|
|
235
|
+
throw new Error('the home patch already has an `- id: browser` row that is not this package; ' +
|
|
236
|
+
'refusing to add a second row for the same id — rename one of them by hand, then retry');
|
|
237
|
+
}
|
|
238
|
+
const nl = original.includes('\r\n') ? '\r\n' : '\n';
|
|
239
|
+
const body = original.slice(row.start, row.end).replace(/[ \t]+$/, '');
|
|
240
|
+
const wrapped = BEGIN + nl + body + nl + END + nl;
|
|
241
|
+
const edited = patchBlockGates(wrapped, gates);
|
|
242
|
+
const check = parseBrowserBlock(edited);
|
|
243
|
+
if (check !== undefined &&
|
|
244
|
+
check.allowEvaluate === allowEvaluate &&
|
|
245
|
+
check.allowCdp === allowCdp &&
|
|
246
|
+
check.diagnostics === diagnostics) {
|
|
247
|
+
return original.slice(0, row.start) + edited + original.slice(row.end);
|
|
248
|
+
}
|
|
249
|
+
const rebuilt = BEGIN + nl + renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) + END + nl;
|
|
250
|
+
return original.slice(0, row.start) + rebuilt + original.slice(row.end);
|
|
251
|
+
}
|
|
192
252
|
const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
|
|
193
253
|
return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
|
|
194
254
|
}
|
package/lib/snapshot-aria.d.ts
CHANGED
|
@@ -48,26 +48,6 @@ export interface AriaParseResult {
|
|
|
48
48
|
/** Content lines that could not be parsed — non-empty means format drift. */
|
|
49
49
|
readonly unparsed: string[];
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Parse Playwright's official ariaSnapshot YAML into a semantic node tree.
|
|
53
|
-
*
|
|
54
|
-
* The real format (verified against playwright-core 1.62, 2026-09-30) is:
|
|
55
|
-
*
|
|
56
|
-
* - generic [active] [ref=e1]: ← trailing ':' = the node has children
|
|
57
|
-
* - link "One" [ref=e6] [cursor=pointer]:
|
|
58
|
-
* - /url: /one ← href rides a deeper child line
|
|
59
|
-
* - text: Name ← text content uses 'text: <value>'
|
|
60
|
-
*
|
|
61
|
-
* The first version of this parser assumed a hand-rolled dialect
|
|
62
|
-
* (`- role "name" [flags] -> href`, nothing after the flags) that real output
|
|
63
|
-
* never produces. Every container line ends in ':', so that regex matched none
|
|
64
|
-
* of them: the whole hierarchy was dropped, links and their hrefs vanished, and
|
|
65
|
-
* `captureAriaSnapshot` still reported success. Unreadable lines are now
|
|
66
|
-
* collected in `unparsed` so the capture layer can refuse the tree and let the
|
|
67
|
-
* provider fall back to the legacy engine.
|
|
68
|
-
* @param yaml - the ariaSnapshot text.
|
|
69
|
-
* @returns parsed roots plus any unreadable content lines.
|
|
70
|
-
*/
|
|
71
51
|
export declare function parseAriaSnapshotWithStats(yaml: string): AriaParseResult;
|
|
72
52
|
/**
|
|
73
53
|
* Parse Playwright's ariaSnapshot YAML into a semantic node tree.
|
package/lib/snapshot-aria.js
CHANGED
|
@@ -54,12 +54,19 @@ export const ARIA_REF_SCRIPT = String.raw `
|
|
|
54
54
|
window.__dshAriaRefsV = 2
|
|
55
55
|
const INTERACTIVE = new Set(['button','link','checkbox','radio','menuitem','menuitemcheckbox','menuitemradio','tab','switch','combobox','listbox','option','textbox','searchbox','slider','spinbutton','row','gridcell'])
|
|
56
56
|
const TAGS = new Set(['a','button','select','textarea','option','summary'])
|
|
57
|
-
const INPUT_ROLE = { checkbox:'checkbox', radio:'radio', button:'button', submit:'button', reset:'button', image:'button', range:'slider', search:'searchbox', hidden:'null' }
|
|
57
|
+
const INPUT_ROLE = { checkbox:'checkbox', radio:'radio', button:'button', submit:'button', reset:'button', image:'button', range:'slider', number:'spinbutton', search:'searchbox', hidden:'null' }
|
|
58
58
|
const DOC_NONCE = String(Math.floor(Math.random() * 1e9)).padStart(9, '0')
|
|
59
59
|
let refSeq = 0
|
|
60
60
|
const roleOf = (el) => {
|
|
61
61
|
const explicit = el.getAttribute && el.getAttribute('role')
|
|
62
|
-
|
|
62
|
+
const explicitRole = explicit && explicit.trim() ? explicit.trim().toLowerCase() : ''
|
|
63
|
+
// aria: an explicit role="presentation"/"none" is IGNORED on a focusable
|
|
64
|
+
// element — the tree still exposes its native role. jQuery-UI tabs do exactly
|
|
65
|
+
// this: <a href="#tabs-1" role="presentation"> shows up as a link in the aria
|
|
66
|
+
// tree (measured 2026-11-12 on MiniWoB click-tab).
|
|
67
|
+
const presentational = explicitRole === 'presentation' || explicitRole === 'none'
|
|
68
|
+
const focusableLink = el.tagName.toLowerCase() === 'a' && el.hasAttribute('href')
|
|
69
|
+
if (explicitRole !== '' && !(presentational && focusableLink)) return explicitRole
|
|
63
70
|
const tag = el.tagName.toLowerCase()
|
|
64
71
|
if (tag === 'input') return INPUT_ROLE[(el.getAttribute('type') || 'text').toLowerCase()] || 'textbox'
|
|
65
72
|
if (tag === 'a') return 'link'
|
|
@@ -68,10 +75,34 @@ export const ARIA_REF_SCRIPT = String.raw `
|
|
|
68
75
|
if (tag === 'textarea') return 'textarea'
|
|
69
76
|
if (tag === 'option') return 'option'
|
|
70
77
|
if (tag === 'summary') return 'summary'
|
|
78
|
+
// Implicit roles for the elements the accessibility tree annotates anyway.
|
|
79
|
+
// Without these the DOM side said "generic" for a <tr> while the tree said
|
|
80
|
+
// "row" (an actionable role), so the minted ref list and the tree disagreed
|
|
81
|
+
// and the whole page fell back (measured 2026-11-12: 6 of the 18 fallback
|
|
82
|
+
// MiniWoB pages were table/row tasks).
|
|
83
|
+
if (tag === 'tr') return 'row'
|
|
84
|
+
if (tag === 'td') return 'cell'
|
|
85
|
+
if (tag === 'th') return 'columnheader'
|
|
86
|
+
if (tag === 'li') return 'listitem'
|
|
87
|
+
if (tag === 'table') return 'table'
|
|
88
|
+
if (tag === 'ul' || tag === 'ol') return 'list'
|
|
89
|
+
if (tag === 'p') return 'paragraph'
|
|
90
|
+
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') return 'heading'
|
|
91
|
+
if (tag === 'img') return 'img'
|
|
71
92
|
return 'generic'
|
|
72
93
|
}
|
|
73
94
|
const isActionable = (el) => {
|
|
74
95
|
if (!(el instanceof Element)) return false
|
|
96
|
+
// An explicit role of presentation / none tells the accessibility tree to drop
|
|
97
|
+
// the element's own semantics — EXCEPT on a focusable link, where the spec says
|
|
98
|
+
// the role is ignored (see roleOf). Skipping those here would mint one ref too
|
|
99
|
+
// few and put the lists out of step (measured 2026-11-12: the MiniWoB tab strips
|
|
100
|
+
// wrap each tab in <a href role="presentation">; 4 pages fell back).
|
|
101
|
+
const explicit = el.getAttribute('role')
|
|
102
|
+
const explicitRole = explicit && explicit.trim() ? explicit.trim().toLowerCase() : ''
|
|
103
|
+
const presentational = explicitRole === 'presentation' || explicitRole === 'none'
|
|
104
|
+
const focusableLink = el.tagName.toLowerCase() === 'a' && el.hasAttribute('href')
|
|
105
|
+
if (presentational && !focusableLink) return false
|
|
75
106
|
const tag = el.tagName.toLowerCase()
|
|
76
107
|
if (TAGS.has(tag)) return true
|
|
77
108
|
const role = roleOf(el)
|
|
@@ -89,6 +120,17 @@ export const ARIA_REF_SCRIPT = String.raw `
|
|
|
89
120
|
if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true') return false
|
|
90
121
|
const style = getComputedStyle(el)
|
|
91
122
|
if (style.display === 'none' || style.visibility === 'hidden') return false
|
|
123
|
+
// <option> has no layout box of its own inside a closed <select>, yet the
|
|
124
|
+
// accessibility tree lists it — so it must keep its slot here, or the two
|
|
125
|
+
// lists drift (measured 2026-11-12: choose-list minted 2 refs while the tree
|
|
126
|
+
// had 7 actionable nodes). Its <select> is the visibility signal: options of
|
|
127
|
+
// a display:none select are absent from the tree too.
|
|
128
|
+
if (el.tagName.toLowerCase() === 'option') {
|
|
129
|
+
const select = el.closest('select')
|
|
130
|
+
if (select === null) return false
|
|
131
|
+
const selectStyle = getComputedStyle(select)
|
|
132
|
+
return selectStyle.display !== 'none' && selectStyle.visibility !== 'hidden'
|
|
133
|
+
}
|
|
92
134
|
// A 'display: contents' box has no rect of its own but lays its children
|
|
93
135
|
// out normally — it belongs to the tree, so it keeps its slot.
|
|
94
136
|
if (style.display === 'contents') return true
|
|
@@ -177,6 +219,29 @@ function readQuoted(text) {
|
|
|
177
219
|
* @param yaml - the ariaSnapshot text.
|
|
178
220
|
* @returns parsed roots plus any unreadable content lines.
|
|
179
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Playwright quotes a whole node line when the name itself contains a double quote:
|
|
224
|
+
* - 'tab "Section #1" [ref=e5]'
|
|
225
|
+
* - 'link "Tab #1" [ref=e7] [cursor=pointer]':
|
|
226
|
+
* Strip the outer single quotes (and the optional trailing ':' that marks children),
|
|
227
|
+
* then parse the inner text like any other line. Measured 2026-11-12: 8 of 130
|
|
228
|
+
* MiniWoB pages fell back to the legacy engine purely over this shape (tab strips,
|
|
229
|
+
* collapsible sections), while the pages themselves read perfectly.
|
|
230
|
+
*/
|
|
231
|
+
function unwrapQuotedNodeBody(body) {
|
|
232
|
+
if (body.length < 3 || body[0] !== "'")
|
|
233
|
+
return body;
|
|
234
|
+
const close = body.lastIndexOf("'");
|
|
235
|
+
if (close <= 0)
|
|
236
|
+
return body;
|
|
237
|
+
const inside = body.slice(1, close);
|
|
238
|
+
const after = body.slice(close + 1).trim();
|
|
239
|
+
// Anything else after the closing quote is not the shape we know; leave it to
|
|
240
|
+
// the normal path so an unknown line is reported rather than silently accepted.
|
|
241
|
+
if (after !== '' && after !== ':')
|
|
242
|
+
return body;
|
|
243
|
+
return inside;
|
|
244
|
+
}
|
|
180
245
|
export function parseAriaSnapshotWithStats(yaml) {
|
|
181
246
|
const roots = [];
|
|
182
247
|
const stack = [];
|
|
@@ -206,7 +271,7 @@ export function parseAriaSnapshotWithStats(yaml) {
|
|
|
206
271
|
continue;
|
|
207
272
|
}
|
|
208
273
|
const indent = Math.floor((m[1] ?? '').length / 2);
|
|
209
|
-
const body = m[2] ?? '';
|
|
274
|
+
const body = unwrapQuotedNodeBody(m[2] ?? '');
|
|
210
275
|
// Attribute child lines describe the node declared above: '- /url: /one',
|
|
211
276
|
// '- /placeholder: Search'. Playwright adds facts over time, and an attribute
|
|
212
277
|
// line carries no tree structure — so an unknown one is safe to ignore.
|
package/lib/swarm-intro.js
CHANGED
|
@@ -729,7 +729,7 @@ export async function showWelcome(page, config) {
|
|
|
729
729
|
return; // 页面还没准备好:留一张空白页也不影响任何事
|
|
730
730
|
}
|
|
731
731
|
const userChoice = runtimeState.getSwarm();
|
|
732
|
-
const enabled = userChoice ?? config?.swarm?.enabled ??
|
|
732
|
+
const enabled = userChoice ?? config?.swarm?.enabled ?? false;
|
|
733
733
|
if (!enabled)
|
|
734
734
|
return;
|
|
735
735
|
const tuning = runtimeState.getSwarmTuning();
|
|
@@ -756,7 +756,7 @@ export function willPlaySwarm(targetUrl, config, headless) {
|
|
|
756
756
|
if (host === '')
|
|
757
757
|
return false;
|
|
758
758
|
const userChoice = runtimeState.getSwarm();
|
|
759
|
-
const enabled = userChoice ?? config?.swarm?.enabled ??
|
|
759
|
+
const enabled = userChoice ?? config?.swarm?.enabled ?? false;
|
|
760
760
|
if (!enabled)
|
|
761
761
|
return false;
|
|
762
762
|
const tuning = runtimeState.getSwarmTuning();
|
|
@@ -783,9 +783,9 @@ export function maybePlaySwarm(page, config, headless) {
|
|
|
783
783
|
const host = new URL(url).hostname;
|
|
784
784
|
if (host === '')
|
|
785
785
|
return;
|
|
786
|
-
//
|
|
786
|
+
// 用户的开关优先;没表态就用配置里的默认值(出厂是关,与 config.ts 的 schema 一致)。
|
|
787
787
|
const userChoice = runtimeState.getSwarm();
|
|
788
|
-
const enabled = userChoice ?? config?.swarm?.enabled ??
|
|
788
|
+
const enabled = userChoice ?? config?.swarm?.enabled ?? false;
|
|
789
789
|
if (!enabled) {
|
|
790
790
|
// 关着也要把可能已提前启动的引擎撤掉:标记可能是上一次导航残留的,
|
|
791
791
|
// 不清就会白屏卡住(2026-11-09 复核 find B)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness, one plugin: 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; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,7 +65,9 @@
|
|
|
65
65
|
"format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
66
66
|
"format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
67
67
|
"prepublishOnly": "npm run build && npm run typecheck && npm test && npm run swarm:check && npm run verify && npm run preflight",
|
|
68
|
-
"preflight": "node scripts/preflight.mjs ."
|
|
68
|
+
"preflight": "node scripts/preflight.mjs .",
|
|
69
|
+
"test:toggle": "node --test tests/cross-repo/*.test.ts",
|
|
70
|
+
"coverage": "node scripts/coverage.mjs"
|
|
69
71
|
},
|
|
70
72
|
"dependencies": {
|
|
71
73
|
"@deepseek-ai/schemastery": "^3.18.4",
|