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.
Files changed (58) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts.map +1 -1
  3. package/dist/aria-snapshot.js +3 -1
  4. package/dist/aria-snapshot.js.map +1 -1
  5. package/dist/bippy.js +1 -1
  6. package/dist/cdp-relay.d.ts.map +1 -1
  7. package/dist/cdp-relay.js +84 -0
  8. package/dist/cdp-relay.js.map +1 -1
  9. package/dist/executor.d.ts.map +1 -1
  10. package/dist/executor.js +8 -6
  11. package/dist/executor.js.map +1 -1
  12. package/dist/ffmpeg.d.ts +6 -6
  13. package/dist/ffmpeg.d.ts.map +1 -1
  14. package/dist/ffmpeg.js +6 -6
  15. package/dist/ffmpeg.js.map +1 -1
  16. package/dist/ghost-cursor-client.js +15 -9
  17. package/dist/prompt.md +71 -337
  18. package/dist/readability.js +16 -2
  19. package/dist/recording-ghost-cursor.d.ts.map +1 -1
  20. package/dist/recording-ghost-cursor.js +1 -1
  21. package/dist/recording-ghost-cursor.js.map +1 -1
  22. package/dist/relay-client.js +1 -1
  23. package/dist/relay-client.js.map +1 -1
  24. package/dist/relay-core.test.d.ts.map +1 -1
  25. package/dist/relay-core.test.js +344 -16
  26. package/dist/relay-core.test.js.map +1 -1
  27. package/dist/relay-navigation.test.d.ts.map +1 -1
  28. package/dist/relay-navigation.test.js +115 -0
  29. package/dist/relay-navigation.test.js.map +1 -1
  30. package/dist/screen-recording.d.ts +24 -0
  31. package/dist/screen-recording.d.ts.map +1 -1
  32. package/dist/screen-recording.js +62 -0
  33. package/dist/screen-recording.js.map +1 -1
  34. package/dist/screen-recording.test.d.ts +2 -0
  35. package/dist/screen-recording.test.d.ts.map +1 -0
  36. package/dist/screen-recording.test.js +102 -0
  37. package/dist/screen-recording.test.js.map +1 -0
  38. package/dist/selector-generator.js +1 -1
  39. package/package.json +2 -2
  40. package/src/aria-snapshot.ts +3 -1
  41. package/src/aria-snapshots/github-interactive.txt +2 -0
  42. package/src/aria-snapshots/github-raw.txt +4 -0
  43. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  44. package/src/aria-snapshots/hackernews-raw.txt +267 -271
  45. package/src/assets/aria-labels-hacker-news.png +0 -0
  46. package/src/cdp-relay.ts +110 -0
  47. package/src/executor.ts +8 -6
  48. package/src/ffmpeg.ts +8 -8
  49. package/src/ghost-cursor-client.ts +3 -2
  50. package/src/recording-ghost-cursor.ts +7 -1
  51. package/src/relay-client.ts +1 -1
  52. package/src/relay-core.test.ts +378 -17
  53. package/src/relay-navigation.test.ts +132 -0
  54. package/src/screen-recording.test.ts +111 -0
  55. package/src/screen-recording.ts +81 -0
  56. package/src/skill.md +71 -339
  57. package/src/snapshots/shadcn-ui-accessibility-full.md +182 -180
  58. package/src/snapshots/shadcn-ui-accessibility-interactive.md +120 -118
@@ -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
- const text = (result as any).content[0].text
1047
- // Strip stack traces and call logs to only match the descriptive error line
1048
- const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('not visible') || l.includes('not stable'))
1049
- expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms 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"`)
1050
- expect((result as any).isError).toBe(true)
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
+  - waiting for locator('#hidden-btn')
1343
+  - locator resolved to <button id="hidden-btn">Hidden Button</button>
1344
+  - attempting click action
1345
+  2 × waiting for element to be visible, enabled and stable
1346
+  - element is not visible
1347
+  - retrying click action
1348
+  - waiting 20ms
1349
+  - waiting for element to be visible, enabled and stable
1350
+  - element is not visible
1351
+  - retrying click action
1352
+  - waiting 100ms
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
- const text = (result as any).content[0].text
1079
- const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('intercepts'))
1080
- expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms exceeded. <div id="overlay">Overlay</div> intercepts pointer events"`)
1081
- expect((result as any).isError).toBe(true)
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
+  - waiting for locator('#covered-btn')
1397
+  - locator resolved to <button id="covered-btn">Covered</button>
1398
+  - attempting click action
1399
+  2 × waiting for element to be visible, enabled and stable
1400
+  - element is visible, enabled and stable
1401
+  - scrolling into view if needed
1402
+  - done scrolling
1403
+  - <div id="overlay">Overlay</div> intercepts pointer events
1404
+  - retrying click action
1405
+  - waiting 20ms
1406
+  - waiting for element to be visible, enabled and stable
1407
+  - element is visible, enabled and stable
1408
+  - scrolling into view if needed
1409
+  - done scrolling
1410
+  - <div id="overlay">Overlay</div> intercepts pointer events
1411
+  - retrying click action
1412
+  - waiting 100ms
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
- const text = (result as any).content[0].text
1104
- const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('not visible'))
1105
- expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms 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"`)
1106
- expect((result as any).isError).toBe(true)
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
+  - waiting for locator('#invisible')
1451
+  - locator resolved to <button id="invisible">Invisible</button>
1452
+  - attempting click action
1453
+  2 × waiting for element to be visible, enabled and stable
1454
+  - element is not visible
1455
+  - retrying click action
1456
+  - waiting 20ms
1457
+  - waiting for element to be visible, enabled and stable
1458
+  - element is not visible
1459
+  - retrying click action
1460
+  - waiting 100ms
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
  })