playwriter 0.0.63 → 0.0.89

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 (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. package/src/wait-for-page-load.ts +6 -1
@@ -11,11 +11,14 @@ import { Sema } from 'async-sema'
11
11
  import type { ICDPSession } from './cdp-session.js'
12
12
  import { getCDPSessionForPage } from './cdp-session.js'
13
13
 
14
-
15
14
  // Import sharp at module level - resolves to null if not available
16
15
  const sharpPromise = import('sharp')
17
- .then((m) => { return m.default })
18
- .catch(() => { return null })
16
+ .then((m) => {
17
+ return m.default
18
+ })
19
+ .catch(() => {
20
+ return null
21
+ })
19
22
 
20
23
  // ============================================================================
21
24
  // Snapshot Format Types
@@ -81,6 +84,113 @@ export interface ScreenshotResult {
81
84
  labelCount: number
82
85
  }
83
86
 
87
+ // ============================================================================
88
+ // Image Resize Utility
89
+ // ============================================================================
90
+
91
+ /**
92
+ * LLM-optimal max dimension. Claude auto-resizes images larger than 1568px
93
+ * on any edge, adding latency. Token cost: (width * height) / 750.
94
+ */
95
+ export const LLM_MAX_DIMENSION = 1568
96
+
97
+ export interface ResizeImageOptions {
98
+ /** Input: file path or Buffer */
99
+ input: string | Buffer
100
+ /** Max pixels on longest edge. Default 1568 (Claude-optimal).
101
+ * Ignored if explicit width/height provided. */
102
+ maxDimension?: number
103
+ /** Explicit target width in px (aspect ratio preserved unless both width+height set) */
104
+ width?: number
105
+ /** Explicit target height in px */
106
+ height?: number
107
+ /** How to fit when both width+height specified. Default 'inside' (preserve aspect ratio, no crop) */
108
+ fit?: 'inside' | 'cover' | 'contain' | 'fill'
109
+ /** JPEG quality 1-100. Default 80 */
110
+ quality?: number
111
+ /** Output file path. Defaults to overwriting the input file (when input is a path) */
112
+ output?: string
113
+ }
114
+
115
+ export interface ResizeImageResult {
116
+ buffer: Buffer
117
+ mimeType: 'image/jpeg'
118
+ /** Only set if output path was provided */
119
+ path?: string
120
+ }
121
+
122
+ /**
123
+ * Resize an image using sharp. Useful for shrinking screenshots before reading
124
+ * them back into context so they consume fewer tokens.
125
+ *
126
+ * Default behavior (no width/height): fits within 1568×1568px, preserving
127
+ * aspect ratio, never upscales. This is optimal for Claude vision.
128
+ *
129
+ * Explicit width/height: resizes to those dimensions using the fit strategy.
130
+ */
131
+ export async function resizeImage(options: ResizeImageOptions): Promise<ResizeImageResult> {
132
+ const sharp = await sharpPromise
133
+ if (!sharp) {
134
+ throw new Error('sharp is not installed — install it with: pnpm add sharp')
135
+ }
136
+
137
+ const inputBuffer = (() => {
138
+ if (Buffer.isBuffer(options.input)) {
139
+ return options.input
140
+ }
141
+ return fs.readFileSync(options.input)
142
+ })()
143
+
144
+ const quality = options.quality ?? 80
145
+ const hasExplicitDimensions = options.width !== undefined || options.height !== undefined
146
+
147
+ const fit = options.fit ?? 'inside'
148
+ const resizeOpts = (() => {
149
+ if (hasExplicitDimensions) {
150
+ return {
151
+ width: options.width,
152
+ height: options.height,
153
+ fit,
154
+ withoutEnlargement: false,
155
+ }
156
+ }
157
+ const max = options.maxDimension ?? LLM_MAX_DIMENSION
158
+ return {
159
+ width: max,
160
+ height: max,
161
+ fit,
162
+ withoutEnlargement: true,
163
+ }
164
+ })()
165
+
166
+ const buffer = await sharp(inputBuffer)
167
+ .resize(resizeOpts)
168
+ .jpeg({ quality })
169
+ .toBuffer()
170
+
171
+ // Default: overwrite input file. When input is a Buffer, no file is written
172
+ // unless output is explicitly set.
173
+ const outputPath = (() => {
174
+ if (options.output) {
175
+ return options.output
176
+ }
177
+ if (typeof options.input === 'string') {
178
+ return options.input
179
+ }
180
+ return undefined
181
+ })()
182
+
183
+ if (outputPath) {
184
+ fs.writeFileSync(outputPath, buffer)
185
+ }
186
+
187
+ return {
188
+ buffer,
189
+ mimeType: 'image/jpeg',
190
+ ...(outputPath ? { path: outputPath } : {}),
191
+ }
192
+ }
193
+
84
194
  export interface AriaSnapshotResult {
85
195
  snapshot: string
86
196
  tree: AriaSnapshotNode[]
@@ -142,9 +252,7 @@ const INTERACTIVE_ROLES = new Set([
142
252
  'audio',
143
253
  ])
144
254
 
145
- const LABEL_ROLES = new Set([
146
- 'labeltext',
147
- ])
255
+ const LABEL_ROLES = new Set(['labeltext'])
148
256
 
149
257
  const MAX_LABEL_POSITION_CONCURRENCY = 24
150
258
  const BOX_MODEL_TIMEOUT_MS = 5000
@@ -165,12 +273,7 @@ const CONTEXT_ROLES = new Set([
165
273
  'cell',
166
274
  ])
167
275
 
168
- const SKIP_WRAPPER_ROLES = new Set([
169
- 'generic',
170
- 'group',
171
- 'none',
172
- 'presentation',
173
- ])
276
+ const SKIP_WRAPPER_ROLES = new Set(['generic', 'group', 'none', 'presentation'])
174
277
 
175
278
  const TEST_ID_ATTRS = [
176
279
  'data-testid',
@@ -231,7 +334,15 @@ function buildLocatorFromStable(stable: { value: string; attr: string }): string
231
334
  return `[${stable.attr}="${escaped}"]`
232
335
  }
233
336
 
234
- function buildBaseLocator({ role, name, stable }: { role: string; name: string; stable: { value: string; attr: string } | null }): string {
337
+ function buildBaseLocator({
338
+ role,
339
+ name,
340
+ stable,
341
+ }: {
342
+ role: string
343
+ name: string
344
+ stable: { value: string; attr: string } | null
345
+ }): string {
235
346
  if (stable) {
236
347
  return buildLocatorFromStable(stable)
237
348
  }
@@ -243,7 +354,6 @@ function buildBaseLocator({ role, name, stable }: { role: string; name: string;
243
354
  return `role=${role}`
244
355
  }
245
356
 
246
-
247
357
  function getAxValueString(value?: Protocol.Accessibility.AXValue): string {
248
358
  if (!value) {
249
359
  return ''
@@ -283,7 +393,13 @@ export type SnapshotNode = {
283
393
  children: SnapshotNode[]
284
394
  }
285
395
 
286
- function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: {
396
+ function buildSnapshotLine({
397
+ role,
398
+ name,
399
+ baseLocator,
400
+ indent,
401
+ hasChildren,
402
+ }: {
287
403
  role: string
288
404
  name: string
289
405
  baseLocator?: string
@@ -308,15 +424,16 @@ function buildTextLine(text: string, indent: number): SnapshotLine {
308
424
  export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
309
425
  return nodes.flatMap((node) => {
310
426
  const nodeIndent = indent + (node.indentOffset ?? 0)
311
- const line = node.role === 'text'
312
- ? buildTextLine(node.name, nodeIndent)
313
- : buildSnapshotLine({
314
- role: node.role,
315
- name: node.name,
316
- baseLocator: node.baseLocator,
317
- indent: nodeIndent,
318
- hasChildren: node.children.length > 0,
319
- })
427
+ const line =
428
+ node.role === 'text'
429
+ ? buildTextLine(node.name, nodeIndent)
430
+ : buildSnapshotLine({
431
+ role: node.role,
432
+ name: node.name,
433
+ baseLocator: node.baseLocator,
434
+ indent: nodeIndent,
435
+ hasChildren: node.children.length > 0,
436
+ })
320
437
  return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)]
321
438
  })
322
439
  }
@@ -339,13 +456,15 @@ export function buildRawSnapshotTree(options: {
339
456
 
340
457
  const role = getAxRole(node)
341
458
  const name = getAxValueString(node.name).trim()
342
- const children = (node.childIds ?? []).map((childId) => {
343
- return buildRawSnapshotTree({
344
- nodeId: childId,
345
- axById: options.axById,
346
- isNodeInScope: options.isNodeInScope,
459
+ const children = (node.childIds ?? [])
460
+ .map((childId) => {
461
+ return buildRawSnapshotTree({
462
+ nodeId: childId,
463
+ axById: options.axById,
464
+ isNodeInScope: options.isNodeInScope,
465
+ })
347
466
  })
348
- }).filter(isTruthy)
467
+ .filter(isTruthy)
349
468
 
350
469
  const inScope = options.isNodeInScope(node) || children.length > 0
351
470
  if (!inScope) {
@@ -367,7 +486,11 @@ export function filterInteractiveSnapshotTree(options: {
367
486
  labelContext: boolean
368
487
  refFilter?: (entry: { role: string; name: string }) => boolean
369
488
  domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
370
- createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
489
+ createRefForNode: (options: {
490
+ backendNodeId?: Protocol.DOM.BackendNodeId
491
+ role: string
492
+ name: string
493
+ }) => string | null
371
494
  }): { nodes: SnapshotNode[]; names: Set<string> } {
372
495
  const role = options.node.role
373
496
  const name = options.node.name
@@ -476,7 +599,11 @@ export function filterFullSnapshotTree(options: {
476
599
  ancestorNames: string[]
477
600
  refFilter?: (entry: { role: string; name: string }) => boolean
478
601
  domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
479
- createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
602
+ createRefForNode: (options: {
603
+ backendNodeId?: Protocol.DOM.BackendNodeId
604
+ role: string
605
+ name: string
606
+ }) => string | null
480
607
  }): { nodes: SnapshotNode[]; names: Set<string> } {
481
608
  const role = options.node.role
482
609
  const name = options.node.name
@@ -613,18 +740,20 @@ export function finalizeSnapshotOutput(
613
740
  }, [])
614
741
 
615
742
  let lineLocatorIndex = 0
616
- const snapshot = lines.map((line) => {
617
- let text = line.text
618
- if (line.baseLocator) {
619
- const locator = locatorSequence[lineLocatorIndex]
620
- lineLocatorIndex += 1
621
- text = buildLocatorLineText({ line, locator })
622
- }
623
- if (line.hasChildren) {
624
- text += ':'
625
- }
626
- return text
627
- }).join('\n')
743
+ const snapshot = lines
744
+ .map((line) => {
745
+ let text = line.text
746
+ if (line.baseLocator) {
747
+ const locator = locatorSequence[lineLocatorIndex]
748
+ lineLocatorIndex += 1
749
+ text = buildLocatorLineText({ line, locator })
750
+ }
751
+ if (line.hasChildren) {
752
+ text += ':'
753
+ }
754
+ return text
755
+ })
756
+ .join('\n')
628
757
 
629
758
  let nodeLocatorIndex = 0
630
759
  const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => {
@@ -676,7 +805,11 @@ function buildDomIndex(nodes: Protocol.DOM.Node[]): {
676
805
  return { domById, domByBackendId, childrenByParent }
677
806
  }
678
807
 
679
- function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrValue: string): Protocol.DOM.NodeId | null {
808
+ function findScopeRootNodeId(
809
+ nodes: Protocol.DOM.Node[],
810
+ attrName: string,
811
+ attrValue: string,
812
+ ): Protocol.DOM.NodeId | null {
680
813
  for (const node of nodes) {
681
814
  if (!node.attributes) {
682
815
  continue
@@ -692,7 +825,11 @@ function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrV
692
825
  return null
693
826
  }
694
827
 
695
- function buildBackendIdSet(rootNodeId: Protocol.DOM.NodeId, childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>, domById: Map<Protocol.DOM.NodeId, DomNodeInfo>): Set<Protocol.DOM.BackendNodeId> {
828
+ function buildBackendIdSet(
829
+ rootNodeId: Protocol.DOM.NodeId,
830
+ childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>,
831
+ domById: Map<Protocol.DOM.NodeId, DomNodeInfo>,
832
+ ): Set<Protocol.DOM.BackendNodeId> {
696
833
  const result = new Set<Protocol.DOM.BackendNodeId>()
697
834
  const stack: Protocol.DOM.NodeId[] = [rootNodeId]
698
835
  while (stack.length > 0) {
@@ -786,7 +923,14 @@ async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; pag
786
923
  * await page.locator(selector).click()
787
924
  * ```
788
925
  */
789
- export async function getAriaSnapshot({ page, frame, locator, refFilter, interactiveOnly = false, cdp }: {
926
+ export async function getAriaSnapshot({
927
+ page,
928
+ frame,
929
+ locator,
930
+ refFilter,
931
+ interactiveOnly = false,
932
+ cdp,
933
+ }: {
790
934
  page: Page
791
935
  frame?: Frame | FrameLocator
792
936
  locator?: Locator
@@ -794,29 +938,29 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
794
938
  interactiveOnly?: boolean
795
939
  cdp?: ICDPSession
796
940
  }): Promise<AriaSnapshotResult> {
797
- const session = cdp || await getCDPSessionForPage({ page })
941
+ const session = cdp || (await getCDPSessionForPage({ page }))
798
942
 
799
943
  // Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
800
944
  // is a scoping helper without CDP access. We need the real Frame from page.frames()
801
945
  // which has frameId() for OOPIF session attachment.
802
946
  const resolvedFrame = await resolveFrame({ frame, page })
803
-
947
+
804
948
  // For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
805
949
  // to get a separate CDP session. Same-origin iframes can use frameId directly.
806
950
  let oopifSessionId: string | null = null
807
951
  const frameId = resolvedFrame?.frameId() ?? null
808
-
952
+
809
953
  if (frameId) {
810
- const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse
954
+ const { targetInfos } = (await session.send('Target.getTargets')) as Protocol.Target.GetTargetsResponse
811
955
  const frameUrl = resolvedFrame!.url()
812
956
  const iframeTarget = targetInfos.find((t) => {
813
957
  return t.type === 'iframe' && t.url === frameUrl
814
958
  })
815
959
  if (iframeTarget) {
816
- const { sessionId } = await session.send('Target.attachToTarget', {
960
+ const { sessionId } = (await session.send('Target.attachToTarget', {
817
961
  targetId: iframeTarget.targetId,
818
962
  flatten: true,
819
- }) as Protocol.Target.AttachToTargetResponse
963
+ })) as Protocol.Target.AttachToTargetResponse
820
964
  oopifSessionId = sessionId
821
965
  await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
822
966
  }
@@ -831,13 +975,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
831
975
 
832
976
  try {
833
977
  if (scopeLocator) {
834
- await scopeLocator.evaluate((element, data) => {
835
- element.setAttribute(data.attr, data.value)
836
- }, { attr: scopeAttr, value: scopeValue })
978
+ await scopeLocator.evaluate(
979
+ (element, data) => {
980
+ element.setAttribute(data.attr, data.value)
981
+ },
982
+ { attr: scopeAttr, value: scopeValue },
983
+ )
837
984
  scopeApplied = true
838
985
  }
839
986
 
840
- const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }, oopifSessionId) as Protocol.DOM.GetFlattenedDocumentResponse
987
+ const { nodes: domNodes } = (await session.send(
988
+ 'DOM.getFlattenedDocument',
989
+ { depth: -1, pierce: true },
990
+ oopifSessionId,
991
+ )) as Protocol.DOM.GetFlattenedDocumentResponse
841
992
  const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
842
993
 
843
994
  let scopeRootNodeId: Protocol.DOM.NodeId | null = null
@@ -852,12 +1003,14 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
852
1003
  }
853
1004
  }
854
1005
 
855
- const allowedBackendIds = scopeRootNodeId
856
- ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById)
857
- : null
1006
+ const allowedBackendIds = scopeRootNodeId ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) : null
858
1007
 
859
1008
  const axParams = !oopifSessionId && frameId ? { frameId } : undefined
860
- const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree', axParams, oopifSessionId) as Protocol.Accessibility.GetFullAXTreeResponse
1009
+ const { nodes: axNodes } = (await session.send(
1010
+ 'Accessibility.getFullAXTree',
1011
+ axParams,
1012
+ oopifSessionId,
1013
+ )) as Protocol.Accessibility.GetFullAXTreeResponse
861
1014
 
862
1015
  const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
863
1016
  for (const node of axNodes) {
@@ -937,16 +1090,18 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
937
1090
  return allowedBackendIds.has(node.backendDOMNodeId)
938
1091
  }
939
1092
 
940
-
941
1093
  let snapshotNodes: SnapshotNode[] = []
942
1094
  if (rootAxNodeId) {
943
1095
  const rootNode = axById.get(rootAxNodeId)
944
1096
  const rootRole = rootNode ? getAxRole(rootNode) : ''
945
- const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
946
- ? rootNode.childIds.map((childId) => {
947
- return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
948
- }).filter(isTruthy)
949
- : [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
1097
+ const rawRoots =
1098
+ rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
1099
+ ? rootNode.childIds
1100
+ .map((childId) => {
1101
+ return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
1102
+ })
1103
+ .filter(isTruthy)
1104
+ : [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
950
1105
 
951
1106
  const filtered = rawRoots.flatMap((rawNode) => {
952
1107
  if (interactiveOnly) {
@@ -1027,7 +1182,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
1027
1182
  } catch {
1028
1183
  return null
1029
1184
  }
1030
- })
1185
+ }),
1031
1186
  )
1032
1187
 
1033
1188
  const matchingRefs = await page.evaluate(
@@ -1037,7 +1192,16 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
1037
1192
  return null
1038
1193
  }
1039
1194
 
1040
- const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw', 'data-qa', 'data-e2e', 'data-automation-id']
1195
+ const testIdAttrs = [
1196
+ 'data-testid',
1197
+ 'data-test-id',
1198
+ 'data-test',
1199
+ 'data-cy',
1200
+ 'data-pw',
1201
+ 'data-qa',
1202
+ 'data-e2e',
1203
+ 'data-automation-id',
1204
+ ]
1041
1205
  for (const attr of testIdAttrs) {
1042
1206
  const value = target.getAttribute(attr)
1043
1207
  if (value) {
@@ -1066,7 +1230,7 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
1066
1230
  {
1067
1231
  targets: targetHandles,
1068
1232
  refData: result.refs,
1069
- }
1233
+ },
1070
1234
  )
1071
1235
 
1072
1236
  return matchingRefs.map((ref) => {
@@ -1096,7 +1260,9 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, interac
1096
1260
  }, scopeAttr)
1097
1261
  }
1098
1262
  if (oopifSessionId) {
1099
- await session.send('Target.detachFromTarget', { sessionId: oopifSessionId }).catch(() => {})
1263
+ await session.send('Target.detachFromTarget', { sessionId: oopifSessionId }).catch((e) => {
1264
+ console.error('[aria-snapshot] Failed to detach OOPIF session:', oopifSessionId, e)
1265
+ })
1100
1266
  }
1101
1267
  if (!cdp) {
1102
1268
  await session.detach()
@@ -1140,7 +1306,7 @@ async function getLabelBoxesForRefs({
1140
1306
  cdp?: ICDPSession
1141
1307
  }): Promise<AriaLabel[]> {
1142
1308
  const log = logger?.info ?? logger?.error ?? console.error
1143
- const session = cdp || await getCDPSessionForPage({ page })
1309
+ const session = cdp || (await getCDPSessionForPage({ page }))
1144
1310
  const sema = new Sema(maxConcurrency)
1145
1311
  const labelRefs = refs.filter((ref) => {
1146
1312
  return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)
@@ -1161,14 +1327,20 @@ async function getLabelBoxesForRefs({
1161
1327
  await sema.acquire()
1162
1328
  try {
1163
1329
  const response = await Promise.race([
1164
- session.send('DOM.getBoxModel', { backendNodeId: ref.backendNodeId }) as Promise<Protocol.DOM.GetBoxModelResponse>,
1330
+ session.send('DOM.getBoxModel', {
1331
+ backendNodeId: ref.backendNodeId,
1332
+ }) as Promise<Protocol.DOM.GetBoxModelResponse>,
1165
1333
  new Promise<null>((resolve) => {
1166
- setTimeout(() => { resolve(null) }, BOX_MODEL_TIMEOUT_MS)
1334
+ setTimeout(() => {
1335
+ resolve(null)
1336
+ }, BOX_MODEL_TIMEOUT_MS)
1167
1337
  }),
1168
1338
  ])
1169
1339
  completed++
1170
1340
  if (completed % 50 === 0 || completed === labelRefs.length) {
1171
- log(`[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`)
1341
+ log(
1342
+ `[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`,
1343
+ )
1172
1344
  }
1173
1345
  if (!response) {
1174
1346
  timedOut++
@@ -1186,9 +1358,11 @@ async function getLabelBoxesForRefs({
1186
1358
  } finally {
1187
1359
  sema.release()
1188
1360
  }
1189
- })
1361
+ }),
1362
+ )
1363
+ log(
1364
+ `[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`,
1190
1365
  )
1191
- log(`[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`)
1192
1366
  return labels.filter(isTruthy)
1193
1367
  } finally {
1194
1368
  if (!cdp) {
@@ -1217,7 +1391,12 @@ async function getLabelBoxesForRefs({
1217
1391
  * await page.locator('[data-testid="submit-btn"]').click()
1218
1392
  * ```
1219
1393
  */
1220
- export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: {
1394
+ export async function showAriaRefLabels({
1395
+ page,
1396
+ locator,
1397
+ interactiveOnly = true,
1398
+ logger,
1399
+ }: {
1221
1400
  page: Page
1222
1401
  locator?: Locator
1223
1402
  interactiveOnly?: boolean
@@ -1240,11 +1419,15 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true,
1240
1419
  try {
1241
1420
  const snapshotStart = Date.now()
1242
1421
  const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, cdp })
1243
- const shortRefMap = new Map(refs.map((entry) => {
1244
- return [entry.ref, entry.shortRef]
1245
- }))
1422
+ const shortRefMap = new Map(
1423
+ refs.map((entry) => {
1424
+ return [entry.ref, entry.shortRef]
1425
+ }),
1426
+ )
1246
1427
  const interactiveRefs = refs.filter((ref) => Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role))
1247
- log(`[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`)
1428
+ log(
1429
+ `[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`,
1430
+ )
1248
1431
 
1249
1432
  const rootHandle = locator ? await locator.elementHandle() : null
1250
1433
 
@@ -1259,22 +1442,30 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true,
1259
1442
  log(`[showAriaRefLabels] getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`)
1260
1443
 
1261
1444
  const renderStart = Date.now()
1262
- const labelCount = await page.evaluate(({ entries, root, interactiveOnly: intOnly }) => {
1263
- const a11y = (globalThis as {
1264
- __a11y?: {
1265
- renderA11yLabels?: (labels: typeof entries) => number
1266
- computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { labelCount: number }
1445
+ const labelCount = await page.evaluate(
1446
+ ({ entries, root, interactiveOnly: intOnly }) => {
1447
+ const a11y = (
1448
+ globalThis as {
1449
+ __a11y?: {
1450
+ renderA11yLabels?: (labels: typeof entries) => number
1451
+ computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => {
1452
+ labelCount: number
1453
+ }
1454
+ }
1455
+ }
1456
+ ).__a11y
1457
+ if (a11y?.renderA11yLabels) {
1458
+ return a11y.renderA11yLabels(entries)
1267
1459
  }
1268
- }).__a11y
1269
- if (a11y?.renderA11yLabels) {
1270
- return a11y.renderA11yLabels(entries)
1271
- }
1272
- if (a11y?.computeA11ySnapshot) {
1273
- const rootElement = root || document.body
1274
- return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }).labelCount
1275
- }
1276
- throw new Error('a11y client not loaded')
1277
- }, { entries: shortLabels, root: rootHandle, interactiveOnly })
1460
+ if (a11y?.computeA11ySnapshot) {
1461
+ const rootElement = root || document.body
1462
+ return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true })
1463
+ .labelCount
1464
+ }
1465
+ throw new Error('a11y client not loaded')
1466
+ },
1467
+ { entries: shortLabels, root: rootHandle, interactiveOnly },
1468
+ )
1278
1469
 
1279
1470
  log(`[showAriaRefLabels] renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`)
1280
1471
  log(`[showAriaRefLabels] total: ${Date.now() - startTime}ms`)
@@ -1324,7 +1515,13 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
1324
1515
  * await page.locator('[data-testid="submit-btn"]').click()
1325
1516
  * ```
1326
1517
  */
1327
- export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, collector, logger }: {
1518
+ export async function screenshotWithAccessibilityLabels({
1519
+ page,
1520
+ locator,
1521
+ interactiveOnly = true,
1522
+ collector,
1523
+ logger,
1524
+ }: {
1328
1525
  page: Page
1329
1526
  locator?: Locator
1330
1527
  interactiveOnly?: boolean
@@ -1351,18 +1548,17 @@ export async function screenshotWithAccessibilityLabels({ page, locator, interac
1351
1548
  const screenshotPath = path.join(tmpDir, filename)
1352
1549
 
1353
1550
  // Get viewport size to clip screenshot to visible area
1354
- const viewport = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })') as { width: number; height: number }
1355
-
1356
- // Max 1568px on any edge (larger gets auto-resized by Claude, adding latency)
1357
- // Token formula: tokens = (width * height) / 750
1358
- const MAX_DIMENSION = 1568
1551
+ const viewport = (await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })')) as {
1552
+ width: number
1553
+ height: number
1554
+ }
1359
1555
 
1360
1556
  // Check if sharp is available for resizing
1361
1557
  const sharp = await sharpPromise
1362
1558
 
1363
- // Clip dimensions: if sharp unavailable, limit capture area to MAX_DIMENSION
1364
- const clipWidth = sharp ? viewport.width : Math.min(viewport.width, MAX_DIMENSION)
1365
- const clipHeight = sharp ? viewport.height : Math.min(viewport.height, MAX_DIMENSION)
1559
+ // Clip dimensions: if sharp unavailable, limit capture area to LLM_MAX_DIMENSION
1560
+ const clipWidth = sharp ? viewport.width : Math.min(viewport.width, LLM_MAX_DIMENSION)
1561
+ const clipHeight = sharp ? viewport.height : Math.min(viewport.height, LLM_MAX_DIMENSION)
1366
1562
 
1367
1563
  // Take viewport screenshot with scale: 'css' to ignore device pixel ratio
1368
1564
  const screenshotStart = Date.now()
@@ -1376,23 +1572,16 @@ export async function screenshotWithAccessibilityLabels({ page, locator, interac
1376
1572
  log(`page.screenshot: ${Date.now() - screenshotStart}ms`)
1377
1573
  }
1378
1574
 
1379
- // Resize with sharp if available, otherwise use clipped raw buffer
1575
+ // Resize with resizeImage if sharp available, otherwise use clipped raw buffer
1380
1576
  const resizeStart = Date.now()
1381
1577
  const buffer = await (async () => {
1382
1578
  if (!sharp) {
1383
- logger?.error?.('[playwriter] sharp not available, using clipped screenshot (max', MAX_DIMENSION, 'px)')
1579
+ logger?.error?.('[playwriter] sharp not available, using clipped screenshot (max', LLM_MAX_DIMENSION, 'px)')
1384
1580
  return rawBuffer
1385
1581
  }
1386
1582
  try {
1387
- return await sharp(rawBuffer)
1388
- .resize({
1389
- width: MAX_DIMENSION,
1390
- height: MAX_DIMENSION,
1391
- fit: 'inside', // Scale down to fit, preserving aspect ratio
1392
- withoutEnlargement: true, // Don't upscale small images
1393
- })
1394
- .jpeg({ quality: 80 })
1395
- .toBuffer()
1583
+ const result = await resizeImage({ input: rawBuffer })
1584
+ return result.buffer
1396
1585
  } catch (err) {
1397
1586
  logger?.error?.('[playwriter] sharp resize failed, using raw buffer:', err)
1398
1587
  return rawBuffer