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,350 @@
1
+ /**
2
+ * The browser-side snapshot script. Installed with page.addInitScript and
3
+ * invoked as window.__dshSnapshot(opts). The returned value crosses the
4
+ * evaluate boundary as plain JSON.
5
+ */
6
+ export const SNAPSHOT_SCRIPT = String.raw `
7
+ ;(() => {
8
+ if (window.__dshSnapshot) return // one install per document
9
+ // Refs are stable for an element's whole lifetime and unique across the
10
+ // session: the per-document nonce separates documents (navigations), and
11
+ // the per-document sequence never reuses a number. Re-rendered elements
12
+ // are new nodes, so they mint new refs — a stale ref can never silently
13
+ // match a different element.
14
+ const DOC_NONCE = String(Math.floor(Math.random() * 1e9)).padStart(9, '0')
15
+ let refSeq = 0
16
+
17
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'meta', 'link', 'head', 'br', 'hr', 'svg'])
18
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'checkbox', 'radio', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'combobox', 'listbox', 'option', 'textbox', 'searchbox', 'slider', 'spinbutton', 'row', 'gridcell'])
19
+ const ROLE_BY_TAG = {
20
+ a: 'link', button: 'button', select: 'combobox', textarea: 'textarea', img: 'img',
21
+ nav: 'navigation', main: 'main', footer: 'contentinfo', header: 'banner',
22
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
23
+ ul: 'list', ol: 'list', li: 'listitem', option: 'option', table: 'table', tr: 'row', td: 'cell',
24
+ th: 'columnheader', form: 'form', dialog: 'dialog', article: 'article',
25
+ aside: 'complementary', figure: 'figure', iframe: 'iframe', summary: 'summary',
26
+ section: 'region', p: 'paragraph',
27
+ }
28
+ const INPUT_ROLE = { checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', reset: 'button', image: 'button', range: 'slider', search: 'searchbox', hidden: null }
29
+ /** input types that label themselves with their value instead of their text. */
30
+ const INPUT_BUTTON_TYPES = new Set(['button', 'submit', 'reset'])
31
+ // Interactive roles whose DESCENDANTS are content, not a label: a <select>'s
32
+ // options or a table row's cells must not be pasted together into the name.
33
+ const CONTENT_ROLES = new Set(['combobox', 'listbox', 'row', 'gridcell'])
34
+
35
+ function roleOf(el) {
36
+ const explicit = el.getAttribute('role')
37
+ if (explicit && explicit.trim()) return explicit.trim().toLowerCase()
38
+ const tag = el.tagName.toLowerCase()
39
+ if (tag === 'input') return INPUT_ROLE[(el.getAttribute('type') || 'text').toLowerCase()] || 'textbox'
40
+ return ROLE_BY_TAG[tag] || 'generic'
41
+ }
42
+
43
+ function isVisible(el) {
44
+ if (!(el instanceof Element)) return false
45
+ if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true') return false
46
+ const style = getComputedStyle(el)
47
+ if (style.display === 'none' || style.visibility === 'hidden') return false
48
+ // A 'display: contents' box has no rect, but its children lay out
49
+ // normally: keep it, or the whole subtree is pruned with it.
50
+ if (style.display === 'contents') return true
51
+ const rect = el.getBoundingClientRect()
52
+ return rect.width > 0 || rect.height > 0
53
+ }
54
+
55
+ function isActionable(el, role) {
56
+ if (el.getAttribute('data-dsh-force-ref') !== null) return true
57
+ const tag = el.tagName.toLowerCase()
58
+ if (tag === 'a' || tag === 'button' || tag === 'select' || tag === 'textarea' || tag === 'option' || tag === 'summary') return true
59
+ if (tag === 'input' && role !== null) return true
60
+ if (INTERACTIVE_ROLES.has(role)) return true
61
+ if (el.hasAttribute('tabindex') || el.hasAttribute('contenteditable')) return true
62
+ return false
63
+ }
64
+
65
+ function clip(text, max) {
66
+ return text.length > max ? text.slice(0, max) + '…' : text
67
+ }
68
+
69
+ function ownText(el, maxText) {
70
+ let out = ''
71
+ for (const node of el.childNodes) {
72
+ if (node.nodeType === 3) out += node.textContent
73
+ else if (node.nodeType === 1 && node.tagName.toLowerCase() === 'br') out += ' '
74
+ }
75
+ const flat = out.replace(/\s+/g, ' ').trim()
76
+ return flat.length > maxText ? flat.slice(0, maxText) + '…' : flat
77
+ }
78
+
79
+ function nameOf(el, maxName, maxText, actionable) {
80
+ const aria = el.getAttribute('aria-label')
81
+ if (aria && aria.trim()) return clip(aria.trim(), maxName)
82
+ if (el.id) {
83
+ try {
84
+ const labelEl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]')
85
+ if (labelEl) {
86
+ const t = (labelEl.textContent || '').replace(/\s+/g, ' ').trim()
87
+ if (t) return clip(t, maxName)
88
+ }
89
+ } catch (_) { /* invalid id selector */ }
90
+ }
91
+ const labelEl = el.closest('label')
92
+ if (labelEl) {
93
+ const t = (labelEl.textContent || '').replace(/\s+/g, ' ').trim()
94
+ if (t) return clip(t, maxName)
95
+ }
96
+ const alt = el.getAttribute('alt')
97
+ if (alt && alt.trim()) return clip(alt.trim(), maxName)
98
+ const placeholder = el.getAttribute('placeholder')
99
+ if (placeholder && placeholder.trim()) return clip(placeholder.trim(), maxName)
100
+ const title = el.getAttribute('title')
101
+ if (title && title.trim()) return clip(title.trim(), maxName)
102
+ const tag = el.tagName.toLowerCase()
103
+ // Button-like controls label themselves with their value: <button>提交</button>
104
+ // and <input type="button|submit|reset" value="提交">. Without this an input
105
+ // button renders as a bare ref with no name.
106
+ // An input button labels itself with its value; a <button> is labelled by
107
+ // its text, which the interactive path below collects from the subtree.
108
+ if (tag === 'input' && INPUT_BUTTON_TYPES.has((el.getAttribute('type') || '').toLowerCase())) {
109
+ const value = (el.getAttribute('value') || el.value || '').trim()
110
+ if (value) return clip(value, maxName)
111
+ }
112
+ // Free-text inputs show their LIVE value (el.value, not the stale HTML
113
+ // attribute) so a snapshot after fill reflects what was typed. Password
114
+ // fields are skipped for privacy; fixed-value types (checkbox "on",
115
+ // hidden, range, color) keep their accessible name only.
116
+ if (tag === 'input' || tag === 'textarea') {
117
+ const type = tag === 'input' ? (el.getAttribute('type') || 'text').toLowerCase() : ''
118
+ const freeText = tag === 'textarea'
119
+ || ['text', 'search', 'tel', 'url', 'email', 'number', 'date', 'time', 'datetime-local', 'month', 'week'].includes(type)
120
+ if (freeText) {
121
+ const value = el.value
122
+ if (value && value.trim()) return clip(value.trim(), maxName)
123
+ }
124
+ }
125
+ // Non-interactive content elements carry their own direct text as the name.
126
+ // An interactive element's accessible name is its whole subtree text, which
127
+ // buildNode collects next; returning '' hands it that job.
128
+ if (actionable) return ''
129
+ return clip(ownText(el, maxText), maxName)
130
+ }
131
+
132
+ /**
133
+ * Bounded name taken from an element's DESCENDANTS, used only when an
134
+ * actionable element has no text of its own. Shared markup patterns put the
135
+ * visible label one level down — <a><span>搜索设置</span></a>,
136
+ * <button><span>提交</span></button>, <a><img alt="首页"></a> — and without
137
+ * this fallback those nodes render as a bare [ref=...] with no name, so the
138
+ * model cannot tell which ref carries the label it can see. Depth-first,
139
+ * skips script/style/hidden subtrees, prefers text and falls back to
140
+ * aria-label/alt/title, and is capped so a huge card cannot bloat the tree.
141
+ */
142
+ function descendantName(el, maxName, maxText) {
143
+ const texts = []
144
+ const labels = []
145
+ let total = 0
146
+ let visited = 0
147
+ const walk = (node, depth) => {
148
+ if (total >= maxText || depth > 6 || visited > 400) return
149
+ for (const child of node.childNodes) {
150
+ if (total >= maxText || visited > 400) return
151
+ if (child.nodeType === 3) {
152
+ const text = (child.textContent || '').replace(/\s+/g, ' ').trim()
153
+ if (text) {
154
+ texts.push(text)
155
+ total += text.length + 1
156
+ }
157
+ }
158
+ else if (child.nodeType === 1) {
159
+ const tag = child.tagName.toLowerCase()
160
+ if (SKIP_TAGS.has(tag)) continue
161
+ if (child.getAttribute('hidden') !== null || child.getAttribute('aria-hidden') === 'true') continue
162
+ visited += 1
163
+ walk(child, depth + 1)
164
+ }
165
+ }
166
+ }
167
+ const collectLabel = (node, depth) => {
168
+ if (labels.length > 0 || depth > 6) return
169
+ for (const child of node.children) {
170
+ if (labels.length > 0) return
171
+ if (!isVisible(child)) continue
172
+ const aria = child.getAttribute('aria-label')
173
+ const alt = child.getAttribute('alt')
174
+ const title = child.getAttribute('title')
175
+ const label = (aria && aria.trim()) || (alt && alt.trim()) || (title && title.trim())
176
+ if (label) {
177
+ labels.push(label)
178
+ return
179
+ }
180
+ collectLabel(child, depth + 1)
181
+ }
182
+ }
183
+ walk(el, 0)
184
+ if (texts.length > 0) return clip(texts.join(' ').replace(/\s+/g, ' ').trim(), maxName)
185
+ collectLabel(el, 0)
186
+ return labels.length > 0 ? clip(labels[0], maxName) : ''
187
+ }
188
+
189
+ function assignRef(el, state) {
190
+ state.refCount += 1
191
+ // Reuse the ref minted for this element by an earlier snapshot of this
192
+ // document, so refs stay stable across snapshots while the DOM is intact.
193
+ const existing = el.getAttribute('data-dsh-ref')
194
+ if (existing !== null) return existing
195
+ const ref = 'e' + DOC_NONCE + String(++refSeq)
196
+ try { el.setAttribute('data-dsh-ref', ref) } catch (_) { /* foreign objects */ }
197
+ return ref
198
+ }
199
+
200
+ function buildNode(el, opts, state, inheritedName) {
201
+ const role = roleOf(el) || 'generic'
202
+ const actionable = isActionable(el, role)
203
+ // Nearest ancestor name, used to fold a child that only repeats it.
204
+ const inherited = typeof inheritedName === 'string' ? inheritedName : ''
205
+ if (opts.interactiveOnly && !actionable) {
206
+ const kids = []
207
+ for (const child of el.children) {
208
+ if (SKIP_TAGS.has(child.tagName.toLowerCase())) continue
209
+ if (!isVisible(child)) continue
210
+ const node = buildNode(child, opts, state, inherited)
211
+ if (node) kids.push(node)
212
+ }
213
+ return kids.length ? { role: 'generic', name: '', children: kids } : null
214
+ }
215
+ state.count += 1
216
+ if (state.count > opts.maxNodes) {
217
+ state.truncated = true
218
+ return null
219
+ }
220
+ let nodeName = nameOf(el, opts.maxNameLength, opts.maxTextLength, actionable)
221
+ if (nodeName === '' && actionable && !CONTENT_ROLES.has(role)) nodeName = descendantName(el, opts.maxNameLength, opts.maxTextLength)
222
+ // A button with no text of its own may still label itself with its value.
223
+ if (nodeName === '' && actionable) {
224
+ const controlTag = el.tagName.toLowerCase()
225
+ if (controlTag === 'button' || (controlTag === 'input' && INPUT_BUTTON_TYPES.has((el.getAttribute('type') || '').toLowerCase()))) {
226
+ const fallbackValue = (el.getAttribute('value') || el.value || '').trim()
227
+ if (fallbackValue) nodeName = clip(fallbackValue, opts.maxNameLength)
228
+ }
229
+ }
230
+ // A <select> shows its current value the way a text input does: the chosen
231
+ // option, not every option it happens to contain.
232
+ if (nodeName === '' && role === 'combobox' && el.tagName.toLowerCase() === 'select' && el.selectedIndex >= 0) {
233
+ const chosen = el.options[el.selectedIndex]
234
+ if (chosen) nodeName = clip((chosen.textContent || '').replace(/\s+/g, ' ').trim(), opts.maxNameLength)
235
+ }
236
+ const childInherited = nodeName !== '' ? nodeName : inherited
237
+ const node = { role, name: nodeName }
238
+ if (actionable) node.ref = assignRef(el, state)
239
+ if (role === 'heading') {
240
+ const m = /^h([1-6])$/.exec(el.tagName.toLowerCase())
241
+ if (m) node.level = Number(m[1])
242
+ }
243
+ if (el.tagName.toLowerCase() === 'input' && (role === 'checkbox' || role === 'radio')) node.checked = !!el.checked
244
+ if (role === 'option') node.selected = !!el.selected
245
+ if (el.disabled) node.disabled = true
246
+ if (role === 'link') {
247
+ const href = el.getAttribute('href')
248
+ if (href && href.trim() && href.indexOf('javascript:') !== 0) node.href = href
249
+ }
250
+ if (role === 'iframe') {
251
+ try {
252
+ if (el.contentDocument && el.contentDocument.body && state.depth < 2) {
253
+ state.depth += 1
254
+ const kids = []
255
+ for (const child of el.contentDocument.body.children) {
256
+ if (SKIP_TAGS.has(child.tagName.toLowerCase())) continue
257
+ const sub = buildNode(child, opts, state)
258
+ if (sub) kids.push(sub)
259
+ }
260
+ state.depth -= 1
261
+ node.children = kids
262
+ return node
263
+ }
264
+ } catch (_) { /* cross-origin frame */ }
265
+ node.children = []
266
+ if (!node.name) node.name = '(frame)'
267
+ return node
268
+ }
269
+ const kids = []
270
+ for (const child of el.children) {
271
+ if (SKIP_TAGS.has(child.tagName.toLowerCase())) continue
272
+ // Collapsed native <select> options have zero layout rects but stay
273
+ // part of the element's accessibility tree: include them regardless.
274
+ const isOption = child.tagName.toLowerCase() === 'option' && el.tagName.toLowerCase() === 'select'
275
+ if (!isOption && !isVisible(child)) continue
276
+ const sub = buildNode(child, opts, state, childInherited)
277
+ if (!sub) continue
278
+ // Fold a child that merely repeats the name an ancestor already carries
279
+ // (e.g. <a><span>搜索设置</span></a>): hoist its subtree and drop the
280
+ // duplicate line. A ref-bearing child is a distinct handle and stays.
281
+ if (sub.ref === undefined && sub.name.length >= 2 && childInherited !== '' && childInherited.indexOf(sub.name) !== -1) {
282
+ for (const grand of sub.children || []) kids.push(grand)
283
+ continue
284
+ }
285
+ kids.push(sub)
286
+ }
287
+ node.children = kids
288
+ if (!actionable && !node.name && !kids.length && !node.checked && !node.selected && !node.disabled) return null
289
+ return node
290
+ }
291
+
292
+ window.__dshSnapshot = (opts) => {
293
+ const o = {
294
+ interactiveOnly: !!opts.interactiveOnly,
295
+ maxNodes: opts.maxNodes || 500,
296
+ maxNameLength: opts.maxNameLength || 120,
297
+ maxTextLength: opts.maxTextLength || 300,
298
+ }
299
+ const state = { count: 0, truncated: false, depth: 0, refCount: 0 }
300
+ const nodes = []
301
+ if (document.body) {
302
+ for (const child of document.body.children) {
303
+ if (SKIP_TAGS.has(child.tagName.toLowerCase())) continue
304
+ if (!isVisible(child)) continue
305
+ const node = buildNode(child, o, state)
306
+ if (node) nodes.push(node)
307
+ }
308
+ }
309
+ return { nodes, truncated: state.truncated, totalRefs: state.refCount }
310
+ }
311
+
312
+ window.__dshPageData = (opts) => {
313
+ const o = { maxTextChars: opts.maxTextChars || 30000, maxLinks: opts.maxLinks || 300, maxInputs: opts.maxInputs || 200 }
314
+ const text = (document.body ? document.body.innerText : '').replace(/\n{3,}/g, '\n\n').trim()
315
+ const links = []
316
+ for (const a of document.querySelectorAll('a[href]')) {
317
+ if (links.length >= o.maxLinks) break
318
+ const label = (a.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 200)
319
+ links.push({ text: label, href: a.getAttribute('href') })
320
+ }
321
+ const inputs = []
322
+ for (const el of document.querySelectorAll('input, select, textarea')) {
323
+ if (inputs.length >= o.maxInputs) break
324
+ const tag = el.tagName.toLowerCase()
325
+ let label = ''
326
+ if (el.id) {
327
+ try {
328
+ const labelEl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]')
329
+ if (labelEl) label = (labelEl.textContent || '').replace(/\s+/g, ' ').trim()
330
+ } catch (_) { /* ignore */ }
331
+ }
332
+ if (!label) {
333
+ const labelEl = el.closest('label')
334
+ if (labelEl) label = (labelEl.textContent || '').replace(/\s+/g, ' ').trim()
335
+ }
336
+ let value = el.value || ''
337
+ if (tag === 'select' && el.selectedOptions && el.selectedOptions[0]) value = el.selectedOptions[0].textContent
338
+ inputs.push({ tag, type: el.getAttribute('type') || '', name: el.getAttribute('name') || '', value, label: label.slice(0, 200), checked: tag === 'input' ? !!el.checked : null })
339
+ }
340
+ return {
341
+ url: location.href,
342
+ title: document.title,
343
+ text: text.length > o.maxTextChars ? text.slice(0, o.maxTextChars) + '…' : text,
344
+ truncated: text.length > o.maxTextChars,
345
+ links,
346
+ inputs,
347
+ }
348
+ }
349
+ })();
350
+ `;