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/dist/extension/cdp-relay.d.ts.map +1 -1
- package/dist/extension/cdp-relay.js +82 -35
- package/dist/extension/cdp-relay.js.map +1 -1
- package/dist/extension/protocol.d.ts +31 -17
- package/dist/extension/protocol.d.ts.map +1 -1
- package/dist/extension/protocol.js.map +1 -1
- package/dist/mcp.js +262 -71
- package/dist/mcp.js.map +1 -1
- package/dist/mcp.test.d.ts +9 -1
- package/dist/mcp.test.d.ts.map +1 -1
- package/dist/mcp.test.js +1014 -6
- package/dist/mcp.test.js.map +1 -1
- package/dist/prompt.md +56 -12
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +15 -2
- package/dist/start-relay-server.js.map +1 -1
- package/dist/utils.d.ts +1 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -2
- package/dist/utils.js.map +1 -1
- package/package.json +6 -2
- package/src/extension/cdp-relay.ts +86 -39
- package/src/extension/protocol.ts +31 -15
- package/src/mcp.test.ts +1191 -6
- package/src/mcp.ts +313 -84
- package/src/prompt.md +56 -12
- package/src/snapshots/hacker-news-initial-accessibility.md +64 -277
- package/src/snapshots/shadcn-ui-accessibility.md +81 -283
- package/src/start-relay-server.ts +19 -2
- package/src/utils.ts +2 -2
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
|
|
19
|
-
if (
|
|
20
|
-
await execAsync(`kill -9 ${
|
|
21
|
-
console.log(`Killed
|
|
22
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
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)
|