playwriter 0.0.89 → 0.0.103

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 (137) hide show
  1. package/dist/aria-snapshot.d.ts +7 -3
  2. package/dist/aria-snapshot.d.ts.map +1 -1
  3. package/dist/aria-snapshot.js +76 -18
  4. package/dist/aria-snapshot.js.map +1 -1
  5. package/dist/aria-snapshot.test.js +94 -0
  6. package/dist/aria-snapshot.test.js.map +1 -1
  7. package/dist/bippy.js +1 -1
  8. package/dist/browser-config.d.ts +12 -1
  9. package/dist/browser-config.d.ts.map +1 -1
  10. package/dist/browser-config.js +101 -38
  11. package/dist/browser-config.js.map +1 -1
  12. package/dist/browser-launch.d.ts +17 -0
  13. package/dist/browser-launch.d.ts.map +1 -0
  14. package/dist/browser-launch.js +44 -0
  15. package/dist/browser-launch.js.map +1 -0
  16. package/dist/cdp-relay.d.ts.map +1 -1
  17. package/dist/cdp-relay.js +34 -10
  18. package/dist/cdp-relay.js.map +1 -1
  19. package/dist/channel-owner-inspect.test.d.ts +2 -0
  20. package/dist/channel-owner-inspect.test.d.ts.map +1 -0
  21. package/dist/channel-owner-inspect.test.js +75 -0
  22. package/dist/channel-owner-inspect.test.js.map +1 -0
  23. package/dist/chrome-discovery.d.ts +65 -0
  24. package/dist/chrome-discovery.d.ts.map +1 -0
  25. package/dist/chrome-discovery.js +165 -0
  26. package/dist/chrome-discovery.js.map +1 -0
  27. package/dist/chrome-discovery.test.d.ts +2 -0
  28. package/dist/chrome-discovery.test.d.ts.map +1 -0
  29. package/dist/chrome-discovery.test.js +41 -0
  30. package/dist/chrome-discovery.test.js.map +1 -0
  31. package/dist/cli.js +357 -53
  32. package/dist/cli.js.map +1 -1
  33. package/dist/executor.d.ts +44 -11
  34. package/dist/executor.d.ts.map +1 -1
  35. package/dist/executor.js +121 -55
  36. package/dist/executor.js.map +1 -1
  37. package/dist/executor.unit.test.js +29 -1
  38. package/dist/executor.unit.test.js.map +1 -1
  39. package/dist/extension/background.js +1704 -0
  40. package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
  41. package/dist/extension/icons/icon-black-128.png +0 -0
  42. package/dist/extension/icons/icon-black-16.png +0 -0
  43. package/dist/extension/icons/icon-black-32.png +0 -0
  44. package/dist/extension/icons/icon-black-48.png +0 -0
  45. package/dist/extension/icons/icon-gray-128.png +0 -0
  46. package/dist/extension/icons/icon-gray-16.png +0 -0
  47. package/dist/extension/icons/icon-gray-32.png +0 -0
  48. package/dist/extension/icons/icon-gray-48.png +0 -0
  49. package/dist/extension/icons/icon-green-128.png +0 -0
  50. package/dist/extension/icons/icon-green-16.png +0 -0
  51. package/dist/extension/icons/icon-green-32.png +0 -0
  52. package/dist/extension/icons/icon-green-48.png +0 -0
  53. package/dist/extension/icons/playwriter-icon-black.png +0 -0
  54. package/dist/extension/icons/playwriter-icon-gray-disabled.png +0 -0
  55. package/dist/extension/icons/playwriter-icon-gray-disabled.svg +1 -0
  56. package/dist/extension/manifest.json +40 -0
  57. package/dist/extension/offscreen.js +214 -0
  58. package/dist/extension/src/offscreen.html +9 -0
  59. package/dist/extension/src/prism-bash.min.js +1 -0
  60. package/dist/extension/src/prism.min.js +1 -0
  61. package/dist/extension/src/welcome.html +399 -0
  62. package/dist/kitty-graphics.d.ts +22 -0
  63. package/dist/kitty-graphics.d.ts.map +1 -0
  64. package/dist/kitty-graphics.js +71 -0
  65. package/dist/kitty-graphics.js.map +1 -0
  66. package/dist/kitty-graphics.test.d.ts +2 -0
  67. package/dist/kitty-graphics.test.d.ts.map +1 -0
  68. package/dist/kitty-graphics.test.js +125 -0
  69. package/dist/kitty-graphics.test.js.map +1 -0
  70. package/dist/mcp.d.ts.map +1 -1
  71. package/dist/mcp.js +76 -12
  72. package/dist/mcp.js.map +1 -1
  73. package/dist/package-paths.d.ts +3 -0
  74. package/dist/package-paths.d.ts.map +1 -0
  75. package/dist/package-paths.js +29 -0
  76. package/dist/package-paths.js.map +1 -0
  77. package/dist/prompt.md +4 -2
  78. package/dist/readability.js +1 -1
  79. package/dist/relay-core.test.js +14 -4
  80. package/dist/relay-core.test.js.map +1 -1
  81. package/dist/relay-navigation.test.d.ts.map +1 -1
  82. package/dist/relay-navigation.test.js +95 -1
  83. package/dist/relay-navigation.test.js.map +1 -1
  84. package/dist/scoped-fs.d.ts +5 -4
  85. package/dist/scoped-fs.d.ts.map +1 -1
  86. package/dist/scoped-fs.js +11 -9
  87. package/dist/scoped-fs.js.map +1 -1
  88. package/dist/scoped-fs.test.d.ts +5 -0
  89. package/dist/scoped-fs.test.d.ts.map +1 -0
  90. package/dist/scoped-fs.test.js +70 -0
  91. package/dist/scoped-fs.test.js.map +1 -0
  92. package/dist/screen-recording.d.ts +3 -6
  93. package/dist/screen-recording.d.ts.map +1 -1
  94. package/dist/screen-recording.js +7 -21
  95. package/dist/screen-recording.js.map +1 -1
  96. package/dist/selector-generator.js +1 -1
  97. package/dist/snapshot-tools.test.js +3 -4
  98. package/dist/snapshot-tools.test.js.map +1 -1
  99. package/dist/test-utils.d.ts +3 -1
  100. package/dist/test-utils.d.ts.map +1 -1
  101. package/dist/test-utils.js +24 -12
  102. package/dist/test-utils.js.map +1 -1
  103. package/package.json +6 -5
  104. package/src/aria-snapshot.test.ts +110 -0
  105. package/src/aria-snapshot.ts +86 -19
  106. package/src/aria-snapshots/contenteditable-interactive.txt +7 -0
  107. package/src/aria-snapshots/contenteditable-raw.txt +14 -0
  108. package/src/aria-snapshots/github-interactive.txt +0 -2
  109. package/src/aria-snapshots/github-raw.txt +0 -4
  110. package/src/aria-snapshots/hackernews-interactive.txt +240 -237
  111. package/src/aria-snapshots/hackernews-raw.txt +262 -258
  112. package/src/aria-snapshots/prosemirror-interactive.txt +63 -0
  113. package/src/aria-snapshots/prosemirror-raw.txt +141 -0
  114. package/src/assets/aria-labels-hacker-news.png +0 -0
  115. package/src/browser-config.ts +143 -37
  116. package/src/browser-launch.ts +75 -0
  117. package/src/cdp-relay.ts +46 -11
  118. package/src/channel-owner-inspect.test.ts +94 -0
  119. package/src/chrome-discovery.test.ts +50 -0
  120. package/src/chrome-discovery.ts +211 -0
  121. package/src/cli.ts +432 -59
  122. package/src/executor.ts +171 -72
  123. package/src/executor.unit.test.ts +32 -1
  124. package/src/kitty-graphics.test.ts +139 -0
  125. package/src/kitty-graphics.ts +79 -0
  126. package/src/mcp.ts +82 -12
  127. package/src/package-paths.ts +36 -0
  128. package/src/relay-core.test.ts +14 -4
  129. package/src/relay-navigation.test.ts +106 -1
  130. package/src/scoped-fs.test.ts +77 -0
  131. package/src/scoped-fs.ts +12 -9
  132. package/src/screen-recording.ts +7 -22
  133. package/src/skill.md +32 -2
  134. package/src/snapshot-tools.test.ts +3 -4
  135. package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
  136. package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
  137. package/src/test-utils.ts +25 -11
@@ -79,7 +79,7 @@ export type AriaSnapshotNode = {
79
79
  export interface ScreenshotResult {
80
80
  path: string
81
81
  base64: string
82
- mimeType: 'image/jpeg'
82
+ mimeType: 'image/png'
83
83
  snapshot: string
84
84
  labelCount: number
85
85
  }
@@ -108,13 +108,15 @@ export interface ResizeImageOptions {
108
108
  fit?: 'inside' | 'cover' | 'contain' | 'fill'
109
109
  /** JPEG quality 1-100. Default 80 */
110
110
  quality?: number
111
+ /** Output format. Default 'jpeg'. Use 'png' for lossless output (e.g. Kitty Graphics). */
112
+ format?: 'jpeg' | 'png'
111
113
  /** Output file path. Defaults to overwriting the input file (when input is a path) */
112
114
  output?: string
113
115
  }
114
116
 
115
117
  export interface ResizeImageResult {
116
118
  buffer: Buffer
117
- mimeType: 'image/jpeg'
119
+ mimeType: 'image/png' | 'image/jpeg'
118
120
  /** Only set if output path was provided */
119
121
  path?: string
120
122
  }
@@ -128,7 +130,7 @@ export interface ResizeImageResult {
128
130
  *
129
131
  * Explicit width/height: resizes to those dimensions using the fit strategy.
130
132
  */
131
- export async function resizeImage(options: ResizeImageOptions): Promise<ResizeImageResult> {
133
+ export async function resizeImageForAgent(options: ResizeImageOptions): Promise<ResizeImageResult> {
132
134
  const sharp = await sharpPromise
133
135
  if (!sharp) {
134
136
  throw new Error('sharp is not installed — install it with: pnpm add sharp')
@@ -163,10 +165,9 @@ export async function resizeImage(options: ResizeImageOptions): Promise<ResizeIm
163
165
  }
164
166
  })()
165
167
 
166
- const buffer = await sharp(inputBuffer)
167
- .resize(resizeOpts)
168
- .jpeg({ quality })
169
- .toBuffer()
168
+ const fmt = options.format ?? 'png'
169
+ const pipeline = sharp(inputBuffer).resize(resizeOpts)
170
+ const buffer = await (fmt === 'png' ? pipeline.png() : pipeline.jpeg({ quality })).toBuffer()
170
171
 
171
172
  // Default: overwrite input file. When input is a Buffer, no file is written
172
173
  // unless output is explicitly set.
@@ -184,9 +185,10 @@ export async function resizeImage(options: ResizeImageOptions): Promise<ResizeIm
184
185
  fs.writeFileSync(outputPath, buffer)
185
186
  }
186
187
 
188
+ const mimeType: 'image/png' | 'image/jpeg' = fmt === 'png' ? 'image/png' : 'image/jpeg'
187
189
  return {
188
190
  buffer,
189
- mimeType: 'image/jpeg',
191
+ mimeType,
190
192
  ...(outputPath ? { path: outputPath } : {}),
191
193
  }
192
194
  }
@@ -338,14 +340,24 @@ function buildBaseLocator({
338
340
  role,
339
341
  name,
340
342
  stable,
343
+ isPromotedContentEditable,
341
344
  }: {
342
345
  role: string
343
346
  name: string
344
347
  stable: { value: string; attr: string } | null
348
+ isPromotedContentEditable?: boolean
345
349
  }): string {
346
350
  if (stable) {
347
351
  return buildLocatorFromStable(stable)
348
352
  }
353
+ // For promoted contenteditable elements (bare <div contenteditable="true"> without
354
+ // role="textbox"), use CSS attribute selector. Playwright's role=textbox selector
355
+ // won't match these because Chrome doesn't assign an implicit textbox role to
356
+ // contenteditable divs. Elements that already had role="textbox" use the normal
357
+ // role-based locator since Playwright matches those correctly.
358
+ if (isPromotedContentEditable) {
359
+ return `[contenteditable="true"]`
360
+ }
349
361
  const trimmedName = name.trim()
350
362
  if (trimmedName.length > 0) {
351
363
  const escapedName = escapeLocatorValue(trimmedName)
@@ -486,6 +498,7 @@ export function filterInteractiveSnapshotTree(options: {
486
498
  labelContext: boolean
487
499
  refFilter?: (entry: { role: string; name: string }) => boolean
488
500
  domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
501
+ promotedContentEditableIds?: Set<Protocol.DOM.BackendNodeId>
489
502
  createRefForNode: (options: {
490
503
  backendNodeId?: Protocol.DOM.BackendNodeId
491
504
  role: string
@@ -507,6 +520,7 @@ export function filterInteractiveSnapshotTree(options: {
507
520
  labelContext: nextLabelContext,
508
521
  refFilter: options.refFilter,
509
522
  domByBackendId: options.domByBackendId,
523
+ promotedContentEditableIds: options.promotedContentEditableIds,
510
524
  createRefForNode: options.createRefForNode,
511
525
  })
512
526
  })
@@ -575,7 +589,8 @@ export function filterInteractiveSnapshotTree(options: {
575
589
  if (includeInteractive) {
576
590
  const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
577
591
  const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
578
- baseLocator = buildBaseLocator({ role, name, stable })
592
+ const isPromoted = options.node.backendNodeId != null && (options.promotedContentEditableIds?.has(options.node.backendNodeId) ?? false)
593
+ baseLocator = buildBaseLocator({ role, name, stable, isPromotedContentEditable: isPromoted })
579
594
  ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
580
595
  }
581
596
 
@@ -599,6 +614,7 @@ export function filterFullSnapshotTree(options: {
599
614
  ancestorNames: string[]
600
615
  refFilter?: (entry: { role: string; name: string }) => boolean
601
616
  domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
617
+ promotedContentEditableIds?: Set<Protocol.DOM.BackendNodeId>
602
618
  createRefForNode: (options: {
603
619
  backendNodeId?: Protocol.DOM.BackendNodeId
604
620
  role: string
@@ -616,6 +632,7 @@ export function filterFullSnapshotTree(options: {
616
632
  ancestorNames: nextAncestors,
617
633
  refFilter: options.refFilter,
618
634
  domByBackendId: options.domByBackendId,
635
+ promotedContentEditableIds: options.promotedContentEditableIds,
619
636
  createRefForNode: options.createRefForNode,
620
637
  })
621
638
  })
@@ -673,7 +690,8 @@ export function filterFullSnapshotTree(options: {
673
690
  if (includeInteractive) {
674
691
  const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
675
692
  const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
676
- baseLocator = buildBaseLocator({ role, name, stable })
693
+ const isPromoted = options.node.backendNodeId != null && (options.promotedContentEditableIds?.has(options.node.backendNodeId) ?? false)
694
+ baseLocator = buildBaseLocator({ role, name, stable, isPromotedContentEditable: isPromoted })
677
695
  ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
678
696
  }
679
697
 
@@ -853,6 +871,17 @@ function isTextRole(role: string): boolean {
853
871
  return role === 'statictext' || role === 'inlinetextbox'
854
872
  }
855
873
 
874
+ // Per HTML spec, contenteditable is editable when the attribute is present
875
+ // with value "true", "" (empty string), or "plaintext-only". Bare attribute
876
+ // (no value) is also treated as empty string by the browser DOM parser.
877
+ function isContentEditable(value: string | undefined | null): boolean {
878
+ if (value == null) {
879
+ return false
880
+ }
881
+ const v = value.trim().toLowerCase()
882
+ return v === '' || v === 'true' || v === 'plaintext-only'
883
+ }
884
+
856
885
  function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
857
886
  for (const str of haystack) {
858
887
  if (str.includes(needle)) {
@@ -1017,11 +1046,41 @@ export async function getAriaSnapshot({
1017
1046
  axById.set(node.nodeId, node)
1018
1047
  }
1019
1048
 
1049
+ // Index AX nodes by backendDOMNodeId for O(1) lookups during promotion
1050
+ // and root finding (instead of repeated O(n) axNodes.find() calls)
1051
+ const axByBackendId = new Map<Protocol.DOM.BackendNodeId, Protocol.Accessibility.AXNode>()
1052
+ for (const node of axNodes) {
1053
+ if (node.backendDOMNodeId) {
1054
+ axByBackendId.set(node.backendDOMNodeId, node)
1055
+ }
1056
+ }
1057
+
1058
+ // Promote contenteditable elements that Chrome's AX tree doesn't classify as
1059
+ // interactive. Rich text editors (ProseMirror, Tiptap, Slate, Lexical, etc.) use
1060
+ // bare <div contenteditable="true"> without role="textbox", so Chrome reports
1061
+ // them as "generic" and they become invisible in the snapshot. We detect these
1062
+ // via the DOM tree and override the AX role to "textbox" so they appear as
1063
+ // interactive elements the AI can target.
1064
+ const promotedContentEditableIds = new Set<Protocol.DOM.BackendNodeId>()
1065
+ for (const [, domInfo] of domByBackendId) {
1066
+ if (!isContentEditable(domInfo.attributes.get('contenteditable'))) {
1067
+ continue
1068
+ }
1069
+ const axNode = axByBackendId.get(domInfo.backendNodeId)
1070
+ if (!axNode) {
1071
+ continue
1072
+ }
1073
+ const currentRole = getAxRole(axNode)
1074
+ if (INTERACTIVE_ROLES.has(currentRole)) {
1075
+ continue
1076
+ }
1077
+ axNode.role = { type: 'role', value: 'textbox' }
1078
+ promotedContentEditableIds.add(domInfo.backendNodeId)
1079
+ }
1080
+
1020
1081
  const findRootAxNodeId = (): Protocol.Accessibility.AXNodeId | null => {
1021
1082
  if (scopeRootBackendId) {
1022
- const scoped = axNodes.find((node) => {
1023
- return node.backendDOMNodeId === scopeRootBackendId
1024
- })
1083
+ const scoped = axByBackendId.get(scopeRootBackendId)
1025
1084
  if (scoped) {
1026
1085
  return scoped.nodeId
1027
1086
  }
@@ -1075,6 +1134,12 @@ export async function getAriaSnapshot({
1075
1134
  if (stable && count === 0) {
1076
1135
  selector = buildLocatorFromStable(stable)
1077
1136
  }
1137
+ // For promoted contenteditable elements without a stable selector, store
1138
+ // [contenteditable="true"] so getSelectorForRef() doesn't fall back to
1139
+ // role=textbox which Playwright can't match on bare contenteditable divs.
1140
+ if (!selector && options.backendNodeId != null && promotedContentEditableIds.has(options.backendNodeId)) {
1141
+ selector = '[contenteditable="true"]'
1142
+ }
1078
1143
 
1079
1144
  refs.push({ ref, role: options.role, name: options.name, selector, backendNodeId: options.backendNodeId })
1080
1145
  return ref
@@ -1111,6 +1176,7 @@ export async function getAriaSnapshot({
1111
1176
  labelContext: false,
1112
1177
  refFilter,
1113
1178
  domByBackendId,
1179
+ promotedContentEditableIds,
1114
1180
  createRefForNode,
1115
1181
  }).nodes
1116
1182
  }
@@ -1119,6 +1185,7 @@ export async function getAriaSnapshot({
1119
1185
  ancestorNames: [],
1120
1186
  refFilter,
1121
1187
  domByBackendId,
1188
+ promotedContentEditableIds,
1122
1189
  createRefForNode,
1123
1190
  }).nodes
1124
1191
  })
@@ -1538,7 +1605,7 @@ export async function screenshotWithAccessibilityLabels({
1538
1605
  // Generate unique filename with timestamp
1539
1606
  const timestamp = Date.now()
1540
1607
  const random = Math.random().toString(36).slice(2, 6)
1541
- const filename = `playwriter-screenshot-${timestamp}-${random}.jpg`
1608
+ const filename = `playwriter-screenshot-${timestamp}-${random}.png`
1542
1609
 
1543
1610
  // Use ./tmp folder (gitignored) instead of system temp
1544
1611
  const tmpDir = path.join(process.cwd(), 'tmp')
@@ -1560,11 +1627,11 @@ export async function screenshotWithAccessibilityLabels({
1560
1627
  const clipWidth = sharp ? viewport.width : Math.min(viewport.width, LLM_MAX_DIMENSION)
1561
1628
  const clipHeight = sharp ? viewport.height : Math.min(viewport.height, LLM_MAX_DIMENSION)
1562
1629
 
1563
- // Take viewport screenshot with scale: 'css' to ignore device pixel ratio
1630
+ // Take viewport screenshot as PNG for Kitty Graphics Protocol compatibility.
1631
+ // PNG is lossless and the only format extracted by kitty-graphics-agent (f=100).
1564
1632
  const screenshotStart = Date.now()
1565
1633
  const rawBuffer = await page.screenshot({
1566
- type: 'jpeg',
1567
- quality: 80,
1634
+ type: 'png',
1568
1635
  scale: 'css',
1569
1636
  clip: { x: 0, y: 0, width: clipWidth, height: clipHeight },
1570
1637
  })
@@ -1580,7 +1647,7 @@ export async function screenshotWithAccessibilityLabels({
1580
1647
  return rawBuffer
1581
1648
  }
1582
1649
  try {
1583
- const result = await resizeImage({ input: rawBuffer })
1650
+ const result = await resizeImageForAgent({ input: rawBuffer, format: 'png' })
1584
1651
  return result.buffer
1585
1652
  } catch (err) {
1586
1653
  logger?.error?.('[playwriter] sharp resize failed, using raw buffer:', err)
@@ -1604,7 +1671,7 @@ export async function screenshotWithAccessibilityLabels({
1604
1671
  collector.push({
1605
1672
  path: screenshotPath,
1606
1673
  base64,
1607
- mimeType: 'image/jpeg',
1674
+ mimeType: 'image/png',
1608
1675
  snapshot,
1609
1676
  labelCount,
1610
1677
  })
@@ -0,0 +1,7 @@
1
+ - labeltext:
2
+ - text: "Editor:"
3
+ - textbox "Main editor" [id="editor1"]
4
+ - role=textbox >> nth=0
5
+ - role=textbox >> nth=1
6
+ - role=textbox[name="Normal text input for comparison"]
7
+ - role=textbox[name="Normal textarea for comparison"]
@@ -0,0 +1,14 @@
1
+ - heading "Rich Text Editor Test"
2
+ - labeltext:
3
+ - text: "Editor:"
4
+ - textbox "Main editor" [id="editor1"]:
5
+ - paragraph:
6
+ - text: "This is some initial content in a contenteditable editor."
7
+ - role=textbox >> nth=0:
8
+ - paragraph:
9
+ - text: "Second editor with no label or id."
10
+ - role=textbox >> nth=1:
11
+ - paragraph:
12
+ - text: "Third editor simulating ProseMirror class."
13
+ - role=textbox[name="Normal text input for comparison"]
14
+ - role=textbox[name="Normal textarea for comparison"]
@@ -1,6 +1,4 @@
1
1
  - role=link[name="Skip to content"]
2
- - role=link[name="join the product roadmap webinar"]
3
- - role=button[name="Close"]
4
2
  - banner:
5
3
  - role=link[name="Homepage"]
6
4
  - navigation "Global":
@@ -1,8 +1,4 @@
1
1
  - role=link[name="Skip to content"]
2
- - alert "Announcement":
3
- - text: "Inside GitHub’s roadmap for agentic development —"
4
- - role=link[name="join the product roadmap webinar"]
5
- - role=button[name="Close"]
6
2
  - banner:
7
3
  - heading "Navigation Menu"
8
4
  - role=link[name="Homepage"]