playwriter 0.0.33 → 0.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aria-snapshot.d.ts +68 -0
- package/dist/aria-snapshot.d.ts.map +1 -0
- package/dist/aria-snapshot.js +359 -0
- package/dist/aria-snapshot.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +95 -5
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cdp-session.d.ts +24 -3
- package/dist/cdp-session.d.ts.map +1 -1
- package/dist/cdp-session.js +23 -0
- package/dist/cdp-session.js.map +1 -1
- package/dist/debugger-api.md +4 -3
- package/dist/debugger.d.ts +4 -3
- package/dist/debugger.d.ts.map +1 -1
- package/dist/debugger.js +3 -1
- package/dist/debugger.js.map +1 -1
- package/dist/editor-api.md +2 -2
- package/dist/editor.d.ts +2 -2
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1 -0
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +151 -14
- package/dist/mcp.js.map +1 -1
- package/dist/mcp.test.js +340 -5
- package/dist/mcp.test.js.map +1 -1
- package/dist/protocol.d.ts +12 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/react-source.d.ts +3 -3
- package/dist/react-source.d.ts.map +1 -1
- package/dist/react-source.js +3 -1
- package/dist/react-source.js.map +1 -1
- package/dist/scoped-fs.d.ts +94 -0
- package/dist/scoped-fs.d.ts.map +1 -0
- package/dist/scoped-fs.js +356 -0
- package/dist/scoped-fs.js.map +1 -0
- package/dist/styles-api.md +3 -3
- package/dist/styles.d.ts +3 -3
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +3 -1
- package/dist/styles.js.map +1 -1
- package/package.json +13 -13
- package/src/aria-snapshot.ts +446 -0
- package/src/assets/aria-labels-github-snapshot.txt +605 -0
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-google-snapshot.txt +110 -0
- package/src/assets/aria-labels-google.png +0 -0
- package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/cdp-relay.ts +103 -5
- package/src/cdp-session.ts +50 -3
- package/src/debugger.ts +6 -4
- package/src/editor.ts +4 -3
- package/src/index.ts +8 -0
- package/src/mcp.test.ts +424 -5
- package/src/mcp.ts +242 -66
- package/src/prompt.md +209 -167
- package/src/protocol.ts +14 -1
- package/src/react-source.ts +5 -3
- package/src/scoped-fs.ts +411 -0
- package/src/styles.ts +5 -3
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { Page, Locator, ElementHandle } from 'playwright-core'
|
|
2
|
+
|
|
3
|
+
export interface AriaRef {
|
|
4
|
+
role: string
|
|
5
|
+
name: string
|
|
6
|
+
ref: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AriaSnapshotResult {
|
|
10
|
+
snapshot: string
|
|
11
|
+
refToElement: Map<string, { role: string; name: string }>
|
|
12
|
+
refHandles: Array<{ ref: string; handle: ElementHandle }>
|
|
13
|
+
getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
|
|
14
|
+
getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
|
|
15
|
+
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LABELS_CONTAINER_ID = '__playwriter_labels__'
|
|
19
|
+
|
|
20
|
+
// Roles that represent truly interactive elements (can be clicked, typed into, etc.)
|
|
21
|
+
const INTERACTIVE_ROLES = new Set([
|
|
22
|
+
'button',
|
|
23
|
+
'link',
|
|
24
|
+
'textbox',
|
|
25
|
+
'combobox',
|
|
26
|
+
'searchbox',
|
|
27
|
+
'checkbox',
|
|
28
|
+
'radio',
|
|
29
|
+
'slider',
|
|
30
|
+
'spinbutton',
|
|
31
|
+
'switch',
|
|
32
|
+
'menuitem',
|
|
33
|
+
'menuitemcheckbox',
|
|
34
|
+
'menuitemradio',
|
|
35
|
+
'option',
|
|
36
|
+
'tab',
|
|
37
|
+
'treeitem',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
// Color categories for different role types - warm color scheme
|
|
41
|
+
// Format: [gradient-top, gradient-bottom, border]
|
|
42
|
+
const ROLE_COLORS: Record<string, [string, string, string]> = {
|
|
43
|
+
// Links - yellow (Vimium-style)
|
|
44
|
+
link: ['#FFF785', '#FFC542', '#E3BE23'],
|
|
45
|
+
// Buttons - orange
|
|
46
|
+
button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
|
|
47
|
+
// Text inputs - coral/red
|
|
48
|
+
textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
49
|
+
combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
50
|
+
searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
51
|
+
spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
52
|
+
// Checkboxes/Radios/Switches - warm pink
|
|
53
|
+
checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
|
54
|
+
radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
|
55
|
+
switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
|
56
|
+
// Sliders - peach
|
|
57
|
+
slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
|
|
58
|
+
// Menu items - salmon
|
|
59
|
+
menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
|
|
60
|
+
menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
|
|
61
|
+
menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
|
|
62
|
+
// Tabs/Options - amber
|
|
63
|
+
tab: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
64
|
+
option: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
65
|
+
treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Default yellow for unknown roles
|
|
69
|
+
const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
|
|
70
|
+
|
|
71
|
+
// Use String.raw for CSS syntax highlighting in editors
|
|
72
|
+
const css = String.raw
|
|
73
|
+
|
|
74
|
+
const LABEL_STYLES = css`
|
|
75
|
+
.__pw_label__ {
|
|
76
|
+
position: absolute;
|
|
77
|
+
font: bold 11px Helvetica, Arial, sans-serif;
|
|
78
|
+
padding: 1px 4px;
|
|
79
|
+
border-radius: 3px;
|
|
80
|
+
color: black;
|
|
81
|
+
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
}
|
|
84
|
+
`
|
|
85
|
+
|
|
86
|
+
const CONTAINER_STYLES = css`
|
|
87
|
+
position: absolute;
|
|
88
|
+
left: 0;
|
|
89
|
+
top: 0;
|
|
90
|
+
z-index: 2147483647;
|
|
91
|
+
pointer-events: none;
|
|
92
|
+
`
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get an accessibility snapshot with utilities to look up aria refs for elements.
|
|
96
|
+
* Uses Playwright's internal aria-ref selector engine.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* const { snapshot, getRefsForLocators } = await getAriaSnapshot({ page })
|
|
101
|
+
* const refs = await getRefsForLocators([page.locator('button'), page.locator('a')])
|
|
102
|
+
* // refs[0].ref is e.g. "e5" - use page.locator('aria-ref=e5') to select
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export async function getAriaSnapshot({ page }: { page: Page }): Promise<AriaSnapshotResult> {
|
|
106
|
+
const snapshotMethod = (page as any)._snapshotForAI
|
|
107
|
+
if (!snapshotMethod) {
|
|
108
|
+
throw new Error('_snapshotForAI not available. Ensure you are using Playwright.')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const snapshot = await snapshotMethod.call(page)
|
|
112
|
+
const snapshotStr = typeof snapshot === 'string' ? snapshot : (snapshot.full || JSON.stringify(snapshot, null, 2))
|
|
113
|
+
|
|
114
|
+
// Discover refs by probing aria-ref=e1, e2, e3... until 10 consecutive misses
|
|
115
|
+
const refToElement = new Map<string, { role: string; name: string }>()
|
|
116
|
+
const refHandles: Array<{ ref: string; handle: ElementHandle }> = []
|
|
117
|
+
|
|
118
|
+
let consecutiveMisses = 0
|
|
119
|
+
let refNum = 1
|
|
120
|
+
|
|
121
|
+
while (consecutiveMisses < 10) {
|
|
122
|
+
const ref = `e${refNum++}`
|
|
123
|
+
try {
|
|
124
|
+
const locator = page.locator(`aria-ref=${ref}`)
|
|
125
|
+
if (await locator.count() === 1) {
|
|
126
|
+
consecutiveMisses = 0
|
|
127
|
+
const [info, handle] = await Promise.all([
|
|
128
|
+
locator.evaluate((el: any) => ({
|
|
129
|
+
role: el.getAttribute('role') || {
|
|
130
|
+
a: el.hasAttribute('href') ? 'link' : 'generic',
|
|
131
|
+
button: 'button', input: { button: 'button', checkbox: 'checkbox', radio: 'radio',
|
|
132
|
+
text: 'textbox', search: 'searchbox', number: 'spinbutton', range: 'slider',
|
|
133
|
+
}[el.type] || 'textbox', select: 'combobox', textarea: 'textbox', img: 'img',
|
|
134
|
+
nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo',
|
|
135
|
+
}[el.tagName.toLowerCase()] || 'generic',
|
|
136
|
+
name: el.getAttribute('aria-label') || el.textContent?.trim() || el.placeholder || '',
|
|
137
|
+
})),
|
|
138
|
+
locator.elementHandle({ timeout: 1000 }),
|
|
139
|
+
])
|
|
140
|
+
refToElement.set(ref, info)
|
|
141
|
+
if (handle) {
|
|
142
|
+
refHandles.push({ ref, handle })
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
consecutiveMisses++
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
consecutiveMisses++
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Find refs for multiple locators in a single evaluate call
|
|
153
|
+
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
|
|
154
|
+
if (locators.length === 0 || refHandles.length === 0) {
|
|
155
|
+
return locators.map(() => null)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const targetHandles = await Promise.all(
|
|
159
|
+
locators.map(async (loc) => {
|
|
160
|
+
try {
|
|
161
|
+
return 'elementHandle' in loc
|
|
162
|
+
? await (loc as Locator).elementHandle({ timeout: 1000 })
|
|
163
|
+
: (loc as ElementHandle)
|
|
164
|
+
} catch {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const matchingRefs = await page.evaluate(
|
|
171
|
+
({ targets, candidates }) => targets.map((target) => {
|
|
172
|
+
if (!target) return null
|
|
173
|
+
return candidates.find(({ element }) => element === target)?.ref ?? null
|
|
174
|
+
}),
|
|
175
|
+
{ targets: targetHandles, candidates: refHandles.map(({ ref, handle }) => ({ ref, element: handle })) }
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return matchingRefs.map((ref) => {
|
|
179
|
+
if (!ref) return null
|
|
180
|
+
const info = refToElement.get(ref)
|
|
181
|
+
return info ? { ...info, ref } : null
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
snapshot: snapshotStr,
|
|
187
|
+
refToElement,
|
|
188
|
+
refHandles,
|
|
189
|
+
getRefsForLocators,
|
|
190
|
+
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
|
|
191
|
+
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Show Vimium-style labels on interactive elements.
|
|
197
|
+
* Labels are yellow badges positioned above each element showing the aria ref (e.g., "e1", "e2").
|
|
198
|
+
* Use with screenshots so agents can see which elements are interactive.
|
|
199
|
+
*
|
|
200
|
+
* Labels auto-hide after 5 seconds to prevent stale labels remaining on the page.
|
|
201
|
+
* Call this function again if the page HTML changes to get fresh labels.
|
|
202
|
+
*
|
|
203
|
+
* By default, only shows labels for truly interactive roles (button, link, textbox, etc.)
|
|
204
|
+
* to reduce visual clutter. Set `interactiveOnly: false` to show all elements with refs.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```ts
|
|
208
|
+
* const { snapshot, labelCount } = await showAriaRefLabels({ page })
|
|
209
|
+
* await page.screenshot({ path: '/tmp/screenshot.png' })
|
|
210
|
+
* // Agent sees [e5] label on "Submit" button
|
|
211
|
+
* await page.locator('aria-ref=e5').click()
|
|
212
|
+
* // Labels auto-hide after 5 seconds, or call hideAriaRefLabels() manually
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
216
|
+
page: Page
|
|
217
|
+
interactiveOnly?: boolean
|
|
218
|
+
}): Promise<{
|
|
219
|
+
snapshot: string
|
|
220
|
+
labelCount: number
|
|
221
|
+
}> {
|
|
222
|
+
const { snapshot, refHandles, refToElement } = await getAriaSnapshot({ page })
|
|
223
|
+
|
|
224
|
+
// Filter to only interactive elements if requested
|
|
225
|
+
const filteredRefs = interactiveOnly
|
|
226
|
+
? refHandles.filter(({ ref }) => {
|
|
227
|
+
const info = refToElement.get(ref)
|
|
228
|
+
return info && INTERACTIVE_ROLES.has(info.role)
|
|
229
|
+
})
|
|
230
|
+
: refHandles
|
|
231
|
+
|
|
232
|
+
// Build refs with role info for color coding
|
|
233
|
+
const refsWithRoles = filteredRefs.map(({ ref, handle }) => ({
|
|
234
|
+
ref,
|
|
235
|
+
element: handle,
|
|
236
|
+
role: refToElement.get(ref)?.role || 'generic',
|
|
237
|
+
}))
|
|
238
|
+
|
|
239
|
+
// Single evaluate call: create container, styles, and all labels
|
|
240
|
+
// ElementHandles get unwrapped to DOM elements in browser context
|
|
241
|
+
// Using 'any' types here since this code runs in browser context
|
|
242
|
+
const labelCount = await page.evaluate(
|
|
243
|
+
// Using 'any' for browser types since this runs in browser context
|
|
244
|
+
({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
|
|
245
|
+
refs: Array<{
|
|
246
|
+
ref: string
|
|
247
|
+
role: string
|
|
248
|
+
element: any // Element in browser context
|
|
249
|
+
}>
|
|
250
|
+
containerId: string
|
|
251
|
+
containerStyles: string
|
|
252
|
+
labelStyles: string
|
|
253
|
+
roleColors: Record<string, [string, string, string]>
|
|
254
|
+
defaultColors: [string, string, string]
|
|
255
|
+
}) => {
|
|
256
|
+
const doc = (globalThis as any).document
|
|
257
|
+
const win = globalThis as any
|
|
258
|
+
|
|
259
|
+
// Cancel any pending auto-hide timer from previous call
|
|
260
|
+
const timerKey = '__playwriter_labels_timer__'
|
|
261
|
+
if (win[timerKey]) {
|
|
262
|
+
win.clearTimeout(win[timerKey])
|
|
263
|
+
win[timerKey] = null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Remove existing labels if present (idempotent)
|
|
267
|
+
doc.getElementById(containerId)?.remove()
|
|
268
|
+
|
|
269
|
+
// Create container - absolute positioned, max z-index, no pointer events
|
|
270
|
+
const container = doc.createElement('div')
|
|
271
|
+
container.id = containerId
|
|
272
|
+
container.style.cssText = containerStyles
|
|
273
|
+
|
|
274
|
+
// Inject base label CSS
|
|
275
|
+
const style = doc.createElement('style')
|
|
276
|
+
style.textContent = labelStyles
|
|
277
|
+
container.appendChild(style)
|
|
278
|
+
|
|
279
|
+
// Track placed label rectangles for overlap detection
|
|
280
|
+
// Each rect is { left, top, right, bottom } in viewport coordinates
|
|
281
|
+
const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
|
|
282
|
+
|
|
283
|
+
// Estimate label dimensions (11px font + padding)
|
|
284
|
+
const LABEL_HEIGHT = 16
|
|
285
|
+
const LABEL_CHAR_WIDTH = 7 // approximate width per character
|
|
286
|
+
|
|
287
|
+
// Parse alpha from rgb/rgba color string (getComputedStyle always returns these formats)
|
|
288
|
+
const getColorAlpha = (color: string): number => {
|
|
289
|
+
if (color === 'transparent') return 0
|
|
290
|
+
// Match rgba(r, g, b, a) or rgb(r, g, b)
|
|
291
|
+
const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
|
|
292
|
+
if (match) {
|
|
293
|
+
return match[1] !== undefined ? parseFloat(match[1]) : 1
|
|
294
|
+
}
|
|
295
|
+
return 1 // Default to opaque for unrecognized formats
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check if an element has an opaque background that would block elements behind it
|
|
299
|
+
const isOpaqueElement = (el: any): boolean => {
|
|
300
|
+
const style = win.getComputedStyle(el)
|
|
301
|
+
|
|
302
|
+
// Check element opacity
|
|
303
|
+
const opacity = parseFloat(style.opacity)
|
|
304
|
+
if (opacity < 0.1) return false
|
|
305
|
+
|
|
306
|
+
// Check background-color alpha
|
|
307
|
+
const bgAlpha = getColorAlpha(style.backgroundColor)
|
|
308
|
+
if (bgAlpha > 0.1) return true
|
|
309
|
+
|
|
310
|
+
// Check if has background-image (usually opaque)
|
|
311
|
+
if (style.backgroundImage !== 'none') return true
|
|
312
|
+
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check if element is visible (not covered by opaque overlay)
|
|
317
|
+
const isElementVisible = (element: any, rect: any): boolean => {
|
|
318
|
+
const centerX = rect.left + rect.width / 2
|
|
319
|
+
const centerY = rect.top + rect.height / 2
|
|
320
|
+
|
|
321
|
+
// Get all elements at this point, from top to bottom
|
|
322
|
+
const stack = doc.elementsFromPoint(centerX, centerY) as any[]
|
|
323
|
+
|
|
324
|
+
// Find our target element in the stack
|
|
325
|
+
const targetIndex = stack.findIndex((el: any) =>
|
|
326
|
+
element.contains(el) || el.contains(element)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
// Element not in stack at all - not visible
|
|
330
|
+
if (targetIndex === -1) return false
|
|
331
|
+
|
|
332
|
+
// Check if any opaque element is above our target
|
|
333
|
+
for (let i = 0; i < targetIndex; i++) {
|
|
334
|
+
const el = stack[i]
|
|
335
|
+
// Skip our own overlay container
|
|
336
|
+
if (el.id === containerId) continue
|
|
337
|
+
// Skip pointer-events: none elements (decorative overlays)
|
|
338
|
+
if (win.getComputedStyle(el).pointerEvents === 'none') continue
|
|
339
|
+
// If this element is opaque, our target is blocked
|
|
340
|
+
if (isOpaqueElement(el)) return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Check if two rectangles overlap
|
|
347
|
+
const rectsOverlap = (
|
|
348
|
+
a: { left: number; top: number; right: number; bottom: number },
|
|
349
|
+
b: { left: number; top: number; right: number; bottom: number }
|
|
350
|
+
) => {
|
|
351
|
+
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Create label for each interactive element
|
|
355
|
+
let count = 0
|
|
356
|
+
for (const { ref, role, element } of refs) {
|
|
357
|
+
const rect = element.getBoundingClientRect()
|
|
358
|
+
|
|
359
|
+
// Skip elements with no size (hidden)
|
|
360
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Skip elements that are covered by opaque overlays
|
|
365
|
+
if (!isElementVisible(element, rect)) {
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Calculate label position and dimensions
|
|
370
|
+
const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8 // +8 for padding
|
|
371
|
+
const labelLeft = rect.left
|
|
372
|
+
const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
|
|
373
|
+
const labelRect = {
|
|
374
|
+
left: labelLeft,
|
|
375
|
+
top: labelTop,
|
|
376
|
+
right: labelLeft + labelWidth,
|
|
377
|
+
bottom: labelTop + LABEL_HEIGHT,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Skip if this label would overlap with any already-placed label
|
|
381
|
+
const overlaps = placedLabels.some((placed) => rectsOverlap(labelRect, placed))
|
|
382
|
+
if (overlaps) {
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Get colors for this role
|
|
387
|
+
const [gradTop, gradBottom, border] = roleColors[role] || defaultColors
|
|
388
|
+
|
|
389
|
+
// Place the label
|
|
390
|
+
const label = doc.createElement('div')
|
|
391
|
+
label.className = '__pw_label__'
|
|
392
|
+
label.textContent = ref
|
|
393
|
+
label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
|
|
394
|
+
label.style.border = `1px solid ${border}`
|
|
395
|
+
|
|
396
|
+
// Position above element, accounting for scroll
|
|
397
|
+
label.style.left = `${win.scrollX + labelLeft}px`
|
|
398
|
+
label.style.top = `${win.scrollY + labelTop}px`
|
|
399
|
+
|
|
400
|
+
container.appendChild(label)
|
|
401
|
+
placedLabels.push(labelRect)
|
|
402
|
+
count++
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
doc.documentElement.appendChild(container)
|
|
406
|
+
|
|
407
|
+
// Auto-hide labels after 5 seconds to prevent stale labels
|
|
408
|
+
// Store timer ID so it can be cancelled if showAriaRefLabels is called again
|
|
409
|
+
win[timerKey] = win.setTimeout(() => {
|
|
410
|
+
doc.getElementById(containerId)?.remove()
|
|
411
|
+
win[timerKey] = null
|
|
412
|
+
}, 5000)
|
|
413
|
+
|
|
414
|
+
return count
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
refs: refsWithRoles.map(({ ref, role, element }) => ({ ref, role, element })),
|
|
418
|
+
containerId: LABELS_CONTAINER_ID,
|
|
419
|
+
containerStyles: CONTAINER_STYLES,
|
|
420
|
+
labelStyles: LABEL_STYLES,
|
|
421
|
+
roleColors: ROLE_COLORS,
|
|
422
|
+
defaultColors: DEFAULT_COLORS,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
return { snapshot, labelCount }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Remove all aria ref labels from the page.
|
|
431
|
+
*/
|
|
432
|
+
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
|
|
433
|
+
await page.evaluate((id) => {
|
|
434
|
+
const doc = (globalThis as any).document
|
|
435
|
+
const win = globalThis as any
|
|
436
|
+
|
|
437
|
+
// Cancel any pending auto-hide timer
|
|
438
|
+
const timerKey = '__playwriter_labels_timer__'
|
|
439
|
+
if (win[timerKey]) {
|
|
440
|
+
win.clearTimeout(win[timerKey])
|
|
441
|
+
win[timerKey] = null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
doc.getElementById(id)?.remove()
|
|
445
|
+
}, LABELS_CONTAINER_ID)
|
|
446
|
+
}
|