playwriter 0.0.80 → 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.
- package/dist/a11y-client.js +18 -8
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +3 -1
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/bippy.js +1 -1
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +84 -0
- package/dist/cdp-relay.js.map +1 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +8 -6
- package/dist/executor.js.map +1 -1
- package/dist/ffmpeg.d.ts +6 -6
- package/dist/ffmpeg.d.ts.map +1 -1
- package/dist/ffmpeg.js +6 -6
- package/dist/ffmpeg.js.map +1 -1
- package/dist/ghost-cursor-client.js +15 -9
- package/dist/prompt.md +71 -337
- package/dist/readability.js +16 -2
- package/dist/recording-ghost-cursor.d.ts.map +1 -1
- package/dist/recording-ghost-cursor.js +1 -1
- package/dist/recording-ghost-cursor.js.map +1 -1
- package/dist/relay-client.js +1 -1
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +344 -16
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +115 -0
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/screen-recording.d.ts +24 -0
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +62 -0
- package/dist/screen-recording.js.map +1 -1
- package/dist/screen-recording.test.d.ts +2 -0
- package/dist/screen-recording.test.d.ts.map +1 -0
- package/dist/screen-recording.test.js +102 -0
- package/dist/screen-recording.test.js.map +1 -0
- package/dist/selector-generator.js +1 -1
- package/package.json +2 -2
- package/src/aria-snapshot.ts +3 -1
- package/src/aria-snapshots/github-interactive.txt +2 -0
- package/src/aria-snapshots/github-raw.txt +4 -0
- package/src/aria-snapshots/hackernews-interactive.txt +238 -241
- package/src/aria-snapshots/hackernews-raw.txt +267 -271
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/cdp-relay.ts +110 -0
- package/src/executor.ts +8 -6
- package/src/ffmpeg.ts +8 -8
- package/src/ghost-cursor-client.ts +3 -2
- package/src/recording-ghost-cursor.ts +7 -1
- package/src/relay-client.ts +1 -1
- package/src/relay-core.test.ts +378 -17
- package/src/relay-navigation.test.ts +132 -0
- package/src/screen-recording.test.ts +111 -0
- package/src/screen-recording.ts +81 -0
- package/src/skill.md +71 -339
- package/src/snapshots/shadcn-ui-accessibility-full.md +182 -180
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +120 -118
package/src/relay-core.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createMCPClient } from './mcp-client.js'
|
|
2
2
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
3
|
+
import { chromium } from '@xmorse/playwright-core'
|
|
3
4
|
import { getCDPSessionForPage } from './cdp-session.js'
|
|
4
|
-
import { getCdpUrl } from './utils.js'
|
|
5
|
+
import { getCdpUrl, LOG_CDP_FILE_PATH } from './utils.js'
|
|
6
|
+
import fs from 'node:fs'
|
|
5
7
|
import {
|
|
6
8
|
setupTestContext,
|
|
7
9
|
cleanupTestContext,
|
|
@@ -40,6 +42,30 @@ describe('Relay Core Tests', () => {
|
|
|
40
42
|
return testCtx.browserContext
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
const ensureConnectedTabForExecute = async (): Promise<void> => {
|
|
46
|
+
const browserContext = getBrowserContext()
|
|
47
|
+
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
48
|
+
const connectedTabCount = await serviceWorker.evaluate(async () => {
|
|
49
|
+
const state = globalThis.getExtensionState()
|
|
50
|
+
return state.tabs.size
|
|
51
|
+
})
|
|
52
|
+
if (connectedTabCount > 0) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const page = await browserContext.newPage()
|
|
57
|
+
await page.goto('about:blank')
|
|
58
|
+
await page.bringToFront()
|
|
59
|
+
|
|
60
|
+
await serviceWorker.evaluate(async () => {
|
|
61
|
+
await globalThis.toggleExtensionForActiveTab()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
await new Promise((r) => {
|
|
65
|
+
setTimeout(r, 100)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
it('should inject script via addScriptTag through CDP relay', async () => {
|
|
44
70
|
const browserContext = getBrowserContext()
|
|
45
71
|
const serviceWorker = await withTimeout({
|
|
@@ -97,6 +123,157 @@ describe('Relay Core Tests', () => {
|
|
|
97
123
|
await page.close()
|
|
98
124
|
}, 60000)
|
|
99
125
|
|
|
126
|
+
it('should emit download events for both Browser and Page domains in extension mode', async () => {
|
|
127
|
+
const browserContext = getBrowserContext()
|
|
128
|
+
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
129
|
+
const logFilePath = LOG_CDP_FILE_PATH
|
|
130
|
+
const logLineCountBefore = fs.existsSync(logFilePath)
|
|
131
|
+
? fs
|
|
132
|
+
.readFileSync(logFilePath, 'utf-8')
|
|
133
|
+
.split('\n')
|
|
134
|
+
.filter((line) => {
|
|
135
|
+
return line.trim().length > 0
|
|
136
|
+
}).length
|
|
137
|
+
: 0
|
|
138
|
+
|
|
139
|
+
const server = await createSimpleServer({
|
|
140
|
+
routes: {
|
|
141
|
+
'/': `<!doctype html>
|
|
142
|
+
<html>
|
|
143
|
+
<body>
|
|
144
|
+
<button id="download-button">Download</button>
|
|
145
|
+
<script>
|
|
146
|
+
const button = document.getElementById('download-button');
|
|
147
|
+
button.addEventListener('click', () => {
|
|
148
|
+
const blob = new Blob(['playwriter-download-test'], { type: 'text/plain' });
|
|
149
|
+
const url = URL.createObjectURL(blob);
|
|
150
|
+
const anchor = document.createElement('a');
|
|
151
|
+
anchor.href = url;
|
|
152
|
+
anchor.download = 'playwriter-download-test.txt';
|
|
153
|
+
document.body.appendChild(anchor);
|
|
154
|
+
anchor.click();
|
|
155
|
+
anchor.remove();
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
URL.revokeObjectURL(url);
|
|
158
|
+
}, 1000);
|
|
159
|
+
});
|
|
160
|
+
</script>
|
|
161
|
+
</body>
|
|
162
|
+
</html>`,
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const page = await browserContext.newPage()
|
|
167
|
+
await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
|
|
168
|
+
await page.bringToFront()
|
|
169
|
+
|
|
170
|
+
await serviceWorker.evaluate(async () => {
|
|
171
|
+
await globalThis.toggleExtensionForActiveTab()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const directBrowser = await withTimeout({
|
|
175
|
+
promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
|
|
176
|
+
timeoutMs: 10000,
|
|
177
|
+
errorMessage: 'Timed out connecting over CDP for download reproduction test',
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const connectedPage = directBrowser
|
|
181
|
+
.contexts()[0]
|
|
182
|
+
.pages()
|
|
183
|
+
.find((candidatePage) => {
|
|
184
|
+
return candidatePage.url() === server.baseUrl + '/'
|
|
185
|
+
})
|
|
186
|
+
if (!connectedPage) {
|
|
187
|
+
throw new Error('Connected page not found for download reproduction test')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const downloadResult = await Promise.all([
|
|
191
|
+
connectedPage.waitForEvent('download', { timeout: 3000 }).then(
|
|
192
|
+
(download) => {
|
|
193
|
+
return { timedOut: false, suggestedFilename: download.suggestedFilename() }
|
|
194
|
+
},
|
|
195
|
+
(error: Error) => {
|
|
196
|
+
return { timedOut: true, errorMessage: error.message }
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
connectedPage.click('#download-button'),
|
|
200
|
+
])
|
|
201
|
+
|
|
202
|
+
expect(downloadResult[0]).toMatchInlineSnapshot(`
|
|
203
|
+
{
|
|
204
|
+
"suggestedFilename": "playwriter-download-test.txt",
|
|
205
|
+
"timedOut": false,
|
|
206
|
+
}
|
|
207
|
+
`)
|
|
208
|
+
|
|
209
|
+
await directBrowser.close()
|
|
210
|
+
await page.close()
|
|
211
|
+
await server.close()
|
|
212
|
+
|
|
213
|
+
const logLinesAfter = fs
|
|
214
|
+
.readFileSync(logFilePath, 'utf-8')
|
|
215
|
+
.split('\n')
|
|
216
|
+
.filter((line) => {
|
|
217
|
+
return line.trim().length > 0
|
|
218
|
+
})
|
|
219
|
+
.slice(logLineCountBefore)
|
|
220
|
+
|
|
221
|
+
const newEntries = logLinesAfter
|
|
222
|
+
.map((line) => {
|
|
223
|
+
return tryJsonParse(line)
|
|
224
|
+
})
|
|
225
|
+
.filter((entry): entry is { direction: string; message: { method?: string } } => {
|
|
226
|
+
return Boolean(entry && typeof entry === 'object' && 'direction' in entry && 'message' in entry)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const methods = newEntries
|
|
230
|
+
.map((entry) => {
|
|
231
|
+
return {
|
|
232
|
+
direction: entry.direction,
|
|
233
|
+
method: typeof entry.message?.method === 'string' ? entry.message.method : 'response',
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.filter((entry) => {
|
|
237
|
+
return (
|
|
238
|
+
entry.method.includes('download') ||
|
|
239
|
+
entry.method === 'Browser.setDownloadBehavior' ||
|
|
240
|
+
entry.method === 'Page.setDownloadBehavior'
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const summary = {
|
|
245
|
+
hasBrowserSetDownloadBehavior: methods.some((entry) => {
|
|
246
|
+
return entry.direction === 'from-playwright' && entry.method === 'Browser.setDownloadBehavior'
|
|
247
|
+
}),
|
|
248
|
+
hasPageSetDownloadBehavior: methods.some((entry) => {
|
|
249
|
+
return entry.direction === 'to-extension' && entry.method === 'Page.setDownloadBehavior'
|
|
250
|
+
}),
|
|
251
|
+
hasPageDownloadWillBegin: methods.some((entry) => {
|
|
252
|
+
return entry.method === 'Page.downloadWillBegin'
|
|
253
|
+
}),
|
|
254
|
+
hasPageDownloadProgress: methods.some((entry) => {
|
|
255
|
+
return entry.method === 'Page.downloadProgress'
|
|
256
|
+
}),
|
|
257
|
+
hasBrowserDownloadWillBegin: methods.some((entry) => {
|
|
258
|
+
return entry.method === 'Browser.downloadWillBegin'
|
|
259
|
+
}),
|
|
260
|
+
hasBrowserDownloadProgress: methods.some((entry) => {
|
|
261
|
+
return entry.method === 'Browser.downloadProgress'
|
|
262
|
+
}),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
expect(summary).toMatchInlineSnapshot(`
|
|
266
|
+
{
|
|
267
|
+
"hasBrowserDownloadProgress": false,
|
|
268
|
+
"hasBrowserDownloadWillBegin": false,
|
|
269
|
+
"hasBrowserSetDownloadBehavior": true,
|
|
270
|
+
"hasPageDownloadProgress": false,
|
|
271
|
+
"hasPageDownloadWillBegin": false,
|
|
272
|
+
"hasPageSetDownloadBehavior": true,
|
|
273
|
+
}
|
|
274
|
+
`)
|
|
275
|
+
}, 120000)
|
|
276
|
+
|
|
100
277
|
it('should execute code and capture console output', async () => {
|
|
101
278
|
await client.callTool({
|
|
102
279
|
name: 'execute',
|
|
@@ -135,6 +312,116 @@ describe('Relay Core Tests', () => {
|
|
|
135
312
|
expect(result.content).toBeDefined()
|
|
136
313
|
}, 30000)
|
|
137
314
|
|
|
315
|
+
// Repro test for https://github.com/remorses/playwriter/issues/66.
|
|
316
|
+
// Current limitation: extension-mode routing does not support root-session
|
|
317
|
+
// Storage.getCookies in playwriter. MUST use Network.getCookies via page CDP
|
|
318
|
+
// session instead (see test below), so this repro stays skipped.
|
|
319
|
+
it.skip('should reproduce page.route failure in MCP execute path (issue #66)', async () => {
|
|
320
|
+
const server = await createSimpleServer({
|
|
321
|
+
routes: {
|
|
322
|
+
'/': '<!doctype html><html><body>route issue repro</body></html>',
|
|
323
|
+
'/api/data': '{"ok":true}',
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const result = await client.callTool({
|
|
329
|
+
name: 'execute',
|
|
330
|
+
arguments: {
|
|
331
|
+
code: js`
|
|
332
|
+
const newPage = await context.newPage();
|
|
333
|
+
state.issue66Page = newPage;
|
|
334
|
+
await newPage.goto('${server.baseUrl}', { waitUntil: 'domcontentloaded' });
|
|
335
|
+
|
|
336
|
+
let routeFetchError = null;
|
|
337
|
+
await newPage.route('**/api/**', async (route) => {
|
|
338
|
+
try {
|
|
339
|
+
const response = await route.fetch();
|
|
340
|
+
await route.fulfill({ response });
|
|
341
|
+
} catch (error) {
|
|
342
|
+
routeFetchError = error instanceof Error ? error.message : String(error);
|
|
343
|
+
await route.abort();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await newPage.evaluate(async () => {
|
|
348
|
+
await fetch('/api/data').catch(() => null);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return { routeFetchError };
|
|
352
|
+
`,
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const resultWithContent = result as { content?: unknown }
|
|
357
|
+
const content = Array.isArray(resultWithContent.content) ? resultWithContent.content : []
|
|
358
|
+
const firstContent = content[0]
|
|
359
|
+
const output =
|
|
360
|
+
typeof firstContent === 'object' && firstContent !== null && 'text' in firstContent
|
|
361
|
+
? String((firstContent as { text?: unknown }).text ?? '')
|
|
362
|
+
: ''
|
|
363
|
+
expect(output).toContain('routeFetchError')
|
|
364
|
+
expect(output).toContain('Storage.getCookies')
|
|
365
|
+
expect(output).toContain('No tab found for method Storage.getCookies')
|
|
366
|
+
} finally {
|
|
367
|
+
try {
|
|
368
|
+
await client.callTool({
|
|
369
|
+
name: 'execute',
|
|
370
|
+
arguments: {
|
|
371
|
+
code: js`
|
|
372
|
+
if (state.issue66Page && !state.issue66Page.isClosed()) {
|
|
373
|
+
await state.issue66Page.close();
|
|
374
|
+
}
|
|
375
|
+
delete state.issue66Page;
|
|
376
|
+
`,
|
|
377
|
+
},
|
|
378
|
+
})
|
|
379
|
+
} catch {
|
|
380
|
+
// Ignore cleanup failure if MCP disconnected due to the repro.
|
|
381
|
+
}
|
|
382
|
+
await server.close()
|
|
383
|
+
}
|
|
384
|
+
}, 30000)
|
|
385
|
+
|
|
386
|
+
it('should read cookies via Network.getCookies through page CDP session', async () => {
|
|
387
|
+
const browserContext = getBrowserContext()
|
|
388
|
+
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
389
|
+
|
|
390
|
+
const server = await createSimpleServer({
|
|
391
|
+
routes: {
|
|
392
|
+
'/': '<!doctype html><html><body>cookies test</body></html>',
|
|
393
|
+
},
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const page = await browserContext.newPage()
|
|
397
|
+
try {
|
|
398
|
+
await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
|
|
399
|
+
await page.bringToFront()
|
|
400
|
+
|
|
401
|
+
await serviceWorker.evaluate(async () => {
|
|
402
|
+
await globalThis.toggleExtensionForActiveTab()
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
await new Promise((r) => {
|
|
406
|
+
setTimeout(r, 200)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
await page.evaluate(() => {
|
|
410
|
+
document.cookie = 'issue66=ok; path=/'
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const cdpSession = await getCDPSessionForPage({ page })
|
|
414
|
+
const cookiesResult = await cdpSession.send('Network.getCookies', { urls: [page.url()] })
|
|
415
|
+
const cookie = cookiesResult.cookies.find((value) => {
|
|
416
|
+
return value.name === 'issue66'
|
|
417
|
+
})
|
|
418
|
+
expect(cookie?.value).toBe('ok')
|
|
419
|
+
} finally {
|
|
420
|
+
await page.close()
|
|
421
|
+
await server.close()
|
|
422
|
+
}
|
|
423
|
+
}, 30000)
|
|
424
|
+
|
|
138
425
|
it('should show extension as connected for pages created via newPage()', async () => {
|
|
139
426
|
const browserContext = getBrowserContext()
|
|
140
427
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
@@ -1020,6 +1307,8 @@ describe('Relay Core Tests', () => {
|
|
|
1020
1307
|
}, 60000)
|
|
1021
1308
|
|
|
1022
1309
|
it('should show descriptive error when clicking a hidden element', async () => {
|
|
1310
|
+
await ensureConnectedTabForExecute()
|
|
1311
|
+
|
|
1023
1312
|
// Create a fresh page and set content with a collapsed details element
|
|
1024
1313
|
await client.callTool({
|
|
1025
1314
|
name: 'execute',
|
|
@@ -1039,20 +1328,42 @@ describe('Relay Core Tests', () => {
|
|
|
1039
1328
|
name: 'execute',
|
|
1040
1329
|
arguments: {
|
|
1041
1330
|
code: js`
|
|
1042
|
-
await state.errorTestPage.click('#hidden-btn');
|
|
1331
|
+
await state.errorTestPage.click('#hidden-btn', { timeout: 100 });
|
|
1043
1332
|
`,
|
|
1044
1333
|
},
|
|
1045
1334
|
})
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1335
|
+
expect(result).toMatchInlineSnapshot(`
|
|
1336
|
+
{
|
|
1337
|
+
"content": [
|
|
1338
|
+
{
|
|
1339
|
+
"text": "
|
|
1340
|
+
Error executing code: page.click: Timeout 100ms exceeded. Element is not visible — it may be hidden by CSS, inside a collapsed <details>, inactive tab, or closed accordion. Try: interact with the page to reveal it first, or use { force: true } to skip visibility checks
|
|
1341
|
+
Call log:
|
|
1342
|
+
[2m - waiting for locator('#hidden-btn')[22m
|
|
1343
|
+
[2m - locator resolved to <button id="hidden-btn">Hidden Button</button>[22m
|
|
1344
|
+
[2m - attempting click action[22m
|
|
1345
|
+
[2m 2 × waiting for element to be visible, enabled and stable[22m
|
|
1346
|
+
[2m - element is not visible[22m
|
|
1347
|
+
[2m - retrying click action[22m
|
|
1348
|
+
[2m - waiting 20ms[22m
|
|
1349
|
+
[2m - waiting for element to be visible, enabled and stable[22m
|
|
1350
|
+
[2m - element is not visible[22m
|
|
1351
|
+
[2m - retrying click action[22m
|
|
1352
|
+
[2m - waiting 100ms[22m
|
|
1353
|
+
",
|
|
1354
|
+
"type": "text",
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
"isError": true,
|
|
1358
|
+
}
|
|
1359
|
+
`)
|
|
1051
1360
|
// Cleanup
|
|
1052
1361
|
await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
|
|
1053
1362
|
}, 30000)
|
|
1054
1363
|
|
|
1055
1364
|
it('should show descriptive error when clicking an element covered by another', async () => {
|
|
1365
|
+
await ensureConnectedTabForExecute()
|
|
1366
|
+
|
|
1056
1367
|
await client.callTool({
|
|
1057
1368
|
name: 'execute',
|
|
1058
1369
|
arguments: {
|
|
@@ -1071,18 +1382,47 @@ describe('Relay Core Tests', () => {
|
|
|
1071
1382
|
name: 'execute',
|
|
1072
1383
|
arguments: {
|
|
1073
1384
|
code: js`
|
|
1074
|
-
await state.errorTestPage.click('#covered-btn');
|
|
1385
|
+
await state.errorTestPage.click('#covered-btn', { timeout: 100 });
|
|
1075
1386
|
`,
|
|
1076
1387
|
},
|
|
1077
1388
|
})
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1389
|
+
expect(result).toMatchInlineSnapshot(`
|
|
1390
|
+
{
|
|
1391
|
+
"content": [
|
|
1392
|
+
{
|
|
1393
|
+
"text": "
|
|
1394
|
+
Error executing code: page.click: Timeout 100ms exceeded. <div id="overlay">Overlay</div> intercepts pointer events
|
|
1395
|
+
Call log:
|
|
1396
|
+
[2m - waiting for locator('#covered-btn')[22m
|
|
1397
|
+
[2m - locator resolved to <button id="covered-btn">Covered</button>[22m
|
|
1398
|
+
[2m - attempting click action[22m
|
|
1399
|
+
[2m 2 × waiting for element to be visible, enabled and stable[22m
|
|
1400
|
+
[2m - element is visible, enabled and stable[22m
|
|
1401
|
+
[2m - scrolling into view if needed[22m
|
|
1402
|
+
[2m - done scrolling[22m
|
|
1403
|
+
[2m - <div id="overlay">Overlay</div> intercepts pointer events[22m
|
|
1404
|
+
[2m - retrying click action[22m
|
|
1405
|
+
[2m - waiting 20ms[22m
|
|
1406
|
+
[2m - waiting for element to be visible, enabled and stable[22m
|
|
1407
|
+
[2m - element is visible, enabled and stable[22m
|
|
1408
|
+
[2m - scrolling into view if needed[22m
|
|
1409
|
+
[2m - done scrolling[22m
|
|
1410
|
+
[2m - <div id="overlay">Overlay</div> intercepts pointer events[22m
|
|
1411
|
+
[2m - retrying click action[22m
|
|
1412
|
+
[2m - waiting 100ms[22m
|
|
1413
|
+
",
|
|
1414
|
+
"type": "text",
|
|
1415
|
+
},
|
|
1416
|
+
],
|
|
1417
|
+
"isError": true,
|
|
1418
|
+
}
|
|
1419
|
+
`)
|
|
1082
1420
|
await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
|
|
1083
1421
|
}, 30000)
|
|
1084
1422
|
|
|
1085
1423
|
it('should show descriptive error when clicking a display:none element', async () => {
|
|
1424
|
+
await ensureConnectedTabForExecute()
|
|
1425
|
+
|
|
1086
1426
|
await client.callTool({
|
|
1087
1427
|
name: 'execute',
|
|
1088
1428
|
arguments: {
|
|
@@ -1096,14 +1436,35 @@ describe('Relay Core Tests', () => {
|
|
|
1096
1436
|
name: 'execute',
|
|
1097
1437
|
arguments: {
|
|
1098
1438
|
code: js`
|
|
1099
|
-
await state.errorTestPage.click('#invisible');
|
|
1439
|
+
await state.errorTestPage.click('#invisible', { timeout: 100 });
|
|
1100
1440
|
`,
|
|
1101
1441
|
},
|
|
1102
1442
|
})
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1443
|
+
expect(result).toMatchInlineSnapshot(`
|
|
1444
|
+
{
|
|
1445
|
+
"content": [
|
|
1446
|
+
{
|
|
1447
|
+
"text": "
|
|
1448
|
+
Error executing code: page.click: Timeout 100ms exceeded. Element is not visible — it may be hidden by CSS, inside a collapsed <details>, inactive tab, or closed accordion. Try: interact with the page to reveal it first, or use { force: true } to skip visibility checks
|
|
1449
|
+
Call log:
|
|
1450
|
+
[2m - waiting for locator('#invisible')[22m
|
|
1451
|
+
[2m - locator resolved to <button id="invisible">Invisible</button>[22m
|
|
1452
|
+
[2m - attempting click action[22m
|
|
1453
|
+
[2m 2 × waiting for element to be visible, enabled and stable[22m
|
|
1454
|
+
[2m - element is not visible[22m
|
|
1455
|
+
[2m - retrying click action[22m
|
|
1456
|
+
[2m - waiting 20ms[22m
|
|
1457
|
+
[2m - waiting for element to be visible, enabled and stable[22m
|
|
1458
|
+
[2m - element is not visible[22m
|
|
1459
|
+
[2m - retrying click action[22m
|
|
1460
|
+
[2m - waiting 100ms[22m
|
|
1461
|
+
",
|
|
1462
|
+
"type": "text",
|
|
1463
|
+
},
|
|
1464
|
+
],
|
|
1465
|
+
"isError": true,
|
|
1466
|
+
}
|
|
1467
|
+
`)
|
|
1107
1468
|
await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
|
|
1108
1469
|
}, 30000)
|
|
1109
1470
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
2
|
import { chromium, type Page } from '@xmorse/playwright-core'
|
|
3
|
+
import WebSocket from 'ws'
|
|
3
4
|
import { getCdpUrl } from './utils.js'
|
|
4
5
|
import {
|
|
5
6
|
setupTestContext,
|
|
@@ -625,4 +626,135 @@ describe('Relay Navigation Tests', () => {
|
|
|
625
626
|
fs.unlinkSync(outputPath)
|
|
626
627
|
fs.unlinkSync(demoPath)
|
|
627
628
|
}, 60000)
|
|
629
|
+
|
|
630
|
+
// Regression test for https://github.com/remorses/playwriter/issues/40
|
|
631
|
+
// When Playwright sends Target.detachFromTarget on the root CDP session (no top-level
|
|
632
|
+
// sessionId), the extension must still route the command by looking at params.sessionId.
|
|
633
|
+
// Previously the extension threw "No tab found for method Target.detachFromTarget"
|
|
634
|
+
// because it only checked the top-level sessionId for routing, which is absent on root
|
|
635
|
+
// session commands. This caused cascading disconnects and instability.
|
|
636
|
+
it('should route Target.detachFromTarget without top-level sessionId (issue #40)', async () => {
|
|
637
|
+
const browserContext = getBrowserContext()
|
|
638
|
+
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
639
|
+
|
|
640
|
+
const server = await createSimpleServer({
|
|
641
|
+
routes: { '/': '<!doctype html><html><body>detach test</body></html>' },
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
const page = await browserContext.newPage()
|
|
645
|
+
try {
|
|
646
|
+
await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
|
|
647
|
+
await page.bringToFront()
|
|
648
|
+
|
|
649
|
+
await withTimeout({
|
|
650
|
+
promise: serviceWorker.evaluate(async () => {
|
|
651
|
+
await globalThis.toggleExtensionForActiveTab()
|
|
652
|
+
}),
|
|
653
|
+
timeoutMs: 5000,
|
|
654
|
+
errorMessage: 'Timed out toggling extension for detach test',
|
|
655
|
+
})
|
|
656
|
+
await new Promise((r) => {
|
|
657
|
+
setTimeout(r, 400)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// Connect a raw WebSocket to the relay — this lets us send CDP messages
|
|
661
|
+
// exactly as they appear on the wire, without Playwright adding sessionId.
|
|
662
|
+
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/cdp/test-detach-raw`)
|
|
663
|
+
await new Promise<void>((resolve, reject) => {
|
|
664
|
+
ws.on('open', () => {
|
|
665
|
+
resolve()
|
|
666
|
+
})
|
|
667
|
+
ws.on('error', reject)
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
let nextId = 1
|
|
671
|
+
const sendCdp = <T = unknown>(msg: Record<string, unknown>): Promise<T> => {
|
|
672
|
+
return new Promise((resolve, reject) => {
|
|
673
|
+
const id = nextId++
|
|
674
|
+
const timeout = setTimeout(() => {
|
|
675
|
+
ws.off('message', handler)
|
|
676
|
+
reject(new Error(`CDP response timeout for id ${id}`))
|
|
677
|
+
}, 5000)
|
|
678
|
+
|
|
679
|
+
const handler = (data: WebSocket.RawData) => {
|
|
680
|
+
const parsed = JSON.parse(data.toString())
|
|
681
|
+
if (parsed.id === id) {
|
|
682
|
+
ws.off('message', handler)
|
|
683
|
+
clearTimeout(timeout)
|
|
684
|
+
resolve(parsed as T)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
ws.on('message', handler)
|
|
688
|
+
ws.send(JSON.stringify({ id, ...msg }))
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Collect async events from the relay
|
|
693
|
+
const events: Array<{ method: string; params: Record<string, unknown>; sessionId?: string }> = []
|
|
694
|
+
ws.on('message', (data) => {
|
|
695
|
+
const msg = JSON.parse(data.toString())
|
|
696
|
+
if (!msg.id && msg.method) {
|
|
697
|
+
events.push(msg)
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
// Trigger Target.setAutoAttach so the relay sends Target.attachedToTarget for
|
|
702
|
+
// all connected tabs. This gives us the page's pw-tab-* sessionId.
|
|
703
|
+
await sendCdp({
|
|
704
|
+
method: 'Target.setAutoAttach',
|
|
705
|
+
params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
// Wait for events to arrive
|
|
709
|
+
await new Promise((r) => {
|
|
710
|
+
setTimeout(r, 500)
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
// Filter for the specific page target by URL to avoid grabbing wrong sessions
|
|
714
|
+
// (welcome tab, extension pages, etc.)
|
|
715
|
+
type AttachParams = { sessionId?: string; targetInfo?: { type?: string; url?: string } }
|
|
716
|
+
const attachEvent = events.find((e) => {
|
|
717
|
+
if (e.method !== 'Target.attachedToTarget') {
|
|
718
|
+
return false
|
|
719
|
+
}
|
|
720
|
+
const p = e.params as AttachParams
|
|
721
|
+
return p.targetInfo?.type === 'page' && p.targetInfo?.url?.startsWith(server.baseUrl)
|
|
722
|
+
})
|
|
723
|
+
expect(attachEvent).toBeDefined()
|
|
724
|
+
const pageSessionId = (attachEvent!.params as AttachParams).sessionId
|
|
725
|
+
expect(pageSessionId).toBeTruthy()
|
|
726
|
+
|
|
727
|
+
// Verify the session is usable before detach — send a command that requires routing.
|
|
728
|
+
const evalBefore = await sendCdp<{ id: number; error?: { message: string }; result?: unknown }>({
|
|
729
|
+
method: 'Runtime.evaluate',
|
|
730
|
+
sessionId: pageSessionId,
|
|
731
|
+
params: { expression: '1 + 1', returnByValue: true },
|
|
732
|
+
})
|
|
733
|
+
expect(evalBefore.error).toBeUndefined()
|
|
734
|
+
expect((evalBefore.result as { result?: { value?: number } })?.result?.value).toBe(2)
|
|
735
|
+
|
|
736
|
+
// NOW: send Target.detachFromTarget WITHOUT a top-level sessionId.
|
|
737
|
+
// This is the exact wire format Playwright uses when sending on the root session
|
|
738
|
+
// (e.g. from CRSession.detach() where _parentSession is the root browser session).
|
|
739
|
+
// The extension must route this by looking at params.sessionId.
|
|
740
|
+
const detachResult = await sendCdp<{ id: number; error?: { message: string }; result?: unknown }>({
|
|
741
|
+
method: 'Target.detachFromTarget',
|
|
742
|
+
// Intentionally NO sessionId field — this is the root session
|
|
743
|
+
params: { sessionId: pageSessionId },
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
// Must not fail with extension routing error — the command must reach Chrome.
|
|
747
|
+
// Chrome returns "No session with given id" because pw-tab-* is a virtual session
|
|
748
|
+
// managed by the relay, not a real Chrome CDP session. This is expected — the key
|
|
749
|
+
// proof is that the extension routed the command to Chrome instead of throwing
|
|
750
|
+
// "No tab found" at the routing layer.
|
|
751
|
+
expect(detachResult.error?.message).not.toContain('No tab found')
|
|
752
|
+
expect(detachResult.error?.message).toContain('No session with given id')
|
|
753
|
+
|
|
754
|
+
ws.close()
|
|
755
|
+
} finally {
|
|
756
|
+
await page.close()
|
|
757
|
+
await server.close()
|
|
758
|
+
}
|
|
759
|
+
}, 30000)
|
|
628
760
|
})
|