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.
Files changed (45) hide show
  1. package/CHANGELOG.md +486 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/SECURITY.md +114 -0
  5. package/cordis.patch.yml +66 -0
  6. package/lib/cdp-policy.d.ts +69 -0
  7. package/lib/cdp-policy.js +105 -0
  8. package/lib/compat.d.ts +37 -0
  9. package/lib/compat.js +31 -0
  10. package/lib/contract.d.ts +74 -0
  11. package/lib/contract.js +117 -0
  12. package/lib/errors.d.ts +25 -0
  13. package/lib/errors.js +21 -0
  14. package/lib/index.d.ts +20 -0
  15. package/lib/index.js +12 -0
  16. package/lib/injected.d.ts +35 -0
  17. package/lib/injected.js +350 -0
  18. package/lib/playwright.d.ts +647 -0
  19. package/lib/playwright.js +2515 -0
  20. package/lib/runtime-state.d.ts +45 -0
  21. package/lib/runtime-state.js +181 -0
  22. package/lib/service.d.ts +56 -0
  23. package/lib/service.js +96 -0
  24. package/lib/snapshot-aria.d.ts +121 -0
  25. package/lib/snapshot-aria.js +485 -0
  26. package/lib/snapshot-render.d.ts +28 -0
  27. package/lib/snapshot-render.js +100 -0
  28. package/lib/tool.d.ts +77 -0
  29. package/lib/tool.js +907 -0
  30. package/lib/types.d.ts +242 -0
  31. package/lib/types.js +5 -0
  32. package/locale/en.json +6 -0
  33. package/locale/zh.json +6 -0
  34. package/package.json +108 -0
  35. package/scripts/analysis/hl-compare.mjs +149 -0
  36. package/scripts/analysis/live-handoff-probe.mjs +97 -0
  37. package/scripts/analysis/needs-human-probe.mjs +83 -0
  38. package/scripts/analysis/repro-minimize.mjs +112 -0
  39. package/scripts/autocommit.ps1 +23 -0
  40. package/scripts/check-doc-links.py +112 -0
  41. package/scripts/doctor.mjs +144 -0
  42. package/scripts/dsh-browser-verify.mjs +475 -0
  43. package/scripts/measure-prompt-cost.mjs +57 -0
  44. package/scripts/prompt-cost.mjs +73 -0
  45. 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
+ }