e2e-pilot 0.0.69

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 (152) hide show
  1. package/bin.js +3 -0
  2. package/dist/aria-snapshot.d.ts +95 -0
  3. package/dist/aria-snapshot.d.ts.map +1 -0
  4. package/dist/aria-snapshot.js +490 -0
  5. package/dist/aria-snapshot.js.map +1 -0
  6. package/dist/bippy.js +971 -0
  7. package/dist/cdp-relay.d.ts +16 -0
  8. package/dist/cdp-relay.d.ts.map +1 -0
  9. package/dist/cdp-relay.js +715 -0
  10. package/dist/cdp-relay.js.map +1 -0
  11. package/dist/cdp-session.d.ts +42 -0
  12. package/dist/cdp-session.d.ts.map +1 -0
  13. package/dist/cdp-session.js +154 -0
  14. package/dist/cdp-session.js.map +1 -0
  15. package/dist/cdp-types.d.ts +63 -0
  16. package/dist/cdp-types.d.ts.map +1 -0
  17. package/dist/cdp-types.js +91 -0
  18. package/dist/cdp-types.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +213 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/create-logger.d.ts +9 -0
  24. package/dist/create-logger.d.ts.map +1 -0
  25. package/dist/create-logger.js +25 -0
  26. package/dist/create-logger.js.map +1 -0
  27. package/dist/debugger-api.md +458 -0
  28. package/dist/debugger-examples-types.d.ts +24 -0
  29. package/dist/debugger-examples-types.d.ts.map +1 -0
  30. package/dist/debugger-examples-types.js +2 -0
  31. package/dist/debugger-examples-types.js.map +1 -0
  32. package/dist/debugger-examples.d.ts +6 -0
  33. package/dist/debugger-examples.d.ts.map +1 -0
  34. package/dist/debugger-examples.js +53 -0
  35. package/dist/debugger-examples.js.map +1 -0
  36. package/dist/debugger.d.ts +381 -0
  37. package/dist/debugger.d.ts.map +1 -0
  38. package/dist/debugger.js +633 -0
  39. package/dist/debugger.js.map +1 -0
  40. package/dist/editor-api.md +364 -0
  41. package/dist/editor-examples.d.ts +11 -0
  42. package/dist/editor-examples.d.ts.map +1 -0
  43. package/dist/editor-examples.js +124 -0
  44. package/dist/editor-examples.js.map +1 -0
  45. package/dist/editor.d.ts +203 -0
  46. package/dist/editor.d.ts.map +1 -0
  47. package/dist/editor.js +336 -0
  48. package/dist/editor.js.map +1 -0
  49. package/dist/execute.d.ts +50 -0
  50. package/dist/execute.d.ts.map +1 -0
  51. package/dist/execute.js +576 -0
  52. package/dist/execute.js.map +1 -0
  53. package/dist/index.d.ts +11 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +7 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/mcp-client.d.ts +20 -0
  58. package/dist/mcp-client.d.ts.map +1 -0
  59. package/dist/mcp-client.js +56 -0
  60. package/dist/mcp-client.js.map +1 -0
  61. package/dist/mcp.d.ts +5 -0
  62. package/dist/mcp.d.ts.map +1 -0
  63. package/dist/mcp.js +720 -0
  64. package/dist/mcp.js.map +1 -0
  65. package/dist/mcp.test.d.ts +10 -0
  66. package/dist/mcp.test.d.ts.map +1 -0
  67. package/dist/mcp.test.js +2999 -0
  68. package/dist/mcp.test.js.map +1 -0
  69. package/dist/network-capture.d.ts +23 -0
  70. package/dist/network-capture.d.ts.map +1 -0
  71. package/dist/network-capture.js +98 -0
  72. package/dist/network-capture.js.map +1 -0
  73. package/dist/protocol.d.ts +54 -0
  74. package/dist/protocol.d.ts.map +1 -0
  75. package/dist/protocol.js +2 -0
  76. package/dist/protocol.js.map +1 -0
  77. package/dist/react-source.d.ts +13 -0
  78. package/dist/react-source.d.ts.map +1 -0
  79. package/dist/react-source.js +68 -0
  80. package/dist/react-source.js.map +1 -0
  81. package/dist/scoped-fs.d.ts +94 -0
  82. package/dist/scoped-fs.d.ts.map +1 -0
  83. package/dist/scoped-fs.js +356 -0
  84. package/dist/scoped-fs.js.map +1 -0
  85. package/dist/selector-generator.js +8126 -0
  86. package/dist/start-relay-server.d.ts +6 -0
  87. package/dist/start-relay-server.d.ts.map +1 -0
  88. package/dist/start-relay-server.js +33 -0
  89. package/dist/start-relay-server.js.map +1 -0
  90. package/dist/styles-api.md +117 -0
  91. package/dist/styles-examples.d.ts +8 -0
  92. package/dist/styles-examples.d.ts.map +1 -0
  93. package/dist/styles-examples.js +64 -0
  94. package/dist/styles-examples.js.map +1 -0
  95. package/dist/styles.d.ts +27 -0
  96. package/dist/styles.d.ts.map +1 -0
  97. package/dist/styles.js +234 -0
  98. package/dist/styles.js.map +1 -0
  99. package/dist/trace-utils.d.ts +14 -0
  100. package/dist/trace-utils.d.ts.map +1 -0
  101. package/dist/trace-utils.js +21 -0
  102. package/dist/trace-utils.js.map +1 -0
  103. package/dist/utils.d.ts +20 -0
  104. package/dist/utils.d.ts.map +1 -0
  105. package/dist/utils.js +75 -0
  106. package/dist/utils.js.map +1 -0
  107. package/dist/wait-for-page-load.d.ts +16 -0
  108. package/dist/wait-for-page-load.d.ts.map +1 -0
  109. package/dist/wait-for-page-load.js +127 -0
  110. package/dist/wait-for-page-load.js.map +1 -0
  111. package/package.json +67 -0
  112. package/src/aria-snapshot.ts +610 -0
  113. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  114. package/src/assets/aria-labels-github.png +0 -0
  115. package/src/assets/aria-labels-google-snapshot.txt +49 -0
  116. package/src/assets/aria-labels-google.png +0 -0
  117. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  118. package/src/assets/aria-labels-hacker-news.png +0 -0
  119. package/src/cdp-relay.ts +925 -0
  120. package/src/cdp-session.ts +203 -0
  121. package/src/cdp-timing.md +128 -0
  122. package/src/cdp-types.ts +155 -0
  123. package/src/cli.ts +250 -0
  124. package/src/create-logger.ts +36 -0
  125. package/src/debugger-examples-types.ts +13 -0
  126. package/src/debugger-examples.ts +66 -0
  127. package/src/debugger.md +453 -0
  128. package/src/debugger.ts +713 -0
  129. package/src/editor-examples.ts +148 -0
  130. package/src/editor.ts +390 -0
  131. package/src/execute.ts +763 -0
  132. package/src/index.ts +10 -0
  133. package/src/mcp-client.ts +78 -0
  134. package/src/mcp.test.ts +3596 -0
  135. package/src/mcp.ts +876 -0
  136. package/src/network-capture.ts +140 -0
  137. package/src/prompt.bak.md +323 -0
  138. package/src/prompt.md +7 -0
  139. package/src/protocol.ts +63 -0
  140. package/src/react-source.ts +94 -0
  141. package/src/resource.md +436 -0
  142. package/src/scoped-fs.ts +411 -0
  143. package/src/snapshots/hacker-news-focused-accessibility.md +202 -0
  144. package/src/snapshots/hacker-news-initial-accessibility.md +11 -0
  145. package/src/snapshots/hacker-news-tabbed-accessibility.md +202 -0
  146. package/src/snapshots/shadcn-ui-accessibility.md +11 -0
  147. package/src/start-relay-server.ts +43 -0
  148. package/src/styles-examples.ts +77 -0
  149. package/src/styles.ts +345 -0
  150. package/src/trace-utils.ts +43 -0
  151. package/src/utils.ts +91 -0
  152. package/src/wait-for-page-load.ts +174 -0
@@ -0,0 +1,3596 @@
1
+ import { createMCPClient } from './mcp-client.js'
2
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
+ import { exec } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { chromium, BrowserContext } from 'playwright-core'
6
+ import path from 'node:path'
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import { getCdpUrl } from './utils.js'
10
+ import type { ExtensionState } from 'e2e-pilot-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 { Debugger } from './debugger.js'
15
+ import { Editor } from './editor.js'
16
+ import { startE2EPilotCDPRelayServer, type RelayServer } from './cdp-relay.js'
17
+ import { createFileLogger } from './create-logger.js'
18
+ import type { CDPCommand } from './cdp-types.js'
19
+ import { killPortProcess } from 'kill-port-process'
20
+
21
+ declare const window: any
22
+ declare const document: any
23
+
24
+ const TEST_PORT = 19987
25
+ const TEST_EXTENSION_OUT_DIR = 'dist-test'
26
+
27
+ const execAsync = promisify(exec)
28
+
29
+ async function getExtensionServiceWorker(context: BrowserContext) {
30
+ let serviceWorkers = context.serviceWorkers().filter((sw) => sw.url().startsWith('chrome-extension://'))
31
+ let serviceWorker = serviceWorkers[0]
32
+ if (!serviceWorker) {
33
+ serviceWorker = await context.waitForEvent('serviceworker', {
34
+ predicate: (sw) => sw.url().startsWith('chrome-extension://'),
35
+ })
36
+ }
37
+
38
+ for (let i = 0; i < 50; i++) {
39
+ const isReady = await serviceWorker.evaluate(() => {
40
+ // @ts-ignore
41
+ return typeof globalThis.toggleExtensionForActiveTab === 'function'
42
+ })
43
+ if (isReady) break
44
+ await new Promise((r) => setTimeout(r, 100))
45
+ }
46
+
47
+ return serviceWorker
48
+ }
49
+
50
+ function js(strings: TemplateStringsArray, ...values: any[]): string {
51
+ return strings.reduce((result, str, i) => result + str + (values[i] || ''), '')
52
+ }
53
+
54
+ async function killProcessOnPort(port: number): Promise<void> {
55
+ try {
56
+ await killPortProcess(port)
57
+ console.log(`Killed processes on port ${port}`)
58
+ } catch (err) {
59
+ console.error('Error killing process on port:', err)
60
+ }
61
+ }
62
+
63
+ interface TestContext {
64
+ browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
65
+ userDataDir: string
66
+ relayServer: RelayServer
67
+ }
68
+
69
+ async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
70
+ await killProcessOnPort(TEST_PORT)
71
+
72
+ console.log('Building extension...')
73
+ await execAsync(
74
+ `TESTING=1 E2E_PILOT_PORT=${TEST_PORT} E2E_PILOT_EXTENSION_OUT_DIR=${TEST_EXTENSION_OUT_DIR} pnpm build`,
75
+ { cwd: '../extension' },
76
+ )
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 startE2EPilotCDPRelayServer({ port: TEST_PORT, logger })
82
+
83
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix))
84
+ const extensionPath = path.resolve('../extension', TEST_EXTENSION_OUT_DIR)
85
+
86
+ const browserContext = await chromium.launchPersistentContext(userDataDir, {
87
+ channel: 'chromium',
88
+ headless: !process.env.HEADFUL,
89
+ colorScheme: 'dark',
90
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
91
+ })
92
+
93
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
94
+
95
+ const page = await browserContext.newPage()
96
+ await page.goto('about:blank')
97
+
98
+ await serviceWorker.evaluate(async () => {
99
+ await globalThis.toggleExtensionForActiveTab()
100
+ })
101
+
102
+ return { browserContext, userDataDir, relayServer }
103
+ }
104
+
105
+ async function cleanupTestContext(ctx: TestContext | null, cleanup?: (() => Promise<void>) | null): Promise<void> {
106
+ if (ctx?.browserContext) {
107
+ await ctx.browserContext.close()
108
+ }
109
+ if (ctx?.relayServer) {
110
+ ctx.relayServer.close()
111
+ }
112
+
113
+ if (ctx?.userDataDir) {
114
+ try {
115
+ fs.rmSync(ctx.userDataDir, { recursive: true, force: true })
116
+ } catch (e) {
117
+ console.error('Failed to cleanup user data dir:', e)
118
+ }
119
+ }
120
+ if (cleanup) {
121
+ await cleanup()
122
+ }
123
+ }
124
+
125
+ declare global {
126
+ var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>
127
+ var getExtensionState: () => ExtensionState
128
+ var disconnectEverything: () => Promise<void>
129
+ }
130
+
131
+ describe('MCP Server Tests', () => {
132
+ let client: Awaited<ReturnType<typeof createMCPClient>>['client']
133
+ let cleanup: (() => Promise<void>) | null = null
134
+ let testCtx: TestContext | null = null
135
+
136
+ beforeAll(async () => {
137
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' })
138
+
139
+ const result = await createMCPClient({ port: TEST_PORT })
140
+ client = result.client
141
+ cleanup = result.cleanup
142
+ }, 600000)
143
+
144
+ afterAll(async () => {
145
+ await cleanupTestContext(testCtx, cleanup)
146
+ cleanup = null
147
+ testCtx = null
148
+ })
149
+
150
+ const getBrowserContext = () => {
151
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
152
+ return testCtx.browserContext
153
+ }
154
+
155
+ it('should inject script via addScriptTag through CDP relay', async () => {
156
+ const browserContext = getBrowserContext()
157
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
158
+
159
+ const page = await browserContext.newPage()
160
+ await page.setContent('<html><body><button id="btn">Click</button></body></html>')
161
+ await page.bringToFront()
162
+
163
+ await serviceWorker.evaluate(async () => {
164
+ await globalThis.toggleExtensionForActiveTab()
165
+ })
166
+ await new Promise((r) => setTimeout(r, 100))
167
+
168
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
169
+ const cdpPage = browser
170
+ .contexts()[0]
171
+ .pages()
172
+ .find((p) => {
173
+ return p.url().startsWith('about:')
174
+ })
175
+ expect(cdpPage).toBeDefined()
176
+
177
+ const hasGlobalBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__testGlobal)
178
+ expect(hasGlobalBefore).toBe(false)
179
+
180
+ await cdpPage!.addScriptTag({ content: 'globalThis.__testGlobal = { foo: "bar" };' })
181
+
182
+ const hasGlobalAfter = await cdpPage!.evaluate(() => (globalThis as any).__testGlobal)
183
+ expect(hasGlobalAfter).toEqual({ foo: 'bar' })
184
+
185
+ await browser.close()
186
+ await page.close()
187
+ }, 60000)
188
+
189
+ it('should execute code and capture console output', async () => {
190
+ await client.callTool({
191
+ name: 'execute',
192
+ arguments: {
193
+ code: js`
194
+ const newPage = await context.newPage();
195
+ state.page = newPage;
196
+ if (!state.pages) state.pages = [];
197
+ state.pages.push(newPage);
198
+ `,
199
+ },
200
+ })
201
+
202
+ const result = await client.callTool({
203
+ name: 'execute',
204
+ arguments: {
205
+ code: js`
206
+ await state.page.goto('https://example.com');
207
+ const title = await state.page.title();
208
+ console.log('Page title:', title);
209
+ return { url: state.page.url(), title };
210
+ `,
211
+ },
212
+ })
213
+ expect(result.content).toMatchInlineSnapshot(`
214
+ [
215
+ {
216
+ "text": "MCP error -32602: Input validation error: Invalid arguments for tool execute: [
217
+ {
218
+ "code": "invalid_type",
219
+ "expected": "string",
220
+ "received": "undefined",
221
+ "path": [
222
+ "intend"
223
+ ],
224
+ "message": "Required"
225
+ }
226
+ ]",
227
+ "type": "text",
228
+ },
229
+ ]
230
+ `)
231
+ expect(result.content).toBeDefined()
232
+ }, 30000)
233
+
234
+ it('should show extension as connected for pages created via newPage()', async () => {
235
+ const browserContext = getBrowserContext()
236
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
237
+
238
+ // Create a page via MCP (which uses context.newPage())
239
+ await client.callTool({
240
+ name: 'execute',
241
+ arguments: {
242
+ code: js`
243
+ const newPage = await context.newPage();
244
+ state.testPage = newPage;
245
+ await newPage.goto('https://example.com/mcp-test');
246
+ return newPage.url();
247
+ `,
248
+ },
249
+ })
250
+
251
+ // Get extension state to verify the page is marked as connected
252
+ const extensionState = await serviceWorker.evaluate(async () => {
253
+ const state = globalThis.getExtensionState()
254
+ const tabs = await chrome.tabs.query({})
255
+ const testTab = tabs.find((t: any) => t.url?.includes('mcp-test'))
256
+ return {
257
+ connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
258
+ tabId: testTab?.id,
259
+ tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
260
+ connectionState: state.connectionState,
261
+ }
262
+ })
263
+
264
+ expect(extensionState.connected).toBe(true)
265
+ expect(extensionState.tabInfo?.state).toBe('connected')
266
+ expect(extensionState.connectionState).toBe('connected')
267
+
268
+ // Clean up
269
+ await client.callTool({
270
+ name: 'execute',
271
+ arguments: {
272
+ code: js`
273
+ if (state.testPage) {
274
+ await state.testPage.close();
275
+ delete state.testPage;
276
+ }
277
+ `,
278
+ },
279
+ })
280
+ }, 30000)
281
+
282
+ it('should get accessibility snapshot of hacker news', async () => {
283
+ await client.callTool({
284
+ name: 'execute',
285
+ arguments: {
286
+ code: js`
287
+ const newPage = await context.newPage();
288
+ state.page = newPage;
289
+ if (!state.pages) state.pages = [];
290
+ state.pages.push(newPage);
291
+ `,
292
+ },
293
+ })
294
+
295
+ const result = await client.callTool({
296
+ name: 'execute',
297
+ arguments: {
298
+ code: js`
299
+ await state.page.goto('https://news.ycombinator.com/item?id=1', { waitUntil: 'domcontentloaded' });
300
+ const snapshot = await state.page._snapshotForAI();
301
+ return snapshot;
302
+ `,
303
+ },
304
+ })
305
+
306
+ const initialData =
307
+ typeof result === 'object' && result.content?.[0]?.text ? tryJsonParse(result.content[0].text) : result
308
+ await expect(initialData).toMatchFileSnapshot('snapshots/hacker-news-initial-accessibility.md')
309
+ expect(result.content).toBeDefined()
310
+ expect(initialData).toContain('table')
311
+ expect(initialData).toContain('Hacker News')
312
+ }, 30000)
313
+
314
+ it('should get accessibility snapshot of shadcn UI', async () => {
315
+ await client.callTool({
316
+ name: 'execute',
317
+ arguments: {
318
+ code: js`
319
+ const newPage = await context.newPage();
320
+ state.page = newPage;
321
+ if (!state.pages) state.pages = [];
322
+ state.pages.push(newPage);
323
+ `,
324
+ },
325
+ })
326
+
327
+ const snapshot = await client.callTool({
328
+ name: 'execute',
329
+ arguments: {
330
+ code: js`
331
+ await state.page.goto('https://ui.shadcn.com/', { waitUntil: 'domcontentloaded' });
332
+ const snapshot = await state.page._snapshotForAI();
333
+ return snapshot;
334
+ `,
335
+ },
336
+ })
337
+
338
+ const data =
339
+ typeof snapshot === 'object' && snapshot.content?.[0]?.text ? tryJsonParse(snapshot.content[0].text) : snapshot
340
+ await expect(data).toMatchFileSnapshot('snapshots/shadcn-ui-accessibility.md')
341
+ expect(snapshot.content).toBeDefined()
342
+ expect(data).toContain('shadcn')
343
+ }, 30000)
344
+
345
+ it('should close all created pages', async () => {
346
+ const result = await client.callTool({
347
+ name: 'execute',
348
+ arguments: {
349
+ code: js`
350
+ if (state.pages && state.pages.length > 0) {
351
+ for (const page of state.pages) {
352
+ await page.close();
353
+ }
354
+ const closedCount = state.pages.length;
355
+ state.pages = [];
356
+ return { closedCount };
357
+ }
358
+ return { closedCount: 0 };
359
+ `,
360
+ },
361
+ })
362
+ })
363
+
364
+ it('should handle new pages and toggling with new connections', async () => {
365
+ const browserContext = getBrowserContext()
366
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
367
+
368
+ // 1. Create a new page
369
+ const page = await browserContext.newPage()
370
+ const testUrl = 'https://example.com/'
371
+ await page.goto(testUrl)
372
+
373
+ await page.bringToFront()
374
+
375
+ // 2. Enable extension on this new tab
376
+ // Since it's a new page, extension is not connected yet
377
+ const result = await serviceWorker.evaluate(async () => {
378
+ return await globalThis.toggleExtensionForActiveTab()
379
+ })
380
+ expect(result.isConnected).toBe(true)
381
+
382
+ // 3. Verify we can connect via direct CDP and see the page
383
+
384
+ let directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
385
+ let contexts = directBrowser.contexts()
386
+ let pages = contexts[0].pages()
387
+
388
+ // Find our page
389
+ let foundPage = pages.find((p) => p.url() === testUrl)
390
+ expect(foundPage).toBeDefined()
391
+ expect(foundPage?.url()).toBe(testUrl)
392
+
393
+ // Verify execution works
394
+ const sum1 = await foundPage?.evaluate(() => 1 + 1)
395
+ expect(sum1).toBe(2)
396
+
397
+ await directBrowser.close()
398
+
399
+ // 4. Disable extension on this tab
400
+ const resultDisabled = await serviceWorker.evaluate(async () => {
401
+ return await globalThis.toggleExtensionForActiveTab()
402
+ })
403
+ expect(resultDisabled.isConnected).toBe(false)
404
+
405
+ // 5. Try to connect/use the page.
406
+ // connecting to relay will succeed, but listing pages should NOT show our page
407
+
408
+ // Connect to relay again
409
+ directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
410
+ contexts = directBrowser.contexts()
411
+ pages = contexts[0].pages()
412
+
413
+ foundPage = pages.find((p) => p.url() === testUrl)
414
+ expect(foundPage).toBeUndefined()
415
+
416
+ await directBrowser.close()
417
+
418
+ // 6. Re-enable extension
419
+ const resultEnabled = await serviceWorker.evaluate(async () => {
420
+ return await globalThis.toggleExtensionForActiveTab()
421
+ })
422
+ expect(resultEnabled.isConnected).toBe(true)
423
+
424
+ // 7. Verify page is back
425
+
426
+ directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
427
+ // Wait a bit for targets to populate
428
+ await new Promise((r) => setTimeout(r, 100))
429
+
430
+ contexts = directBrowser.contexts()
431
+ // pages() might need a moment if target attached event comes in
432
+ if (contexts[0].pages().length === 0) {
433
+ await new Promise((r) => setTimeout(r, 100))
434
+ }
435
+ pages = contexts[0].pages()
436
+
437
+ foundPage = pages.find((p) => p.url() === testUrl)
438
+ expect(foundPage).toBeDefined()
439
+ expect(foundPage?.url()).toBe(testUrl)
440
+
441
+ // Verify execution works again
442
+ const sum2 = await foundPage?.evaluate(() => 2 + 2)
443
+ expect(sum2).toBe(4)
444
+
445
+ await directBrowser.close()
446
+ await page.close()
447
+ })
448
+
449
+ it('should handle new pages and toggling with persistent connection', async () => {
450
+ const browserContext = getBrowserContext()
451
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
452
+
453
+ // Connect once
454
+ const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
455
+ // Wait a bit for connection and initial target discovery
456
+ await new Promise((r) => setTimeout(r, 100))
457
+
458
+ // 1. Create a new page
459
+ const page = await browserContext.newPage()
460
+ const testUrl = 'https://example.com/persistent'
461
+ await page.goto(testUrl)
462
+ await page.bringToFront()
463
+
464
+ // 2. Enable extension
465
+ await serviceWorker.evaluate(async () => {
466
+ await globalThis.toggleExtensionForActiveTab()
467
+ })
468
+
469
+ // 3. Verify page appears (polling)
470
+ let foundPage
471
+ for (let i = 0; i < 50; i++) {
472
+ const pages = directBrowser.contexts()[0].pages()
473
+ foundPage = pages.find((p) => p.url() === testUrl)
474
+ if (foundPage) break
475
+ await new Promise((r) => setTimeout(r, 100))
476
+ }
477
+ expect(foundPage).toBeDefined()
478
+ expect(foundPage?.url()).toBe(testUrl)
479
+
480
+ // Verify execution works
481
+ const sum1 = await foundPage?.evaluate(() => 10 + 20)
482
+ expect(sum1).toBe(30)
483
+
484
+ // 4. Disable extension
485
+ await serviceWorker.evaluate(async () => {
486
+ await globalThis.toggleExtensionForActiveTab()
487
+ })
488
+
489
+ // 5. Verify page disappears (polling)
490
+ for (let i = 0; i < 50; i++) {
491
+ const pages = directBrowser.contexts()[0].pages()
492
+ foundPage = pages.find((p) => p.url() === testUrl)
493
+ if (!foundPage) break
494
+ await new Promise((r) => setTimeout(r, 100))
495
+ }
496
+ expect(foundPage).toBeUndefined()
497
+
498
+ // 6. Re-enable extension
499
+ await serviceWorker.evaluate(async () => {
500
+ await globalThis.toggleExtensionForActiveTab()
501
+ })
502
+
503
+ // 7. Verify page reappears (polling)
504
+ for (let i = 0; i < 50; i++) {
505
+ const pages = directBrowser.contexts()[0].pages()
506
+ foundPage = pages.find((p) => p.url() === testUrl)
507
+ if (foundPage) break
508
+ await new Promise((r) => setTimeout(r, 100))
509
+ }
510
+ expect(foundPage).toBeDefined()
511
+ expect(foundPage?.url()).toBe(testUrl)
512
+
513
+ // Verify execution works again
514
+ const sum2 = await foundPage?.evaluate(() => 30 + 40)
515
+ expect(sum2).toBe(70)
516
+
517
+ await page.close()
518
+ await directBrowser.close()
519
+ })
520
+ it('should maintain connection across reloads and navigation', async () => {
521
+ const browserContext = getBrowserContext()
522
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
523
+
524
+ // 1. Setup page
525
+ const page = await browserContext.newPage()
526
+ const initialUrl = 'https://example.com/'
527
+ await page.goto(initialUrl)
528
+ await page.bringToFront()
529
+
530
+ // 2. Enable extension
531
+ await serviceWorker.evaluate(async () => {
532
+ await globalThis.toggleExtensionForActiveTab()
533
+ })
534
+
535
+ // 3. Connect via CDP
536
+ const cdpUrl = getCdpUrl({ port: TEST_PORT })
537
+ const directBrowser = await chromium.connectOverCDP(cdpUrl)
538
+ const connectedPage = directBrowser
539
+ .contexts()[0]
540
+ .pages()
541
+ .find((p) => p.url() === initialUrl)
542
+ expect(connectedPage).toBeDefined()
543
+
544
+ // Verify execution
545
+ expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2)
546
+
547
+ // 4. Reload
548
+ // We use a loop to check if it's still connected because reload might cause temporary disconnect/reconnect events
549
+ // that Playwright handles natively if the session ID stays valid.
550
+ await connectedPage?.reload()
551
+ await connectedPage?.waitForLoadState('domcontentloaded')
552
+ expect(await connectedPage?.title()).toBe('Example Domain')
553
+
554
+ // Verify execution after reload
555
+ expect(await connectedPage?.evaluate(() => 2 + 2)).toBe(4)
556
+
557
+ // 5. Navigate to new URL
558
+ const newUrl = 'https://example.org/'
559
+ await connectedPage?.goto(newUrl)
560
+ await connectedPage?.waitForLoadState('domcontentloaded')
561
+
562
+ expect(connectedPage?.url()).toBe(newUrl)
563
+ expect(await connectedPage?.title()).toContain('Example Domain')
564
+
565
+ // Verify execution after navigation
566
+ expect(await connectedPage?.evaluate(() => 3 + 3)).toBe(6)
567
+
568
+ await directBrowser.close()
569
+ await page.close()
570
+ })
571
+
572
+ it('should support multiple concurrent tabs', async () => {
573
+ const browserContext = getBrowserContext()
574
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
575
+ await new Promise((resolve) => setTimeout(resolve, 100))
576
+
577
+ // Tab A
578
+ const pageA = await browserContext.newPage()
579
+ await pageA.goto('https://example.com/tab-a')
580
+ await pageA.bringToFront()
581
+ await new Promise((resolve) => setTimeout(resolve, 100))
582
+ await serviceWorker.evaluate(async () => {
583
+ await globalThis.toggleExtensionForActiveTab()
584
+ })
585
+
586
+ // Tab B
587
+ const pageB = await browserContext.newPage()
588
+ await pageB.goto('https://example.com/tab-b')
589
+ await pageB.bringToFront()
590
+ await new Promise((resolve) => setTimeout(resolve, 100))
591
+ await serviceWorker.evaluate(async () => {
592
+ await globalThis.toggleExtensionForActiveTab()
593
+ })
594
+
595
+ // Get target IDs for both
596
+ const targetIds = await serviceWorker.evaluate(async () => {
597
+ const state = globalThis.getExtensionState()
598
+ const chrome = globalThis.chrome
599
+ const tabs = await chrome.tabs.query({})
600
+ const tabA = tabs.find((t: any) => t.url?.includes('tab-a'))
601
+ const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
602
+ return {
603
+ idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
604
+ idB: state.tabs.get(tabB?.id ?? -1)?.targetId,
605
+ }
606
+ })
607
+
608
+ expect(targetIds).toMatchInlineSnapshot(
609
+ {
610
+ idA: expect.any(String),
611
+ idB: expect.any(String),
612
+ },
613
+ `
614
+ {
615
+ "idA": Any<String>,
616
+ "idB": Any<String>,
617
+ }
618
+ `,
619
+ )
620
+ expect(targetIds.idA).not.toBe(targetIds.idB)
621
+
622
+ // Verify independent connections
623
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
624
+
625
+ const pages = browser.contexts()[0].pages()
626
+
627
+ const results = await Promise.all(
628
+ pages.map(async (p) => ({
629
+ url: p.url(),
630
+ title: await p.title(),
631
+ })),
632
+ )
633
+
634
+ expect(results).toMatchInlineSnapshot(`
635
+ [
636
+ {
637
+ "title": "",
638
+ "url": "about:blank",
639
+ },
640
+ {
641
+ "title": "Example Domain",
642
+ "url": "https://example.com/tab-a",
643
+ },
644
+ {
645
+ "title": "Example Domain",
646
+ "url": "https://example.com/tab-b",
647
+ },
648
+ ]
649
+ `)
650
+
651
+ // Verify execution on both pages
652
+ const pageA_CDP = pages.find((p) => p.url().includes('tab-a'))
653
+ const pageB_CDP = pages.find((p) => p.url().includes('tab-b'))
654
+
655
+ expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
656
+ expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
657
+
658
+ await browser.close()
659
+ await pageA.close()
660
+ await pageB.close()
661
+ })
662
+
663
+ it('should show correct url when enabling extension after navigation', async () => {
664
+ const browserContext = getBrowserContext()
665
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
666
+
667
+ const page = await browserContext.newPage()
668
+ const targetUrl = 'https://example.com/late-enable'
669
+ await page.goto(targetUrl)
670
+ await page.bringToFront()
671
+
672
+ // Wait for load
673
+ await page.waitForLoadState('domcontentloaded')
674
+
675
+ // 2. Enable extension for this page
676
+ await serviceWorker.evaluate(async () => {
677
+ await globalThis.toggleExtensionForActiveTab()
678
+ })
679
+
680
+ // 3. Verify via CDP that the correct URL is shown
681
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
682
+ // Wait for sync
683
+ await new Promise((r) => setTimeout(r, 100))
684
+
685
+ const cdpPage = browser
686
+ .contexts()[0]
687
+ .pages()
688
+ .find((p) => p.url() === targetUrl)
689
+
690
+ expect(cdpPage).toBeDefined()
691
+ expect(cdpPage?.url()).toBe(targetUrl)
692
+
693
+ await browser.close()
694
+ await page.close()
695
+ }, 60000)
696
+
697
+ it('should be able to reconnect after disconnecting everything', async () => {
698
+ const browserContext = getBrowserContext()
699
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
700
+
701
+ const pages = await browserContext.pages()
702
+ expect(pages.length).toBeGreaterThan(0)
703
+ const page = pages[0]
704
+
705
+ await page.goto('https://example.com/disconnect-test')
706
+ await page.waitForLoadState('domcontentloaded')
707
+ await page.bringToFront()
708
+
709
+ // Enable extension on this page
710
+ const initialEnable = await serviceWorker.evaluate(async () => {
711
+ return await globalThis.toggleExtensionForActiveTab()
712
+ })
713
+ console.log('Initial enable result:', initialEnable)
714
+ expect(initialEnable.isConnected).toBe(true)
715
+
716
+ // Wait for extension to fully connect
717
+ await new Promise((resolve) => setTimeout(resolve, 100))
718
+
719
+ // Verify MCP can see the page
720
+ const beforeDisconnect = await client.callTool({
721
+ name: 'execute',
722
+ arguments: {
723
+ code: js`
724
+ const pages = context.pages();
725
+ console.log('Pages before disconnect:', pages.length);
726
+ const testPage = pages.find(p => p.url().includes('disconnect-test'));
727
+ console.log('Found test page:', !!testPage);
728
+ return { pagesCount: pages.length, foundTestPage: !!testPage };
729
+ `,
730
+ },
731
+ })
732
+
733
+ const beforeOutput = (beforeDisconnect as any).content[0].text
734
+ expect(beforeOutput).toContain('foundTestPage')
735
+ console.log('Before disconnect:', beforeOutput)
736
+
737
+ // 2. Disconnect everything
738
+ console.log('Calling disconnectEverything...')
739
+ await serviceWorker.evaluate(async () => {
740
+ await globalThis.disconnectEverything()
741
+ })
742
+
743
+ // Wait for disconnect to complete
744
+ await new Promise((resolve) => setTimeout(resolve, 100))
745
+
746
+ // 3. Verify MCP cannot see the page anymore
747
+ const afterDisconnect = await client.callTool({
748
+ name: 'execute',
749
+ arguments: {
750
+ code: js`
751
+ const pages = context.pages();
752
+ console.log('Pages after disconnect:', pages.length);
753
+ return { pagesCount: pages.length };
754
+ `,
755
+ },
756
+ })
757
+
758
+ const afterDisconnectOutput = (afterDisconnect as any).content[0].text
759
+ console.log('After disconnect:', afterDisconnectOutput)
760
+ expect(afterDisconnectOutput).toContain('Pages after disconnect: 0')
761
+
762
+ // 4. Re-enable extension on the same page
763
+ console.log('Re-enabling extension...')
764
+ await page.bringToFront()
765
+ const reconnectResult = await serviceWorker.evaluate(async () => {
766
+ console.log('About to call toggleExtensionForActiveTab')
767
+ const result = await globalThis.toggleExtensionForActiveTab()
768
+ console.log('toggleExtensionForActiveTab result:', result)
769
+ return result
770
+ })
771
+
772
+ console.log('Reconnect result:', reconnectResult)
773
+ expect(reconnectResult.isConnected).toBe(true)
774
+
775
+ // Wait for extension to fully reconnect and relay server to be ready
776
+ console.log('Waiting for reconnection to stabilize...')
777
+ await new Promise((resolve) => setTimeout(resolve, 100))
778
+
779
+ // 5. Reset the MCP client's playwright connection since it was closed by disconnectEverything
780
+ console.log('Resetting MCP playwright connection...')
781
+ const resetResult = await client.callTool({
782
+ name: 'execute',
783
+ arguments: {
784
+ code: js`
785
+ console.log('Resetting playwright connection');
786
+ const result = await resetPlaywright();
787
+ console.log('Reset complete, checking pages');
788
+ const pages = context.pages();
789
+ console.log('Pages after reset:', pages.length);
790
+ return { reset: true, pagesCount: pages.length };
791
+ `,
792
+ },
793
+ })
794
+ console.log('Reset result:', (resetResult as any).content[0].text)
795
+
796
+ // 6. Verify MCP can see the page again
797
+ console.log('Attempting to access page via MCP...')
798
+ const afterReconnect = await client.callTool({
799
+ name: 'execute',
800
+ arguments: {
801
+ code: js`
802
+ console.log('Checking pages after reconnect...');
803
+ const pages = context.pages();
804
+ console.log('Pages after reconnect:', pages.length);
805
+
806
+ if (pages.length === 0) {
807
+ console.log('No pages found!');
808
+ return { pagesCount: 0, foundTestPage: false };
809
+ }
810
+
811
+ const testPage = pages.find(p => p.url().includes('disconnect-test'));
812
+ console.log('Found test page after reconnect:', !!testPage);
813
+
814
+ if (testPage) {
815
+ console.log('Test page URL:', testPage.url());
816
+ return { pagesCount: pages.length, foundTestPage: true, url: testPage.url() };
817
+ }
818
+
819
+ return { pagesCount: pages.length, foundTestPage: false };
820
+ `,
821
+ },
822
+ })
823
+
824
+ const afterReconnectOutput = (afterReconnect as any).content[0].text
825
+ console.log('After reconnect:', afterReconnectOutput)
826
+ expect(afterReconnectOutput).toContain('foundTestPage')
827
+ expect(afterReconnectOutput).toContain('disconnect-test')
828
+
829
+ // Clean up - navigate page back to about:blank to not interfere with other tests
830
+ await page.goto('about:blank')
831
+ })
832
+
833
+ it('should auto-reconnect MCP after extension WebSocket reconnects', async () => {
834
+ // This test verifies that the MCP automatically reconnects when the browser
835
+ // disconnects (e.g., when the extension WebSocket reconnects and the relay
836
+ // server closes all playwright clients). The fix adds browser.on('disconnected')
837
+ // handler that clears state.isConnected, so ensureConnection() creates a new connection.
838
+
839
+ const serviceWorker = await getExtensionServiceWorker(testCtx!.browserContext)
840
+
841
+ // 1. Create a test page and enable extension
842
+ const page = await testCtx!.browserContext.newPage()
843
+ await page.goto('https://example.com/auto-reconnect-test')
844
+ await page.waitForLoadState('domcontentloaded')
845
+ await page.bringToFront()
846
+
847
+ const initialEnable = await serviceWorker.evaluate(async () => {
848
+ return await globalThis.toggleExtensionForActiveTab()
849
+ })
850
+ expect(initialEnable.isConnected).toBe(true)
851
+ await new Promise((resolve) => setTimeout(resolve, 100))
852
+
853
+ // 2. Verify MCP can execute commands
854
+ const beforeResult = await client.callTool({
855
+ name: 'execute',
856
+ arguments: {
857
+ code: js`
858
+ const pages = context.pages();
859
+ const testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
860
+ return { pagesCount: pages.length, foundTestPage: !!testPage };
861
+ `,
862
+ },
863
+ })
864
+ const beforeOutput = (beforeResult as any).content[0].text
865
+ expect(beforeOutput).toContain('foundTestPage')
866
+ expect(beforeOutput).toContain('true')
867
+
868
+ // 3. Simulate extension WebSocket reconnection
869
+ // This causes relay server to close all playwright client WebSockets
870
+ await serviceWorker.evaluate(async () => {
871
+ await globalThis.disconnectEverything()
872
+ })
873
+ await new Promise((resolve) => setTimeout(resolve, 100))
874
+
875
+ // Re-enable extension (simulates extension reconnecting)
876
+ await page.bringToFront()
877
+ const reconnectResult = await serviceWorker.evaluate(async () => {
878
+ return await globalThis.toggleExtensionForActiveTab()
879
+ })
880
+ expect(reconnectResult.isConnected).toBe(true)
881
+ await new Promise((resolve) => setTimeout(resolve, 100))
882
+
883
+ // 4. Execute command WITHOUT calling resetPlaywright()
884
+ // The browser.on('disconnected') handler should have cleared state.isConnected,
885
+ // causing ensureConnection() to automatically create a new connection
886
+ const afterResult = await client.callTool({
887
+ name: 'execute',
888
+ arguments: {
889
+ code: js`
890
+ const pages = context.pages();
891
+ const testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
892
+ return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() };
893
+ `,
894
+ },
895
+ })
896
+
897
+ const afterOutput = (afterResult as any).content[0].text
898
+ // The command should succeed and find our test page
899
+ expect(afterOutput).toContain('foundTestPage')
900
+ expect(afterOutput).toContain('true')
901
+ expect(afterOutput).toContain('auto-reconnect-test')
902
+ // Should NOT contain error about extension not connected
903
+ expect(afterOutput).not.toContain('Extension not connected')
904
+ expect((afterResult as any).isError).not.toBe(true)
905
+
906
+ // Clean up
907
+ await page.goto('about:blank')
908
+ })
909
+
910
+ it('should capture browser console logs with getLatestLogs', async () => {
911
+ // Ensure clean state and clear any existing logs
912
+ const resetResult = await client.callTool({
913
+ name: 'execute',
914
+ arguments: {
915
+ code: js`
916
+ // Clear any existing logs from previous tests
917
+ clearAllLogs();
918
+ console.log('Cleared all existing logs');
919
+
920
+ // Verify connection is working
921
+ const pages = context.pages();
922
+ console.log('Current pages count:', pages.length);
923
+
924
+ return { success: true, pagesCount: pages.length };
925
+ `,
926
+ },
927
+ })
928
+ console.log('Cleanup result:', resetResult)
929
+
930
+ // Create a new page for this test
931
+ await client.callTool({
932
+ name: 'execute',
933
+ arguments: {
934
+ code: js`
935
+ const newPage = await context.newPage();
936
+ state.testLogPage = newPage;
937
+ await newPage.goto('about:blank');
938
+ `,
939
+ },
940
+ })
941
+
942
+ // Generate some console logs in the browser
943
+ await client.callTool({
944
+ name: 'execute',
945
+ arguments: {
946
+ code: js`
947
+ await state.testLogPage.evaluate(() => {
948
+ console.log('Test log 12345');
949
+ console.error('Test error 67890');
950
+ console.warn('Test warning 11111');
951
+ console.log('Test log 2 with', { data: 'object' });
952
+ });
953
+ // Wait for logs to be captured
954
+ await new Promise(resolve => setTimeout(resolve, 100));
955
+ `,
956
+ },
957
+ })
958
+
959
+ // Test getting all logs
960
+ const allLogsResult = await client.callTool({
961
+ name: 'execute',
962
+ arguments: {
963
+ code: js`
964
+ const logs = await getLatestLogs();
965
+ logs.forEach(log => console.log(log));
966
+ `,
967
+ },
968
+ })
969
+
970
+ const output = (allLogsResult as any).content[0].text
971
+ expect(output).toContain('[log] Test log 12345')
972
+ expect(output).toContain('[error] Test error 67890')
973
+ expect(output).toContain('[warning] Test warning 11111')
974
+
975
+ // Test filtering by search string
976
+ const errorLogsResult = await client.callTool({
977
+ name: 'execute',
978
+ arguments: {
979
+ code: js`
980
+ const logs = await getLatestLogs({ search: 'error' });
981
+ logs.forEach(log => console.log(log));
982
+ `,
983
+ },
984
+ })
985
+
986
+ const errorOutput = (errorLogsResult as any).content[0].text
987
+ expect(errorOutput).toContain('[error] Test error 67890')
988
+ expect(errorOutput).not.toContain('[log] Test log 12345')
989
+
990
+ // Test that logs are cleared on page reload
991
+ await client.callTool({
992
+ name: 'execute',
993
+ arguments: {
994
+ code: js`
995
+ // First add a log before reload
996
+ await state.testLogPage.evaluate(() => {
997
+ console.log('Before reload 99999');
998
+ });
999
+ await new Promise(resolve => setTimeout(resolve, 100));
1000
+ `,
1001
+ },
1002
+ })
1003
+
1004
+ // Verify the log exists
1005
+ const beforeReloadResult = await client.callTool({
1006
+ name: 'execute',
1007
+ arguments: {
1008
+ code: js`
1009
+ const logs = await getLatestLogs({ page: state.testLogPage });
1010
+ console.log('Logs before reload:', logs.length);
1011
+ logs.forEach(log => console.log(log));
1012
+ `,
1013
+ },
1014
+ })
1015
+
1016
+ const beforeReloadOutput = (beforeReloadResult as any).content[0].text
1017
+ expect(beforeReloadOutput).toContain('[log] Before reload 99999')
1018
+
1019
+ // Reload the page
1020
+ await client.callTool({
1021
+ name: 'execute',
1022
+ arguments: {
1023
+ code: js`
1024
+ await state.testLogPage.reload();
1025
+ await state.testLogPage.evaluate(() => {
1026
+ console.log('After reload 88888');
1027
+ });
1028
+ await new Promise(resolve => setTimeout(resolve, 100));
1029
+ `,
1030
+ },
1031
+ })
1032
+
1033
+ // Check logs after reload - old logs should be gone
1034
+ const afterReloadResult = await client.callTool({
1035
+ name: 'execute',
1036
+ arguments: {
1037
+ code: js`
1038
+ const logs = await getLatestLogs({ page: state.testLogPage });
1039
+ console.log('Logs after reload:', logs.length);
1040
+ logs.forEach(log => console.log(log));
1041
+ `,
1042
+ },
1043
+ })
1044
+
1045
+ const afterReloadOutput = (afterReloadResult as any).content[0].text
1046
+ expect(afterReloadOutput).toContain('[log] After reload 88888')
1047
+ expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
1048
+
1049
+ // Clean up
1050
+ await client.callTool({
1051
+ name: 'execute',
1052
+ arguments: {
1053
+ code: js`
1054
+ await state.testLogPage.close();
1055
+ delete state.testLogPage;
1056
+ `,
1057
+ },
1058
+ })
1059
+ }, 30000)
1060
+
1061
+ it('should keep logs separate between different pages', async () => {
1062
+ // Clear any existing logs from previous tests
1063
+ await client.callTool({
1064
+ name: 'execute',
1065
+ arguments: {
1066
+ code: js`
1067
+ clearAllLogs();
1068
+ console.log('Cleared all existing logs for second log test');
1069
+ `,
1070
+ },
1071
+ })
1072
+
1073
+ // Create two pages
1074
+ await client.callTool({
1075
+ name: 'execute',
1076
+ arguments: {
1077
+ code: js`
1078
+ state.pageA = await context.newPage();
1079
+ state.pageB = await context.newPage();
1080
+ await state.pageA.goto('about:blank');
1081
+ await state.pageB.goto('about:blank');
1082
+ `,
1083
+ },
1084
+ })
1085
+
1086
+ // Generate logs in page A
1087
+ await client.callTool({
1088
+ name: 'execute',
1089
+ arguments: {
1090
+ code: js`
1091
+ await state.pageA.evaluate(() => {
1092
+ console.log('PageA log 11111');
1093
+ console.error('PageA error 22222');
1094
+ });
1095
+ await new Promise(resolve => setTimeout(resolve, 100));
1096
+ `,
1097
+ },
1098
+ })
1099
+
1100
+ // Generate logs in page B
1101
+ await client.callTool({
1102
+ name: 'execute',
1103
+ arguments: {
1104
+ code: js`
1105
+ await state.pageB.evaluate(() => {
1106
+ console.log('PageB log 33333');
1107
+ console.error('PageB error 44444');
1108
+ });
1109
+ await new Promise(resolve => setTimeout(resolve, 100));
1110
+ `,
1111
+ },
1112
+ })
1113
+
1114
+ // Check logs for page A - should only have page A logs
1115
+ const pageALogsResult = await client.callTool({
1116
+ name: 'execute',
1117
+ arguments: {
1118
+ code: js`
1119
+ const logs = await getLatestLogs({ page: state.pageA });
1120
+ console.log('Page A logs:', logs.length);
1121
+ logs.forEach(log => console.log(log));
1122
+ `,
1123
+ },
1124
+ })
1125
+
1126
+ const pageAOutput = (pageALogsResult as any).content[0].text
1127
+ expect(pageAOutput).toContain('[log] PageA log 11111')
1128
+ expect(pageAOutput).toContain('[error] PageA error 22222')
1129
+ expect(pageAOutput).not.toContain('PageB')
1130
+
1131
+ // Check logs for page B - should only have page B logs
1132
+ const pageBLogsResult = await client.callTool({
1133
+ name: 'execute',
1134
+ arguments: {
1135
+ code: js`
1136
+ const logs = await getLatestLogs({ page: state.pageB });
1137
+ console.log('Page B logs:', logs.length);
1138
+ logs.forEach(log => console.log(log));
1139
+ `,
1140
+ },
1141
+ })
1142
+
1143
+ const pageBOutput = (pageBLogsResult as any).content[0].text
1144
+ expect(pageBOutput).toContain('[log] PageB log 33333')
1145
+ expect(pageBOutput).toContain('[error] PageB error 44444')
1146
+ expect(pageBOutput).not.toContain('PageA')
1147
+
1148
+ // Check all logs - should have logs from both pages
1149
+ const allLogsResult = await client.callTool({
1150
+ name: 'execute',
1151
+ arguments: {
1152
+ code: js`
1153
+ const logs = await getLatestLogs();
1154
+ console.log('All logs:', logs.length);
1155
+ logs.forEach(log => console.log(log));
1156
+ `,
1157
+ },
1158
+ })
1159
+
1160
+ const allOutput = (allLogsResult as any).content[0].text
1161
+ expect(allOutput).toContain('[log] PageA log 11111')
1162
+ expect(allOutput).toContain('[log] PageB log 33333')
1163
+
1164
+ // Test that reloading page A clears only page A logs
1165
+ await client.callTool({
1166
+ name: 'execute',
1167
+ arguments: {
1168
+ code: js`
1169
+ await state.pageA.reload();
1170
+ await state.pageA.evaluate(() => {
1171
+ console.log('PageA after reload 55555');
1172
+ });
1173
+ await new Promise(resolve => setTimeout(resolve, 100));
1174
+ `,
1175
+ },
1176
+ })
1177
+
1178
+ // Check page A logs - should only have new log
1179
+ const pageAAfterReloadResult = await client.callTool({
1180
+ name: 'execute',
1181
+ arguments: {
1182
+ code: js`
1183
+ const logs = await getLatestLogs({ page: state.pageA });
1184
+ console.log('Page A logs after reload:', logs.length);
1185
+ logs.forEach(log => console.log(log));
1186
+ `,
1187
+ },
1188
+ })
1189
+
1190
+ const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
1191
+ expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
1192
+ expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
1193
+
1194
+ // Check page B logs - should still have original logs
1195
+ const pageBAfterAReloadResult = await client.callTool({
1196
+ name: 'execute',
1197
+ arguments: {
1198
+ code: js`
1199
+ const logs = await getLatestLogs({ page: state.pageB });
1200
+ console.log('Page B logs after A reload:', logs.length);
1201
+ logs.forEach(log => console.log(log));
1202
+ `,
1203
+ },
1204
+ })
1205
+
1206
+ const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text
1207
+ expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333')
1208
+ expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444')
1209
+
1210
+ // Test that logs are deleted when page is closed
1211
+ await client.callTool({
1212
+ name: 'execute',
1213
+ arguments: {
1214
+ code: js`
1215
+ // Close page A
1216
+ await state.pageA.close();
1217
+ await new Promise(resolve => setTimeout(resolve, 100));
1218
+ `,
1219
+ },
1220
+ })
1221
+
1222
+ // Check all logs - page A logs should be gone
1223
+ const logsAfterCloseResult = await client.callTool({
1224
+ name: 'execute',
1225
+ arguments: {
1226
+ code: js`
1227
+ const logs = await getLatestLogs();
1228
+ console.log('All logs after closing page A:', logs.length);
1229
+ logs.forEach(log => console.log(log));
1230
+ `,
1231
+ },
1232
+ })
1233
+
1234
+ const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
1235
+ expect(logsAfterCloseOutput).not.toContain('PageA')
1236
+ expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
1237
+
1238
+ // Clean up remaining page
1239
+ await client.callTool({
1240
+ name: 'execute',
1241
+ arguments: {
1242
+ code: js`
1243
+ await state.pageB.close();
1244
+ delete state.pageA;
1245
+ delete state.pageB;
1246
+ `,
1247
+ },
1248
+ })
1249
+ }, 30000)
1250
+
1251
+ it('should maintain correct page.url() with service worker pages', async () => {
1252
+ const browserContext = getBrowserContext()
1253
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1254
+
1255
+ const page = await browserContext.newPage()
1256
+ const targetUrl = 'https://example.com/sw-test'
1257
+ await page.goto(targetUrl)
1258
+ await page.bringToFront()
1259
+
1260
+ await serviceWorker.evaluate(async () => {
1261
+ await globalThis.toggleExtensionForActiveTab()
1262
+ })
1263
+
1264
+ await new Promise((r) => setTimeout(r, 100))
1265
+
1266
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1267
+ const pages = browser.contexts()[0].pages()
1268
+ const testPage = pages.find((p) => p.url().includes('sw-test'))
1269
+
1270
+ expect(testPage).toBeDefined()
1271
+ expect(testPage?.url()).toContain('sw-test')
1272
+ expect(testPage?.url()).not.toContain('sw.js')
1273
+
1274
+ await browser.close()
1275
+ await page.close()
1276
+ }, 30000)
1277
+
1278
+ it('should maintain correct page.url() after repeated connections', async () => {
1279
+ const browserContext = getBrowserContext()
1280
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1281
+
1282
+ const page = await browserContext.newPage()
1283
+ const targetUrl = 'https://example.com/repeated-test'
1284
+ await page.goto(targetUrl)
1285
+ await page.bringToFront()
1286
+
1287
+ await serviceWorker.evaluate(async () => {
1288
+ await globalThis.toggleExtensionForActiveTab()
1289
+ })
1290
+
1291
+ for (let i = 0; i < 5; i++) {
1292
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1293
+ const pages = browser.contexts()[0].pages()
1294
+ const testPage = pages.find((p) => p.url().includes('repeated-test'))
1295
+
1296
+ expect(testPage).toBeDefined()
1297
+ expect(testPage?.url()).toBe(targetUrl)
1298
+
1299
+ await browser.close()
1300
+ await new Promise((r) => setTimeout(r, 100))
1301
+ }
1302
+
1303
+ await page.close()
1304
+ }, 30000)
1305
+
1306
+ it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
1307
+ const browserContext = getBrowserContext()
1308
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1309
+
1310
+ const page = await browserContext.newPage()
1311
+ const targetUrl = 'https://example.com/concurrent-test'
1312
+ await page.goto(targetUrl)
1313
+ await page.bringToFront()
1314
+
1315
+ await serviceWorker.evaluate(async () => {
1316
+ await globalThis.toggleExtensionForActiveTab()
1317
+ })
1318
+
1319
+ await new Promise((r) => setTimeout(r, 400))
1320
+
1321
+ const [mcpResult, cdpBrowser] = await Promise.all([
1322
+ client.callTool({
1323
+ name: 'execute',
1324
+ arguments: {
1325
+ code: js`
1326
+ const pages = context.pages();
1327
+ const testPage = pages.find(p => p.url().includes('concurrent-test'));
1328
+ return { url: testPage?.url(), found: !!testPage };
1329
+ `,
1330
+ },
1331
+ }),
1332
+ chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
1333
+ ])
1334
+
1335
+ const mcpOutput = (mcpResult as any).content[0].text
1336
+ expect(mcpOutput).toContain(targetUrl)
1337
+
1338
+ const cdpPages = cdpBrowser.contexts()[0].pages()
1339
+ const cdpPage = cdpPages.find((p) => p.url().includes('concurrent-test'))
1340
+ expect(cdpPage?.url()).toBe(targetUrl)
1341
+
1342
+ await cdpBrowser.close()
1343
+ await page.close()
1344
+ }, 30000)
1345
+
1346
+ it('should be usable after toggle with valid URL', async () => {
1347
+ // This test validates the extension properly waits for valid URLs before
1348
+ // sending Target.attachedToTarget. Uses Discord - a heavy React SPA.
1349
+ //
1350
+ // We use waitForEvent('page') to wait for Playwright to process the event.
1351
+ // The KEY assertion is that when the event fires, the URL is VALID (not empty).
1352
+ // Before the fix: event fired with empty URL -> page broken forever
1353
+ // After the fix: event fires with valid URL -> page works immediately
1354
+
1355
+ const _browserContext = getBrowserContext()
1356
+ const serviceWorker = await getExtensionServiceWorker(_browserContext)
1357
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1358
+ const context = browser.contexts()[0]
1359
+
1360
+ const page = await _browserContext.newPage()
1361
+ await page.goto('https://discord.com/login')
1362
+ await page.bringToFront()
1363
+
1364
+ // Set up listener BEFORE toggle
1365
+ const pagePromise = context.waitForEvent('page', { timeout: 10000 })
1366
+
1367
+ // Toggle extension - extension waits for valid URL before sending event
1368
+ await serviceWorker.evaluate(async () => {
1369
+ await globalThis.toggleExtensionForActiveTab()
1370
+ })
1371
+
1372
+ // Wait for page event
1373
+ const targetPage = await pagePromise
1374
+ console.log('Page URL when event fired:', targetPage.url())
1375
+
1376
+ // KEY ASSERTION: URL must NOT be empty - this is what the extension fix guarantees
1377
+ expect(targetPage.url()).not.toBe('')
1378
+ expect(targetPage.url()).not.toBe(':')
1379
+ expect(targetPage.url()).toContain('discord.com')
1380
+
1381
+ // evaluate() works immediately - no waiting needed
1382
+ const result = await targetPage.evaluate(() => window.location.href)
1383
+ expect(result).toContain('discord.com')
1384
+
1385
+ await browser.close()
1386
+ await page.close()
1387
+ }, 60000)
1388
+
1389
+ it('should have non-empty URLs when connecting to already-loaded pages', async () => {
1390
+ // This test validates that when we connect to a browser with already-loaded pages,
1391
+ // all pages have non-empty URLs. Empty URLs break Playwright permanently.
1392
+
1393
+ const _browserContext = getBrowserContext()
1394
+ const serviceWorker = await getExtensionServiceWorker(_browserContext)
1395
+
1396
+ // Create and fully load a heavy page BEFORE connecting
1397
+ const page = await _browserContext.newPage()
1398
+ await page.goto('https://discord.com/login', { waitUntil: 'load' })
1399
+ await page.bringToFront()
1400
+
1401
+ // Toggle extension to attach to the loaded page
1402
+ await serviceWorker.evaluate(async () => {
1403
+ await globalThis.toggleExtensionForActiveTab()
1404
+ })
1405
+
1406
+ // NOW connect via CDP - page should already be attached
1407
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1408
+ const context = browser.contexts()[0]
1409
+
1410
+ // Get all pages and verify NONE have empty URLs
1411
+ const pages = context.pages()
1412
+ console.log(
1413
+ 'All page URLs:',
1414
+ pages.map((p) => p.url()),
1415
+ )
1416
+
1417
+ expect(pages.length).toBeGreaterThan(0)
1418
+ for (const p of pages) {
1419
+ expect(p.url()).not.toBe('')
1420
+ expect(p.url()).not.toBe(':')
1421
+ expect(p.url()).not.toBeUndefined()
1422
+ }
1423
+
1424
+ // Find Discord page and verify it works
1425
+ const discordPage = pages.find((p) => p.url().includes('discord.com'))
1426
+ expect(discordPage).toBeDefined()
1427
+
1428
+ const result = await discordPage!.evaluate(() => window.location.href)
1429
+ expect(result).toContain('discord.com')
1430
+
1431
+ await browser.close()
1432
+ await page.close()
1433
+ }, 60000)
1434
+
1435
+ it('should maintain correct page.url() with iframe-heavy pages', async () => {
1436
+ const browserContext = getBrowserContext()
1437
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1438
+
1439
+ const page = await browserContext.newPage()
1440
+ await page.setContent(`
1441
+ <html>
1442
+ <head><title>Iframe Test Page</title></head>
1443
+ <body>
1444
+ <h1>Iframe Heavy Page</h1>
1445
+ <iframe src="about:blank" id="frame1"></iframe>
1446
+ <iframe src="about:blank" id="frame2"></iframe>
1447
+ <iframe src="about:blank" id="frame3"></iframe>
1448
+ </body>
1449
+ </html>
1450
+ `)
1451
+ await page.bringToFront()
1452
+
1453
+ await serviceWorker.evaluate(async () => {
1454
+ await globalThis.toggleExtensionForActiveTab()
1455
+ })
1456
+
1457
+ await new Promise((r) => setTimeout(r, 100))
1458
+
1459
+ for (let i = 0; i < 3; i++) {
1460
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1461
+ const pages = browser.contexts()[0].pages()
1462
+ let iframePage
1463
+ for (const p of pages) {
1464
+ const html = await p.content()
1465
+ if (html.includes('Iframe Heavy Page')) {
1466
+ iframePage = p
1467
+ break
1468
+ }
1469
+ }
1470
+
1471
+ expect(iframePage).toBeDefined()
1472
+ expect(iframePage?.url()).toContain('about:')
1473
+
1474
+ await browser.close()
1475
+ await new Promise((r) => setTimeout(r, 100))
1476
+ }
1477
+
1478
+ await page.close()
1479
+ }, 30000)
1480
+
1481
+ it('should capture screenshot correctly', async () => {
1482
+ const browserContext = getBrowserContext()
1483
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1484
+
1485
+ const page = await browserContext.newPage()
1486
+ await page.goto('https://example.com/')
1487
+ await page.bringToFront()
1488
+
1489
+ await serviceWorker.evaluate(async () => {
1490
+ await globalThis.toggleExtensionForActiveTab()
1491
+ })
1492
+
1493
+ await new Promise((r) => setTimeout(r, 100))
1494
+
1495
+ const capturedCommands: CDPCommand[] = []
1496
+ const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
1497
+ if (command.method === 'Page.captureScreenshot') {
1498
+ capturedCommands.push(command)
1499
+ }
1500
+ }
1501
+ testCtx!.relayServer.on('cdp:command', commandHandler)
1502
+
1503
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1504
+ const cdpPage = browser
1505
+ .contexts()[0]
1506
+ .pages()
1507
+ .find((p) => p.url().includes('example.com'))
1508
+
1509
+ expect(cdpPage).toBeDefined()
1510
+
1511
+ const viewportSize = cdpPage!.viewportSize()
1512
+ console.log('Viewport size:', viewportSize)
1513
+
1514
+ const viewportScreenshot = await cdpPage!.screenshot()
1515
+ expect(viewportScreenshot).toBeDefined()
1516
+
1517
+ const viewportDimensions = imageSize(viewportScreenshot)
1518
+ console.log('Viewport screenshot dimensions:', viewportDimensions)
1519
+ expect(viewportDimensions.width).toBeGreaterThan(0)
1520
+ expect(viewportDimensions.height).toBeGreaterThan(0)
1521
+ if (viewportSize) {
1522
+ expect(viewportDimensions.width).toBe(viewportSize.width)
1523
+ expect(viewportDimensions.height).toBe(viewportSize.height)
1524
+ }
1525
+
1526
+ const fullPageScreenshot = await cdpPage!.screenshot({ fullPage: true })
1527
+ expect(fullPageScreenshot).toBeDefined()
1528
+
1529
+ const fullPageDimensions = imageSize(fullPageScreenshot)
1530
+ console.log('Full page screenshot dimensions:', fullPageDimensions)
1531
+ expect(fullPageDimensions.width).toBeGreaterThan(0)
1532
+ expect(fullPageDimensions.height).toBeGreaterThan(0)
1533
+ expect(fullPageDimensions.width).toBeGreaterThanOrEqual(viewportDimensions.width!)
1534
+
1535
+ testCtx!.relayServer.off('cdp:command', commandHandler)
1536
+
1537
+ expect(capturedCommands.length).toBe(2)
1538
+ expect(
1539
+ capturedCommands.map((c) => ({
1540
+ method: c.method,
1541
+ params: c.params,
1542
+ })),
1543
+ ).toMatchInlineSnapshot(`
1544
+ [
1545
+ {
1546
+ "method": "Page.captureScreenshot",
1547
+ "params": {
1548
+ "captureBeyondViewport": false,
1549
+ "clip": {
1550
+ "height": 720,
1551
+ "scale": 1,
1552
+ "width": 1280,
1553
+ "x": 0,
1554
+ "y": 0,
1555
+ },
1556
+ "format": "png",
1557
+ },
1558
+ },
1559
+ {
1560
+ "method": "Page.captureScreenshot",
1561
+ "params": {
1562
+ "captureBeyondViewport": false,
1563
+ "clip": {
1564
+ "height": 581,
1565
+ "scale": 1,
1566
+ "width": 1280,
1567
+ "x": 0,
1568
+ "y": 0,
1569
+ },
1570
+ "format": "png",
1571
+ },
1572
+ },
1573
+ ]
1574
+ `)
1575
+
1576
+ const screenshotPath = path.join(os.tmpdir(), 'e2e-pilot-test-screenshot.png')
1577
+ fs.writeFileSync(screenshotPath, viewportScreenshot)
1578
+ console.log('Screenshot saved to:', screenshotPath)
1579
+
1580
+ await browser.close()
1581
+ await page.close()
1582
+ }, 60000)
1583
+
1584
+ it('should capture element screenshot with correct coordinates', async () => {
1585
+ const browserContext = getBrowserContext()
1586
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1587
+
1588
+ const target = { x: 200, y: 150, width: 300, height: 100 }
1589
+ const scrolledTarget = { x: 100, y: 1500, width: 200, height: 80 }
1590
+
1591
+ const page = await browserContext.newPage()
1592
+ await page.setContent(`
1593
+ <html>
1594
+ <head>
1595
+ <style>
1596
+ body { margin: 0; padding: 0; height: 2000px; }
1597
+ #target {
1598
+ position: absolute;
1599
+ top: ${target.y}px;
1600
+ left: ${target.x}px;
1601
+ width: ${target.width}px;
1602
+ height: ${target.height}px;
1603
+ background: red;
1604
+ }
1605
+ #scrolled-target {
1606
+ position: absolute;
1607
+ top: ${scrolledTarget.y}px;
1608
+ left: ${scrolledTarget.x}px;
1609
+ width: ${scrolledTarget.width}px;
1610
+ height: ${scrolledTarget.height}px;
1611
+ background: blue;
1612
+ }
1613
+ </style>
1614
+ </head>
1615
+ <body>
1616
+ <div id="target">Target Element</div>
1617
+ <div id="scrolled-target">Scrolled Target</div>
1618
+ </body>
1619
+ </html>
1620
+ `)
1621
+ await page.bringToFront()
1622
+
1623
+ await serviceWorker.evaluate(async () => {
1624
+ await globalThis.toggleExtensionForActiveTab()
1625
+ })
1626
+
1627
+ await new Promise((r) => setTimeout(r, 100))
1628
+
1629
+ const capturedCommands: CDPCommand[] = []
1630
+ const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
1631
+ if (command.method === 'Page.captureScreenshot') {
1632
+ capturedCommands.push(command)
1633
+ }
1634
+ }
1635
+ testCtx!.relayServer.on('cdp:command', commandHandler)
1636
+
1637
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1638
+ let cdpPage
1639
+ for (const p of browser.contexts()[0].pages()) {
1640
+ const html = await p.content()
1641
+ if (html.includes('scrolled-target')) {
1642
+ cdpPage = p
1643
+ break
1644
+ }
1645
+ }
1646
+ expect(cdpPage).toBeDefined()
1647
+
1648
+ await cdpPage!.locator('#target').screenshot()
1649
+
1650
+ await cdpPage!.locator('#scrolled-target').screenshot()
1651
+
1652
+ testCtx!.relayServer.off('cdp:command', commandHandler)
1653
+
1654
+ expect(capturedCommands.length).toBe(2)
1655
+
1656
+ const targetCmd = capturedCommands[0]
1657
+ expect(targetCmd.method).toBe('Page.captureScreenshot')
1658
+ const targetClip = (targetCmd.params as any).clip
1659
+ expect(targetClip.x).toBe(target.x)
1660
+ expect(targetClip.y).toBe(target.y)
1661
+ expect(targetClip.width).toBe(target.width)
1662
+ expect(targetClip.height).toBe(target.height)
1663
+
1664
+ const scrolledCmd = capturedCommands[1]
1665
+ expect(scrolledCmd.method).toBe('Page.captureScreenshot')
1666
+ const scrolledClip = (scrolledCmd.params as any).clip
1667
+ expect(scrolledClip.x).toBe(scrolledTarget.x)
1668
+ expect(scrolledClip.y).toBe(scrolledTarget.y)
1669
+ expect(scrolledClip.width).toBe(scrolledTarget.width)
1670
+ expect(scrolledClip.height).toBe(scrolledTarget.height)
1671
+
1672
+ await browser.close()
1673
+ await page.close()
1674
+ }, 60000)
1675
+
1676
+ it('should get locator string for element using getLocatorStringForElement', async () => {
1677
+ const browserContext = getBrowserContext()
1678
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1679
+
1680
+ const page = await browserContext.newPage()
1681
+ await page.setContent(`
1682
+ <html>
1683
+ <body>
1684
+ <button id="test-btn">Click Me</button>
1685
+ <input type="text" placeholder="Enter name" />
1686
+ </body>
1687
+ </html>
1688
+ `)
1689
+ await page.bringToFront()
1690
+
1691
+ await serviceWorker.evaluate(async () => {
1692
+ await globalThis.toggleExtensionForActiveTab()
1693
+ })
1694
+
1695
+ await new Promise((r) => setTimeout(r, 400))
1696
+
1697
+ const result = await client.callTool({
1698
+ name: 'execute',
1699
+ arguments: {
1700
+ intend: 'Get locator string for button element',
1701
+ code: js`
1702
+ let testPage;
1703
+ for (const p of context.pages()) {
1704
+ const html = await p.content();
1705
+ if (html.includes('test-btn')) { testPage = p; break; }
1706
+ }
1707
+ if (!testPage) throw new Error('Test page not found');
1708
+ const btn = testPage.locator('#test-btn');
1709
+ const locatorString = await getLocatorStringForElement(btn);
1710
+ console.log('Locator string:', locatorString);
1711
+ const locatorFromString = eval('testPage.' + locatorString);
1712
+ const count = await locatorFromString.count();
1713
+ console.log('Locator count:', count);
1714
+ const text = await locatorFromString.textContent();
1715
+ console.log('Locator text:', text);
1716
+ `,
1717
+ timeout: 30000,
1718
+ },
1719
+ })
1720
+
1721
+ expect(result.isError).toBeFalsy()
1722
+ const text = (result.content as any)[0]?.text || ''
1723
+ expect(text).toContain('Locator string:')
1724
+ expect(text).toContain("getByRole('button', { name: 'Click Me' })")
1725
+ expect(text).toContain('Locator count: 1')
1726
+ expect(text).toContain('Locator text: Click Me')
1727
+
1728
+ await page.close()
1729
+ }, 60000)
1730
+
1731
+ it('should get styles for element using getStylesForLocator', async () => {
1732
+ const browserContext = getBrowserContext()
1733
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1734
+
1735
+ const page = await browserContext.newPage()
1736
+ await page.setContent(`
1737
+ <html>
1738
+ <head>
1739
+ <style>
1740
+ body { font-family: Arial, sans-serif; color: #333; }
1741
+ .container { padding: 20px; margin: 10px; }
1742
+ #main-btn { background-color: blue; color: white; border-radius: 4px; }
1743
+ .btn { padding: 8px 16px; }
1744
+ </style>
1745
+ </head>
1746
+ <body>
1747
+ <div class="container">
1748
+ <button id="main-btn" class="btn" style="font-weight: bold;">Click Me</button>
1749
+ </div>
1750
+ </body>
1751
+ </html>
1752
+ `)
1753
+ await page.bringToFront()
1754
+
1755
+ await serviceWorker.evaluate(async () => {
1756
+ await globalThis.toggleExtensionForActiveTab()
1757
+ })
1758
+
1759
+ await new Promise((r) => setTimeout(r, 400))
1760
+
1761
+ const stylesResult = await client.callTool({
1762
+ name: 'execute',
1763
+ arguments: {
1764
+ code: js`
1765
+ let testPage;
1766
+ for (const p of context.pages()) {
1767
+ const html = await p.content();
1768
+ if (html.includes('main-btn')) { testPage = p; break; }
1769
+ }
1770
+ if (!testPage) throw new Error('Test page not found');
1771
+ const btn = testPage.locator('#main-btn');
1772
+ const styles = await getStylesForLocator({ locator: btn });
1773
+ return styles;
1774
+ `,
1775
+ timeout: 30000,
1776
+ },
1777
+ })
1778
+
1779
+ expect(stylesResult.isError).toBeFalsy()
1780
+ const stylesText = (stylesResult.content as any)[0]?.text || ''
1781
+ expect(stylesText).toMatchInlineSnapshot(`
1782
+ "Return value:
1783
+ {
1784
+ "element": "button#main-btn.btn",
1785
+ "inlineStyle": {
1786
+ "font-weight": "bold"
1787
+ },
1788
+ "rules": [
1789
+ {
1790
+ "selector": ".btn",
1791
+ "source": null,
1792
+ "origin": "regular",
1793
+ "declarations": {
1794
+ "padding": "8px 16px",
1795
+ "padding-top": "8px",
1796
+ "padding-right": "16px",
1797
+ "padding-bottom": "8px",
1798
+ "padding-left": "16px"
1799
+ },
1800
+ "inheritedFrom": null
1801
+ },
1802
+ {
1803
+ "selector": "#main-btn",
1804
+ "source": null,
1805
+ "origin": "regular",
1806
+ "declarations": {
1807
+ "background-color": "blue",
1808
+ "color": "white",
1809
+ "border-radius": "4px",
1810
+ "border-top-left-radius": "4px",
1811
+ "border-top-right-radius": "4px",
1812
+ "border-bottom-right-radius": "4px",
1813
+ "border-bottom-left-radius": "4px"
1814
+ },
1815
+ "inheritedFrom": null
1816
+ },
1817
+ {
1818
+ "selector": ".container",
1819
+ "source": null,
1820
+ "origin": "regular",
1821
+ "declarations": {
1822
+ "padding": "20px",
1823
+ "margin": "10px",
1824
+ "padding-top": "20px",
1825
+ "padding-right": "20px",
1826
+ "padding-bottom": "20px",
1827
+ "padding-left": "20px",
1828
+ "margin-top": "10px",
1829
+ "margin-right": "10px",
1830
+ "margin-bottom": "10px",
1831
+ "margin-left": "10px"
1832
+ },
1833
+ "inheritedFrom": "ancestor[1]"
1834
+ },
1835
+ {
1836
+ "selector": "body",
1837
+ "source": null,
1838
+ "origin": "regular",
1839
+ "declarations": {
1840
+ "font-family": "Arial, sans-serif",
1841
+ "color": "rgb(51, 51, 51)"
1842
+ },
1843
+ "inheritedFrom": "ancestor[2]"
1844
+ }
1845
+ ]
1846
+ }"
1847
+ `)
1848
+
1849
+ const formattedResult = await client.callTool({
1850
+ name: 'execute',
1851
+ arguments: {
1852
+ code: js`
1853
+ let testPage;
1854
+ for (const p of context.pages()) {
1855
+ const html = await p.content();
1856
+ if (html.includes('main-btn')) { testPage = p; break; }
1857
+ }
1858
+ if (!testPage) throw new Error('Test page not found');
1859
+ const btn = testPage.locator('#main-btn');
1860
+ const styles = await getStylesForLocator({ locator: btn });
1861
+ return formatStylesAsText(styles);
1862
+ `,
1863
+ timeout: 30000,
1864
+ },
1865
+ })
1866
+
1867
+ expect(formattedResult.isError).toBeFalsy()
1868
+ const formattedText = (formattedResult.content as any)[0]?.text || ''
1869
+ expect(formattedText).toMatchInlineSnapshot(`
1870
+ "Return value:
1871
+ Element: button#main-btn.btn
1872
+
1873
+ Inline styles:
1874
+ font-weight: bold
1875
+
1876
+ Matched rules:
1877
+ .btn {
1878
+ padding: 8px 16px;
1879
+ padding-top: 8px;
1880
+ padding-right: 16px;
1881
+ padding-bottom: 8px;
1882
+ padding-left: 16px;
1883
+ }
1884
+ #main-btn {
1885
+ background-color: blue;
1886
+ color: white;
1887
+ border-radius: 4px;
1888
+ border-top-left-radius: 4px;
1889
+ border-top-right-radius: 4px;
1890
+ border-bottom-right-radius: 4px;
1891
+ border-bottom-left-radius: 4px;
1892
+ }
1893
+
1894
+ Inherited from ancestor[1]:
1895
+ .container {
1896
+ padding: 20px;
1897
+ margin: 10px;
1898
+ padding-top: 20px;
1899
+ padding-right: 20px;
1900
+ padding-bottom: 20px;
1901
+ padding-left: 20px;
1902
+ margin-top: 10px;
1903
+ margin-right: 10px;
1904
+ margin-bottom: 10px;
1905
+ margin-left: 10px;
1906
+ }
1907
+
1908
+ Inherited from ancestor[2]:
1909
+ body {
1910
+ font-family: Arial, sans-serif;
1911
+ color: rgb(51, 51, 51);
1912
+ }"
1913
+ `)
1914
+
1915
+ await page.close()
1916
+ }, 60000)
1917
+
1918
+ it('should return correct layout metrics via CDP', async () => {
1919
+ const browserContext = getBrowserContext()
1920
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1921
+
1922
+ const page = await browserContext.newPage()
1923
+ await page.goto('https://example.com/')
1924
+ await page.bringToFront()
1925
+
1926
+ await serviceWorker.evaluate(async () => {
1927
+ await globalThis.toggleExtensionForActiveTab()
1928
+ })
1929
+
1930
+ await new Promise((r) => setTimeout(r, 100))
1931
+
1932
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1933
+ const cdpPage = browser
1934
+ .contexts()[0]
1935
+ .pages()
1936
+ .find((p) => p.url().includes('example.com'))
1937
+ expect(cdpPage).toBeDefined()
1938
+
1939
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
1940
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
1941
+
1942
+ const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics')
1943
+
1944
+ const normalized = {
1945
+ cssLayoutViewport: layoutMetrics.cssLayoutViewport,
1946
+ cssVisualViewport: layoutMetrics.cssVisualViewport,
1947
+ layoutViewport: layoutMetrics.layoutViewport,
1948
+ visualViewport: layoutMetrics.visualViewport,
1949
+ devicePixelRatio:
1950
+ layoutMetrics.cssVisualViewport.clientWidth > 0
1951
+ ? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth
1952
+ : 1,
1953
+ }
1954
+
1955
+ expect(normalized).toMatchInlineSnapshot(`
1956
+ {
1957
+ "cssLayoutViewport": {
1958
+ "clientHeight": 581,
1959
+ "clientWidth": 1280,
1960
+ "pageX": 0,
1961
+ "pageY": 0,
1962
+ },
1963
+ "cssVisualViewport": {
1964
+ "clientHeight": 581,
1965
+ "clientWidth": 1280,
1966
+ "offsetX": 0,
1967
+ "offsetY": 0,
1968
+ "pageX": 0,
1969
+ "pageY": 0,
1970
+ "scale": 1,
1971
+ "zoom": 1,
1972
+ },
1973
+ "devicePixelRatio": 1,
1974
+ "layoutViewport": {
1975
+ "clientHeight": 581,
1976
+ "clientWidth": 1280,
1977
+ "pageX": 0,
1978
+ "pageY": 0,
1979
+ },
1980
+ "visualViewport": {
1981
+ "clientHeight": 581,
1982
+ "clientWidth": 1280,
1983
+ "offsetX": 0,
1984
+ "offsetY": 0,
1985
+ "pageX": 0,
1986
+ "pageY": 0,
1987
+ "scale": 1,
1988
+ "zoom": 1,
1989
+ },
1990
+ }
1991
+ `)
1992
+
1993
+ const windowDpr = await cdpPage!.evaluate(() => (globalThis as any).devicePixelRatio)
1994
+ console.log('window.devicePixelRatio:', windowDpr)
1995
+ expect(windowDpr).toBe(1)
1996
+
1997
+ cdpSession.close()
1998
+ await browser.close()
1999
+ await page.close()
2000
+ }, 60000)
2001
+
2002
+ it('should support getCDPSession through the relay', async () => {
2003
+ const browserContext = getBrowserContext()
2004
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2005
+
2006
+ const page = await browserContext.newPage()
2007
+ await page.goto('https://example.com/')
2008
+ await page.bringToFront()
2009
+
2010
+ await serviceWorker.evaluate(async () => {
2011
+ await globalThis.toggleExtensionForActiveTab()
2012
+ })
2013
+
2014
+ await new Promise((r) => setTimeout(r, 100))
2015
+
2016
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2017
+ const cdpPage = browser
2018
+ .contexts()[0]
2019
+ .pages()
2020
+ .find((p) => p.url().includes('example.com'))
2021
+ expect(cdpPage).toBeDefined()
2022
+
2023
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2024
+ const client = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2025
+
2026
+ const layoutMetrics = await client.send('Page.getLayoutMetrics')
2027
+ expect(layoutMetrics.cssVisualViewport).toBeDefined()
2028
+ expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
2029
+
2030
+ client.close()
2031
+ await browser.close()
2032
+ await page.close()
2033
+ }, 60000)
2034
+
2035
+ it('should work with stagehand', async () => {
2036
+ const browserContext = getBrowserContext()
2037
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2038
+
2039
+ await serviceWorker.evaluate(async () => {
2040
+ await globalThis.disconnectEverything()
2041
+ })
2042
+ await new Promise((r) => setTimeout(r, 100))
2043
+
2044
+ const targetUrl = 'https://example.com/'
2045
+
2046
+ const enableResult = await serviceWorker.evaluate(async (url) => {
2047
+ const tab = await chrome.tabs.create({ url, active: true })
2048
+ await new Promise((r) => setTimeout(r, 100))
2049
+ return await globalThis.toggleExtensionForActiveTab()
2050
+ }, targetUrl)
2051
+
2052
+ console.log('Extension enabled:', enableResult)
2053
+ expect(enableResult.isConnected).toBe(true)
2054
+
2055
+ await new Promise((r) => setTimeout(r, 100))
2056
+
2057
+ const { Stagehand } = await import('@browserbasehq/stagehand')
2058
+
2059
+ const stagehand = new Stagehand({
2060
+ env: 'LOCAL',
2061
+ verbose: 1,
2062
+ disablePino: true,
2063
+ localBrowserLaunchOptions: {
2064
+ cdpUrl: getCdpUrl({ port: TEST_PORT }),
2065
+ },
2066
+ })
2067
+
2068
+ console.log('Initializing Stagehand...')
2069
+ await stagehand.init()
2070
+ console.log('Stagehand initialized')
2071
+
2072
+ const context = stagehand.context
2073
+ // console.log('Stagehand context:', context)
2074
+ expect(context).toBeDefined()
2075
+
2076
+ const pages = context.pages()
2077
+ console.log(
2078
+ 'Stagehand pages:',
2079
+ pages.length,
2080
+ pages.map((p) => p.url()),
2081
+ )
2082
+
2083
+ const stagehandPage = pages.find((p) => p.url().includes('example.com'))
2084
+ expect(stagehandPage).toBeDefined()
2085
+
2086
+ const url = stagehandPage!.url()
2087
+ console.log('Stagehand page URL:', url)
2088
+ expect(url).toContain('example.com')
2089
+
2090
+ await stagehand.close()
2091
+ }, 60000)
2092
+
2093
+ it('should preserve system color scheme instead of forcing light mode', async () => {
2094
+ const browserContext = getBrowserContext()
2095
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2096
+
2097
+ const page = await browserContext.newPage()
2098
+ await page.goto('https://example.com')
2099
+ await page.bringToFront()
2100
+
2101
+ const colorSchemeBefore = await page.evaluate(() => {
2102
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
2103
+ })
2104
+ console.log('Color scheme before MCP connection:', colorSchemeBefore)
2105
+
2106
+ await serviceWorker.evaluate(async () => {
2107
+ await globalThis.toggleExtensionForActiveTab()
2108
+ })
2109
+ await new Promise((r) => setTimeout(r, 100))
2110
+
2111
+ const result = await client.callTool({
2112
+ name: 'execute',
2113
+ arguments: {
2114
+ code: js`
2115
+ const pages = context.pages();
2116
+ const urls = pages.map(p => p.url());
2117
+ const targetPage = pages.find(p => p.url().includes('example.com'));
2118
+ if (!targetPage) {
2119
+ return { error: 'Page not found', urls };
2120
+ }
2121
+ const isDark = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
2122
+ const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches);
2123
+ return { matchesDark: isDark, matchesLight: isLight };
2124
+ `,
2125
+ },
2126
+ })
2127
+
2128
+ console.log('Color scheme after MCP connection:', result.content)
2129
+
2130
+ expect(result.content).toMatchInlineSnapshot(`
2131
+ [
2132
+ {
2133
+ "text": "MCP error -32602: Input validation error: Invalid arguments for tool execute: [
2134
+ {
2135
+ "code": "invalid_type",
2136
+ "expected": "string",
2137
+ "received": "undefined",
2138
+ "path": [
2139
+ "intend"
2140
+ ],
2141
+ "message": "Required"
2142
+ }
2143
+ ]",
2144
+ "type": "text",
2145
+ },
2146
+ ]
2147
+ `)
2148
+
2149
+ await page.close()
2150
+ }, 60000)
2151
+
2152
+ it('should get aria ref for locator using getAriaSnapshot', async () => {
2153
+ const browserContext = getBrowserContext()
2154
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2155
+
2156
+ const page = await browserContext.newPage()
2157
+ await page.setContent(`
2158
+ <html>
2159
+ <body>
2160
+ <button id="submit-btn">Submit Form</button>
2161
+ <a href="/about">About Us</a>
2162
+ <input type="text" placeholder="Enter your name" />
2163
+ </body>
2164
+ </html>
2165
+ `)
2166
+ await page.bringToFront()
2167
+
2168
+ await serviceWorker.evaluate(async () => {
2169
+ await globalThis.toggleExtensionForActiveTab()
2170
+ })
2171
+ await new Promise((r) => setTimeout(r, 400))
2172
+
2173
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2174
+ let cdpPage
2175
+ for (const p of browser.contexts()[0].pages()) {
2176
+ const html = await p.content()
2177
+ if (html.includes('submit-btn')) {
2178
+ cdpPage = p
2179
+ break
2180
+ }
2181
+ }
2182
+ expect(cdpPage).toBeDefined()
2183
+
2184
+ const { getAriaSnapshot } = await import('./aria-snapshot.js')
2185
+
2186
+ // Get aria snapshot and verify we can get refs
2187
+ const ariaResult = await getAriaSnapshot({ page: cdpPage! })
2188
+
2189
+ expect(ariaResult.snapshot).toBeDefined()
2190
+ expect(ariaResult.snapshot.length).toBeGreaterThan(0)
2191
+ expect(ariaResult.snapshot).toContain('Submit Form')
2192
+
2193
+ // Verify refToElement map is populated
2194
+ expect(ariaResult.refToElement.size).toBeGreaterThan(0)
2195
+ console.log('RefToElement map size:', ariaResult.refToElement.size)
2196
+ console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
2197
+
2198
+ // Verify we can select elements using aria-ref selectors
2199
+ const btnViaAriaRef = cdpPage!.locator('aria-ref=e2')
2200
+ const btnTextViaRef = await btnViaAriaRef.textContent()
2201
+ console.log('Button text via aria-ref=e2:', btnTextViaRef)
2202
+ expect(btnTextViaRef).toBe('Submit Form')
2203
+
2204
+ // Get ref for the submit button using getRefForLocator
2205
+ const submitBtn = cdpPage!.locator('#submit-btn')
2206
+ const btnAriaRef = await ariaResult.getRefForLocator(submitBtn)
2207
+ console.log('Button ariaRef:', btnAriaRef)
2208
+ expect(btnAriaRef).toBeDefined()
2209
+ expect(btnAriaRef?.role).toBe('button')
2210
+ expect(btnAriaRef?.name).toBe('Submit Form')
2211
+ expect(btnAriaRef?.ref).toMatch(/^e\d+$/)
2212
+
2213
+ // Verify the ref matches what we can use to select
2214
+ const btnFromRef = cdpPage!.locator(`aria-ref=${btnAriaRef?.ref}`)
2215
+ const btnText = await btnFromRef.textContent()
2216
+ expect(btnText).toBe('Submit Form')
2217
+
2218
+ // Test getRefStringForLocator
2219
+ const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn)
2220
+ console.log('Button ref string:', btnRefStr)
2221
+ expect(btnRefStr).toBe(btnAriaRef?.ref)
2222
+
2223
+ // Test link
2224
+ const aboutLink = cdpPage!.locator('a')
2225
+ const linkAriaRef = await ariaResult.getRefForLocator(aboutLink)
2226
+ console.log('Link ariaRef:', linkAriaRef)
2227
+ expect(linkAriaRef).toBeDefined()
2228
+ expect(linkAriaRef?.role).toBe('link')
2229
+ expect(linkAriaRef?.name).toBe('About Us')
2230
+
2231
+ // Verify the link ref works
2232
+ const linkFromRef = cdpPage!.locator(`aria-ref=${linkAriaRef?.ref}`)
2233
+ const linkText = await linkFromRef.textContent()
2234
+ expect(linkText).toBe('About Us')
2235
+
2236
+ // Test input field
2237
+ const inputField = cdpPage!.locator('input')
2238
+ const inputAriaRef = await ariaResult.getRefForLocator(inputField)
2239
+ console.log('Input ariaRef:', inputAriaRef)
2240
+ expect(inputAriaRef).toBeDefined()
2241
+ expect(inputAriaRef?.role).toBe('textbox')
2242
+
2243
+ // Test batch getRefsForLocators - single evaluate call for multiple elements
2244
+ const batchRefs = await ariaResult.getRefsForLocators([submitBtn, aboutLink, inputField])
2245
+ console.log('Batch refs:', batchRefs)
2246
+ expect(batchRefs).toHaveLength(3)
2247
+ expect(batchRefs[0]?.ref).toBe(btnAriaRef?.ref)
2248
+ expect(batchRefs[1]?.ref).toBe(linkAriaRef?.ref)
2249
+ expect(batchRefs[2]?.ref).toBe(inputAriaRef?.ref)
2250
+
2251
+ await browser.close()
2252
+ await page.close()
2253
+ }, 60000)
2254
+
2255
+ it('should show aria ref labels on real pages and save screenshots', async () => {
2256
+ const browserContext = getBrowserContext()
2257
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2258
+
2259
+ const { showAriaRefLabels, hideAriaRefLabels } = await import('./aria-snapshot.js')
2260
+ const fs = await import('node:fs')
2261
+ const path = await import('node:path')
2262
+
2263
+ // Create assets folder for screenshots
2264
+ const assetsDir = path.join(path.dirname(new URL(import.meta.url).pathname), 'assets')
2265
+ if (!fs.existsSync(assetsDir)) {
2266
+ fs.mkdirSync(assetsDir, { recursive: true })
2267
+ }
2268
+
2269
+ const testPages = [
2270
+ { name: 'hacker-news', url: 'https://news.ycombinator.com/' },
2271
+ { name: 'google', url: 'https://www.google.com/' },
2272
+ { name: 'github', url: 'https://github.com/' },
2273
+ ]
2274
+
2275
+ // Create all pages and enable extension for each
2276
+ const pages = await Promise.all(
2277
+ testPages.map(async ({ name, url }) => {
2278
+ const page = await browserContext.newPage()
2279
+ await page.goto(url, { waitUntil: 'domcontentloaded' })
2280
+ return { name, url, page }
2281
+ }),
2282
+ )
2283
+
2284
+ // Enable extension for each tab (must be done sequentially as it uses active tab)
2285
+ for (const { page } of pages) {
2286
+ await page.bringToFront()
2287
+ await serviceWorker.evaluate(async () => {
2288
+ await globalThis.toggleExtensionForActiveTab()
2289
+ })
2290
+ }
2291
+
2292
+ // Connect CDP and process all pages concurrently
2293
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2294
+
2295
+ await Promise.all(
2296
+ pages.map(async ({ name, url, page }) => {
2297
+ const cdpPage = browser
2298
+ .contexts()[0]
2299
+ .pages()
2300
+ .find((p) => p.url().includes(new URL(url).hostname))
2301
+
2302
+ if (!cdpPage) {
2303
+ console.log(`Could not find CDP page for ${name}, skipping...`)
2304
+ return
2305
+ }
2306
+
2307
+ // Show aria ref labels
2308
+ const { snapshot, labelCount } = await showAriaRefLabels({ page: cdpPage })
2309
+ console.log(`${name}: ${labelCount} labels shown`)
2310
+ expect(labelCount).toBeGreaterThan(0)
2311
+
2312
+ // Take screenshot with labels visible
2313
+ const screenshot = await cdpPage.screenshot({ type: 'png', fullPage: false })
2314
+ const screenshotPath = path.join(assetsDir, `aria-labels-${name}.png`)
2315
+ fs.writeFileSync(screenshotPath, screenshot)
2316
+ console.log(`Screenshot saved: ${screenshotPath}`)
2317
+
2318
+ // Save snapshot text for reference
2319
+ const snapshotPath = path.join(assetsDir, `aria-labels-${name}-snapshot.txt`)
2320
+ fs.writeFileSync(snapshotPath, snapshot)
2321
+
2322
+ // Verify labels are in DOM
2323
+ const labelElements = await cdpPage.evaluate(() => document.querySelectorAll('.__pw_label__').length)
2324
+ expect(labelElements).toBe(labelCount)
2325
+
2326
+ // Cleanup
2327
+ await hideAriaRefLabels({ page: cdpPage })
2328
+
2329
+ // Verify labels removed
2330
+ const labelsAfterHide = await cdpPage.evaluate(() => document.getElementById('__e2e_pilot_labels__'))
2331
+ expect(labelsAfterHide).toBeNull()
2332
+
2333
+ await page.close()
2334
+ }),
2335
+ )
2336
+
2337
+ await browser.close()
2338
+ console.log(`Screenshots saved to: ${assetsDir}`)
2339
+ }, 120000)
2340
+
2341
+ it('should take screenshot with accessibility labels via MCP execute tool', async () => {
2342
+ const browserContext = getBrowserContext()
2343
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2344
+
2345
+ const page = await browserContext.newPage()
2346
+ await page.setContent(`
2347
+ <html>
2348
+ <head>
2349
+ <style>
2350
+ body {
2351
+ margin: 0;
2352
+ background: #e8f4f8;
2353
+ position: relative;
2354
+ min-height: 100vh;
2355
+ }
2356
+ .controls {
2357
+ padding: 20px;
2358
+ position: relative;
2359
+ z-index: 10;
2360
+ }
2361
+ .grid-marker {
2362
+ position: absolute;
2363
+ background: rgba(255, 100, 100, 0.3);
2364
+ border: 1px solid #ff6464;
2365
+ font-size: 10px;
2366
+ color: #333;
2367
+ display: flex;
2368
+ align-items: center;
2369
+ justify-content: center;
2370
+ }
2371
+ .h-marker {
2372
+ left: 0;
2373
+ width: 100%;
2374
+ height: 20px;
2375
+ }
2376
+ .v-marker {
2377
+ top: 0;
2378
+ height: 100%;
2379
+ width: 20px;
2380
+ }
2381
+ </style>
2382
+ </head>
2383
+ <body>
2384
+ <div class="controls">
2385
+ <button id="submit-btn">Submit Form</button>
2386
+ <a href="/about">About Us</a>
2387
+ <input type="text" placeholder="Enter your name" />
2388
+ </div>
2389
+ <!-- Horizontal markers every 200px -->
2390
+ <div class="grid-marker h-marker" style="top: 200px;">200px</div>
2391
+ <div class="grid-marker h-marker" style="top: 400px;">400px</div>
2392
+ <div class="grid-marker h-marker" style="top: 600px;">600px</div>
2393
+ <!-- Vertical markers every 200px -->
2394
+ <div class="grid-marker v-marker" style="left: 200px;">200</div>
2395
+ <div class="grid-marker v-marker" style="left: 400px;">400</div>
2396
+ <div class="grid-marker v-marker" style="left: 600px;">600</div>
2397
+ <div class="grid-marker v-marker" style="left: 800px;">800</div>
2398
+ <div class="grid-marker v-marker" style="left: 1000px;">1000</div>
2399
+ <div class="grid-marker v-marker" style="left: 1200px;">1200</div>
2400
+ </body>
2401
+ </html>
2402
+ `)
2403
+ await page.bringToFront()
2404
+
2405
+ await serviceWorker.evaluate(async () => {
2406
+ await globalThis.toggleExtensionForActiveTab()
2407
+ })
2408
+ await new Promise((r) => setTimeout(r, 400))
2409
+
2410
+ // Take screenshot with accessibility labels via MCP
2411
+ const result = await client.callTool({
2412
+ name: 'execute',
2413
+ arguments: {
2414
+ code: js`
2415
+ let testPage;
2416
+ for (const p of context.pages()) {
2417
+ const html = await p.content();
2418
+ if (html.includes('submit-btn')) { testPage = p; break; }
2419
+ }
2420
+ if (!testPage) throw new Error('Test page not found');
2421
+ await screenshotWithAccessibilityLabels({ page: testPage });
2422
+ `,
2423
+ timeout: 15000,
2424
+ },
2425
+ })
2426
+
2427
+ expect(result.isError).toBeFalsy()
2428
+
2429
+ // Verify response has both text and image content
2430
+ const content = result.content as any[]
2431
+ expect(content.length).toBe(2)
2432
+
2433
+ // Check text content
2434
+ const textContent = content.find((c) => c.type === 'text')
2435
+ expect(textContent).toBeDefined()
2436
+ expect(textContent.text).toContain('Screenshot saved to:')
2437
+ expect(textContent.text).toContain('.jpg')
2438
+ expect(textContent.text).toContain('Labels shown:')
2439
+ expect(textContent.text).toContain('Accessibility snapshot:')
2440
+ expect(textContent.text).toContain('Submit Form')
2441
+
2442
+ // Check image content
2443
+ const imageContent = content.find((c) => c.type === 'image')
2444
+ expect(imageContent).toBeDefined()
2445
+ expect(imageContent.mimeType).toBe('image/jpeg')
2446
+ expect(imageContent.data).toBeDefined()
2447
+ expect(imageContent.data.length).toBeGreaterThan(100) // base64 data should be substantial
2448
+
2449
+ // Verify the image is valid JPEG by checking base64
2450
+ const buffer = Buffer.from(imageContent.data, 'base64')
2451
+ const dimensions = imageSize(buffer)
2452
+
2453
+ // Get actual viewport size from page
2454
+ const viewport = await page.evaluate(() => ({
2455
+ innerWidth: window.innerWidth,
2456
+ innerHeight: window.innerHeight,
2457
+ outerWidth: window.outerWidth,
2458
+ outerHeight: window.outerHeight,
2459
+ }))
2460
+ console.log('Screenshot dimensions:', dimensions.width, 'x', dimensions.height)
2461
+ console.log('Window viewport:', viewport)
2462
+
2463
+ expect(dimensions.type).toBe('jpg')
2464
+ expect(dimensions.width).toBeGreaterThan(0)
2465
+ expect(dimensions.height).toBeGreaterThan(0)
2466
+
2467
+ await page.close()
2468
+ }, 60000)
2469
+
2470
+ it('should capture network requests during execute', async () => {
2471
+ const browserContext = getBrowserContext()
2472
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2473
+
2474
+ const page = await browserContext.newPage()
2475
+ await page.setContent(`
2476
+ <html>
2477
+ <body>
2478
+ <button id="fetch-btn">Fetch Data</button>
2479
+ <div id="result"></div>
2480
+ <script>
2481
+ document.getElementById('fetch-btn').addEventListener('click', async () => {
2482
+ const response = await fetch('https://httpbin.org/post', {
2483
+ method: 'POST',
2484
+ headers: { 'Content-Type': 'application/json' },
2485
+ body: JSON.stringify({ test: 'data' })
2486
+ });
2487
+ const data = await response.json();
2488
+ document.getElementById('result').textContent = JSON.stringify(data);
2489
+ });
2490
+ </script>
2491
+ </body>
2492
+ </html>
2493
+ `)
2494
+ await page.bringToFront()
2495
+
2496
+ await serviceWorker.evaluate(async () => {
2497
+ await globalThis.toggleExtensionForActiveTab()
2498
+ })
2499
+ await new Promise((r) => setTimeout(r, 400))
2500
+
2501
+ // Execute code that triggers a network request
2502
+ const result = await client.callTool({
2503
+ name: 'execute',
2504
+ arguments: {
2505
+ intend: 'Test network capture',
2506
+ code: js`
2507
+ let testPage;
2508
+ for (const p of context.pages()) {
2509
+ const html = await p.content();
2510
+ if (html.includes('fetch-btn')) { testPage = p; break; }
2511
+ }
2512
+ if (!testPage) throw new Error('Test page not found');
2513
+ await testPage.click('#fetch-btn');
2514
+ await testPage.waitForSelector('#result:not(:empty)', { timeout: 10000 });
2515
+ `,
2516
+ timeout: 15000,
2517
+ },
2518
+ })
2519
+
2520
+ expect(result.isError).toBeFalsy()
2521
+
2522
+ // Parse the response to get snapshot path
2523
+ const content = result.content as any[]
2524
+ const textContent = content.find((c) => c.type === 'text')
2525
+ expect(textContent).toBeDefined()
2526
+
2527
+ // Extract snapshot path from response
2528
+ const match = textContent.text.match(/Full snapshot: (.+\.e2e-pilot\/snapshots\/[^\/]+)\/full\.txt/)
2529
+ expect(match).toBeTruthy()
2530
+
2531
+ const snapshotDir = match![1]
2532
+ const networksPath = path.join(snapshotDir, 'networks.jsonl')
2533
+
2534
+ // Check that networks.jsonl was created
2535
+ expect(fs.existsSync(networksPath)).toBe(true)
2536
+
2537
+ // Read and verify the network log
2538
+ const networksContent = fs.readFileSync(networksPath, 'utf-8')
2539
+ const lines = networksContent.trim().split('\n')
2540
+ expect(lines.length).toBeGreaterThan(0)
2541
+
2542
+ // Parse and verify the captured request
2543
+ const networkEntry = JSON.parse(lines[0])
2544
+ expect(networkEntry.method).toBe('POST')
2545
+ expect(networkEntry.url).toContain('httpbin.org/post')
2546
+ expect(networkEntry.status).toBe(200)
2547
+ expect(networkEntry.reqBody).toContain('test')
2548
+ expect(networkEntry.resBody).toBeDefined()
2549
+
2550
+ await page.close()
2551
+ }, 60000)
2552
+ })
2553
+
2554
+ function tryJsonParse(str: string) {
2555
+ try {
2556
+ return JSON.parse(str)
2557
+ } catch {
2558
+ return str
2559
+ }
2560
+ }
2561
+
2562
+ describe('CDP Session Tests', () => {
2563
+ let testCtx: TestContext | null = null
2564
+
2565
+ beforeAll(async () => {
2566
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-cdp-test-' })
2567
+
2568
+ const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext)
2569
+ await serviceWorker.evaluate(async () => {
2570
+ await globalThis.disconnectEverything()
2571
+ })
2572
+ await new Promise((r) => setTimeout(r, 100))
2573
+ }, 600000)
2574
+
2575
+ afterAll(async () => {
2576
+ await cleanupTestContext(testCtx)
2577
+ testCtx = null
2578
+ })
2579
+
2580
+ const getBrowserContext = () => {
2581
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
2582
+ return testCtx.browserContext
2583
+ }
2584
+
2585
+ it('should use Debugger class to set breakpoints and inspect variables', async () => {
2586
+ const browserContext = getBrowserContext()
2587
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2588
+
2589
+ const page = await browserContext.newPage()
2590
+ await page.goto('https://example.com/')
2591
+ await page.bringToFront()
2592
+
2593
+ await serviceWorker.evaluate(async () => {
2594
+ await globalThis.toggleExtensionForActiveTab()
2595
+ })
2596
+ await new Promise((r) => setTimeout(r, 100))
2597
+
2598
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2599
+ const cdpPage = browser
2600
+ .contexts()[0]
2601
+ .pages()
2602
+ .find((p) => p.url().includes('example.com'))
2603
+ expect(cdpPage).toBeDefined()
2604
+
2605
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2606
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2607
+ const dbg = new Debugger({ cdp: cdpSession })
2608
+
2609
+ await dbg.enable()
2610
+
2611
+ expect(dbg.isPaused()).toBe(false)
2612
+
2613
+ const pausedPromise = new Promise<void>((resolve) => {
2614
+ cdpSession.on('Debugger.paused', () => {
2615
+ resolve()
2616
+ })
2617
+ })
2618
+
2619
+ cdpPage!.evaluate(`
2620
+ (function testFunction() {
2621
+ const localVar = 'hello';
2622
+ const numberVar = 42;
2623
+ debugger;
2624
+ return localVar + numberVar;
2625
+ })()
2626
+ `)
2627
+
2628
+ await Promise.race([
2629
+ pausedPromise,
2630
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)),
2631
+ ])
2632
+
2633
+ expect(dbg.isPaused()).toBe(true)
2634
+
2635
+ const location = await dbg.getLocation()
2636
+ expect(location.callstack[0].functionName).toBe('testFunction')
2637
+ expect(location.sourceContext).toContain('debugger')
2638
+
2639
+ const vars = await dbg.inspectLocalVariables()
2640
+ expect(vars).toMatchInlineSnapshot(`
2641
+ {
2642
+ "localVar": "hello",
2643
+ "numberVar": 42,
2644
+ }
2645
+ `)
2646
+
2647
+ const evalResult = await dbg.evaluate({ expression: 'localVar + " world"' })
2648
+ expect(evalResult.value).toBe('hello world')
2649
+
2650
+ await dbg.resume()
2651
+ await new Promise((r) => setTimeout(r, 100))
2652
+ expect(dbg.isPaused()).toBe(false)
2653
+
2654
+ cdpSession.close()
2655
+ await browser.close()
2656
+ await page.close()
2657
+ }, 60000)
2658
+
2659
+ it('should list scripts with Debugger class', async () => {
2660
+ const browserContext = getBrowserContext()
2661
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2662
+
2663
+ const page = await browserContext.newPage()
2664
+ await page.setContent(`
2665
+ <html>
2666
+ <head>
2667
+ <script src="data:text/javascript,function testScript() { return 42; }"></script>
2668
+ </head>
2669
+ <body><h1>Script Test</h1></body>
2670
+ </html>
2671
+ `)
2672
+ await page.bringToFront()
2673
+
2674
+ await serviceWorker.evaluate(async () => {
2675
+ await globalThis.toggleExtensionForActiveTab()
2676
+ })
2677
+ await new Promise((r) => setTimeout(r, 100))
2678
+
2679
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2680
+ let cdpPage
2681
+ for (const p of browser.contexts()[0].pages()) {
2682
+ const html = await p.content()
2683
+ if (html.includes('Script Test')) {
2684
+ cdpPage = p
2685
+ break
2686
+ }
2687
+ }
2688
+ expect(cdpPage).toBeDefined()
2689
+
2690
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2691
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2692
+ const dbg = new Debugger({ cdp: cdpSession })
2693
+
2694
+ const scripts = await dbg.listScripts()
2695
+ expect(scripts.length).toBeGreaterThan(0)
2696
+ expect(scripts[0]).toHaveProperty('scriptId')
2697
+ expect(scripts[0]).toHaveProperty('url')
2698
+
2699
+ const dataScripts = await dbg.listScripts({ search: 'data:' })
2700
+ expect(dataScripts.length).toBeGreaterThan(0)
2701
+
2702
+ cdpSession.close()
2703
+ await browser.close()
2704
+ await page.close()
2705
+ }, 60000)
2706
+
2707
+ it('should manage breakpoints with Debugger class', async () => {
2708
+ const browserContext = getBrowserContext()
2709
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2710
+
2711
+ const page = await browserContext.newPage()
2712
+ await page.setContent(`
2713
+ <html>
2714
+ <head>
2715
+ <script src="data:text/javascript,function testFunc() { return 42; }"></script>
2716
+ </head>
2717
+ <body></body>
2718
+ </html>
2719
+ `)
2720
+ await page.bringToFront()
2721
+
2722
+ await serviceWorker.evaluate(async () => {
2723
+ await globalThis.toggleExtensionForActiveTab()
2724
+ })
2725
+ await new Promise((r) => setTimeout(r, 100))
2726
+
2727
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2728
+ let cdpPage
2729
+ for (const p of browser.contexts()[0].pages()) {
2730
+ const html = await p.content()
2731
+ if (html.includes('testFunc')) {
2732
+ cdpPage = p
2733
+ break
2734
+ }
2735
+ }
2736
+ expect(cdpPage).toBeDefined()
2737
+
2738
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2739
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2740
+ const dbg = new Debugger({ cdp: cdpSession })
2741
+
2742
+ await dbg.enable()
2743
+
2744
+ expect(dbg.listBreakpoints()).toHaveLength(0)
2745
+
2746
+ const bpId = await dbg.setBreakpoint({ file: 'https://example.com/test.js', line: 1 })
2747
+ expect(typeof bpId).toBe('string')
2748
+ expect(dbg.listBreakpoints()).toHaveLength(1)
2749
+ expect(dbg.listBreakpoints()[0]).toMatchObject({
2750
+ id: bpId,
2751
+ file: 'https://example.com/test.js',
2752
+ line: 1,
2753
+ })
2754
+
2755
+ await dbg.deleteBreakpoint({ breakpointId: bpId })
2756
+ expect(dbg.listBreakpoints()).toHaveLength(0)
2757
+
2758
+ cdpSession.close()
2759
+ await browser.close()
2760
+ await page.close()
2761
+ }, 60000)
2762
+
2763
+ it('should step through code with Debugger class', async () => {
2764
+ const browserContext = getBrowserContext()
2765
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2766
+
2767
+ const page = await browserContext.newPage()
2768
+ await page.goto('https://example.com/')
2769
+ await page.bringToFront()
2770
+
2771
+ await serviceWorker.evaluate(async () => {
2772
+ await globalThis.toggleExtensionForActiveTab()
2773
+ })
2774
+ await new Promise((r) => setTimeout(r, 100))
2775
+
2776
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2777
+ const cdpPage = browser
2778
+ .contexts()[0]
2779
+ .pages()
2780
+ .find((p) => p.url().includes('example.com'))
2781
+ expect(cdpPage).toBeDefined()
2782
+
2783
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2784
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2785
+ const dbg = new Debugger({ cdp: cdpSession })
2786
+
2787
+ await dbg.enable()
2788
+
2789
+ const pausedPromise = new Promise<void>((resolve) => {
2790
+ cdpSession.on('Debugger.paused', () => resolve())
2791
+ })
2792
+
2793
+ cdpPage!.evaluate(`
2794
+ (function outer() {
2795
+ function inner() {
2796
+ const x = 1;
2797
+ debugger;
2798
+ const y = 2;
2799
+ return x + y;
2800
+ }
2801
+ const result = inner();
2802
+ return result;
2803
+ })()
2804
+ `)
2805
+
2806
+ await pausedPromise
2807
+ expect(dbg.isPaused()).toBe(true)
2808
+
2809
+ const location1 = await dbg.getLocation()
2810
+ expect(location1.callstack.length).toBeGreaterThanOrEqual(2)
2811
+ expect(location1.callstack[0].functionName).toBe('inner')
2812
+ expect(location1.callstack[1].functionName).toBe('outer')
2813
+
2814
+ const stepOverPromise = new Promise<void>((resolve) => {
2815
+ cdpSession.on('Debugger.paused', () => resolve())
2816
+ })
2817
+ await dbg.stepOver()
2818
+ await stepOverPromise
2819
+
2820
+ const location2 = await dbg.getLocation()
2821
+ expect(location2.lineNumber).toBeGreaterThan(location1.lineNumber)
2822
+
2823
+ const stepOutPromise = new Promise<void>((resolve) => {
2824
+ cdpSession.on('Debugger.paused', () => resolve())
2825
+ })
2826
+ await dbg.stepOut()
2827
+ await stepOutPromise
2828
+
2829
+ const location3 = await dbg.getLocation()
2830
+ expect(location3.callstack[0].functionName).toBe('outer')
2831
+
2832
+ await dbg.resume()
2833
+
2834
+ cdpSession.close()
2835
+ await browser.close()
2836
+ await page.close()
2837
+ }, 60000)
2838
+
2839
+ it('should profile JavaScript execution using CDP Profiler', async () => {
2840
+ const browserContext = getBrowserContext()
2841
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2842
+
2843
+ const page = await browserContext.newPage()
2844
+ await page.goto('https://example.com/')
2845
+ await page.bringToFront()
2846
+
2847
+ await serviceWorker.evaluate(async () => {
2848
+ await globalThis.toggleExtensionForActiveTab()
2849
+ })
2850
+ await new Promise((r) => setTimeout(r, 100))
2851
+
2852
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2853
+ const cdpPage = browser
2854
+ .contexts()[0]
2855
+ .pages()
2856
+ .find((p) => p.url().includes('example.com'))
2857
+ expect(cdpPage).toBeDefined()
2858
+
2859
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2860
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2861
+ await cdpSession.send('Profiler.enable')
2862
+ await cdpSession.send('Profiler.start')
2863
+
2864
+ await cdpPage!.evaluate(`
2865
+ (() => {
2866
+ function fibonacci(n) {
2867
+ if (n <= 1) return n
2868
+ return fibonacci(n - 1) + fibonacci(n - 2)
2869
+ }
2870
+ for (let i = 0; i < 5; i++) {
2871
+ fibonacci(20)
2872
+ }
2873
+ for (let i = 0; i < 1000; i++) {
2874
+ document.querySelectorAll('*')
2875
+ }
2876
+ })()
2877
+ `)
2878
+
2879
+ const stopResult = await cdpSession.send('Profiler.stop')
2880
+ const profile = stopResult.profile
2881
+
2882
+ const functionNames = profile.nodes
2883
+ .map((n) => n.callFrame.functionName)
2884
+ .filter((name) => name && name.length > 0)
2885
+ .slice(0, 10)
2886
+
2887
+ expect(profile.nodes.length).toBeGreaterThan(0)
2888
+ expect(profile.endTime - profile.startTime).toBeGreaterThan(0)
2889
+ expect(functionNames.every((name) => typeof name === 'string')).toBe(true)
2890
+
2891
+ await cdpSession.send('Profiler.disable')
2892
+ cdpSession.close()
2893
+ await browser.close()
2894
+ await page.close()
2895
+ }, 60000)
2896
+
2897
+ it('should update Target.getTargets URL after page navigation', async () => {
2898
+ const browserContext = getBrowserContext()
2899
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2900
+
2901
+ // Clear any existing connected tabs from previous tests
2902
+ await serviceWorker.evaluate(async () => {
2903
+ await globalThis.disconnectEverything()
2904
+ })
2905
+
2906
+ const page = await browserContext.newPage()
2907
+ await page.goto('https://example.com/')
2908
+ await page.bringToFront()
2909
+
2910
+ await serviceWorker.evaluate(async () => {
2911
+ await globalThis.toggleExtensionForActiveTab()
2912
+ })
2913
+ await new Promise((r) => setTimeout(r, 100))
2914
+
2915
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2916
+ const cdpPage = browser
2917
+ .contexts()[0]
2918
+ .pages()
2919
+ .find((p) => p.url().includes('example.com'))
2920
+ expect(cdpPage).toBeDefined()
2921
+
2922
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2923
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2924
+
2925
+ const initialTargets = await cdpSession.send('Target.getTargets')
2926
+ const initialPageTarget = initialTargets.targetInfos.find((t) => t.type === 'page' && t.url.includes('example.com'))
2927
+ expect(initialPageTarget?.url).toBe('https://example.com/')
2928
+
2929
+ await cdpPage!.goto('https://example.org/', { waitUntil: 'domcontentloaded' })
2930
+ await new Promise((r) => setTimeout(r, 100))
2931
+
2932
+ const afterNavTargets = await cdpSession.send('Target.getTargets')
2933
+ const allPageTargets = afterNavTargets.targetInfos.filter((t) => t.type === 'page')
2934
+
2935
+ const aboutBlankTargets = allPageTargets.filter((t) => t.url === 'about:blank')
2936
+ expect(aboutBlankTargets).toHaveLength(0)
2937
+
2938
+ const exampleComTargets = allPageTargets.filter((t) => t.url.includes('example.com'))
2939
+ expect(exampleComTargets).toHaveLength(0)
2940
+
2941
+ const exampleOrgTargets = allPageTargets.filter((t) => t.url.includes('example.org'))
2942
+ expect(exampleOrgTargets).toHaveLength(1)
2943
+
2944
+ cdpSession.close()
2945
+ await browser.close()
2946
+ await page.close()
2947
+ }, 60000)
2948
+
2949
+ it('should return correct targets for multiple pages via Target.getTargets', async () => {
2950
+ const browserContext = getBrowserContext()
2951
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
2952
+
2953
+ // Clear any existing connected tabs from previous tests
2954
+ await serviceWorker.evaluate(async () => {
2955
+ await globalThis.disconnectEverything()
2956
+ })
2957
+
2958
+ const page1 = await browserContext.newPage()
2959
+ await page1.goto('https://example.com/')
2960
+ await page1.bringToFront()
2961
+ await serviceWorker.evaluate(async () => {
2962
+ await globalThis.toggleExtensionForActiveTab()
2963
+ })
2964
+
2965
+ const page2 = await browserContext.newPage()
2966
+ await page2.goto('https://example.org/')
2967
+ await page2.bringToFront()
2968
+ await serviceWorker.evaluate(async () => {
2969
+ await globalThis.toggleExtensionForActiveTab()
2970
+ })
2971
+
2972
+ await new Promise((r) => setTimeout(r, 100))
2973
+
2974
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
2975
+ const cdpPage = browser
2976
+ .contexts()[0]
2977
+ .pages()
2978
+ .find((p) => p.url().includes('example.com'))
2979
+ expect(cdpPage).toBeDefined()
2980
+
2981
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
2982
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
2983
+
2984
+ const { targetInfos } = await cdpSession.send('Target.getTargets')
2985
+ const allPageTargets = targetInfos.filter((t) => t.type === 'page')
2986
+
2987
+ const aboutBlankTargets = allPageTargets.filter((t) => t.url === 'about:blank')
2988
+ expect(aboutBlankTargets).toHaveLength(0)
2989
+
2990
+ const pageTargets = allPageTargets
2991
+ .map((t) => ({ type: t.type, url: t.url }))
2992
+ .sort((a, b) => a.url.localeCompare(b.url))
2993
+
2994
+ expect(pageTargets).toMatchInlineSnapshot(`
2995
+ [
2996
+ {
2997
+ "type": "page",
2998
+ "url": "https://example.com/",
2999
+ },
3000
+ {
3001
+ "type": "page",
3002
+ "url": "https://example.org/",
3003
+ },
3004
+ ]
3005
+ `)
3006
+
3007
+ cdpSession.close()
3008
+ await browser.close()
3009
+ await page1.close()
3010
+ await page2.close()
3011
+ }, 60000)
3012
+
3013
+ it('should create CDP session for page after navigation', async () => {
3014
+ const browserContext = getBrowserContext()
3015
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3016
+
3017
+ const page = await browserContext.newPage()
3018
+ await page.goto('https://example.com/')
3019
+ await page.bringToFront()
3020
+
3021
+ await serviceWorker.evaluate(async () => {
3022
+ await globalThis.toggleExtensionForActiveTab()
3023
+ })
3024
+ await new Promise((r) => setTimeout(r, 100))
3025
+
3026
+ await page.goto('https://example.org/', { waitUntil: 'domcontentloaded' })
3027
+ await new Promise((r) => setTimeout(r, 100))
3028
+
3029
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3030
+ const cdpPage = browser
3031
+ .contexts()[0]
3032
+ .pages()
3033
+ .find((p) => p.url().includes('example.org'))
3034
+ expect(cdpPage).toBeDefined()
3035
+
3036
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3037
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3038
+
3039
+ const evalResult = await cdpSession.send('Runtime.evaluate', {
3040
+ expression: 'document.title',
3041
+ returnByValue: true,
3042
+ })
3043
+ expect(evalResult.result.value).toContain('Example Domain')
3044
+
3045
+ cdpSession.close()
3046
+ await browser.close()
3047
+ await page.close()
3048
+ }, 60000)
3049
+
3050
+ it('should maintain CDP session functionality after page URL change', async () => {
3051
+ const browserContext = getBrowserContext()
3052
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3053
+
3054
+ const page = await browserContext.newPage()
3055
+ const initialUrl = 'https://example.com/'
3056
+ await page.goto(initialUrl)
3057
+ await page.bringToFront()
3058
+
3059
+ await serviceWorker.evaluate(async () => {
3060
+ await globalThis.toggleExtensionForActiveTab()
3061
+ })
3062
+ await new Promise((r) => setTimeout(r, 100))
3063
+
3064
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3065
+ const cdpPage = browser
3066
+ .contexts()[0]
3067
+ .pages()
3068
+ .find((p) => p.url().includes('example.com'))
3069
+ expect(cdpPage).toBeDefined()
3070
+
3071
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3072
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3073
+
3074
+ const initialEvalResult = await cdpSession.send('Runtime.evaluate', {
3075
+ expression: 'document.title',
3076
+ returnByValue: true,
3077
+ })
3078
+ expect(initialEvalResult.result.value).toBe('Example Domain')
3079
+
3080
+ const newUrl = 'https://example.org/'
3081
+ await cdpPage!.goto(newUrl, { waitUntil: 'domcontentloaded' })
3082
+
3083
+ expect(cdpPage!.url()).toBe(newUrl)
3084
+
3085
+ const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics')
3086
+ expect(layoutMetrics.cssVisualViewport).toBeDefined()
3087
+ expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
3088
+
3089
+ const afterNavEvalResult = await cdpSession.send('Runtime.evaluate', {
3090
+ expression: 'document.title',
3091
+ returnByValue: true,
3092
+ })
3093
+ expect(afterNavEvalResult.result.value).toContain('Example Domain')
3094
+
3095
+ const locationResult = await cdpSession.send('Runtime.evaluate', {
3096
+ expression: 'window.location.href',
3097
+ returnByValue: true,
3098
+ })
3099
+ expect(locationResult.result.value).toBe(newUrl)
3100
+
3101
+ cdpSession.close()
3102
+ await browser.close()
3103
+ await page.close()
3104
+ }, 60000)
3105
+
3106
+ it('should pause on all exceptions with setPauseOnExceptions', async () => {
3107
+ const browserContext = getBrowserContext()
3108
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3109
+
3110
+ const page = await browserContext.newPage()
3111
+ await page.goto('https://example.com/')
3112
+ await page.bringToFront()
3113
+
3114
+ await serviceWorker.evaluate(async () => {
3115
+ await globalThis.toggleExtensionForActiveTab()
3116
+ })
3117
+ await new Promise((r) => setTimeout(r, 100))
3118
+
3119
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3120
+ const cdpPage = browser
3121
+ .contexts()[0]
3122
+ .pages()
3123
+ .find((p) => p.url().includes('example.com'))
3124
+ expect(cdpPage).toBeDefined()
3125
+
3126
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3127
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3128
+ const dbg = new Debugger({ cdp: cdpSession })
3129
+
3130
+ await dbg.enable()
3131
+ await dbg.setPauseOnExceptions({ state: 'all' })
3132
+
3133
+ const pausedPromise = new Promise<void>((resolve) => {
3134
+ cdpSession.on('Debugger.paused', () => resolve())
3135
+ })
3136
+
3137
+ cdpPage!
3138
+ .evaluate(
3139
+ `
3140
+ (function() {
3141
+ try {
3142
+ throw new Error('Caught test error');
3143
+ } catch (e) {
3144
+ // caught but should still pause with state 'all'
3145
+ }
3146
+ })()
3147
+ `,
3148
+ )
3149
+ .catch(() => {})
3150
+
3151
+ await Promise.race([
3152
+ pausedPromise,
3153
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)),
3154
+ ])
3155
+
3156
+ expect(dbg.isPaused()).toBe(true)
3157
+
3158
+ const location = await dbg.getLocation()
3159
+ expect(location.sourceContext).toContain('throw')
3160
+
3161
+ await dbg.resume()
3162
+
3163
+ await dbg.setPauseOnExceptions({ state: 'none' })
3164
+
3165
+ cdpSession.close()
3166
+ await browser.close()
3167
+ await page.close()
3168
+ }, 60000)
3169
+
3170
+ it('should inspect local and global variables with inline snapshots', async () => {
3171
+ const browserContext = getBrowserContext()
3172
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3173
+
3174
+ const page = await browserContext.newPage()
3175
+ await page.setContent(`
3176
+ <html>
3177
+ <head>
3178
+ <script>
3179
+ const GLOBAL_CONFIG = 'production';
3180
+ function runTest() {
3181
+ const userName = 'Alice';
3182
+ const userAge = 25;
3183
+ const settings = { theme: 'dark', lang: 'en' };
3184
+ const scores = [10, 20, 30];
3185
+ debugger;
3186
+ return userName;
3187
+ }
3188
+ </script>
3189
+ </head>
3190
+ <body>
3191
+ <button onclick="runTest()">Run</button>
3192
+ </body>
3193
+ </html>
3194
+ `)
3195
+ await page.bringToFront()
3196
+
3197
+ await serviceWorker.evaluate(async () => {
3198
+ await globalThis.toggleExtensionForActiveTab()
3199
+ })
3200
+ await new Promise((r) => setTimeout(r, 100))
3201
+
3202
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3203
+ let cdpPage
3204
+ for (const p of browser.contexts()[0].pages()) {
3205
+ const html = await p.content()
3206
+ if (html.includes('runTest')) {
3207
+ cdpPage = p
3208
+ break
3209
+ }
3210
+ }
3211
+ expect(cdpPage).toBeDefined()
3212
+
3213
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3214
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3215
+ const dbg = new Debugger({ cdp: cdpSession })
3216
+
3217
+ await dbg.enable()
3218
+
3219
+ const globalVars = await dbg.inspectGlobalVariables()
3220
+ expect(globalVars).toMatchInlineSnapshot(`
3221
+ [
3222
+ "GLOBAL_CONFIG",
3223
+ ]
3224
+ `)
3225
+
3226
+ const pausedPromise = new Promise<void>((resolve) => {
3227
+ cdpSession.on('Debugger.paused', () => resolve())
3228
+ })
3229
+
3230
+ cdpPage!.evaluate('runTest()')
3231
+
3232
+ await pausedPromise
3233
+ expect(dbg.isPaused()).toBe(true)
3234
+
3235
+ const localVars = await dbg.inspectLocalVariables()
3236
+ expect(localVars).toMatchInlineSnapshot(`
3237
+ {
3238
+ "GLOBAL_CONFIG": "production",
3239
+ "scores": "[array]",
3240
+ "settings": "[object]",
3241
+ "userAge": 25,
3242
+ "userName": "Alice",
3243
+ }
3244
+ `)
3245
+
3246
+ await dbg.resume()
3247
+
3248
+ cdpSession.close()
3249
+ await browser.close()
3250
+ await page.close()
3251
+ }, 60000)
3252
+
3253
+ it('should click at correct coordinates on high-DPI simulation', async () => {
3254
+ const browserContext = getBrowserContext()
3255
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3256
+
3257
+ const page = await browserContext.newPage()
3258
+ await page.goto('https://example.com/')
3259
+ await page.bringToFront()
3260
+
3261
+ await serviceWorker.evaluate(async () => {
3262
+ await globalThis.toggleExtensionForActiveTab()
3263
+ })
3264
+ await new Promise((r) => setTimeout(r, 100))
3265
+
3266
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3267
+ const cdpPage = browser
3268
+ .contexts()[0]
3269
+ .pages()
3270
+ .find((p) => p.url().includes('example.com'))
3271
+ expect(cdpPage).toBeDefined()
3272
+
3273
+ const h1Bounds = await cdpPage!.locator('h1').boundingBox()
3274
+ expect(h1Bounds).toBeDefined()
3275
+ console.log('H1 bounding box:', h1Bounds)
3276
+
3277
+ await cdpPage!.evaluate(() => {
3278
+ ;(window as any).clickedAt = null
3279
+ document.addEventListener('click', (e) => {
3280
+ ;(window as any).clickedAt = { x: e.clientX, y: e.clientY }
3281
+ })
3282
+ })
3283
+
3284
+ await cdpPage!.locator('h1').click()
3285
+
3286
+ const clickedAt = await cdpPage!.evaluate(() => (window as any).clickedAt)
3287
+ console.log('Clicked at:', clickedAt)
3288
+
3289
+ expect(clickedAt).toBeDefined()
3290
+ expect(clickedAt.x).toBeGreaterThan(0)
3291
+ expect(clickedAt.y).toBeGreaterThan(0)
3292
+
3293
+ await browser.close()
3294
+ await page.close()
3295
+ }, 60000)
3296
+
3297
+ it('should use Editor class to list, read, and edit scripts', async () => {
3298
+ const browserContext = getBrowserContext()
3299
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3300
+
3301
+ const page = await browserContext.newPage()
3302
+ await page.goto('https://example.com/')
3303
+ await page.bringToFront()
3304
+
3305
+ await serviceWorker.evaluate(async () => {
3306
+ await globalThis.toggleExtensionForActiveTab()
3307
+ })
3308
+ await new Promise((r) => setTimeout(r, 100))
3309
+
3310
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3311
+ const cdpPage = browser
3312
+ .contexts()[0]
3313
+ .pages()
3314
+ .find((p) => p.url().includes('example.com'))
3315
+ expect(cdpPage).toBeDefined()
3316
+
3317
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3318
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3319
+ const editor = new Editor({ cdp: cdpSession })
3320
+
3321
+ await editor.enable()
3322
+
3323
+ await cdpPage!.addScriptTag({
3324
+ content: `
3325
+ function greetUser(name) {
3326
+ console.log('Hello, ' + name);
3327
+ return 'Hello, ' + name;
3328
+ }
3329
+ `,
3330
+ })
3331
+ await new Promise((r) => setTimeout(r, 100))
3332
+ const scripts = await editor.list()
3333
+ expect(scripts.length).toBeGreaterThan(0)
3334
+
3335
+ const matches = await editor.grep({ regex: /greetUser/ })
3336
+ expect(matches.length).toBeGreaterThan(0)
3337
+
3338
+ const match = matches[0]
3339
+ const { content, totalLines } = await editor.read({ url: match.url })
3340
+ expect(content).toContain('greetUser')
3341
+ expect(totalLines).toBeGreaterThan(0)
3342
+
3343
+ await editor.edit({
3344
+ url: match.url,
3345
+ oldString: "console.log('Hello, ' + name);",
3346
+ newString: "console.log('Hello, ' + name); console.log('EDITOR_TEST_MARKER');",
3347
+ })
3348
+
3349
+ const consoleLogs: string[] = []
3350
+ cdpPage!.on('console', (msg) => {
3351
+ consoleLogs.push(msg.text())
3352
+ })
3353
+
3354
+ await cdpPage!.evaluate(() => {
3355
+ ;(window as any).greetUser('World')
3356
+ })
3357
+ await new Promise((r) => setTimeout(r, 100))
3358
+
3359
+ expect(consoleLogs).toContain('Hello, World')
3360
+ expect(consoleLogs).toContain('EDITOR_TEST_MARKER')
3361
+
3362
+ cdpSession.close()
3363
+ await browser.close()
3364
+ await page.close()
3365
+ }, 60000)
3366
+
3367
+ it('editor can list, read, and edit CSS stylesheets', async () => {
3368
+ const browserContext = getBrowserContext()
3369
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3370
+
3371
+ const page = await browserContext.newPage()
3372
+ await page.goto('https://example.com/')
3373
+ await page.bringToFront()
3374
+
3375
+ await serviceWorker.evaluate(async () => {
3376
+ await globalThis.toggleExtensionForActiveTab()
3377
+ })
3378
+ await new Promise((r) => setTimeout(r, 100))
3379
+
3380
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3381
+ const cdpPage = browser
3382
+ .contexts()[0]
3383
+ .pages()
3384
+ .find((p) => p.url().includes('example.com'))
3385
+ expect(cdpPage).toBeDefined()
3386
+
3387
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3388
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3389
+ const editor = new Editor({ cdp: cdpSession })
3390
+
3391
+ await editor.enable()
3392
+
3393
+ await cdpPage!.addStyleTag({
3394
+ content: `
3395
+ .editor-test-element {
3396
+ color: rgb(255, 0, 0);
3397
+ background-color: rgb(0, 0, 255);
3398
+ }
3399
+ `,
3400
+ })
3401
+ await new Promise((r) => setTimeout(r, 100))
3402
+ const stylesheets = await editor.list({ pattern: /inline-css:/ })
3403
+ expect(stylesheets.length).toBeGreaterThan(0)
3404
+
3405
+ const cssMatches = await editor.grep({ regex: /editor-test-element/, pattern: /inline-css:/ })
3406
+ expect(cssMatches.length).toBeGreaterThan(0)
3407
+
3408
+ const cssMatch = cssMatches[0]
3409
+ const { content, totalLines } = await editor.read({ url: cssMatch.url })
3410
+ expect(content).toContain('editor-test-element')
3411
+ expect(content).toContain('rgb(255, 0, 0)')
3412
+ expect(totalLines).toBeGreaterThan(0)
3413
+
3414
+ await cdpPage!.evaluate(() => {
3415
+ const el = document.createElement('div')
3416
+ el.className = 'editor-test-element'
3417
+ el.id = 'test-div'
3418
+ el.textContent = 'Test'
3419
+ document.body.appendChild(el)
3420
+ })
3421
+
3422
+ const colorBefore = await cdpPage!.evaluate(() => {
3423
+ const el = document.getElementById('test-div')!
3424
+ return window.getComputedStyle(el).color
3425
+ })
3426
+ expect(colorBefore).toBe('rgb(255, 0, 0)')
3427
+
3428
+ await editor.edit({
3429
+ url: cssMatch.url,
3430
+ oldString: 'color: rgb(255, 0, 0);',
3431
+ newString: 'color: rgb(0, 255, 0);',
3432
+ })
3433
+
3434
+ const colorAfter = await cdpPage!.evaluate(() => {
3435
+ const el = document.getElementById('test-div')!
3436
+ return window.getComputedStyle(el).color
3437
+ })
3438
+ expect(colorAfter).toBe('rgb(0, 255, 0)')
3439
+
3440
+ cdpSession.close()
3441
+ await browser.close()
3442
+ await page.close()
3443
+ }, 60000)
3444
+
3445
+ it('should inject bippy and find React fiber with getReactSource', async () => {
3446
+ const browserContext = getBrowserContext()
3447
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3448
+
3449
+ const page = await browserContext.newPage()
3450
+ await page.setContent(`
3451
+ <!DOCTYPE html>
3452
+ <html>
3453
+ <head>
3454
+ <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
3455
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
3456
+ </head>
3457
+ <body>
3458
+ <div id="root"></div>
3459
+ <script>
3460
+ function MyComponent() {
3461
+ return React.createElement('button', { id: 'react-btn' }, 'Click me');
3462
+ }
3463
+ const root = ReactDOM.createRoot(document.getElementById('root'));
3464
+ root.render(React.createElement(MyComponent));
3465
+ </script>
3466
+ </body>
3467
+ </html>
3468
+ `)
3469
+ await page.bringToFront()
3470
+
3471
+ await serviceWorker.evaluate(async () => {
3472
+ await globalThis.toggleExtensionForActiveTab()
3473
+ })
3474
+ await new Promise((r) => setTimeout(r, 500))
3475
+
3476
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3477
+ const pages = browser.contexts()[0].pages()
3478
+ const cdpPage = pages.find((p) => p.url().startsWith('about:'))
3479
+ expect(cdpPage).toBeDefined()
3480
+
3481
+ const btn = cdpPage!.locator('#react-btn')
3482
+ const btnCount = await btn.count()
3483
+ expect(btnCount).toBe(1)
3484
+
3485
+ const hasBippyBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
3486
+ expect(hasBippyBefore).toBe(false)
3487
+
3488
+ const wsUrl = getCdpUrl({ port: TEST_PORT })
3489
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
3490
+
3491
+ const { getReactSource } = await import('./react-source.js')
3492
+ const source = await getReactSource({ locator: btn, cdp: cdpSession })
3493
+
3494
+ const hasBippyAfter = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
3495
+ expect(hasBippyAfter).toBe(true)
3496
+
3497
+ const hasFiber = await btn.evaluate((el) => {
3498
+ const bippy = (globalThis as any).__bippy
3499
+ const fiber = bippy.getFiberFromHostInstance(el)
3500
+ return !!fiber
3501
+ })
3502
+ expect(hasFiber).toBe(true)
3503
+
3504
+ const componentName = await btn.evaluate((el) => {
3505
+ const bippy = (globalThis as any).__bippy
3506
+ const fiber = bippy.getFiberFromHostInstance(el)
3507
+ let current = fiber
3508
+ while (current) {
3509
+ if (bippy.isCompositeFiber(current)) {
3510
+ return bippy.getDisplayName(current.type)
3511
+ }
3512
+ current = current.return
3513
+ }
3514
+ return null
3515
+ })
3516
+ expect(componentName).toBe('MyComponent')
3517
+
3518
+ console.log('Component name from fiber:', componentName)
3519
+ console.log('Source location (null for UMD React, works on local dev servers with JSX transform):', source)
3520
+
3521
+ await browser.close()
3522
+ await page.close()
3523
+ }, 60000)
3524
+ })
3525
+
3526
+ describe('Auto-enable Tests', () => {
3527
+ let testCtx: TestContext | null = null
3528
+
3529
+ // Set env var before any setup runs
3530
+ process.env.E2E_PILOT_AUTO_ENABLE = '1'
3531
+
3532
+ beforeAll(async () => {
3533
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-auto-test-' })
3534
+
3535
+ // Disconnect all tabs to start with a clean state
3536
+ const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext)
3537
+ await serviceWorker.evaluate(async () => {
3538
+ await globalThis.disconnectEverything()
3539
+ })
3540
+ await new Promise((r) => setTimeout(r, 100))
3541
+ }, 600000)
3542
+
3543
+ afterAll(async () => {
3544
+ delete process.env.E2E_PILOT_AUTO_ENABLE
3545
+ await cleanupTestContext(testCtx)
3546
+ testCtx = null
3547
+ })
3548
+
3549
+ const getBrowserContext = () => {
3550
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
3551
+ return testCtx.browserContext
3552
+ }
3553
+
3554
+ it('should auto-create a tab when Playwright connects and no tabs exist', async () => {
3555
+ const browserContext = getBrowserContext()
3556
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
3557
+
3558
+ // Ensure clean state - disconnect any tabs from previous tests or setup
3559
+ await serviceWorker.evaluate(async () => {
3560
+ await globalThis.disconnectEverything()
3561
+ })
3562
+ await new Promise((r) => setTimeout(r, 100))
3563
+
3564
+ // Verify no tabs are connected
3565
+ const tabCountBefore = await serviceWorker.evaluate(() => {
3566
+ const state = globalThis.getExtensionState()
3567
+ return state.tabs.size
3568
+ })
3569
+ expect(tabCountBefore).toBe(0)
3570
+
3571
+ // Connect Playwright - this should trigger auto-create
3572
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
3573
+
3574
+ // Verify a page was auto-created
3575
+ const pages = browser.contexts()[0].pages()
3576
+ expect(pages.length).toBeGreaterThan(0)
3577
+ expect(pages.length).toBe(1)
3578
+
3579
+ const autoCreatedPage = pages[0]
3580
+ expect(autoCreatedPage.url()).toBe('about:blank')
3581
+
3582
+ // Verify extension state shows the tab as connected
3583
+ const tabCountAfter = await serviceWorker.evaluate(() => {
3584
+ const state = globalThis.getExtensionState()
3585
+ return state.tabs.size
3586
+ })
3587
+ expect(tabCountAfter).toBe(1)
3588
+
3589
+ // Verify we can interact with the auto-created page
3590
+ await autoCreatedPage.setContent('<h1>Auto-created page</h1>')
3591
+ const title = await autoCreatedPage.locator('h1').textContent()
3592
+ expect(title).toBe('Auto-created page')
3593
+
3594
+ await browser.close()
3595
+ }, 60000)
3596
+ })