playwriter 0.0.15 → 0.0.20

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 (47) hide show
  1. package/dist/cdp-session.d.ts +21 -0
  2. package/dist/cdp-session.d.ts.map +1 -0
  3. package/dist/cdp-session.js +114 -0
  4. package/dist/cdp-session.js.map +1 -0
  5. package/dist/cdp-types.d.ts +15 -0
  6. package/dist/cdp-types.d.ts.map +1 -1
  7. package/dist/cdp-types.js.map +1 -1
  8. package/dist/create-logger.d.ts +9 -0
  9. package/dist/create-logger.d.ts.map +1 -0
  10. package/dist/create-logger.js +43 -0
  11. package/dist/create-logger.js.map +1 -0
  12. package/dist/extension/cdp-relay.d.ts +7 -3
  13. package/dist/extension/cdp-relay.d.ts.map +1 -1
  14. package/dist/extension/cdp-relay.js +46 -13
  15. package/dist/extension/cdp-relay.js.map +1 -1
  16. package/dist/mcp.js +52 -27
  17. package/dist/mcp.js.map +1 -1
  18. package/dist/mcp.test.d.ts.map +1 -1
  19. package/dist/mcp.test.js +625 -185
  20. package/dist/mcp.test.js.map +1 -1
  21. package/dist/prompt.md +36 -8
  22. package/dist/selector-generator.js +331 -0
  23. package/dist/start-relay-server.d.ts +1 -3
  24. package/dist/start-relay-server.d.ts.map +1 -1
  25. package/dist/start-relay-server.js +3 -16
  26. package/dist/start-relay-server.js.map +1 -1
  27. package/dist/utils.d.ts +3 -0
  28. package/dist/utils.d.ts.map +1 -1
  29. package/dist/utils.js +34 -0
  30. package/dist/utils.js.map +1 -1
  31. package/dist/wait-for-page-load.d.ts +16 -0
  32. package/dist/wait-for-page-load.d.ts.map +1 -0
  33. package/dist/wait-for-page-load.js +114 -0
  34. package/dist/wait-for-page-load.js.map +1 -0
  35. package/package.json +4 -2
  36. package/src/cdp-session.ts +142 -0
  37. package/src/cdp-types.ts +6 -0
  38. package/src/create-logger.ts +56 -0
  39. package/src/debugger.md +453 -0
  40. package/src/extension/cdp-relay.ts +57 -15
  41. package/src/mcp.test.ts +743 -191
  42. package/src/mcp.ts +63 -29
  43. package/src/prompt.md +36 -8
  44. package/src/snapshots/shadcn-ui-accessibility.md +94 -91
  45. package/src/start-relay-server.ts +3 -20
  46. package/src/utils.ts +43 -0
  47. package/src/wait-for-page-load.ts +162 -0
package/src/mcp.test.ts CHANGED
@@ -8,17 +8,21 @@ import fs from 'node:fs'
8
8
  import os from 'node:os'
9
9
  import { getCdpUrl } from './utils.js'
10
10
  import type { ExtensionState } from 'mcp-extension/src/types.js'
11
+ import type { Protocol } from 'devtools-protocol'
12
+ import { imageSize } from 'image-size'
13
+ import { getCDPSessionForPage } from './cdp-session.js'
14
+ import { startPlayWriterCDPRelayServer, type RelayServer } from './extension/cdp-relay.js'
15
+ import { createFileLogger } from './create-logger.js'
16
+ import type { CDPCommand } from './cdp-types.js'
11
17
 
12
- import { spawn } from 'node:child_process'
18
+ declare const window: any
19
+ declare const document: any
13
20
 
14
21
 
15
22
  const execAsync = promisify(exec)
16
23
 
17
24
  async function getExtensionServiceWorker(context: BrowserContext) {
18
-
19
25
  let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'))
20
-
21
-
22
26
  let serviceWorker = serviceWorkers[0]
23
27
  if (!serviceWorker) {
24
28
  serviceWorker = await context.waitForEvent('serviceworker', {
@@ -26,6 +30,14 @@ async function getExtensionServiceWorker(context: BrowserContext) {
26
30
  })
27
31
  }
28
32
 
33
+ for (let i = 0; i < 50; i++) {
34
+ const isReady = await serviceWorker.evaluate(() => {
35
+ // @ts-ignore
36
+ return typeof globalThis.toggleExtensionForActiveTab === 'function'
37
+ })
38
+ if (isReady) break
39
+ await new Promise(r => setTimeout(r, 100))
40
+ }
29
41
 
30
42
  return serviceWorker
31
43
  }
@@ -51,6 +63,67 @@ async function killProcessOnPort(port: number): Promise<void> {
51
63
  }
52
64
  }
53
65
 
66
+ interface TestContext {
67
+ browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
68
+ userDataDir: string
69
+ relayServer: RelayServer
70
+ }
71
+
72
+ async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
73
+ await killProcessOnPort(19988)
74
+
75
+ console.log('Building extension...')
76
+ await execAsync('TESTING=1 pnpm build', { cwd: '../extension' })
77
+ console.log('Extension built')
78
+
79
+ const localLogPath = path.join(process.cwd(), 'relay-server.log')
80
+ const logger = createFileLogger({ logFilePath: localLogPath })
81
+ const relayServer = await startPlayWriterCDPRelayServer({ port: 19988, logger })
82
+
83
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix))
84
+ const extensionPath = path.resolve('../extension/dist')
85
+
86
+ const browserContext = await chromium.launchPersistentContext(userDataDir, {
87
+ channel: 'chromium',
88
+ headless: !process.env.HEADFUL,
89
+ args: [
90
+ `--disable-extensions-except=${extensionPath}`,
91
+ `--load-extension=${extensionPath}`,
92
+ ],
93
+ })
94
+
95
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
96
+
97
+ const page = await browserContext.newPage()
98
+ await page.goto('about:blank')
99
+
100
+ await serviceWorker.evaluate(async () => {
101
+ await globalThis.toggleExtensionForActiveTab()
102
+ })
103
+
104
+ return { browserContext, userDataDir, relayServer }
105
+ }
106
+
107
+ async function cleanupTestContext(ctx: TestContext | null, cleanup?: (() => Promise<void>) | null): Promise<void> {
108
+ if (ctx?.browserContext) {
109
+ await ctx.browserContext.close()
110
+ }
111
+ if (ctx?.relayServer) {
112
+ ctx.relayServer.close()
113
+ }
114
+
115
+ if (ctx?.userDataDir) {
116
+ try {
117
+ fs.rmSync(ctx.userDataDir, { recursive: true, force: true })
118
+ } catch (e) {
119
+ console.error('Failed to cleanup user data dir:', e)
120
+ }
121
+ }
122
+ if (cleanup) {
123
+ await cleanup()
124
+ }
125
+ }
126
+
54
127
  declare global {
55
128
  var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
56
129
  var getExtensionState: () => ExtensionState;
@@ -60,106 +133,57 @@ declare global {
60
133
  describe('MCP Server Tests', () => {
61
134
  let client: Awaited<ReturnType<typeof createMCPClient>>['client']
62
135
  let cleanup: (() => Promise<void>) | null = null
63
- let browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>> | null = null
64
- let userDataDir: string
65
- let relayServerProcess: any
136
+ let testCtx: TestContext | null = null
66
137
 
67
138
  beforeAll(async () => {
68
- await killProcessOnPort(19988)
69
-
70
- // Build extension
71
- console.log('Building extension...')
72
- await execAsync('TESTING=1 pnpm build', { cwd: '../extension' })
73
- console.log('Extension built')
74
-
75
- // Start Relay Server manually
76
- relayServerProcess = spawn('pnpm', ['tsx', 'src/start-relay-server.ts'], {
77
- cwd: process.cwd(),
78
- stdio: 'inherit'
79
- })
80
-
81
- // Wait for port 19988 to be ready
82
- await new Promise<void>((resolve, reject) => {
83
- let retries = 0
84
- const interval = setInterval(async () => {
85
- try {
86
- const { stdout } = await execAsync('lsof -ti:19988')
87
- if (stdout.trim()) {
88
- clearInterval(interval)
89
- resolve()
90
- }
91
- } catch {
92
- // ignore
93
- }
94
- retries++
95
- if (retries > 30) {
96
- clearInterval(interval)
97
- reject(new Error('Relay server failed to start'))
98
- }
99
- }, 1000)
100
- })
139
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' })
101
140
 
102
141
  const result = await createMCPClient()
103
142
  client = result.client
104
143
  cleanup = result.cleanup
144
+ }, 600000)
105
145
 
106
- userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-test-'))
107
- const extensionPath = path.resolve('../extension/dist')
146
+ afterAll(async () => {
147
+ await cleanupTestContext(testCtx, cleanup)
148
+ cleanup = null
149
+ testCtx = null
150
+ })
108
151
 
109
- browserContext = await chromium.launchPersistentContext(userDataDir, {
110
- channel: 'chromium', // <- this opts into new headless
111
- headless: !process.env.HEADFUL,
112
- args: [
113
- `--disable-extensions-except=${extensionPath}`,
114
- `--load-extension=${extensionPath}`,
115
- ],
116
- })
152
+ const getBrowserContext = () => {
153
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
154
+ return testCtx.browserContext
155
+ }
117
156
 
118
- // Wait for service worker and connect
157
+ it('should inject script via addScriptTag through CDP relay', async () => {
158
+ const browserContext = getBrowserContext()
119
159
  const serviceWorker = await getExtensionServiceWorker(browserContext)
120
160
 
121
- // Wait for extension to initialize global functions
122
- for (let i = 0; i < 50; i++) {
123
- const isReady = await serviceWorker.evaluate(() => {
124
- // @ts-ignore
125
- return typeof globalThis.toggleExtensionForActiveTab === 'function'
126
- })
127
- if (isReady) break
128
- await new Promise(r => setTimeout(r, 100))
129
- }
130
-
131
- // Create a page to attach to
132
161
  const page = await browserContext.newPage()
133
- await page.goto('about:blank')
162
+ await page.setContent('<html><body><button id="btn">Click</button></body></html>')
163
+ await page.bringToFront()
134
164
 
135
- // Connect the tab
136
165
  await serviceWorker.evaluate(async () => {
137
- await globalThis.toggleExtensionForActiveTab()
166
+ await globalThis.toggleExtensionForActiveTab()
138
167
  })
168
+ await new Promise(r => setTimeout(r, 500))
139
169
 
140
- }, 600000) // 10 minutes timeout
170
+ const browser = await chromium.connectOverCDP(getCdpUrl())
171
+ const cdpPage = browser.contexts()[0].pages().find(p => {
172
+ return p.url().startsWith('about:')
173
+ })
174
+ expect(cdpPage).toBeDefined()
141
175
 
142
- afterAll(async () => {
143
- if (browserContext) {
144
- await browserContext.close()
145
- }
146
- if (relayServerProcess) {
147
- relayServerProcess.kill()
148
- }
149
- await killProcessOnPort(19988)
176
+ const hasGlobalBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__testGlobal)
177
+ expect(hasGlobalBefore).toBe(false)
150
178
 
151
- if (userDataDir) {
152
- try {
153
- fs.rmSync(userDataDir, { recursive: true, force: true })
154
- } catch (e) {
155
- console.error('Failed to cleanup user data dir:', e)
156
- }
157
- }
158
- if (cleanup) {
159
- await cleanup()
160
- cleanup = null
161
- }
162
- })
179
+ await cdpPage!.addScriptTag({ content: 'globalThis.__testGlobal = { foo: "bar" };' })
180
+
181
+ const hasGlobalAfter = await cdpPage!.evaluate(() => (globalThis as any).__testGlobal)
182
+ expect(hasGlobalAfter).toEqual({ foo: 'bar' })
183
+
184
+ await browser.close()
185
+ await page.close()
186
+ }, 60000)
163
187
 
164
188
  it('should execute code and capture console output', async () => {
165
189
  await client.callTool({
@@ -204,7 +228,7 @@ describe('MCP Server Tests', () => {
204
228
  }, 30000)
205
229
 
206
230
  it('should show extension as connected for pages created via newPage()', async () => {
207
- if (!browserContext) throw new Error('Browser not initialized')
231
+ const browserContext = getBrowserContext()
208
232
  const serviceWorker = await getExtensionServiceWorker(browserContext)
209
233
 
210
234
  // Create a page via MCP (which uses context.newPage())
@@ -341,9 +365,7 @@ describe('MCP Server Tests', () => {
341
365
  })
342
366
 
343
367
  it('should handle new pages and toggling with new connections', async () => {
344
- if (!browserContext) throw new Error('Browser not initialized')
345
-
346
- // Find the correct service worker by URL
368
+ const browserContext = getBrowserContext()
347
369
  const serviceWorker = await getExtensionServiceWorker(browserContext)
348
370
 
349
371
  // 1. Create a new page
@@ -430,8 +452,7 @@ describe('MCP Server Tests', () => {
430
452
  })
431
453
 
432
454
  it('should handle new pages and toggling with persistent connection', async () => {
433
- if (!browserContext) throw new Error('Browser not initialized')
434
-
455
+ const browserContext = getBrowserContext()
435
456
  const serviceWorker = await getExtensionServiceWorker(browserContext)
436
457
 
437
458
  // Connect once
@@ -502,7 +523,7 @@ describe('MCP Server Tests', () => {
502
523
  await directBrowser.close()
503
524
  })
504
525
  it('should maintain connection across reloads and navigation', async () => {
505
- if (!browserContext) throw new Error('Browser not initialized')
526
+ const browserContext = getBrowserContext()
506
527
  const serviceWorker = await getExtensionServiceWorker(browserContext)
507
528
 
508
529
  // 1. Setup page
@@ -551,7 +572,7 @@ describe('MCP Server Tests', () => {
551
572
  })
552
573
 
553
574
  it('should support multiple concurrent tabs', async () => {
554
- if (!browserContext) throw new Error('Browser not initialized')
575
+ const browserContext = getBrowserContext()
555
576
  const serviceWorker = await getExtensionServiceWorker(browserContext)
556
577
  await new Promise(resolve => setTimeout(resolve, 500))
557
578
 
@@ -610,94 +631,8 @@ describe('MCP Server Tests', () => {
610
631
  expect(results).toMatchInlineSnapshot(`
611
632
  [
612
633
  {
613
- "title": "🎄 Twelve Days of Shell 🎄",
614
- "url": "https://12days.cmdchallenge.com/",
615
- },
616
- {
617
- "title": "Example Domain",
618
- "url": "https://example.com/tab-a",
619
- },
620
- {
621
- "title": "Example Domain",
622
- "url": "https://example.com/tab-b",
623
- },
624
- ]
625
- `)
626
-
627
- // Verify execution on both pages
628
- const pageA_CDP = pages.find(p => p.url().includes('tab-a'))
629
- const pageB_CDP = pages.find(p => p.url().includes('tab-b'))
630
-
631
- expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
632
- expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
633
-
634
- await browser.close()
635
- await pageA.close()
636
- await pageB.close()
637
- })
638
-
639
- it('should support multiple concurrent tabs', async () => {
640
- if (!browserContext) throw new Error('Browser not initialized')
641
- const serviceWorker = await getExtensionServiceWorker(browserContext)
642
- await new Promise(resolve => setTimeout(resolve, 500))
643
-
644
- // Tab A
645
- const pageA = await browserContext.newPage()
646
- await pageA.goto('https://example.com/tab-a')
647
- await pageA.bringToFront()
648
- await new Promise(resolve => setTimeout(resolve, 500))
649
- await serviceWorker.evaluate(async () => {
650
- await globalThis.toggleExtensionForActiveTab()
651
- })
652
-
653
- // Tab B
654
- const pageB = await browserContext.newPage()
655
- await pageB.goto('https://example.com/tab-b')
656
- await pageB.bringToFront()
657
- await new Promise(resolve => setTimeout(resolve, 500))
658
- await serviceWorker.evaluate(async () => {
659
- await globalThis.toggleExtensionForActiveTab()
660
- })
661
-
662
- // Get target IDs for both
663
- const targetIds = await serviceWorker.evaluate(async () => {
664
- const state = globalThis.getExtensionState()
665
- const chrome = globalThis.chrome
666
- const tabs = await chrome.tabs.query({})
667
- const tabA = tabs.find((t: any) => t.url?.includes('tab-a'))
668
- const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
669
- return {
670
- idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
671
- idB: state.tabs.get(tabB?.id ?? -1)?.targetId
672
- }
673
- })
674
-
675
- expect(targetIds).toMatchInlineSnapshot({
676
- idA: expect.any(String),
677
- idB: expect.any(String)
678
- }, `
679
- {
680
- "idA": Any<String>,
681
- "idB": Any<String>,
682
- }
683
- `)
684
- expect(targetIds.idA).not.toBe(targetIds.idB)
685
-
686
- // Verify independent connections
687
- const browser = await chromium.connectOverCDP(getCdpUrl())
688
-
689
- const pages = browser.contexts()[0].pages()
690
-
691
- const results = await Promise.all(pages.map(async (p) => ({
692
- url: p.url(),
693
- title: await p.title()
694
- })))
695
-
696
- expect(results).toMatchInlineSnapshot(`
697
- [
698
- {
699
- "title": "🎄 Twelve Days of Shell 🎄",
700
- "url": "https://12days.cmdchallenge.com/",
634
+ "title": "",
635
+ "url": "about:blank",
701
636
  },
702
637
  {
703
638
  "title": "Example Domain",
@@ -723,10 +658,9 @@ describe('MCP Server Tests', () => {
723
658
  })
724
659
 
725
660
  it('should show correct url when enabling extension after navigation', async () => {
726
- if (!browserContext) throw new Error('Browser not initialized')
661
+ const browserContext = getBrowserContext()
727
662
  const serviceWorker = await getExtensionServiceWorker(browserContext)
728
663
 
729
- // 1. Open a new page (extension not yet enabled for it)
730
664
  const page = await browserContext.newPage()
731
665
  const targetUrl = 'https://example.com/late-enable'
732
666
  await page.goto(targetUrl)
@@ -755,10 +689,9 @@ describe('MCP Server Tests', () => {
755
689
  })
756
690
 
757
691
  it('should be able to reconnect after disconnecting everything', async () => {
758
- if (!browserContext) throw new Error('Browser not initialized')
692
+ const browserContext = getBrowserContext()
759
693
  const serviceWorker = await getExtensionServiceWorker(browserContext)
760
694
 
761
- // 1. Use the existing about:blank page from beforeAll
762
695
  const pages = await browserContext.pages()
763
696
  expect(pages.length).toBeGreaterThan(0)
764
697
  const page = pages[0]
@@ -1233,7 +1166,7 @@ describe('MCP Server Tests', () => {
1233
1166
  }, 30000)
1234
1167
 
1235
1168
  it('should maintain correct page.url() with service worker pages', async () => {
1236
- if (!browserContext) throw new Error('Browser not initialized')
1169
+ const browserContext = getBrowserContext()
1237
1170
  const serviceWorker = await getExtensionServiceWorker(browserContext)
1238
1171
 
1239
1172
  const page = await browserContext.newPage()
@@ -1260,7 +1193,7 @@ describe('MCP Server Tests', () => {
1260
1193
  }, 30000)
1261
1194
 
1262
1195
  it('should maintain correct page.url() after repeated connections', async () => {
1263
- if (!browserContext) throw new Error('Browser not initialized')
1196
+ const browserContext = getBrowserContext()
1264
1197
  const serviceWorker = await getExtensionServiceWorker(browserContext)
1265
1198
 
1266
1199
  const page = await browserContext.newPage()
@@ -1288,7 +1221,7 @@ describe('MCP Server Tests', () => {
1288
1221
  }, 30000)
1289
1222
 
1290
1223
  it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
1291
- if (!browserContext) throw new Error('Browser not initialized')
1224
+ const browserContext = getBrowserContext()
1292
1225
  const serviceWorker = await getExtensionServiceWorker(browserContext)
1293
1226
 
1294
1227
  const page = await browserContext.newPage()
@@ -1328,7 +1261,7 @@ describe('MCP Server Tests', () => {
1328
1261
  }, 30000)
1329
1262
 
1330
1263
  it('should maintain correct page.url() with iframe-heavy pages', async () => {
1331
- if (!browserContext) throw new Error('Browser not initialized')
1264
+ const browserContext = getBrowserContext()
1332
1265
  const serviceWorker = await getExtensionServiceWorker(browserContext)
1333
1266
 
1334
1267
  const page = await browserContext.newPage()
@@ -1357,8 +1290,362 @@ describe('MCP Server Tests', () => {
1357
1290
  await page.close()
1358
1291
  }, 60000)
1359
1292
 
1293
+ it('should capture screenshot correctly', async () => {
1294
+ const browserContext = getBrowserContext()
1295
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1296
+
1297
+ const page = await browserContext.newPage()
1298
+ await page.goto('https://example.com/')
1299
+ await page.bringToFront()
1300
+
1301
+ await serviceWorker.evaluate(async () => {
1302
+ await globalThis.toggleExtensionForActiveTab()
1303
+ })
1304
+
1305
+ await new Promise(r => setTimeout(r, 500))
1306
+
1307
+ const capturedCommands: CDPCommand[] = []
1308
+ const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
1309
+ if (command.method === 'Page.captureScreenshot') {
1310
+ capturedCommands.push(command)
1311
+ }
1312
+ }
1313
+ testCtx!.relayServer.on('cdp:command', commandHandler)
1314
+
1315
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1316
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
1317
+
1318
+ expect(cdpPage).toBeDefined()
1319
+
1320
+ const viewportSize = cdpPage!.viewportSize()
1321
+ console.log('Viewport size:', viewportSize)
1322
+
1323
+ const viewportScreenshot = await cdpPage!.screenshot()
1324
+ expect(viewportScreenshot).toBeDefined()
1325
+
1326
+ const viewportDimensions = imageSize(viewportScreenshot)
1327
+ console.log('Viewport screenshot dimensions:', viewportDimensions)
1328
+ expect(viewportDimensions.width).toBeGreaterThan(0)
1329
+ expect(viewportDimensions.height).toBeGreaterThan(0)
1330
+ if (viewportSize) {
1331
+ expect(viewportDimensions.width).toBe(viewportSize.width)
1332
+ expect(viewportDimensions.height).toBe(viewportSize.height)
1333
+ }
1334
+
1335
+ const fullPageScreenshot = await cdpPage!.screenshot({ fullPage: true })
1336
+ expect(fullPageScreenshot).toBeDefined()
1337
+
1338
+ const fullPageDimensions = imageSize(fullPageScreenshot)
1339
+ console.log('Full page screenshot dimensions:', fullPageDimensions)
1340
+ expect(fullPageDimensions.width).toBeGreaterThan(0)
1341
+ expect(fullPageDimensions.height).toBeGreaterThan(0)
1342
+ expect(fullPageDimensions.width).toBeGreaterThanOrEqual(viewportDimensions.width!)
1343
+
1344
+ testCtx!.relayServer.off('cdp:command', commandHandler)
1345
+
1346
+ expect(capturedCommands.length).toBe(2)
1347
+ expect(capturedCommands.map(c => ({
1348
+ method: c.method,
1349
+ params: c.params
1350
+ }))).toMatchInlineSnapshot(`
1351
+ [
1352
+ {
1353
+ "method": "Page.captureScreenshot",
1354
+ "params": {
1355
+ "captureBeyondViewport": false,
1356
+ "clip": {
1357
+ "height": 720,
1358
+ "scale": 1,
1359
+ "width": 1280,
1360
+ "x": 0,
1361
+ "y": 0,
1362
+ },
1363
+ "format": "png",
1364
+ },
1365
+ },
1366
+ {
1367
+ "method": "Page.captureScreenshot",
1368
+ "params": {
1369
+ "captureBeyondViewport": false,
1370
+ "clip": {
1371
+ "height": 528,
1372
+ "scale": 1,
1373
+ "width": 1280,
1374
+ "x": 0,
1375
+ "y": 0,
1376
+ },
1377
+ "format": "png",
1378
+ },
1379
+ },
1380
+ ]
1381
+ `)
1382
+
1383
+ const screenshotPath = path.join(os.tmpdir(), 'playwriter-test-screenshot.png')
1384
+ fs.writeFileSync(screenshotPath, viewportScreenshot)
1385
+ console.log('Screenshot saved to:', screenshotPath)
1386
+
1387
+ await browser.close()
1388
+ await page.close()
1389
+ }, 60000)
1390
+
1391
+ it('should capture element screenshot with correct coordinates', async () => {
1392
+ const browserContext = getBrowserContext()
1393
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1394
+
1395
+ const target = { x: 200, y: 150, width: 300, height: 100 }
1396
+ const scrolledTarget = { x: 100, y: 1500, width: 200, height: 80 }
1397
+
1398
+ const page = await browserContext.newPage()
1399
+ await page.setContent(`
1400
+ <html>
1401
+ <head>
1402
+ <style>
1403
+ body { margin: 0; padding: 0; height: 2000px; }
1404
+ #target {
1405
+ position: absolute;
1406
+ top: ${target.y}px;
1407
+ left: ${target.x}px;
1408
+ width: ${target.width}px;
1409
+ height: ${target.height}px;
1410
+ background: red;
1411
+ }
1412
+ #scrolled-target {
1413
+ position: absolute;
1414
+ top: ${scrolledTarget.y}px;
1415
+ left: ${scrolledTarget.x}px;
1416
+ width: ${scrolledTarget.width}px;
1417
+ height: ${scrolledTarget.height}px;
1418
+ background: blue;
1419
+ }
1420
+ </style>
1421
+ </head>
1422
+ <body>
1423
+ <div id="target">Target Element</div>
1424
+ <div id="scrolled-target">Scrolled Target</div>
1425
+ </body>
1426
+ </html>
1427
+ `)
1428
+ await page.bringToFront()
1429
+
1430
+ await serviceWorker.evaluate(async () => {
1431
+ await globalThis.toggleExtensionForActiveTab()
1432
+ })
1433
+
1434
+ await new Promise(r => setTimeout(r, 500))
1435
+
1436
+ const capturedCommands: CDPCommand[] = []
1437
+ const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
1438
+ if (command.method === 'Page.captureScreenshot') {
1439
+ capturedCommands.push(command)
1440
+ }
1441
+ }
1442
+ testCtx!.relayServer.on('cdp:command', commandHandler)
1443
+
1444
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1445
+ let cdpPage
1446
+ for (const p of browser.contexts()[0].pages()) {
1447
+ const html = await p.content()
1448
+ if (html.includes('scrolled-target')) {
1449
+ cdpPage = p
1450
+ break
1451
+ }
1452
+ }
1453
+ expect(cdpPage).toBeDefined()
1454
+
1455
+ await cdpPage!.locator('#target').screenshot()
1456
+
1457
+ await cdpPage!.locator('#scrolled-target').screenshot()
1458
+
1459
+ testCtx!.relayServer.off('cdp:command', commandHandler)
1460
+
1461
+ expect(capturedCommands.length).toBe(2)
1462
+
1463
+ const targetCmd = capturedCommands[0]
1464
+ expect(targetCmd.method).toBe('Page.captureScreenshot')
1465
+ const targetClip = (targetCmd.params as any).clip
1466
+ expect(targetClip.x).toBe(target.x)
1467
+ expect(targetClip.y).toBe(target.y)
1468
+ expect(targetClip.width).toBe(target.width)
1469
+ expect(targetClip.height).toBe(target.height)
1470
+
1471
+ const scrolledCmd = capturedCommands[1]
1472
+ expect(scrolledCmd.method).toBe('Page.captureScreenshot')
1473
+ const scrolledClip = (scrolledCmd.params as any).clip
1474
+ expect(scrolledClip.x).toBe(scrolledTarget.x)
1475
+ expect(scrolledClip.y).toBe(scrolledTarget.y)
1476
+ expect(scrolledClip.width).toBe(scrolledTarget.width)
1477
+ expect(scrolledClip.height).toBe(scrolledTarget.height)
1478
+
1479
+ await browser.close()
1480
+ await page.close()
1481
+ }, 60000)
1482
+
1483
+ it('should get locator string for element using getLocatorStringForElement', async () => {
1484
+ const browserContext = getBrowserContext()
1485
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1486
+
1487
+ const page = await browserContext.newPage()
1488
+ await page.setContent(`
1489
+ <html>
1490
+ <body>
1491
+ <button id="test-btn">Click Me</button>
1492
+ <input type="text" placeholder="Enter name" />
1493
+ </body>
1494
+ </html>
1495
+ `)
1496
+ await page.bringToFront()
1497
+
1498
+ await serviceWorker.evaluate(async () => {
1499
+ await globalThis.toggleExtensionForActiveTab()
1500
+ })
1501
+
1502
+ await new Promise(r => setTimeout(r, 500))
1503
+
1504
+ const result = await client.callTool({
1505
+ name: 'execute',
1506
+ arguments: {
1507
+ code: js`
1508
+ let testPage;
1509
+ for (const p of context.pages()) {
1510
+ const html = await p.content();
1511
+ if (html.includes('test-btn')) { testPage = p; break; }
1512
+ }
1513
+ if (!testPage) throw new Error('Test page not found');
1514
+ const btn = testPage.locator('#test-btn');
1515
+ const locatorString = await getLocatorStringForElement(btn);
1516
+ console.log('Locator string:', locatorString);
1517
+ const locatorFromString = eval('testPage.' + locatorString);
1518
+ const count = await locatorFromString.count();
1519
+ console.log('Locator count:', count);
1520
+ const text = await locatorFromString.textContent();
1521
+ console.log('Locator text:', text);
1522
+ `,
1523
+ timeout: 30000,
1524
+ },
1525
+ })
1526
+
1527
+ expect(result.isError).toBeFalsy()
1528
+ const text = (result.content as any)[0]?.text || ''
1529
+ expect(text).toContain('Locator string:')
1530
+ expect(text).toContain("getByRole('button', { name: 'Click Me' })")
1531
+ expect(text).toContain('Locator count: 1')
1532
+ expect(text).toContain('Locator text: Click Me')
1533
+
1534
+ await page.close()
1535
+ }, 60000)
1536
+
1537
+ it('should return correct layout metrics via CDP', async () => {
1538
+ const browserContext = getBrowserContext()
1539
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1540
+
1541
+ const page = await browserContext.newPage()
1542
+ await page.goto('https://example.com/')
1543
+ await page.bringToFront()
1544
+
1545
+ await serviceWorker.evaluate(async () => {
1546
+ await globalThis.toggleExtensionForActiveTab()
1547
+ })
1548
+
1549
+ await new Promise(r => setTimeout(r, 500))
1550
+
1551
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1552
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
1553
+ expect(cdpPage).toBeDefined()
1554
+
1555
+ const wsUrl = getCdpUrl()
1556
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
1557
+
1558
+ const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics')
1559
+
1560
+ const normalized = {
1561
+ cssLayoutViewport: layoutMetrics.cssLayoutViewport,
1562
+ cssVisualViewport: layoutMetrics.cssVisualViewport,
1563
+ layoutViewport: layoutMetrics.layoutViewport,
1564
+ visualViewport: layoutMetrics.visualViewport,
1565
+ devicePixelRatio: layoutMetrics.cssVisualViewport.clientWidth > 0
1566
+ ? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth
1567
+ : 1,
1568
+ }
1569
+
1570
+ expect(normalized).toMatchInlineSnapshot(`
1571
+ {
1572
+ "cssLayoutViewport": {
1573
+ "clientHeight": 720,
1574
+ "clientWidth": 1280,
1575
+ "pageX": 0,
1576
+ "pageY": 0,
1577
+ },
1578
+ "cssVisualViewport": {
1579
+ "clientHeight": 720,
1580
+ "clientWidth": 1280,
1581
+ "offsetX": 0,
1582
+ "offsetY": 0,
1583
+ "pageX": 0,
1584
+ "pageY": 0,
1585
+ "scale": 1,
1586
+ "zoom": 1,
1587
+ },
1588
+ "devicePixelRatio": 1,
1589
+ "layoutViewport": {
1590
+ "clientHeight": 720,
1591
+ "clientWidth": 1280,
1592
+ "pageX": 0,
1593
+ "pageY": 0,
1594
+ },
1595
+ "visualViewport": {
1596
+ "clientHeight": 720,
1597
+ "clientWidth": 1280,
1598
+ "offsetX": 0,
1599
+ "offsetY": 0,
1600
+ "pageX": 0,
1601
+ "pageY": 0,
1602
+ "scale": 1,
1603
+ "zoom": 1,
1604
+ },
1605
+ }
1606
+ `)
1607
+
1608
+ const windowDpr = await cdpPage!.evaluate(() => (globalThis as any).devicePixelRatio)
1609
+ console.log('window.devicePixelRatio:', windowDpr)
1610
+ expect(windowDpr).toBe(1)
1611
+
1612
+ cdpSession.detach()
1613
+ await browser.close()
1614
+ await page.close()
1615
+ }, 60000)
1616
+
1617
+ it('should support getCDPSession through the relay', async () => {
1618
+ const browserContext = getBrowserContext()
1619
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1620
+
1621
+ const page = await browserContext.newPage()
1622
+ await page.goto('https://example.com/')
1623
+ await page.bringToFront()
1624
+
1625
+ await serviceWorker.evaluate(async () => {
1626
+ await globalThis.toggleExtensionForActiveTab()
1627
+ })
1628
+
1629
+ await new Promise(r => setTimeout(r, 500))
1630
+
1631
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1632
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
1633
+ expect(cdpPage).toBeDefined()
1634
+
1635
+ const wsUrl = getCdpUrl()
1636
+ const client = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
1637
+
1638
+ const layoutMetrics = await client.send('Page.getLayoutMetrics')
1639
+ expect(layoutMetrics.cssVisualViewport).toBeDefined()
1640
+ expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
1641
+
1642
+ client.detach()
1643
+ await browser.close()
1644
+ await page.close()
1645
+ }, 60000)
1646
+
1360
1647
  it('should work with stagehand', async () => {
1361
- if (!browserContext) throw new Error('Browser not initialized')
1648
+ const browserContext = getBrowserContext()
1362
1649
  const serviceWorker = await getExtensionServiceWorker(browserContext)
1363
1650
 
1364
1651
  await serviceWorker.evaluate(async () => {
@@ -1421,3 +1708,268 @@ function tryJsonParse(str: string) {
1421
1708
  return str
1422
1709
  }
1423
1710
  }
1711
+
1712
+ describe('CDP Session Tests', () => {
1713
+ let testCtx: TestContext | null = null
1714
+
1715
+ beforeAll(async () => {
1716
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-cdp-test-' })
1717
+ }, 600000)
1718
+
1719
+ afterAll(async () => {
1720
+ await cleanupTestContext(testCtx)
1721
+ testCtx = null
1722
+ })
1723
+
1724
+ const getBrowserContext = () => {
1725
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
1726
+ return testCtx.browserContext
1727
+ }
1728
+
1729
+ it('should enable debugger and pause on debugger statement via CDP session', async () => {
1730
+ const browserContext = getBrowserContext()
1731
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1732
+
1733
+ const page = await browserContext.newPage()
1734
+ await page.goto('https://example.com/')
1735
+ await page.bringToFront()
1736
+
1737
+ await serviceWorker.evaluate(async () => {
1738
+ await globalThis.toggleExtensionForActiveTab()
1739
+ })
1740
+ await new Promise(r => setTimeout(r, 500))
1741
+
1742
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1743
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
1744
+ expect(cdpPage).toBeDefined()
1745
+
1746
+ const wsUrl = getCdpUrl()
1747
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
1748
+ await cdpSession.send('Debugger.enable')
1749
+
1750
+ const pausedPromise = new Promise<Protocol.Debugger.PausedEvent>((resolve) => {
1751
+ cdpSession.on('Debugger.paused', (params) => {
1752
+ resolve(params as Protocol.Debugger.PausedEvent)
1753
+ })
1754
+ })
1755
+
1756
+ cdpPage!.evaluate(`
1757
+ (function testFunction() {
1758
+ const localVar = 'hello';
1759
+ const numberVar = 42;
1760
+ const objVar = { key: 'value', nested: { a: 1 } };
1761
+ debugger;
1762
+ return localVar + numberVar;
1763
+ })()
1764
+ `)
1765
+
1766
+ const pausedEvent = await Promise.race([
1767
+ pausedPromise,
1768
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
1769
+ ])
1770
+
1771
+ const stackTrace = pausedEvent.callFrames.map(frame => ({
1772
+ functionName: frame.functionName || '(anonymous)',
1773
+ lineNumber: frame.location.lineNumber,
1774
+ columnNumber: frame.location.columnNumber,
1775
+ }))
1776
+
1777
+ expect({
1778
+ reason: pausedEvent.reason,
1779
+ stackTrace: stackTrace.slice(0, 3),
1780
+ }).toMatchInlineSnapshot(`
1781
+ {
1782
+ "reason": "other",
1783
+ "stackTrace": [
1784
+ {
1785
+ "columnNumber": 16,
1786
+ "functionName": "testFunction",
1787
+ "lineNumber": 4,
1788
+ },
1789
+ {
1790
+ "columnNumber": 14,
1791
+ "functionName": "(anonymous)",
1792
+ "lineNumber": 6,
1793
+ },
1794
+ {
1795
+ "columnNumber": 29,
1796
+ "functionName": "evaluate",
1797
+ "lineNumber": 289,
1798
+ },
1799
+ ],
1800
+ }
1801
+ `)
1802
+
1803
+ const topFrame = pausedEvent.callFrames[0]
1804
+ const scopeChain = topFrame.scopeChain
1805
+
1806
+ const localScope = scopeChain.find(s => s.type === 'local')
1807
+ const localVars: Record<string, unknown> = {}
1808
+
1809
+ if (localScope?.object.objectId) {
1810
+ const propsResult = await cdpSession.send('Runtime.getProperties', {
1811
+ objectId: localScope.object.objectId,
1812
+ ownProperties: true,
1813
+ })
1814
+
1815
+ for (const prop of propsResult.result) {
1816
+ if (prop.value) {
1817
+ localVars[prop.name] = prop.value.type === 'object'
1818
+ ? `[object ${prop.value.className || prop.value.subtype || 'Object'}]`
1819
+ : prop.value.value
1820
+ }
1821
+ }
1822
+ }
1823
+
1824
+ expect({
1825
+ scopeTypes: scopeChain.map(s => s.type),
1826
+ localVariables: localVars,
1827
+ }).toMatchInlineSnapshot(`
1828
+ {
1829
+ "localVariables": {
1830
+ "localVar": "hello",
1831
+ "numberVar": 42,
1832
+ "objVar": "[object Object]",
1833
+ },
1834
+ "scopeTypes": [
1835
+ "local",
1836
+ "global",
1837
+ ],
1838
+ }
1839
+ `)
1840
+
1841
+ const evalResult = await cdpSession.send('Debugger.evaluateOnCallFrame', {
1842
+ callFrameId: topFrame.callFrameId,
1843
+ expression: 'localVar + " world " + numberVar',
1844
+ })
1845
+
1846
+ expect({
1847
+ evaluatedExpression: 'localVar + " world " + numberVar',
1848
+ result: evalResult.result.value,
1849
+ type: evalResult.result.type,
1850
+ }).toMatchInlineSnapshot(`
1851
+ {
1852
+ "evaluatedExpression": "localVar + " world " + numberVar",
1853
+ "result": "hello world 42",
1854
+ "type": "string",
1855
+ }
1856
+ `)
1857
+
1858
+ await cdpSession.send('Debugger.resume')
1859
+ await cdpSession.send('Debugger.disable')
1860
+ cdpSession.detach()
1861
+ await browser.close()
1862
+ await page.close()
1863
+ }, 60000)
1864
+
1865
+ it('should profile JavaScript execution using CDP Profiler', async () => {
1866
+ const browserContext = getBrowserContext()
1867
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1868
+
1869
+ const page = await browserContext.newPage()
1870
+ await page.goto('https://example.com/')
1871
+ await page.bringToFront()
1872
+
1873
+ await serviceWorker.evaluate(async () => {
1874
+ await globalThis.toggleExtensionForActiveTab()
1875
+ })
1876
+ await new Promise(r => setTimeout(r, 500))
1877
+
1878
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1879
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
1880
+ expect(cdpPage).toBeDefined()
1881
+
1882
+ const wsUrl = getCdpUrl()
1883
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
1884
+ await cdpSession.send('Profiler.enable')
1885
+ await cdpSession.send('Profiler.start')
1886
+
1887
+ await cdpPage!.evaluate(`
1888
+ (() => {
1889
+ function fibonacci(n) {
1890
+ if (n <= 1) return n
1891
+ return fibonacci(n - 1) + fibonacci(n - 2)
1892
+ }
1893
+ for (let i = 0; i < 5; i++) {
1894
+ fibonacci(20)
1895
+ }
1896
+ for (let i = 0; i < 1000; i++) {
1897
+ document.querySelectorAll('*')
1898
+ }
1899
+ })()
1900
+ `)
1901
+
1902
+ const stopResult = await cdpSession.send('Profiler.stop')
1903
+ const profile = stopResult.profile
1904
+
1905
+ const functionNames = profile.nodes
1906
+ .map(n => n.callFrame.functionName)
1907
+ .filter(name => name && name.length > 0)
1908
+ .slice(0, 10)
1909
+
1910
+ expect({
1911
+ hasNodes: profile.nodes.length > 0,
1912
+ nodeCount: profile.nodes.length,
1913
+ durationMicroseconds: profile.endTime - profile.startTime,
1914
+ sampleFunctionNames: functionNames,
1915
+ }).toMatchInlineSnapshot(`
1916
+ {
1917
+ "durationMicroseconds": 11057,
1918
+ "hasNodes": true,
1919
+ "nodeCount": 4,
1920
+ "sampleFunctionNames": [
1921
+ "(root)",
1922
+ "(program)",
1923
+ "(idle)",
1924
+ "fibonacci",
1925
+ ],
1926
+ }
1927
+ `)
1928
+
1929
+ await cdpSession.send('Profiler.disable')
1930
+ cdpSession.detach()
1931
+ await browser.close()
1932
+ await page.close()
1933
+ }, 60000)
1934
+
1935
+ it('should click at correct coordinates on high-DPI simulation', async () => {
1936
+ const browserContext = getBrowserContext()
1937
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1938
+
1939
+ const page = await browserContext.newPage()
1940
+ await page.goto('https://example.com/')
1941
+ await page.bringToFront()
1942
+
1943
+ await serviceWorker.evaluate(async () => {
1944
+ await globalThis.toggleExtensionForActiveTab()
1945
+ })
1946
+ await new Promise(r => setTimeout(r, 500))
1947
+
1948
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1949
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
1950
+ expect(cdpPage).toBeDefined()
1951
+
1952
+ const h1Bounds = await cdpPage!.locator('h1').boundingBox()
1953
+ expect(h1Bounds).toBeDefined()
1954
+ console.log('H1 bounding box:', h1Bounds)
1955
+
1956
+ await cdpPage!.evaluate(() => {
1957
+ (window as any).clickedAt = null;
1958
+ document.addEventListener('click', (e) => {
1959
+ (window as any).clickedAt = { x: e.clientX, y: e.clientY };
1960
+ });
1961
+ })
1962
+
1963
+ await cdpPage!.locator('h1').click()
1964
+
1965
+ const clickedAt = await cdpPage!.evaluate(() => (window as any).clickedAt)
1966
+ console.log('Clicked at:', clickedAt)
1967
+
1968
+ expect(clickedAt).toBeDefined()
1969
+ expect(clickedAt.x).toBeGreaterThan(0)
1970
+ expect(clickedAt.y).toBeGreaterThan(0)
1971
+
1972
+ await browser.close()
1973
+ await page.close()
1974
+ }, 60000)
1975
+ })