playwriter 0.0.8 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.test.ts CHANGED
@@ -2,9 +2,34 @@ import { createMCPClient } from './mcp-client.js'
2
2
  import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
3
  import { exec } from 'node:child_process'
4
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 'mcp-extension/src/types.js'
11
+
12
+ import { spawn } from 'node:child_process'
13
+
5
14
 
6
15
  const execAsync = promisify(exec)
7
16
 
17
+ async function getExtensionServiceWorker(context: BrowserContext) {
18
+
19
+ let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'))
20
+
21
+
22
+ let serviceWorker = serviceWorkers[0]
23
+ if (!serviceWorker) {
24
+ serviceWorker = await context.waitForEvent('serviceworker', {
25
+ predicate: (sw) => sw.url().startsWith('chrome-extension://')
26
+ })
27
+ }
28
+
29
+
30
+ return serviceWorker
31
+ }
32
+
8
33
  function js(strings: TemplateStringsArray, ...values: any[]): string {
9
34
  return strings.reduce(
10
35
  (result, str, i) => result + str + (values[i] || ''),
@@ -15,29 +40,121 @@ function js(strings: TemplateStringsArray, ...values: any[]): string {
15
40
  async function killProcessOnPort(port: number): Promise<void> {
16
41
  try {
17
42
  const { stdout } = await execAsync(`lsof -ti:${port}`)
18
- const pid = stdout.trim()
19
- if (pid) {
20
- await execAsync(`kill -9 ${pid}`)
21
- console.log(`Killed process ${pid} on port ${port}`)
22
- await new Promise((resolve) => setTimeout(resolve, 500))
43
+ const pids = stdout.trim().split('\n').filter(Boolean)
44
+ if (pids.length > 0) {
45
+ await execAsync(`kill -9 ${pids.join(' ')}`)
46
+ console.log(`Killed processes ${pids.join(', ')} on port ${port}`)
47
+ await new Promise((resolve) => setTimeout(resolve, 1000))
23
48
  }
24
49
  } catch (error) {
25
50
  // No process running on port or already killed
26
51
  }
27
52
  }
28
53
 
54
+ declare global {
55
+ var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
56
+ var getExtensionState: () => ExtensionState;
57
+ var disconnectEverything: () => Promise<void>;
58
+ }
59
+
29
60
  describe('MCP Server Tests', () => {
30
61
  let client: Awaited<ReturnType<typeof createMCPClient>>['client']
31
62
  let cleanup: (() => Promise<void>) | null = null
63
+ let browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>> | null = null
64
+ let userDataDir: string
65
+ let relayServerProcess: any
32
66
 
33
67
  beforeAll(async () => {
34
68
  await killProcessOnPort(19988)
69
+
70
+ // Build extension
71
+ console.log('Building extension...')
72
+ await execAsync('TESTING=1 pnpm build', { cwd: '../extension' })
73
+ console.log('Extension built')
74
+
75
+ // Start Relay Server manually
76
+ relayServerProcess = spawn('pnpm', ['tsx', 'src/start-relay-server.ts'], {
77
+ cwd: process.cwd(),
78
+ stdio: 'inherit'
79
+ })
80
+
81
+ // Wait for port 19988 to be ready
82
+ await new Promise<void>((resolve, reject) => {
83
+ let retries = 0
84
+ const interval = setInterval(async () => {
85
+ try {
86
+ const { stdout } = await execAsync('lsof -ti:19988')
87
+ if (stdout.trim()) {
88
+ clearInterval(interval)
89
+ resolve()
90
+ }
91
+ } catch {
92
+ // ignore
93
+ }
94
+ retries++
95
+ if (retries > 30) {
96
+ clearInterval(interval)
97
+ reject(new Error('Relay server failed to start'))
98
+ }
99
+ }, 1000)
100
+ })
101
+
35
102
  const result = await createMCPClient()
36
103
  client = result.client
37
104
  cleanup = result.cleanup
38
- })
105
+
106
+ userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-test-'))
107
+ const extensionPath = path.resolve('../extension/dist')
108
+
109
+ browserContext = await chromium.launchPersistentContext(userDataDir, {
110
+ channel: 'chromium', // <- this opts into new headless
111
+ headless: !process.env.HEADFUL,
112
+ args: [
113
+ `--disable-extensions-except=${extensionPath}`,
114
+ `--load-extension=${extensionPath}`,
115
+ ],
116
+ })
117
+
118
+ // Wait for service worker and connect
119
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
120
+
121
+ // Wait for extension to initialize global functions
122
+ for (let i = 0; i < 50; i++) {
123
+ const isReady = await serviceWorker.evaluate(() => {
124
+ // @ts-ignore
125
+ return typeof globalThis.toggleExtensionForActiveTab === 'function'
126
+ })
127
+ if (isReady) break
128
+ await new Promise(r => setTimeout(r, 100))
129
+ }
130
+
131
+ // Create a page to attach to
132
+ const page = await browserContext.newPage()
133
+ await page.goto('about:blank')
134
+
135
+ // Connect the tab
136
+ await serviceWorker.evaluate(async () => {
137
+ await globalThis.toggleExtensionForActiveTab()
138
+ })
139
+
140
+ }, 600000) // 10 minutes timeout
39
141
 
40
142
  afterAll(async () => {
143
+ if (browserContext) {
144
+ await browserContext.close()
145
+ }
146
+ if (relayServerProcess) {
147
+ relayServerProcess.kill()
148
+ }
149
+ await killProcessOnPort(19988)
150
+
151
+ if (userDataDir) {
152
+ try {
153
+ fs.rmSync(userDataDir, { recursive: true, force: true })
154
+ } catch (e) {
155
+ console.error('Failed to cleanup user data dir:', e)
156
+ }
157
+ }
41
158
  if (cleanup) {
42
159
  await cleanup()
43
160
  cleanup = null
@@ -86,6 +203,54 @@ describe('MCP Server Tests', () => {
86
203
  expect(result.content).toBeDefined()
87
204
  }, 30000)
88
205
 
206
+ it('should show extension as connected for pages created via newPage()', async () => {
207
+ if (!browserContext) throw new Error('Browser not initialized')
208
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
209
+
210
+ // Create a page via MCP (which uses context.newPage())
211
+ await client.callTool({
212
+ name: 'execute',
213
+ arguments: {
214
+ code: js`
215
+ const newPage = await context.newPage();
216
+ state.testPage = newPage;
217
+ await newPage.goto('https://example.com/mcp-test');
218
+ return newPage.url();
219
+ `,
220
+ },
221
+ })
222
+
223
+ // Get extension state to verify the page is marked as connected
224
+ const extensionState = await serviceWorker.evaluate(async () => {
225
+ const state = globalThis.getExtensionState()
226
+ const tabs = await chrome.tabs.query({})
227
+ const testTab = tabs.find((t: any) => t.url?.includes('mcp-test'))
228
+ return {
229
+ connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
230
+ tabId: testTab?.id,
231
+ tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
232
+ connectionState: state.connectionState
233
+ }
234
+ })
235
+
236
+ expect(extensionState.connected).toBe(true)
237
+ expect(extensionState.tabInfo?.state).toBe('connected')
238
+ expect(extensionState.connectionState).toBe('connected')
239
+
240
+ // Clean up
241
+ await client.callTool({
242
+ name: 'execute',
243
+ arguments: {
244
+ code: js`
245
+ if (state.testPage) {
246
+ await state.testPage.close();
247
+ delete state.testPage;
248
+ }
249
+ `,
250
+ },
251
+ })
252
+ }, 30000)
253
+
89
254
  it('should get accessibility snapshot of hacker news', async () => {
90
255
  await client.callTool({
91
256
  name: 'execute',
@@ -174,7 +339,1027 @@ describe('MCP Server Tests', () => {
174
339
  })
175
340
 
176
341
  })
342
+
343
+ it('should handle new pages and toggling with new connections', async () => {
344
+ if (!browserContext) throw new Error('Browser not initialized')
345
+
346
+ // Find the correct service worker by URL
347
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
348
+
349
+ // 1. Create a new page
350
+ const page = await browserContext.newPage()
351
+ const testUrl = 'https://example.com/'
352
+ await page.goto(testUrl)
353
+
354
+ await page.bringToFront()
355
+
356
+ // 2. Enable extension on this new tab
357
+ // Since it's a new page, extension is not connected yet
358
+ const result = await serviceWorker.evaluate(async () => {
359
+ return await globalThis.toggleExtensionForActiveTab()
360
+ })
361
+ expect(result.isConnected).toBe(true)
362
+
363
+ // 3. Verify we can connect via direct CDP and see the page
364
+
365
+ let directBrowser = await chromium.connectOverCDP(getCdpUrl())
366
+ let contexts = directBrowser.contexts()
367
+ let pages = contexts[0].pages()
368
+
369
+ // Find our page
370
+ let foundPage = pages.find(p => p.url() === testUrl)
371
+ expect(foundPage).toBeDefined()
372
+ expect(foundPage?.url()).toBe(testUrl)
373
+
374
+ // Verify execution works
375
+ const sum1 = await foundPage?.evaluate(() => 1 + 1)
376
+ expect(sum1).toBe(2)
377
+
378
+ await directBrowser.close()
379
+
380
+
381
+ // 4. Disable extension on this tab
382
+ const resultDisabled = await serviceWorker.evaluate(async () => {
383
+ return await globalThis.toggleExtensionForActiveTab()
384
+ })
385
+ expect(resultDisabled.isConnected).toBe(false)
386
+
387
+ // 5. Try to connect/use the page.
388
+ // connecting to relay will succeed, but listing pages should NOT show our page
389
+
390
+ // Connect to relay again
391
+ directBrowser = await chromium.connectOverCDP(getCdpUrl())
392
+ contexts = directBrowser.contexts()
393
+ pages = contexts[0].pages()
394
+
395
+ foundPage = pages.find(p => p.url() === testUrl)
396
+ expect(foundPage).toBeUndefined()
397
+
398
+ await directBrowser.close()
399
+
400
+
401
+ // 6. Re-enable extension
402
+ const resultEnabled = await serviceWorker.evaluate(async () => {
403
+ return await globalThis.toggleExtensionForActiveTab()
404
+ })
405
+ expect(resultEnabled.isConnected).toBe(true)
406
+
407
+ // 7. Verify page is back
408
+
409
+ directBrowser = await chromium.connectOverCDP(getCdpUrl())
410
+ // Wait a bit for targets to populate
411
+ await new Promise(r => setTimeout(r, 500))
412
+
413
+ contexts = directBrowser.contexts()
414
+ // pages() might need a moment if target attached event comes in
415
+ if (contexts[0].pages().length === 0) {
416
+ await new Promise(r => setTimeout(r, 1000))
417
+ }
418
+ pages = contexts[0].pages()
419
+
420
+ foundPage = pages.find(p => p.url() === testUrl)
421
+ expect(foundPage).toBeDefined()
422
+ expect(foundPage?.url()).toBe(testUrl)
423
+
424
+ // Verify execution works again
425
+ const sum2 = await foundPage?.evaluate(() => 2 + 2)
426
+ expect(sum2).toBe(4)
427
+
428
+ await directBrowser.close()
429
+ await page.close()
430
+ })
431
+
432
+ it('should handle new pages and toggling with persistent connection', async () => {
433
+ if (!browserContext) throw new Error('Browser not initialized')
434
+
435
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
436
+
437
+ // Connect once
438
+ const directBrowser = await chromium.connectOverCDP(getCdpUrl())
439
+ // Wait a bit for connection and initial target discovery
440
+ await new Promise(r => setTimeout(r, 500))
441
+
442
+ // 1. Create a new page
443
+ const page = await browserContext.newPage()
444
+ const testUrl = 'https://example.com/persistent'
445
+ await page.goto(testUrl)
446
+ await page.bringToFront()
447
+
448
+ // 2. Enable extension
449
+ await serviceWorker.evaluate(async () => {
450
+ await globalThis.toggleExtensionForActiveTab()
451
+ })
452
+
453
+ // 3. Verify page appears (polling)
454
+ let foundPage
455
+ for (let i = 0; i < 50; i++) {
456
+ const pages = directBrowser.contexts()[0].pages()
457
+ foundPage = pages.find(p => p.url() === testUrl)
458
+ if (foundPage) break
459
+ await new Promise(r => setTimeout(r, 100))
460
+ }
461
+ expect(foundPage).toBeDefined()
462
+ expect(foundPage?.url()).toBe(testUrl)
463
+
464
+ // Verify execution works
465
+ const sum1 = await foundPage?.evaluate(() => 10 + 20)
466
+ expect(sum1).toBe(30)
467
+
468
+ // 4. Disable extension
469
+ await serviceWorker.evaluate(async () => {
470
+ await globalThis.toggleExtensionForActiveTab()
471
+ })
472
+
473
+ // 5. Verify page disappears (polling)
474
+ for (let i = 0; i < 50; i++) {
475
+ const pages = directBrowser.contexts()[0].pages()
476
+ foundPage = pages.find(p => p.url() === testUrl)
477
+ if (!foundPage) break
478
+ await new Promise(r => setTimeout(r, 100))
479
+ }
480
+ expect(foundPage).toBeUndefined()
481
+
482
+ // 6. Re-enable extension
483
+ await serviceWorker.evaluate(async () => {
484
+ await globalThis.toggleExtensionForActiveTab()
485
+ })
486
+
487
+ // 7. Verify page reappears (polling)
488
+ for (let i = 0; i < 50; i++) {
489
+ const pages = directBrowser.contexts()[0].pages()
490
+ foundPage = pages.find(p => p.url() === testUrl)
491
+ if (foundPage) break
492
+ await new Promise(r => setTimeout(r, 100))
493
+ }
494
+ expect(foundPage).toBeDefined()
495
+ expect(foundPage?.url()).toBe(testUrl)
496
+
497
+ // Verify execution works again
498
+ const sum2 = await foundPage?.evaluate(() => 30 + 40)
499
+ expect(sum2).toBe(70)
500
+
501
+ await page.close()
502
+ await directBrowser.close()
503
+ })
504
+ it('should maintain connection across reloads and navigation', async () => {
505
+ if (!browserContext) throw new Error('Browser not initialized')
506
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
507
+
508
+ // 1. Setup page
509
+ const page = await browserContext.newPage()
510
+ const initialUrl = 'https://example.com/'
511
+ await page.goto(initialUrl)
512
+ await page.bringToFront()
513
+
514
+ // 2. Enable extension
515
+ await serviceWorker.evaluate(async () => {
516
+ await globalThis.toggleExtensionForActiveTab()
517
+ })
518
+
519
+ // 3. Connect via CDP
520
+ const cdpUrl = getCdpUrl()
521
+ const directBrowser = await chromium.connectOverCDP(cdpUrl)
522
+ const connectedPage = directBrowser.contexts()[0].pages().find(p => p.url() === initialUrl)
523
+ expect(connectedPage).toBeDefined()
524
+
525
+ // Verify execution
526
+ expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2)
527
+
528
+ // 4. Reload
529
+ // We use a loop to check if it's still connected because reload might cause temporary disconnect/reconnect events
530
+ // that Playwright handles natively if the session ID stays valid.
531
+ await connectedPage?.reload()
532
+ await connectedPage?.waitForLoadState('networkidle')
533
+ expect(await connectedPage?.title()).toBe('Example Domain')
534
+
535
+ // Verify execution after reload
536
+ expect(await connectedPage?.evaluate(() => 2 + 2)).toBe(4)
537
+
538
+ // 5. Navigate to new URL
539
+ const newUrl = 'https://news.ycombinator.com/'
540
+ await connectedPage?.goto(newUrl)
541
+ await connectedPage?.waitForLoadState('networkidle')
542
+
543
+ expect(connectedPage?.url()).toBe(newUrl)
544
+ expect(await connectedPage?.title()).toContain('Hacker News')
545
+
546
+ // Verify execution after navigation
547
+ expect(await connectedPage?.evaluate(() => 3 + 3)).toBe(6)
548
+
549
+ await directBrowser.close()
550
+ await page.close()
551
+ })
552
+
553
+ it('should support multiple concurrent tabs', async () => {
554
+ if (!browserContext) throw new Error('Browser not initialized')
555
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
556
+ await new Promise(resolve => setTimeout(resolve, 500))
557
+
558
+ // Tab A
559
+ const pageA = await browserContext.newPage()
560
+ await pageA.goto('https://example.com/tab-a')
561
+ await pageA.bringToFront()
562
+ await new Promise(resolve => setTimeout(resolve, 500))
563
+ await serviceWorker.evaluate(async () => {
564
+ await globalThis.toggleExtensionForActiveTab()
565
+ })
566
+
567
+ // Tab B
568
+ const pageB = await browserContext.newPage()
569
+ await pageB.goto('https://example.com/tab-b')
570
+ await pageB.bringToFront()
571
+ await new Promise(resolve => setTimeout(resolve, 500))
572
+ await serviceWorker.evaluate(async () => {
573
+ await globalThis.toggleExtensionForActiveTab()
574
+ })
575
+
576
+ // Get target IDs for both
577
+ const targetIds = await serviceWorker.evaluate(async () => {
578
+ const state = globalThis.getExtensionState()
579
+ const chrome = globalThis.chrome
580
+ const tabs = await chrome.tabs.query({})
581
+ const tabA = tabs.find((t: any) => t.url?.includes('tab-a'))
582
+ const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
583
+ return {
584
+ idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
585
+ idB: state.tabs.get(tabB?.id ?? -1)?.targetId
586
+ }
587
+ })
588
+
589
+ expect(targetIds).toMatchInlineSnapshot({
590
+ idA: expect.any(String),
591
+ idB: expect.any(String)
592
+ }, `
593
+ {
594
+ "idA": Any<String>,
595
+ "idB": Any<String>,
596
+ }
597
+ `)
598
+ expect(targetIds.idA).not.toBe(targetIds.idB)
599
+
600
+ // Verify independent connections
601
+ const browser = await chromium.connectOverCDP(getCdpUrl())
602
+
603
+ const pages = browser.contexts()[0].pages()
604
+
605
+ const results = await Promise.all(pages.map(async (p) => ({
606
+ url: p.url(),
607
+ title: await p.title()
608
+ })))
609
+
610
+ expect(results).toMatchInlineSnapshot(`
611
+ [
612
+ {
613
+ "title": "",
614
+ "url": "about:blank",
615
+ },
616
+ {
617
+ "title": "Example Domain",
618
+ "url": "https://example.com/tab-a",
619
+ },
620
+ {
621
+ "title": "Example Domain",
622
+ "url": "https://example.com/tab-b",
623
+ },
624
+ ]
625
+ `)
626
+
627
+ // Verify execution on both pages
628
+ const pageA_CDP = pages.find(p => p.url().includes('tab-a'))
629
+ const pageB_CDP = pages.find(p => p.url().includes('tab-b'))
630
+
631
+ expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
632
+ expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
633
+
634
+ await browser.close()
635
+ await pageA.close()
636
+ await pageB.close()
637
+ })
638
+
639
+ it('should support multiple concurrent tabs', async () => {
640
+ if (!browserContext) throw new Error('Browser not initialized')
641
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
642
+ await new Promise(resolve => setTimeout(resolve, 500))
643
+
644
+ // Tab A
645
+ const pageA = await browserContext.newPage()
646
+ await pageA.goto('https://example.com/tab-a')
647
+ await pageA.bringToFront()
648
+ await new Promise(resolve => setTimeout(resolve, 500))
649
+ await serviceWorker.evaluate(async () => {
650
+ await globalThis.toggleExtensionForActiveTab()
651
+ })
652
+
653
+ // Tab B
654
+ const pageB = await browserContext.newPage()
655
+ await pageB.goto('https://example.com/tab-b')
656
+ await pageB.bringToFront()
657
+ await new Promise(resolve => setTimeout(resolve, 500))
658
+ await serviceWorker.evaluate(async () => {
659
+ await globalThis.toggleExtensionForActiveTab()
660
+ })
661
+
662
+ // Get target IDs for both
663
+ const targetIds = await serviceWorker.evaluate(async () => {
664
+ const state = globalThis.getExtensionState()
665
+ const chrome = globalThis.chrome
666
+ const tabs = await chrome.tabs.query({})
667
+ const tabA = tabs.find((t: any) => t.url?.includes('tab-a'))
668
+ const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
669
+ return {
670
+ idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
671
+ idB: state.tabs.get(tabB?.id ?? -1)?.targetId
672
+ }
673
+ })
674
+
675
+ expect(targetIds).toMatchInlineSnapshot({
676
+ idA: expect.any(String),
677
+ idB: expect.any(String)
678
+ }, `
679
+ {
680
+ "idA": Any<String>,
681
+ "idB": Any<String>,
682
+ }
683
+ `)
684
+ expect(targetIds.idA).not.toBe(targetIds.idB)
685
+
686
+ // Verify independent connections
687
+ const browser = await chromium.connectOverCDP(getCdpUrl())
688
+
689
+ const pages = browser.contexts()[0].pages()
690
+
691
+ const results = await Promise.all(pages.map(async (p) => ({
692
+ url: p.url(),
693
+ title: await p.title()
694
+ })))
695
+
696
+ expect(results).toMatchInlineSnapshot(`
697
+ [
698
+ {
699
+ "title": "",
700
+ "url": "about:blank",
701
+ },
702
+ {
703
+ "title": "Example Domain",
704
+ "url": "https://example.com/tab-a",
705
+ },
706
+ {
707
+ "title": "Example Domain",
708
+ "url": "https://example.com/tab-b",
709
+ },
710
+ ]
711
+ `)
712
+
713
+ // Verify execution on both pages
714
+ const pageA_CDP = pages.find(p => p.url().includes('tab-a'))
715
+ const pageB_CDP = pages.find(p => p.url().includes('tab-b'))
716
+
717
+ expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
718
+ expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
719
+
720
+ await browser.close()
721
+ await pageA.close()
722
+ await pageB.close()
723
+ })
724
+
725
+ it('should show correct url when enabling extension after navigation', async () => {
726
+ if (!browserContext) throw new Error('Browser not initialized')
727
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
728
+
729
+ // 1. Open a new page (extension not yet enabled for it)
730
+ const page = await browserContext.newPage()
731
+ const targetUrl = 'https://example.com/late-enable'
732
+ await page.goto(targetUrl)
733
+ await page.bringToFront()
734
+
735
+ // Wait for load
736
+ await page.waitForLoadState('networkidle')
737
+
738
+ // 2. Enable extension for this page
739
+ await serviceWorker.evaluate(async () => {
740
+ await globalThis.toggleExtensionForActiveTab()
741
+ })
742
+
743
+ // 3. Verify via CDP that the correct URL is shown
744
+ const browser = await chromium.connectOverCDP(getCdpUrl())
745
+ // Wait for sync
746
+ await new Promise(r => setTimeout(r, 1000))
747
+
748
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url() === targetUrl)
749
+
750
+ expect(cdpPage).toBeDefined()
751
+ expect(cdpPage?.url()).toBe(targetUrl)
752
+
753
+ await browser.close()
754
+ await page.close()
755
+ })
756
+
757
+ it('should be able to reconnect after disconnecting everything', async () => {
758
+ if (!browserContext) throw new Error('Browser not initialized')
759
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
760
+
761
+ // 1. Use the existing about:blank page from beforeAll
762
+ const pages = await browserContext.pages()
763
+ expect(pages.length).toBeGreaterThan(0)
764
+ const page = pages[0]
765
+
766
+ await page.goto('https://example.com/disconnect-test')
767
+ await page.waitForLoadState('networkidle')
768
+ await page.bringToFront()
769
+
770
+ // Enable extension on this page
771
+ const initialEnable = await serviceWorker.evaluate(async () => {
772
+ return await globalThis.toggleExtensionForActiveTab()
773
+ })
774
+ console.log('Initial enable result:', initialEnable)
775
+ expect(initialEnable.isConnected).toBe(true)
776
+
777
+ // Wait for extension to fully connect
778
+ await new Promise(resolve => setTimeout(resolve, 500))
779
+
780
+ // Verify MCP can see the page
781
+ const beforeDisconnect = await client.callTool({
782
+ name: 'execute',
783
+ arguments: {
784
+ code: js`
785
+ const pages = context.pages();
786
+ console.log('Pages before disconnect:', pages.length);
787
+ const testPage = pages.find(p => p.url().includes('disconnect-test'));
788
+ console.log('Found test page:', !!testPage);
789
+ return { pagesCount: pages.length, foundTestPage: !!testPage };
790
+ `,
791
+ },
792
+ })
793
+
794
+ const beforeOutput = (beforeDisconnect as any).content[0].text
795
+ expect(beforeOutput).toContain('foundTestPage')
796
+ console.log('Before disconnect:', beforeOutput)
797
+
798
+ // 2. Disconnect everything
799
+ console.log('Calling disconnectEverything...')
800
+ await serviceWorker.evaluate(async () => {
801
+ await globalThis.disconnectEverything()
802
+ })
803
+
804
+ // Wait for disconnect to complete
805
+ await new Promise(resolve => setTimeout(resolve, 500))
806
+
807
+ // 3. Verify MCP cannot see the page anymore
808
+ const afterDisconnect = await client.callTool({
809
+ name: 'execute',
810
+ arguments: {
811
+ code: js`
812
+ const pages = context.pages();
813
+ console.log('Pages after disconnect:', pages.length);
814
+ return { pagesCount: pages.length };
815
+ `,
816
+ },
817
+ })
818
+
819
+ const afterDisconnectOutput = (afterDisconnect as any).content[0].text
820
+ console.log('After disconnect:', afterDisconnectOutput)
821
+ expect(afterDisconnectOutput).toContain('Pages after disconnect: 0')
822
+
823
+ // 4. Re-enable extension on the same page
824
+ console.log('Re-enabling extension...')
825
+ await page.bringToFront()
826
+ const reconnectResult = await serviceWorker.evaluate(async () => {
827
+ console.log('About to call toggleExtensionForActiveTab')
828
+ const result = await globalThis.toggleExtensionForActiveTab()
829
+ console.log('toggleExtensionForActiveTab result:', result)
830
+ return result
831
+ })
832
+
833
+ console.log('Reconnect result:', reconnectResult)
834
+ expect(reconnectResult.isConnected).toBe(true)
835
+
836
+ // Wait for extension to fully reconnect and relay server to be ready
837
+ console.log('Waiting for reconnection to stabilize...')
838
+ await new Promise(resolve => setTimeout(resolve, 1000))
839
+
840
+ // 5. Reset the MCP client's playwright connection since it was closed by disconnectEverything
841
+ console.log('Resetting MCP playwright connection...')
842
+ const resetResult = await client.callTool({
843
+ name: 'execute',
844
+ arguments: {
845
+ code: js`
846
+ console.log('Resetting playwright connection');
847
+ const result = await resetPlaywright();
848
+ console.log('Reset complete, checking pages');
849
+ const pages = context.pages();
850
+ console.log('Pages after reset:', pages.length);
851
+ return { reset: true, pagesCount: pages.length };
852
+ `,
853
+ },
854
+ })
855
+ console.log('Reset result:', (resetResult as any).content[0].text)
856
+
857
+ // 6. Verify MCP can see the page again
858
+ console.log('Attempting to access page via MCP...')
859
+ const afterReconnect = await client.callTool({
860
+ name: 'execute',
861
+ arguments: {
862
+ code: js`
863
+ console.log('Checking pages after reconnect...');
864
+ const pages = context.pages();
865
+ console.log('Pages after reconnect:', pages.length);
866
+
867
+ if (pages.length === 0) {
868
+ console.log('No pages found!');
869
+ return { pagesCount: 0, foundTestPage: false };
870
+ }
871
+
872
+ const testPage = pages.find(p => p.url().includes('disconnect-test'));
873
+ console.log('Found test page after reconnect:', !!testPage);
874
+
875
+ if (testPage) {
876
+ console.log('Test page URL:', testPage.url());
877
+ return { pagesCount: pages.length, foundTestPage: true, url: testPage.url() };
878
+ }
879
+
880
+ return { pagesCount: pages.length, foundTestPage: false };
881
+ `,
882
+ },
883
+ })
884
+
885
+ const afterReconnectOutput = (afterReconnect as any).content[0].text
886
+ console.log('After reconnect:', afterReconnectOutput)
887
+ expect(afterReconnectOutput).toContain('foundTestPage')
888
+ expect(afterReconnectOutput).toContain('disconnect-test')
889
+
890
+ // Clean up - navigate page back to about:blank to not interfere with other tests
891
+ await page.goto('about:blank')
892
+ })
893
+
894
+ it('should capture browser console logs with getLatestLogs', async () => {
895
+ // Ensure clean state and clear any existing logs
896
+ const resetResult = await client.callTool({
897
+ name: 'execute',
898
+ arguments: {
899
+ code: js`
900
+ // Clear any existing logs from previous tests
901
+ clearAllLogs();
902
+ console.log('Cleared all existing logs');
903
+
904
+ // Verify connection is working
905
+ const pages = context.pages();
906
+ console.log('Current pages count:', pages.length);
907
+
908
+ return { success: true, pagesCount: pages.length };
909
+ `,
910
+ },
911
+ })
912
+ console.log('Cleanup result:', resetResult)
913
+
914
+ // Create a new page for this test
915
+ await client.callTool({
916
+ name: 'execute',
917
+ arguments: {
918
+ code: js`
919
+ const newPage = await context.newPage();
920
+ state.testLogPage = newPage;
921
+ await newPage.goto('about:blank');
922
+ `,
923
+ },
924
+ })
925
+
926
+ // Generate some console logs in the browser
927
+ await client.callTool({
928
+ name: 'execute',
929
+ arguments: {
930
+ code: js`
931
+ await state.testLogPage.evaluate(() => {
932
+ console.log('Test log 12345');
933
+ console.error('Test error 67890');
934
+ console.warn('Test warning 11111');
935
+ console.log('Test log 2 with', { data: 'object' });
936
+ });
937
+ // Wait for logs to be captured
938
+ await new Promise(resolve => setTimeout(resolve, 100));
939
+ `,
940
+ },
941
+ })
942
+
943
+ // Test getting all logs
944
+ const allLogsResult = await client.callTool({
945
+ name: 'execute',
946
+ arguments: {
947
+ code: js`
948
+ const logs = await getLatestLogs();
949
+ logs.forEach(log => console.log(log));
950
+ `,
951
+ },
952
+ })
953
+
954
+ const output = (allLogsResult as any).content[0].text
955
+ expect(output).toContain('[log] Test log 12345')
956
+ expect(output).toContain('[error] Test error 67890')
957
+ expect(output).toContain('[warning] Test warning 11111')
958
+
959
+ // Test filtering by search string
960
+ const errorLogsResult = await client.callTool({
961
+ name: 'execute',
962
+ arguments: {
963
+ code: js`
964
+ const logs = await getLatestLogs({ search: 'error' });
965
+ logs.forEach(log => console.log(log));
966
+ `,
967
+ },
968
+ })
969
+
970
+ const errorOutput = (errorLogsResult as any).content[0].text
971
+ expect(errorOutput).toContain('[error] Test error 67890')
972
+ expect(errorOutput).not.toContain('[log] Test log 12345')
973
+
974
+ // Test that logs are cleared on page reload
975
+ await client.callTool({
976
+ name: 'execute',
977
+ arguments: {
978
+ code: js`
979
+ // First add a log before reload
980
+ await state.testLogPage.evaluate(() => {
981
+ console.log('Before reload 99999');
982
+ });
983
+ await new Promise(resolve => setTimeout(resolve, 100));
984
+ `,
985
+ },
986
+ })
987
+
988
+ // Verify the log exists
989
+ const beforeReloadResult = await client.callTool({
990
+ name: 'execute',
991
+ arguments: {
992
+ code: js`
993
+ const logs = await getLatestLogs({ page: state.testLogPage });
994
+ console.log('Logs before reload:', logs.length);
995
+ logs.forEach(log => console.log(log));
996
+ `,
997
+ },
998
+ })
999
+
1000
+ const beforeReloadOutput = (beforeReloadResult as any).content[0].text
1001
+ expect(beforeReloadOutput).toContain('[log] Before reload 99999')
1002
+
1003
+ // Reload the page
1004
+ await client.callTool({
1005
+ name: 'execute',
1006
+ arguments: {
1007
+ code: js`
1008
+ await state.testLogPage.reload();
1009
+ await state.testLogPage.evaluate(() => {
1010
+ console.log('After reload 88888');
1011
+ });
1012
+ await new Promise(resolve => setTimeout(resolve, 100));
1013
+ `,
1014
+ },
1015
+ })
1016
+
1017
+ // Check logs after reload - old logs should be gone
1018
+ const afterReloadResult = await client.callTool({
1019
+ name: 'execute',
1020
+ arguments: {
1021
+ code: js`
1022
+ const logs = await getLatestLogs({ page: state.testLogPage });
1023
+ console.log('Logs after reload:', logs.length);
1024
+ logs.forEach(log => console.log(log));
1025
+ `,
1026
+ },
1027
+ })
1028
+
1029
+ const afterReloadOutput = (afterReloadResult as any).content[0].text
1030
+ expect(afterReloadOutput).toContain('[log] After reload 88888')
1031
+ expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
1032
+
1033
+ // Clean up
1034
+ await client.callTool({
1035
+ name: 'execute',
1036
+ arguments: {
1037
+ code: js`
1038
+ await state.testLogPage.close();
1039
+ delete state.testLogPage;
1040
+ `,
1041
+ },
1042
+ })
1043
+ }, 30000)
1044
+
1045
+ it('should keep logs separate between different pages', async () => {
1046
+ // Clear any existing logs from previous tests
1047
+ await client.callTool({
1048
+ name: 'execute',
1049
+ arguments: {
1050
+ code: js`
1051
+ clearAllLogs();
1052
+ console.log('Cleared all existing logs for second log test');
1053
+ `,
1054
+ },
1055
+ })
1056
+
1057
+ // Create two pages
1058
+ await client.callTool({
1059
+ name: 'execute',
1060
+ arguments: {
1061
+ code: js`
1062
+ state.pageA = await context.newPage();
1063
+ state.pageB = await context.newPage();
1064
+ await state.pageA.goto('about:blank');
1065
+ await state.pageB.goto('about:blank');
1066
+ `,
1067
+ },
1068
+ })
1069
+
1070
+ // Generate logs in page A
1071
+ await client.callTool({
1072
+ name: 'execute',
1073
+ arguments: {
1074
+ code: js`
1075
+ await state.pageA.evaluate(() => {
1076
+ console.log('PageA log 11111');
1077
+ console.error('PageA error 22222');
1078
+ });
1079
+ await new Promise(resolve => setTimeout(resolve, 100));
1080
+ `,
1081
+ },
1082
+ })
1083
+
1084
+ // Generate logs in page B
1085
+ await client.callTool({
1086
+ name: 'execute',
1087
+ arguments: {
1088
+ code: js`
1089
+ await state.pageB.evaluate(() => {
1090
+ console.log('PageB log 33333');
1091
+ console.error('PageB error 44444');
1092
+ });
1093
+ await new Promise(resolve => setTimeout(resolve, 100));
1094
+ `,
1095
+ },
1096
+ })
1097
+
1098
+ // Check logs for page A - should only have page A logs
1099
+ const pageALogsResult = await client.callTool({
1100
+ name: 'execute',
1101
+ arguments: {
1102
+ code: js`
1103
+ const logs = await getLatestLogs({ page: state.pageA });
1104
+ console.log('Page A logs:', logs.length);
1105
+ logs.forEach(log => console.log(log));
1106
+ `,
1107
+ },
1108
+ })
1109
+
1110
+ const pageAOutput = (pageALogsResult as any).content[0].text
1111
+ expect(pageAOutput).toContain('[log] PageA log 11111')
1112
+ expect(pageAOutput).toContain('[error] PageA error 22222')
1113
+ expect(pageAOutput).not.toContain('PageB')
1114
+
1115
+ // Check logs for page B - should only have page B logs
1116
+ const pageBLogsResult = await client.callTool({
1117
+ name: 'execute',
1118
+ arguments: {
1119
+ code: js`
1120
+ const logs = await getLatestLogs({ page: state.pageB });
1121
+ console.log('Page B logs:', logs.length);
1122
+ logs.forEach(log => console.log(log));
1123
+ `,
1124
+ },
1125
+ })
1126
+
1127
+ const pageBOutput = (pageBLogsResult as any).content[0].text
1128
+ expect(pageBOutput).toContain('[log] PageB log 33333')
1129
+ expect(pageBOutput).toContain('[error] PageB error 44444')
1130
+ expect(pageBOutput).not.toContain('PageA')
1131
+
1132
+ // Check all logs - should have logs from both pages
1133
+ const allLogsResult = await client.callTool({
1134
+ name: 'execute',
1135
+ arguments: {
1136
+ code: js`
1137
+ const logs = await getLatestLogs();
1138
+ console.log('All logs:', logs.length);
1139
+ logs.forEach(log => console.log(log));
1140
+ `,
1141
+ },
1142
+ })
1143
+
1144
+ const allOutput = (allLogsResult as any).content[0].text
1145
+ expect(allOutput).toContain('[log] PageA log 11111')
1146
+ expect(allOutput).toContain('[log] PageB log 33333')
1147
+
1148
+ // Test that reloading page A clears only page A logs
1149
+ await client.callTool({
1150
+ name: 'execute',
1151
+ arguments: {
1152
+ code: js`
1153
+ await state.pageA.reload();
1154
+ await state.pageA.evaluate(() => {
1155
+ console.log('PageA after reload 55555');
1156
+ });
1157
+ await new Promise(resolve => setTimeout(resolve, 100));
1158
+ `,
1159
+ },
1160
+ })
1161
+
1162
+ // Check page A logs - should only have new log
1163
+ const pageAAfterReloadResult = await client.callTool({
1164
+ name: 'execute',
1165
+ arguments: {
1166
+ code: js`
1167
+ const logs = await getLatestLogs({ page: state.pageA });
1168
+ console.log('Page A logs after reload:', logs.length);
1169
+ logs.forEach(log => console.log(log));
1170
+ `,
1171
+ },
1172
+ })
1173
+
1174
+ const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
1175
+ expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
1176
+ expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
1177
+
1178
+ // Check page B logs - should still have original logs
1179
+ const pageBAfterAReloadResult = await client.callTool({
1180
+ name: 'execute',
1181
+ arguments: {
1182
+ code: js`
1183
+ const logs = await getLatestLogs({ page: state.pageB });
1184
+ console.log('Page B logs after A reload:', logs.length);
1185
+ logs.forEach(log => console.log(log));
1186
+ `,
1187
+ },
1188
+ })
1189
+
1190
+ const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text
1191
+ expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333')
1192
+ expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444')
1193
+
1194
+ // Test that logs are deleted when page is closed
1195
+ await client.callTool({
1196
+ name: 'execute',
1197
+ arguments: {
1198
+ code: js`
1199
+ // Close page A
1200
+ await state.pageA.close();
1201
+ await new Promise(resolve => setTimeout(resolve, 100));
1202
+ `,
1203
+ },
1204
+ })
1205
+
1206
+ // Check all logs - page A logs should be gone
1207
+ const logsAfterCloseResult = await client.callTool({
1208
+ name: 'execute',
1209
+ arguments: {
1210
+ code: js`
1211
+ const logs = await getLatestLogs();
1212
+ console.log('All logs after closing page A:', logs.length);
1213
+ logs.forEach(log => console.log(log));
1214
+ `,
1215
+ },
1216
+ })
1217
+
1218
+ const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
1219
+ expect(logsAfterCloseOutput).not.toContain('PageA')
1220
+ expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
1221
+
1222
+ // Clean up remaining page
1223
+ await client.callTool({
1224
+ name: 'execute',
1225
+ arguments: {
1226
+ code: js`
1227
+ await state.pageB.close();
1228
+ delete state.pageA;
1229
+ delete state.pageB;
1230
+ `,
1231
+ },
1232
+ })
1233
+ }, 30000)
1234
+
1235
+ it('should maintain correct page.url() with service worker pages', async () => {
1236
+ if (!browserContext) throw new Error('Browser not initialized')
1237
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1238
+
1239
+ const page = await browserContext.newPage()
1240
+ const targetUrl = 'https://x.com'
1241
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded' })
1242
+ await page.bringToFront()
1243
+
1244
+ await serviceWorker.evaluate(async () => {
1245
+ await globalThis.toggleExtensionForActiveTab()
1246
+ })
1247
+
1248
+ await new Promise(r => setTimeout(r, 2000))
1249
+
1250
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1251
+ const pages = browser.contexts()[0].pages()
1252
+ const xPage = pages.find(p => p.url().includes('x.com'))
1253
+
1254
+ expect(xPage).toBeDefined()
1255
+ expect(xPage?.url()).toContain('x.com')
1256
+ expect(xPage?.url()).not.toContain('sw.js')
1257
+
1258
+ await browser.close()
1259
+ await page.close()
1260
+ }, 30000)
1261
+
1262
+ it('should maintain correct page.url() after repeated connections', async () => {
1263
+ if (!browserContext) throw new Error('Browser not initialized')
1264
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1265
+
1266
+ const page = await browserContext.newPage()
1267
+ const targetUrl = 'https://example.com/repeated-test'
1268
+ await page.goto(targetUrl)
1269
+ await page.bringToFront()
1270
+
1271
+ await serviceWorker.evaluate(async () => {
1272
+ await globalThis.toggleExtensionForActiveTab()
1273
+ })
1274
+
1275
+ for (let i = 0; i < 5; i++) {
1276
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1277
+ const pages = browser.contexts()[0].pages()
1278
+ const testPage = pages.find(p => p.url().includes('repeated-test'))
1279
+
1280
+ expect(testPage).toBeDefined()
1281
+ expect(testPage?.url()).toBe(targetUrl)
1282
+
1283
+ await browser.close()
1284
+ await new Promise(r => setTimeout(r, 200))
1285
+ }
1286
+
1287
+ await page.close()
1288
+ }, 30000)
1289
+
1290
+ it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
1291
+ if (!browserContext) throw new Error('Browser not initialized')
1292
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1293
+
1294
+ const page = await browserContext.newPage()
1295
+ const targetUrl = 'https://example.com/concurrent-test'
1296
+ await page.goto(targetUrl)
1297
+ await page.bringToFront()
1298
+
1299
+ await serviceWorker.evaluate(async () => {
1300
+ await globalThis.toggleExtensionForActiveTab()
1301
+ })
1302
+
1303
+ await new Promise(r => setTimeout(r, 500))
1304
+
1305
+ const [mcpResult, cdpBrowser] = await Promise.all([
1306
+ client.callTool({
1307
+ name: 'execute',
1308
+ arguments: {
1309
+ code: js`
1310
+ const pages = context.pages();
1311
+ const testPage = pages.find(p => p.url().includes('concurrent-test'));
1312
+ return { url: testPage?.url(), found: !!testPage };
1313
+ `,
1314
+ },
1315
+ }),
1316
+ chromium.connectOverCDP(getCdpUrl())
1317
+ ])
1318
+
1319
+ const mcpOutput = (mcpResult as any).content[0].text
1320
+ expect(mcpOutput).toContain(targetUrl)
1321
+
1322
+ const cdpPages = cdpBrowser.contexts()[0].pages()
1323
+ const cdpPage = cdpPages.find(p => p.url().includes('concurrent-test'))
1324
+ expect(cdpPage?.url()).toBe(targetUrl)
1325
+
1326
+ await cdpBrowser.close()
1327
+ await page.close()
1328
+ }, 30000)
1329
+
1330
+ it('should maintain correct page.url() with iframe-heavy pages', async () => {
1331
+ if (!browserContext) throw new Error('Browser not initialized')
1332
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1333
+
1334
+ const page = await browserContext.newPage()
1335
+ const targetUrl = 'https://www.youtube.com'
1336
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded' })
1337
+ await page.bringToFront()
1338
+
1339
+ await serviceWorker.evaluate(async () => {
1340
+ await globalThis.toggleExtensionForActiveTab()
1341
+ })
1342
+
1343
+ await new Promise(r => setTimeout(r, 3000))
1344
+
1345
+ for (let i = 0; i < 3; i++) {
1346
+ const browser = await chromium.connectOverCDP(getCdpUrl())
1347
+ const pages = browser.contexts()[0].pages()
1348
+ const ytPage = pages.find(p => p.url().includes('youtube.com'))
1349
+
1350
+ expect(ytPage).toBeDefined()
1351
+ expect(ytPage?.url()).toContain('youtube.com')
1352
+
1353
+ await browser.close()
1354
+ await new Promise(r => setTimeout(r, 500))
1355
+ }
1356
+
1357
+ await page.close()
1358
+ }, 60000)
1359
+
177
1360
  })
1361
+
1362
+
178
1363
  function tryJsonParse(str: string) {
179
1364
  try {
180
1365
  return JSON.parse(str)