dsh-browser-playwright-codex 0.5.1
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 +486 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/SECURITY.md +114 -0
- package/cordis.patch.yml +66 -0
- package/lib/cdp-policy.d.ts +69 -0
- package/lib/cdp-policy.js +105 -0
- package/lib/compat.d.ts +37 -0
- package/lib/compat.js +31 -0
- package/lib/contract.d.ts +74 -0
- package/lib/contract.js +117 -0
- package/lib/errors.d.ts +25 -0
- package/lib/errors.js +21 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +12 -0
- package/lib/injected.d.ts +35 -0
- package/lib/injected.js +350 -0
- package/lib/playwright.d.ts +647 -0
- package/lib/playwright.js +2515 -0
- package/lib/runtime-state.d.ts +45 -0
- package/lib/runtime-state.js +181 -0
- package/lib/service.d.ts +56 -0
- package/lib/service.js +96 -0
- package/lib/snapshot-aria.d.ts +121 -0
- package/lib/snapshot-aria.js +485 -0
- package/lib/snapshot-render.d.ts +28 -0
- package/lib/snapshot-render.js +100 -0
- package/lib/tool.d.ts +77 -0
- package/lib/tool.js +907 -0
- package/lib/types.d.ts +242 -0
- package/lib/types.js +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +108 -0
- package/scripts/analysis/hl-compare.mjs +149 -0
- package/scripts/analysis/live-handoff-probe.mjs +97 -0
- package/scripts/analysis/needs-human-probe.mjs +83 -0
- package/scripts/analysis/repro-minimize.mjs +112 -0
- package/scripts/autocommit.ps1 +23 -0
- package/scripts/check-doc-links.py +112 -0
- package/scripts/doctor.mjs +144 -0
- package/scripts/dsh-browser-verify.mjs +475 -0
- package/scripts/measure-prompt-cost.mjs +57 -0
- package/scripts/prompt-cost.mjs +73 -0
- package/scripts/shipped-gates.mjs +43 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aria semantic snapshot engine: uses Playwright's official
|
|
3
|
+
* locator.ariaSnapshot({ mode: 'ai' }) as the semantic tree source, then
|
|
4
|
+
* re-aligns refs onto the project's stable data-dsh-ref scheme.
|
|
5
|
+
*
|
|
6
|
+
* Why a ref-alignment layer: ariaSnapshot's `[ref=eN]` are Playwright-internal
|
|
7
|
+
* per-invocation handles — they are NOT locator-addressable and their numbering
|
|
8
|
+
* does not survive the project's cross-snapshot stability contract. This module
|
|
9
|
+
* therefore (1) walks the DOM to mint/keep the same e<docNonce><seq> refs the
|
|
10
|
+
* legacy engine uses, (2) parses the official YAML into a semantic tree, and
|
|
11
|
+
* (3) assigns the DOM-minted refs to actionable nodes in document order, so
|
|
12
|
+
* refLocator('[data-dsh-ref=...]') and REF_PATTERN keep working unchanged.
|
|
13
|
+
*
|
|
14
|
+
* The actionable set is intentionally role-based (the same role set the
|
|
15
|
+
* injected engine uses) so DOM-side ref assignment and tree-side ref alignment
|
|
16
|
+
* agree on ordering; elements that only tabindex/contenteditable would make
|
|
17
|
+
* actionable in the legacy engine are out of scope for this engine.
|
|
18
|
+
* @module dsh-browser-playwright-codex/snapshot-aria
|
|
19
|
+
*/
|
|
20
|
+
/** Roles that carry a ref (mirrors the legacy engine's INTERACTIVE_ROLES). */
|
|
21
|
+
const ACTIONABLE_ROLES = new Set([
|
|
22
|
+
'button',
|
|
23
|
+
'link',
|
|
24
|
+
'checkbox',
|
|
25
|
+
'radio',
|
|
26
|
+
'menuitem',
|
|
27
|
+
'menuitemcheckbox',
|
|
28
|
+
'menuitemradio',
|
|
29
|
+
'tab',
|
|
30
|
+
'switch',
|
|
31
|
+
'combobox',
|
|
32
|
+
'listbox',
|
|
33
|
+
'option',
|
|
34
|
+
'textbox',
|
|
35
|
+
'searchbox',
|
|
36
|
+
'slider',
|
|
37
|
+
'spinbutton',
|
|
38
|
+
'row',
|
|
39
|
+
'gridcell',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* Page-context script: walks the DOM in document order and mints/keeps
|
|
43
|
+
* data-dsh-ref on every actionable element (same rule as the legacy engine,
|
|
44
|
+
* minus the tabindex/contenteditable escape hatches). Returns the refs in
|
|
45
|
+
* document order plus the per-document nonce, so the alignment layer can pair
|
|
46
|
+
* them with the aria tree's actionable nodes depth-first.
|
|
47
|
+
*/
|
|
48
|
+
export const ARIA_REF_SCRIPT = String.raw `
|
|
49
|
+
;(() => {
|
|
50
|
+
// Versioned install guard: a page that already ran an older revision of this
|
|
51
|
+
// script keeps the old closure (and its ref list) otherwise, and a stale
|
|
52
|
+
// closure would silently skip the role verification below.
|
|
53
|
+
if (window.__dshAriaRefsV === 2) return window.__dshAriaRefs()
|
|
54
|
+
window.__dshAriaRefsV = 2
|
|
55
|
+
const INTERACTIVE = new Set(['button','link','checkbox','radio','menuitem','menuitemcheckbox','menuitemradio','tab','switch','combobox','listbox','option','textbox','searchbox','slider','spinbutton','row','gridcell'])
|
|
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' }
|
|
58
|
+
const DOC_NONCE = String(Math.floor(Math.random() * 1e9)).padStart(9, '0')
|
|
59
|
+
let refSeq = 0
|
|
60
|
+
const roleOf = (el) => {
|
|
61
|
+
const explicit = el.getAttribute && el.getAttribute('role')
|
|
62
|
+
if (explicit && explicit.trim()) return explicit.trim().toLowerCase()
|
|
63
|
+
const tag = el.tagName.toLowerCase()
|
|
64
|
+
if (tag === 'input') return INPUT_ROLE[(el.getAttribute('type') || 'text').toLowerCase()] || 'textbox'
|
|
65
|
+
if (tag === 'a') return 'link'
|
|
66
|
+
if (tag === 'button') return 'button'
|
|
67
|
+
if (tag === 'select') return 'combobox'
|
|
68
|
+
if (tag === 'textarea') return 'textarea'
|
|
69
|
+
if (tag === 'option') return 'option'
|
|
70
|
+
if (tag === 'summary') return 'summary'
|
|
71
|
+
return 'generic'
|
|
72
|
+
}
|
|
73
|
+
const isActionable = (el) => {
|
|
74
|
+
if (!(el instanceof Element)) return false
|
|
75
|
+
const tag = el.tagName.toLowerCase()
|
|
76
|
+
if (TAGS.has(tag)) return true
|
|
77
|
+
const role = roleOf(el)
|
|
78
|
+
if (tag === 'input' && role !== 'null') return true
|
|
79
|
+
return INTERACTIVE.has(role)
|
|
80
|
+
}
|
|
81
|
+
// Visibility must agree with the accessibility tree, or the two lists drift.
|
|
82
|
+
// The tree only contains what a screen reader can reach, so an actionable
|
|
83
|
+
// element the tree cannot see (a hidden menu copy, a 0x0 leftover from a
|
|
84
|
+
// re-render) must not consume a ref slot here: one extra element ahead of a
|
|
85
|
+
// node shifts every later ref onto the wrong element (measured 2026-10-02 on
|
|
86
|
+
// dsh-plugin.market: 7 hidden nav copies moved the textbox's ref 12 slots).
|
|
87
|
+
const isVisible = (el) => {
|
|
88
|
+
try {
|
|
89
|
+
if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true') return false
|
|
90
|
+
const style = getComputedStyle(el)
|
|
91
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false
|
|
92
|
+
// A 'display: contents' box has no rect of its own but lays its children
|
|
93
|
+
// out normally — it belongs to the tree, so it keeps its slot.
|
|
94
|
+
if (style.display === 'contents') return true
|
|
95
|
+
const rect = el.getBoundingClientRect()
|
|
96
|
+
return rect.width > 0 || rect.height > 0
|
|
97
|
+
} catch (_) {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const assignRef = (el) => {
|
|
102
|
+
const existing = el.getAttribute('data-dsh-ref')
|
|
103
|
+
if (existing !== null) return existing
|
|
104
|
+
const ref = 'e' + DOC_NONCE + String(++refSeq)
|
|
105
|
+
try { el.setAttribute('data-dsh-ref', ref) } catch (_) { /* foreign objects */ }
|
|
106
|
+
return ref
|
|
107
|
+
}
|
|
108
|
+
window.__dshAriaRefs = () => {
|
|
109
|
+
const refs = []
|
|
110
|
+
// Role of each ref, in the same order, so the alignment layer can verify
|
|
111
|
+
// that ref k really belongs to the node it is about to be pasted onto.
|
|
112
|
+
const roles = []
|
|
113
|
+
const walk = (root) => {
|
|
114
|
+
for (const el of root.children || []) {
|
|
115
|
+
if (el.tagName) {
|
|
116
|
+
const tag = el.tagName.toLowerCase()
|
|
117
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue
|
|
118
|
+
if (isActionable(el) && isVisible(el)) {
|
|
119
|
+
refs.push(assignRef(el))
|
|
120
|
+
roles.push(roleOf(el))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (el.contentDocument && el.contentDocument.body) {
|
|
124
|
+
walk(el.contentDocument.body)
|
|
125
|
+
} else {
|
|
126
|
+
walk(el)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (document.body) walk(document.body)
|
|
131
|
+
return { refs, roles, nonce: DOC_NONCE }
|
|
132
|
+
}
|
|
133
|
+
return window.__dshAriaRefs()
|
|
134
|
+
})()
|
|
135
|
+
`;
|
|
136
|
+
const LINE_RE = /^(\s*)-\s+(.*)$/;
|
|
137
|
+
const ROLE_RE = /^([A-Za-z][\w-]*)\s*/;
|
|
138
|
+
const FLAG_RE = /\[([^\]]*)\]/g;
|
|
139
|
+
/** Read a leading `"quoted"` name, honoring backslash escapes. */
|
|
140
|
+
function readQuoted(text) {
|
|
141
|
+
if (!text.startsWith('"'))
|
|
142
|
+
return undefined;
|
|
143
|
+
let out = '';
|
|
144
|
+
let i = 1;
|
|
145
|
+
while (i < text.length) {
|
|
146
|
+
const ch = text[i];
|
|
147
|
+
if (ch === '\\') {
|
|
148
|
+
const next = text[i + 1];
|
|
149
|
+
out += next === '"' ? '"' : next === '\\' ? '\\' : next === 'n' ? '\n' : (next ?? '');
|
|
150
|
+
i += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === '"')
|
|
154
|
+
return { name: out, rest: text.slice(i + 1) };
|
|
155
|
+
out += ch;
|
|
156
|
+
i += 1;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Parse Playwright's official ariaSnapshot YAML into a semantic node tree.
|
|
162
|
+
*
|
|
163
|
+
* The real format (verified against playwright-core 1.62, 2026-09-30) is:
|
|
164
|
+
*
|
|
165
|
+
* - generic [active] [ref=e1]: ← trailing ':' = the node has children
|
|
166
|
+
* - link "One" [ref=e6] [cursor=pointer]:
|
|
167
|
+
* - /url: /one ← href rides a deeper child line
|
|
168
|
+
* - text: Name ← text content uses 'text: <value>'
|
|
169
|
+
*
|
|
170
|
+
* The first version of this parser assumed a hand-rolled dialect
|
|
171
|
+
* (`- role "name" [flags] -> href`, nothing after the flags) that real output
|
|
172
|
+
* never produces. Every container line ends in ':', so that regex matched none
|
|
173
|
+
* of them: the whole hierarchy was dropped, links and their hrefs vanished, and
|
|
174
|
+
* `captureAriaSnapshot` still reported success. Unreadable lines are now
|
|
175
|
+
* collected in `unparsed` so the capture layer can refuse the tree and let the
|
|
176
|
+
* provider fall back to the legacy engine.
|
|
177
|
+
* @param yaml - the ariaSnapshot text.
|
|
178
|
+
* @returns parsed roots plus any unreadable content lines.
|
|
179
|
+
*/
|
|
180
|
+
export function parseAriaSnapshotWithStats(yaml) {
|
|
181
|
+
const roots = [];
|
|
182
|
+
const stack = [];
|
|
183
|
+
const unparsed = [];
|
|
184
|
+
const push = (indent, node) => {
|
|
185
|
+
while (stack.length > 0) {
|
|
186
|
+
const top = stack[stack.length - 1];
|
|
187
|
+
if (top === undefined || top.indent < indent)
|
|
188
|
+
break;
|
|
189
|
+
stack.pop();
|
|
190
|
+
}
|
|
191
|
+
const parent = stack[stack.length - 1];
|
|
192
|
+
if (parent === undefined)
|
|
193
|
+
roots.push(node);
|
|
194
|
+
else
|
|
195
|
+
parent.node.children.push(node);
|
|
196
|
+
stack.push({ indent, node });
|
|
197
|
+
};
|
|
198
|
+
for (const raw of yaml.split(/\r?\n/)) {
|
|
199
|
+
const line = raw.replace(/\s+$/, '');
|
|
200
|
+
const trimmed = line.trim();
|
|
201
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
202
|
+
continue;
|
|
203
|
+
const m = LINE_RE.exec(line);
|
|
204
|
+
if (m === null) {
|
|
205
|
+
unparsed.push(trimmed);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const indent = Math.floor((m[1] ?? '').length / 2);
|
|
209
|
+
const body = m[2] ?? '';
|
|
210
|
+
// Attribute child lines describe the node declared above: '- /url: /one',
|
|
211
|
+
// '- /placeholder: Search'. Playwright adds facts over time, and an attribute
|
|
212
|
+
// line carries no tree structure — so an unknown one is safe to ignore.
|
|
213
|
+
// Refusing the whole tree over one is what silently turned EVERY page with a
|
|
214
|
+
// placeholder input into a legacy fallback (measured 2026-10-02: the mode
|
|
215
|
+
// selector said smart mode while the engine never got to run).
|
|
216
|
+
if (body.startsWith('/')) {
|
|
217
|
+
const owner = stack[stack.length - 1];
|
|
218
|
+
const colon = body.indexOf(':');
|
|
219
|
+
const key = (colon === -1 ? body.slice(1) : body.slice(1, colon)).trim();
|
|
220
|
+
const value = colon === -1 ? '' : body.slice(colon + 1).trim();
|
|
221
|
+
if (owner === undefined || (key === 'url' && value === '')) {
|
|
222
|
+
unparsed.push(trimmed);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// `javascript:` targets carry no navigable URL, so they are not exposed.
|
|
226
|
+
if (key === 'url') {
|
|
227
|
+
if (!value.startsWith('javascript:'))
|
|
228
|
+
owner.node.href = value;
|
|
229
|
+
}
|
|
230
|
+
else if (key === 'placeholder') {
|
|
231
|
+
owner.node.placeholder = value;
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// Text-content line: '- text: <value>'.
|
|
236
|
+
if (body.startsWith('text:')) {
|
|
237
|
+
push(indent, { role: 'text', name: body.slice('text:'.length).trim(), children: [] });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const roleMatch = ROLE_RE.exec(body);
|
|
241
|
+
if (roleMatch === null) {
|
|
242
|
+
unparsed.push(trimmed);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const role = (roleMatch[1] ?? '').toLowerCase();
|
|
246
|
+
let rest = body.slice(roleMatch[0].length);
|
|
247
|
+
const node = { role, name: '', children: [] };
|
|
248
|
+
// Optional quoted name, else an inline ': value' form.
|
|
249
|
+
const quoted = readQuoted(rest);
|
|
250
|
+
if (quoted !== undefined) {
|
|
251
|
+
node.name = quoted.name;
|
|
252
|
+
rest = quoted.rest;
|
|
253
|
+
}
|
|
254
|
+
else if (rest.startsWith(':')) {
|
|
255
|
+
node.name = rest.slice(1).trim();
|
|
256
|
+
rest = '';
|
|
257
|
+
}
|
|
258
|
+
// Flags may appear in any position after the name; the trailing ':' that
|
|
259
|
+
// marks children is simply ignored.
|
|
260
|
+
for (const flagMatch of rest.matchAll(FLAG_RE)) {
|
|
261
|
+
for (const flag of (flagMatch[1] ?? '').split(',')) {
|
|
262
|
+
const f = flag.trim();
|
|
263
|
+
if (f === '')
|
|
264
|
+
continue;
|
|
265
|
+
const eq = f.indexOf('=');
|
|
266
|
+
const key = eq === -1 ? f : f.slice(0, eq);
|
|
267
|
+
const value = eq === -1 ? '' : f.slice(eq + 1);
|
|
268
|
+
if (key === 'ref')
|
|
269
|
+
node.ariaRef = value;
|
|
270
|
+
else if (key === 'level')
|
|
271
|
+
node.level = Number(value);
|
|
272
|
+
else if (key === 'checked')
|
|
273
|
+
node.checked = true;
|
|
274
|
+
else if (key === 'selected')
|
|
275
|
+
node.selected = true;
|
|
276
|
+
else if (key === 'disabled')
|
|
277
|
+
node.disabled = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Inline name AFTER the flags: '- paragraph [ref=e5]: (未点)'. The trailing ':'
|
|
281
|
+
// normally marks children, but whatever follows it is the node's own text.
|
|
282
|
+
// Ignoring it lost page content (measured 2026-10-02: in smart mode every
|
|
283
|
+
// paragraph rendered nameless, and nameless childless nodes are pruned — the
|
|
284
|
+
// text simply never reached the model). Only text after the last ']' counts,
|
|
285
|
+
// so a colon inside a flag value can never be mistaken for a name.
|
|
286
|
+
if (node.name === '') {
|
|
287
|
+
const lastBracket = rest.lastIndexOf(']');
|
|
288
|
+
const tail = (lastBracket === -1 ? rest : rest.slice(lastBracket + 1)).trim();
|
|
289
|
+
if (tail.startsWith(':')) {
|
|
290
|
+
const inline = tail.slice(1).trim();
|
|
291
|
+
if (inline !== '')
|
|
292
|
+
node.name = inline;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
push(indent, node);
|
|
296
|
+
}
|
|
297
|
+
return { roots: roots, unparsed };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Parse Playwright's ariaSnapshot YAML into a semantic node tree.
|
|
301
|
+
* @param yaml - the ariaSnapshot text.
|
|
302
|
+
* @returns the parsed root nodes (see {@link parseAriaSnapshotWithStats} for
|
|
303
|
+
* the unreadable-line report this is a thin wrapper over).
|
|
304
|
+
*/
|
|
305
|
+
export function parseAriaSnapshot(yaml) {
|
|
306
|
+
return parseAriaSnapshotWithStats(yaml).roots;
|
|
307
|
+
}
|
|
308
|
+
/** Whether an aria role carries a ref (must match the DOM-side assignment). */
|
|
309
|
+
export function isActionableRole(role) {
|
|
310
|
+
return ACTIONABLE_ROLES.has(role);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Role names the DOM-side walker and the accessibility tree spell differently.
|
|
314
|
+
* Everything the walker reports must compare equal to the tree's spelling, or
|
|
315
|
+
* the alignment below would refuse perfectly good trees.
|
|
316
|
+
*/
|
|
317
|
+
const ROLE_ALIAS = {
|
|
318
|
+
textarea: 'textbox',
|
|
319
|
+
summary: 'button',
|
|
320
|
+
image: 'button',
|
|
321
|
+
};
|
|
322
|
+
function normalizeRole(role) {
|
|
323
|
+
return ROLE_ALIAS[role] ?? role;
|
|
324
|
+
}
|
|
325
|
+
function convertAriaNode(node, opts, refs, roles, state) {
|
|
326
|
+
const actionable = isActionableRole(node.role);
|
|
327
|
+
if (opts.interactiveOnly === true && !actionable) {
|
|
328
|
+
const kids = [];
|
|
329
|
+
for (const child of node.children) {
|
|
330
|
+
const sub = convertAriaNode(child, opts, refs, roles, state);
|
|
331
|
+
if (sub)
|
|
332
|
+
kids.push(sub);
|
|
333
|
+
}
|
|
334
|
+
return kids.length ? { role: 'generic', name: '', children: kids } : null;
|
|
335
|
+
}
|
|
336
|
+
state.count += 1;
|
|
337
|
+
if (state.count > opts.maxNodes) {
|
|
338
|
+
state.truncated = true;
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
// Name fallback: an input Playwright names by its placeholder (`/placeholder:`)
|
|
342
|
+
// must read the same here as it does in the legacy walker, or the same page
|
|
343
|
+
// gets two different trees depending on the mode.
|
|
344
|
+
const rawName = node.name !== '' ? node.name : (node.placeholder ?? '');
|
|
345
|
+
const out = {
|
|
346
|
+
role: node.role,
|
|
347
|
+
name: rawName.length > opts.maxNameLength ? rawName.slice(0, opts.maxNameLength) + '…' : rawName,
|
|
348
|
+
children: [],
|
|
349
|
+
};
|
|
350
|
+
if (actionable) {
|
|
351
|
+
if (state.refIndex >= refs.length) {
|
|
352
|
+
// More actionable nodes in the tree than ref slots minted in the DOM: the
|
|
353
|
+
// two sequences are not the same list, so nothing below can be paired.
|
|
354
|
+
state.misaligned = true;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const r = refs[state.refIndex];
|
|
358
|
+
const domRole = roles?.[state.refIndex];
|
|
359
|
+
// Identity check. Positional pairing alone is a guess: one element the
|
|
360
|
+
// tree cannot see (a hidden menu copy, a 0x0 re-render leftover) shifts
|
|
361
|
+
// every later ref onto a different element. Comparing the walker's role
|
|
362
|
+
// with the node's role catches exactly that, and a mismatch means we must
|
|
363
|
+
// not hand the model a ref at all.
|
|
364
|
+
if (roles !== undefined && domRole !== undefined && normalizeRole(domRole) !== normalizeRole(node.role)) {
|
|
365
|
+
state.misaligned = true;
|
|
366
|
+
}
|
|
367
|
+
else if (r !== undefined) {
|
|
368
|
+
out.ref = r;
|
|
369
|
+
state.refIndex += 1;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (node.level !== undefined)
|
|
374
|
+
out.level = node.level;
|
|
375
|
+
if (node.checked === true)
|
|
376
|
+
out.checked = true;
|
|
377
|
+
if (node.selected === true)
|
|
378
|
+
out.selected = true;
|
|
379
|
+
if (node.disabled === true)
|
|
380
|
+
out.disabled = true;
|
|
381
|
+
if (node.href !== undefined)
|
|
382
|
+
out.href = node.href;
|
|
383
|
+
for (const child of node.children) {
|
|
384
|
+
const sub = convertAriaNode(child, opts, refs, roles, state);
|
|
385
|
+
if (sub)
|
|
386
|
+
out.children.push(sub);
|
|
387
|
+
}
|
|
388
|
+
if (!actionable &&
|
|
389
|
+
out.name === '' &&
|
|
390
|
+
out.children.length === 0 &&
|
|
391
|
+
out.checked === undefined &&
|
|
392
|
+
out.selected === undefined &&
|
|
393
|
+
out.disabled === undefined) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Build BrowserNode[] from the parsed aria tree, aligning DOM-minted refs onto
|
|
400
|
+
* actionable nodes in depth-first order, with node/name caps and truncation.
|
|
401
|
+
* @param nodes - parsed aria tree roots.
|
|
402
|
+
* @param opts - bounding options.
|
|
403
|
+
* @param refs - data-dsh-refs minted by the page-side walk, in document order.
|
|
404
|
+
* @returns the converted tree plus the truncation flag.
|
|
405
|
+
*/
|
|
406
|
+
export function ariaTreeToBrowserNodes(nodes, opts, refs, roles) {
|
|
407
|
+
const state = { count: 0, truncated: false, refIndex: 0, misaligned: false };
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const node of nodes) {
|
|
410
|
+
const sub = convertAriaNode(node, opts, refs, roles, state);
|
|
411
|
+
if (sub)
|
|
412
|
+
out.push(sub);
|
|
413
|
+
}
|
|
414
|
+
// Leftover ref slots mean the DOM minted elements the tree never consumed: the
|
|
415
|
+
// two lists are not the same list, so the pairing above cannot be trusted.
|
|
416
|
+
// Truncation is the one legitimate reason to stop consuming early.
|
|
417
|
+
if (roles !== undefined && !state.truncated && state.refIndex !== refs.length)
|
|
418
|
+
state.misaligned = true;
|
|
419
|
+
return { nodes: out, truncated: state.truncated, totalRefs: refs.length, misaligned: state.misaligned };
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Capture one aria snapshot from a live page: official ariaSnapshot(mode:'ai')
|
|
423
|
+
* for the semantic tree, DOM-side ref minting for stable refs, then alignment.
|
|
424
|
+
* Returns the same shape the legacy capture produces, so playwright.ts can
|
|
425
|
+
* dispatch transparently.
|
|
426
|
+
*
|
|
427
|
+
* `missing: true` is the honest-failure contract: the official call threw, the
|
|
428
|
+
* YAML was empty, or the text carried lines this parser cannot read. The caller
|
|
429
|
+
* must treat it as "engine unavailable" (the provider falls back to the legacy
|
|
430
|
+
* DOM walker) and must never forward the empty node list to the model.
|
|
431
|
+
*/
|
|
432
|
+
export async function captureAriaSnapshot(page, opts) {
|
|
433
|
+
const o = {
|
|
434
|
+
interactiveOnly: opts?.interactiveOnly === true,
|
|
435
|
+
maxNodes: opts?.maxNodes ?? 500,
|
|
436
|
+
maxNameLength: opts?.maxNameLength ?? 120,
|
|
437
|
+
maxTextLength: opts?.maxTextLength ?? 300,
|
|
438
|
+
};
|
|
439
|
+
let aria = '';
|
|
440
|
+
try {
|
|
441
|
+
aria = await page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
445
|
+
}
|
|
446
|
+
if (aria === '')
|
|
447
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
448
|
+
let assigned;
|
|
449
|
+
try {
|
|
450
|
+
assigned = await page.evaluate(ARIA_REF_SCRIPT);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
454
|
+
}
|
|
455
|
+
// No roles means no way to verify the pairing — refuse rather than guess.
|
|
456
|
+
if (!assigned || !Array.isArray(assigned.refs) || !Array.isArray(assigned.roles)) {
|
|
457
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
458
|
+
}
|
|
459
|
+
const parsed = parseAriaSnapshotWithStats(aria);
|
|
460
|
+
// Format-drift guard. A line this parser cannot read means the tree is not
|
|
461
|
+
// trustworthy — that is exactly how the first version silently dropped every
|
|
462
|
+
// container line (and all hrefs) while still reporting success. Refuse the
|
|
463
|
+
// capture instead: the provider then falls back to the legacy engine.
|
|
464
|
+
if (parsed.unparsed.length > 0) {
|
|
465
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
466
|
+
}
|
|
467
|
+
const built = ariaTreeToBrowserNodes(parsed.roots, o, assigned.refs, assigned.roles);
|
|
468
|
+
// Ref-identity guard. The alignment pairs two independently produced lists
|
|
469
|
+
// (DOM walk order vs accessibility-tree order); when they disagree the refs
|
|
470
|
+
// would name one element and resolve to another, and a click on such a ref
|
|
471
|
+
// could land on a different element without any error. Measured 2026-10-02:
|
|
472
|
+
// a page with 7 hidden nav copies shifted every ref after them, so the model
|
|
473
|
+
// was told the search box was the ref of an invisible link. Refuse the whole
|
|
474
|
+
// capture instead — the legacy engine mints refs while it walks, so it cannot
|
|
475
|
+
// drift.
|
|
476
|
+
if (built.misaligned) {
|
|
477
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
478
|
+
}
|
|
479
|
+
// A tree with no nodes while the DOM minted refs means the semantic tree lost
|
|
480
|
+
// everything actionable: refuse it rather than hand the model an empty page.
|
|
481
|
+
if (built.nodes.length === 0 && assigned.refs.length > 0) {
|
|
482
|
+
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
483
|
+
}
|
|
484
|
+
return built;
|
|
485
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure snapshot rendering: model-facing tree text. No I/O, no clock — these
|
|
3
|
+
* functions run on live calls and on session replay alike.
|
|
4
|
+
* @module dsh-browser-playwright-codex/snapshot-render
|
|
5
|
+
*/
|
|
6
|
+
import type { BrowserNode, BrowserSnapshot, SnapshotDiff } from './types.ts';
|
|
7
|
+
/**
|
|
8
|
+
* Render a node tree as indented lines, one element per line.
|
|
9
|
+
* @param nodes - the snapshot's root nodes.
|
|
10
|
+
* @returns the rendered tree text.
|
|
11
|
+
*/
|
|
12
|
+
export declare function renderSnapshotTree(nodes: readonly BrowserNode[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Render a complete snapshot: header facts plus the tree.
|
|
15
|
+
* @param snapshot - the bounded accessibility snapshot.
|
|
16
|
+
* @returns the model-facing snapshot text.
|
|
17
|
+
*/
|
|
18
|
+
export declare function renderSnapshot(snapshot: BrowserSnapshot): string;
|
|
19
|
+
/**
|
|
20
|
+
* Render an incremental snapshot diff as model-facing lines: `+` for added,
|
|
21
|
+
* `-` for removed, `~` for changed, each line carrying its nearest ancestor
|
|
22
|
+
* ref so the model keeps tree context without a full re-render. A navigation
|
|
23
|
+
* reset renders as a marker line: the delta is void and the full snapshot
|
|
24
|
+
* that follows is authoritative.
|
|
25
|
+
* @param diff - the incremental delta produced by the provider.
|
|
26
|
+
* @returns the rendered diff text.
|
|
27
|
+
*/
|
|
28
|
+
export declare function renderSnapshotDiff(diff: SnapshotDiff): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure snapshot rendering: model-facing tree text. No I/O, no clock — these
|
|
3
|
+
* functions run on live calls and on session replay alike.
|
|
4
|
+
* @module dsh-browser-playwright-codex/snapshot-render
|
|
5
|
+
*/
|
|
6
|
+
/** Render one node line into the accumulating line list. */
|
|
7
|
+
function renderNode(node, depth, lines) {
|
|
8
|
+
const indent = ' '.repeat(depth);
|
|
9
|
+
let line = indent + '- ' + node.role;
|
|
10
|
+
if (node.name !== '')
|
|
11
|
+
line += ' "' + node.name + '"';
|
|
12
|
+
const flags = [];
|
|
13
|
+
if (node.level !== undefined)
|
|
14
|
+
flags.push('level=' + String(node.level));
|
|
15
|
+
if (node.checked === true)
|
|
16
|
+
flags.push('checked');
|
|
17
|
+
if (node.selected === true)
|
|
18
|
+
flags.push('selected');
|
|
19
|
+
if (node.disabled === true)
|
|
20
|
+
flags.push('disabled');
|
|
21
|
+
if (node.ref !== undefined)
|
|
22
|
+
flags.push('ref=' + node.ref);
|
|
23
|
+
if (flags.length > 0)
|
|
24
|
+
line += ' [' + flags.join(', ') + ']';
|
|
25
|
+
if (node.href !== undefined)
|
|
26
|
+
line += ' -> ' + node.href;
|
|
27
|
+
lines.push(line);
|
|
28
|
+
for (const child of node.children)
|
|
29
|
+
renderNode(child, depth + 1, lines);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Render a node tree as indented lines, one element per line.
|
|
33
|
+
* @param nodes - the snapshot's root nodes.
|
|
34
|
+
* @returns the rendered tree text.
|
|
35
|
+
*/
|
|
36
|
+
export function renderSnapshotTree(nodes) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const node of nodes)
|
|
39
|
+
renderNode(node, 0, lines);
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Render a complete snapshot: header facts plus the tree.
|
|
44
|
+
* @param snapshot - the bounded accessibility snapshot.
|
|
45
|
+
* @returns the model-facing snapshot text.
|
|
46
|
+
*/
|
|
47
|
+
export function renderSnapshot(snapshot) {
|
|
48
|
+
const tree = renderSnapshotTree(snapshot.nodes);
|
|
49
|
+
const header = 'URL: ' +
|
|
50
|
+
snapshot.url +
|
|
51
|
+
'\n' +
|
|
52
|
+
'Title: ' +
|
|
53
|
+
snapshot.title +
|
|
54
|
+
'\n' +
|
|
55
|
+
'Refs: ' +
|
|
56
|
+
String(snapshot.totalRefs) +
|
|
57
|
+
(snapshot.truncated ? ' (truncated)' : '');
|
|
58
|
+
return header + '\n\n' + (tree === '' ? '(no visible elements)' : tree);
|
|
59
|
+
}
|
|
60
|
+
/** Render one diff entry line: +/-/~ prefix plus role, name, flags, parent chain. */
|
|
61
|
+
function renderDiffEntry(prefix, entry) {
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (entry.role !== undefined)
|
|
64
|
+
parts.push(entry.role);
|
|
65
|
+
if (entry.name !== undefined && entry.name !== '')
|
|
66
|
+
parts.push('"' + entry.name + '"');
|
|
67
|
+
if (entry.flags !== undefined && entry.flags.length > 0)
|
|
68
|
+
parts.push('[' + entry.flags.join(', ') + ']');
|
|
69
|
+
if (entry.flagsDelta !== undefined && entry.flagsDelta.length > 0)
|
|
70
|
+
parts.push('[delta: ' + entry.flagsDelta.join(', ') + ']');
|
|
71
|
+
parts.push('ref=' + entry.ref);
|
|
72
|
+
if (entry.parentRef !== undefined)
|
|
73
|
+
parts.push('parent=' + entry.parentRef);
|
|
74
|
+
return prefix + ' ' + parts.join(' ');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Render an incremental snapshot diff as model-facing lines: `+` for added,
|
|
78
|
+
* `-` for removed, `~` for changed, each line carrying its nearest ancestor
|
|
79
|
+
* ref so the model keeps tree context without a full re-render. A navigation
|
|
80
|
+
* reset renders as a marker line: the delta is void and the full snapshot
|
|
81
|
+
* that follows is authoritative.
|
|
82
|
+
* @param diff - the incremental delta produced by the provider.
|
|
83
|
+
* @returns the rendered diff text.
|
|
84
|
+
*/
|
|
85
|
+
export function renderSnapshotDiff(diff) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
if (diff.navigationReset) {
|
|
88
|
+
lines.push('(navigation reset: full snapshot follows)');
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
for (const entry of diff.added)
|
|
92
|
+
lines.push(renderDiffEntry('+', entry));
|
|
93
|
+
for (const ref of diff.removed)
|
|
94
|
+
lines.push('- ' + ref);
|
|
95
|
+
for (const entry of diff.changed)
|
|
96
|
+
lines.push(renderDiffEntry('~', entry));
|
|
97
|
+
}
|
|
98
|
+
lines.push('same: ' + String(diff.same));
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|