test(playwriter): speed up MCP suite
Split MCP tests into parallel files and use local fixtures/polling so attach and reconnect checks stay stable under parallel runs.
This commit is contained in:
@@ -0,0 +1,633 @@
|
||||
import { createMCPClient } from './mcp-client.js'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { getCdpUrl } from './utils.js'
|
||||
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, js } from './test-utils.js'
|
||||
import './test-declarations.js'
|
||||
|
||||
const TEST_PORT = 19990
|
||||
|
||||
describe('MCP Connection Tests', () => {
|
||||
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
|
||||
let cleanup: (() => Promise<void>) | null = null
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-conn-test-', toggleExtension: true })
|
||||
|
||||
const result = await createMCPClient({ port: TEST_PORT })
|
||||
client = result.client
|
||||
cleanup = result.cleanup
|
||||
}, 600000)
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestContext(testCtx, cleanup)
|
||||
cleanup = null
|
||||
testCtx = null
|
||||
})
|
||||
|
||||
const getBrowserContext = () => {
|
||||
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||
return testCtx.browserContext
|
||||
}
|
||||
|
||||
it('should handle new pages and toggling with new connections', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// 1. Create a new page
|
||||
const page = await browserContext.newPage()
|
||||
const testUrl = 'https://example.com/'
|
||||
await page.goto(testUrl)
|
||||
|
||||
await page.bringToFront()
|
||||
|
||||
// 2. Enable extension on this new tab
|
||||
const result = await serviceWorker.evaluate(async () => {
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
expect(result.isConnected).toBe(true)
|
||||
|
||||
// 3. Verify we can connect via direct CDP and see the page
|
||||
let directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
let contexts = directBrowser.contexts()
|
||||
let pages = contexts[0].pages()
|
||||
|
||||
let foundPage = pages.find(p => p.url() === testUrl)
|
||||
expect(foundPage).toBeDefined()
|
||||
expect(foundPage?.url()).toBe(testUrl)
|
||||
|
||||
const sum1 = await foundPage?.evaluate(() => 1 + 1)
|
||||
expect(sum1).toBe(2)
|
||||
|
||||
await directBrowser.close()
|
||||
|
||||
// 4. Disable extension on this tab
|
||||
const resultDisabled = await serviceWorker.evaluate(async () => {
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
expect(resultDisabled.isConnected).toBe(false)
|
||||
|
||||
// 5. Connect again - page should NOT be visible
|
||||
directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
contexts = directBrowser.contexts()
|
||||
pages = contexts[0].pages()
|
||||
|
||||
foundPage = pages.find(p => p.url() === testUrl)
|
||||
expect(foundPage).toBeUndefined()
|
||||
|
||||
await directBrowser.close()
|
||||
|
||||
// 6. Re-enable extension
|
||||
const resultEnabled = await serviceWorker.evaluate(async () => {
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
expect(resultEnabled.isConnected).toBe(true)
|
||||
|
||||
// 7. Verify page is back
|
||||
directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
contexts = directBrowser.contexts()
|
||||
if (contexts[0].pages().length === 0) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
pages = contexts[0].pages()
|
||||
|
||||
foundPage = pages.find(p => p.url() === testUrl)
|
||||
expect(foundPage).toBeDefined()
|
||||
expect(foundPage?.url()).toBe(testUrl)
|
||||
|
||||
const sum2 = await foundPage?.evaluate(() => 2 + 2)
|
||||
expect(sum2).toBe(4)
|
||||
|
||||
await directBrowser.close()
|
||||
await page.close()
|
||||
})
|
||||
|
||||
it('should handle new pages and toggling with persistent connection', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 1. Create a new page
|
||||
const page = await browserContext.newPage()
|
||||
const testUrl = 'https://example.com/persistent'
|
||||
await page.goto(testUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
// 2. Enable extension
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
// 3. Verify page appears (polling)
|
||||
let foundPage
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pages = directBrowser.contexts()[0].pages()
|
||||
foundPage = pages.find(p => p.url() === testUrl)
|
||||
if (foundPage) break
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
expect(foundPage).toBeDefined()
|
||||
expect(foundPage?.url()).toBe(testUrl)
|
||||
|
||||
const sum1 = await foundPage?.evaluate(() => 10 + 20)
|
||||
expect(sum1).toBe(30)
|
||||
|
||||
// 4. Disable extension
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
// 5. Verify page disappears (polling)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pages = directBrowser.contexts()[0].pages()
|
||||
foundPage = pages.find(p => p.url() === testUrl)
|
||||
if (!foundPage) break
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
expect(foundPage).toBeUndefined()
|
||||
|
||||
// 6. Re-enable extension
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
// 7. Verify page reappears (polling)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pages = directBrowser.contexts()[0].pages()
|
||||
foundPage = pages.find(p => p.url() === testUrl)
|
||||
if (foundPage) break
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
expect(foundPage).toBeDefined()
|
||||
expect(foundPage?.url()).toBe(testUrl)
|
||||
|
||||
const sum2 = await foundPage?.evaluate(() => 30 + 40)
|
||||
expect(sum2).toBe(70)
|
||||
|
||||
await page.close()
|
||||
await directBrowser.close()
|
||||
})
|
||||
|
||||
it('should maintain connection across reloads and navigation', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// 1. Setup page
|
||||
const page = await browserContext.newPage()
|
||||
const initialUrl = 'https://example.com/'
|
||||
await page.goto(initialUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
// 2. Enable extension
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
// 3. Connect via CDP
|
||||
const cdpUrl = getCdpUrl({ port: TEST_PORT })
|
||||
const directBrowser = await chromium.connectOverCDP(cdpUrl)
|
||||
const connectedPage = directBrowser.contexts()[0].pages().find(p => p.url() === initialUrl)
|
||||
expect(connectedPage).toBeDefined()
|
||||
|
||||
expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2)
|
||||
|
||||
// 4. Reload
|
||||
await connectedPage?.reload()
|
||||
await connectedPage?.waitForLoadState('domcontentloaded')
|
||||
expect(await connectedPage?.title()).toBe('Example Domain')
|
||||
|
||||
expect(await connectedPage?.evaluate(() => 2 + 2)).toBe(4)
|
||||
|
||||
// 5. Navigate to new URL
|
||||
const newUrl = 'https://example.org/'
|
||||
await connectedPage?.goto(newUrl)
|
||||
await connectedPage?.waitForLoadState('domcontentloaded')
|
||||
|
||||
expect(connectedPage?.url()).toBe(newUrl)
|
||||
expect(await connectedPage?.title()).toContain('Example Domain')
|
||||
|
||||
expect(await connectedPage?.evaluate(() => 3 + 3)).toBe(6)
|
||||
|
||||
await directBrowser.close()
|
||||
await page.close()
|
||||
})
|
||||
|
||||
it('should support multiple concurrent tabs', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Tab A
|
||||
const pageA = await browserContext.newPage()
|
||||
await pageA.goto('https://example.com/tab-a')
|
||||
await pageA.bringToFront()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
// Tab B
|
||||
const pageB = await browserContext.newPage()
|
||||
await pageB.goto('https://example.com/tab-b')
|
||||
await pageB.bringToFront()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
// Get target IDs for both
|
||||
const targetIds = await serviceWorker.evaluate(async () => {
|
||||
const state = globalThis.getExtensionState()
|
||||
const chrome = globalThis.chrome
|
||||
const tabs = await chrome.tabs.query({})
|
||||
const tabA = tabs.find((t: any) => t.url?.includes('tab-a'))
|
||||
const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
|
||||
return {
|
||||
idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
|
||||
idB: state.tabs.get(tabB?.id ?? -1)?.targetId
|
||||
}
|
||||
})
|
||||
|
||||
expect(targetIds).toMatchInlineSnapshot({
|
||||
idA: expect.any(String),
|
||||
idB: expect.any(String)
|
||||
}, `
|
||||
{
|
||||
"idA": Any<String>,
|
||||
"idB": Any<String>,
|
||||
}
|
||||
`)
|
||||
expect(targetIds.idA).not.toBe(targetIds.idB)
|
||||
|
||||
// Verify independent connections
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
|
||||
const pages = browser.contexts()[0].pages()
|
||||
|
||||
const results = await Promise.all(pages.map(async (p) => ({
|
||||
url: p.url(),
|
||||
title: await p.title()
|
||||
})))
|
||||
|
||||
expect(results).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"title": "",
|
||||
"url": "about:blank",
|
||||
},
|
||||
{
|
||||
"title": "Example Domain",
|
||||
"url": "https://example.com/tab-a",
|
||||
},
|
||||
{
|
||||
"title": "Example Domain",
|
||||
"url": "https://example.com/tab-b",
|
||||
},
|
||||
]
|
||||
`)
|
||||
|
||||
// Verify execution on both pages
|
||||
const pageA_CDP = pages.find(p => p.url().includes('tab-a'))
|
||||
const pageB_CDP = pages.find(p => p.url().includes('tab-b'))
|
||||
|
||||
expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
|
||||
expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
|
||||
|
||||
await browser.close()
|
||||
await pageA.close()
|
||||
await pageB.close()
|
||||
})
|
||||
|
||||
it('should show correct url when enabling extension after navigation', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
const targetUrl = 'https://example.com/late-enable'
|
||||
await page.goto(targetUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url() === targetUrl)
|
||||
|
||||
expect(cdpPage).toBeDefined()
|
||||
expect(cdpPage?.url()).toBe(targetUrl)
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should be able to reconnect after disconnecting everything', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const pages = await browserContext.pages()
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
const page = pages[0]
|
||||
|
||||
await page.goto('https://example.com/disconnect-test')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.bringToFront()
|
||||
|
||||
// Enable extension on this page
|
||||
const initialEnable = await serviceWorker.evaluate(async () => {
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
console.log('Initial enable result:', initialEnable)
|
||||
expect(initialEnable.isConnected).toBe(true)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Verify MCP can see the page
|
||||
const beforeDisconnect = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const pages = context.pages();
|
||||
console.log('Pages before disconnect:', pages.length);
|
||||
const testPage = pages.find(p => p.url().includes('disconnect-test'));
|
||||
console.log('Found test page:', !!testPage);
|
||||
return { pagesCount: pages.length, foundTestPage: !!testPage };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const beforeOutput = (beforeDisconnect as any).content[0].text
|
||||
expect(beforeOutput).toContain('foundTestPage')
|
||||
console.log('Before disconnect:', beforeOutput)
|
||||
|
||||
// 2. Disconnect everything
|
||||
console.log('Calling disconnectEverything...')
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.disconnectEverything()
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 3. Verify MCP cannot execute code anymore (no pages available)
|
||||
const afterDisconnect = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const pages = context.pages();
|
||||
console.log('Pages after disconnect:', pages.length);
|
||||
return { pagesCount: pages.length };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const afterDisconnectOutput = (afterDisconnect as any).content[0].text
|
||||
console.log('After disconnect:', afterDisconnectOutput)
|
||||
expect((afterDisconnect as any).isError).toBe(true)
|
||||
expect(afterDisconnectOutput).toContain('No Playwright pages are available')
|
||||
|
||||
// 4. Re-enable extension on the same page
|
||||
console.log('Re-enabling extension...')
|
||||
await page.bringToFront()
|
||||
const reconnectResult = await serviceWorker.evaluate(async () => {
|
||||
console.log('About to call toggleExtensionForActiveTab')
|
||||
const result = await globalThis.toggleExtensionForActiveTab()
|
||||
console.log('toggleExtensionForActiveTab result:', result)
|
||||
return result
|
||||
})
|
||||
|
||||
console.log('Reconnect result:', reconnectResult)
|
||||
expect(reconnectResult.isConnected).toBe(true)
|
||||
|
||||
console.log('Waiting for reconnection to stabilize...')
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 5. Reset the MCP client's playwright connection
|
||||
console.log('Resetting MCP playwright connection...')
|
||||
const resetResult = await client.callTool({
|
||||
name: 'reset',
|
||||
arguments: {},
|
||||
})
|
||||
console.log('Reset result:', (resetResult as any).content[0].text)
|
||||
expect((resetResult as any).content[0].text).toContain('Connection reset successfully')
|
||||
|
||||
// 6. Verify MCP can see the page again
|
||||
console.log('Attempting to access page via MCP...')
|
||||
const afterReconnect = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
console.log('Checking pages after reconnect...');
|
||||
const pages = context.pages();
|
||||
console.log('Pages after reconnect:', pages.length);
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.log('No pages found!');
|
||||
return { pagesCount: 0, foundTestPage: false };
|
||||
}
|
||||
|
||||
const testPage = pages.find(p => p.url().includes('disconnect-test'));
|
||||
console.log('Found test page after reconnect:', !!testPage);
|
||||
|
||||
if (testPage) {
|
||||
console.log('Test page URL:', testPage.url());
|
||||
return { pagesCount: pages.length, foundTestPage: true, url: testPage.url() };
|
||||
}
|
||||
|
||||
return { pagesCount: pages.length, foundTestPage: false };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const afterReconnectOutput = (afterReconnect as any).content[0].text
|
||||
console.log('After reconnect:', afterReconnectOutput)
|
||||
expect(afterReconnectOutput).toContain('foundTestPage')
|
||||
expect(afterReconnectOutput).toContain('disconnect-test')
|
||||
|
||||
// Clean up
|
||||
await page.goto('about:blank')
|
||||
})
|
||||
|
||||
it('should auto-reconnect MCP after extension WebSocket reconnects', async () => {
|
||||
const serviceWorker = await getExtensionServiceWorker(testCtx!.browserContext)
|
||||
|
||||
// 1. Create a test page and enable extension
|
||||
const page = await testCtx!.browserContext.newPage()
|
||||
await page.goto('https://example.com/auto-reconnect-test')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.bringToFront()
|
||||
|
||||
const initialEnable = await serviceWorker.evaluate(async () => {
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
expect(initialEnable.isConnected).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 2. Verify MCP can execute commands
|
||||
const beforeResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const pages = context.pages();
|
||||
testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
|
||||
if (testPage) break;
|
||||
await new Promise((r) => { setTimeout(r, 100) });
|
||||
}
|
||||
const pages = context.pages();
|
||||
return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() };
|
||||
`,
|
||||
},
|
||||
})
|
||||
const beforeOutput = (beforeResult as any).content[0].text
|
||||
expect(beforeOutput).toContain('foundTestPage')
|
||||
expect(beforeOutput).toContain('true')
|
||||
expect(beforeOutput).toContain('auto-reconnect-test')
|
||||
|
||||
// 3. Simulate extension WebSocket reconnection
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.disconnectEverything()
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Re-enable extension
|
||||
await page.bringToFront()
|
||||
const reconnectResult = await serviceWorker.evaluate(async () => {
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
expect(reconnectResult.isConnected).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 4. Execute command WITHOUT calling resetPlaywright()
|
||||
const afterResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const pages = context.pages();
|
||||
testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
|
||||
if (testPage) break;
|
||||
await new Promise((r) => { setTimeout(r, 100) });
|
||||
}
|
||||
const pages = context.pages();
|
||||
return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const afterOutput = (afterResult as any).content[0].text
|
||||
expect(afterOutput).toContain('foundTestPage')
|
||||
expect(afterOutput).toContain('true')
|
||||
expect(afterOutput).toContain('auto-reconnect-test')
|
||||
expect(afterOutput).not.toContain('Extension not connected')
|
||||
expect((afterResult as any).isError).not.toBe(true)
|
||||
|
||||
// Clean up
|
||||
await page.goto('about:blank')
|
||||
})
|
||||
|
||||
it('should maintain correct page.url() with service worker pages', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
const targetUrl = 'https://example.com/sw-test'
|
||||
await page.goto(targetUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPages = browser.contexts()[0].pages()
|
||||
const testPage = cdpPages.find(p => p.url().includes('sw-test'))
|
||||
|
||||
expect(testPage).toBeDefined()
|
||||
expect(testPage?.url()).toContain('sw-test')
|
||||
expect(testPage?.url()).not.toContain('sw.js')
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 30000)
|
||||
|
||||
it('should maintain correct page.url() after repeated connections', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
const targetUrl = 'https://example.com/repeated-test'
|
||||
await page.goto(targetUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPages = browser.contexts()[0].pages()
|
||||
const testPage = cdpPages.find(p => p.url().includes('repeated-test'))
|
||||
|
||||
expect(testPage).toBeDefined()
|
||||
expect(testPage?.url()).toBe(targetUrl)
|
||||
|
||||
await browser.close()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
|
||||
await page.close()
|
||||
}, 30000)
|
||||
|
||||
it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
const targetUrl = 'https://example.com/concurrent-test'
|
||||
await page.goto(targetUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
const [mcpResult, cdpBrowser] = await Promise.all([
|
||||
client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const pages = context.pages();
|
||||
const testPage = pages.find(p => p.url().includes('concurrent-test'));
|
||||
return { url: testPage?.url(), found: !!testPage };
|
||||
`,
|
||||
},
|
||||
}),
|
||||
chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
])
|
||||
|
||||
const mcpOutput = (mcpResult as any).content[0].text
|
||||
expect(mcpOutput).toContain(targetUrl)
|
||||
|
||||
const cdpPages = cdpBrowser.contexts()[0].pages()
|
||||
const cdpPage = cdpPages.find(p => p.url().includes('concurrent-test'))
|
||||
expect(cdpPage?.url()).toBe(targetUrl)
|
||||
|
||||
await cdpBrowser.close()
|
||||
await page.close()
|
||||
}, 30000)
|
||||
})
|
||||
@@ -0,0 +1,806 @@
|
||||
import { createMCPClient } from './mcp-client.js'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { getCdpUrl } from './utils.js'
|
||||
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, js, tryJsonParse } from './test-utils.js'
|
||||
import './test-declarations.js'
|
||||
|
||||
const TEST_PORT = 19987
|
||||
|
||||
describe('MCP Core Tests', () => {
|
||||
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
|
||||
let cleanup: (() => Promise<void>) | null = null
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true })
|
||||
|
||||
const result = await createMCPClient({ port: TEST_PORT })
|
||||
client = result.client
|
||||
cleanup = result.cleanup
|
||||
}, 600000)
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestContext(testCtx, cleanup)
|
||||
cleanup = null
|
||||
testCtx = null
|
||||
})
|
||||
|
||||
const getBrowserContext = () => {
|
||||
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||
return testCtx.browserContext
|
||||
}
|
||||
|
||||
it('should inject script via addScriptTag through CDP relay', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await withTimeout({
|
||||
promise: getExtensionServiceWorker(browserContext),
|
||||
timeoutMs: 5000,
|
||||
errorMessage: 'Timed out waiting for extension service worker for iframe test',
|
||||
})
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent('<html><body><button id="btn">Click</button></body></html>')
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise((r) => { setTimeout(r, 100) })
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => {
|
||||
return p.url().startsWith('about:')
|
||||
})
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const hasGlobalBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__testGlobal)
|
||||
expect(hasGlobalBefore).toBe(false)
|
||||
|
||||
await cdpPage!.addScriptTag({ content: 'globalThis.__testGlobal = { foo: "bar" };' })
|
||||
|
||||
const hasGlobalAfter = await cdpPage!.evaluate(() => (globalThis as any).__testGlobal)
|
||||
expect(hasGlobalAfter).toEqual({ foo: 'bar' })
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should execute code and capture console output', async () => {
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const newPage = await context.newPage();
|
||||
state.page = newPage;
|
||||
if (!state.pages) state.pages = [];
|
||||
state.pages.push(newPage);
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.page.goto('https://example.com');
|
||||
const title = await state.page.title();
|
||||
console.log('Page title:', title);
|
||||
return { url: state.page.url(), title };
|
||||
`,
|
||||
},
|
||||
})
|
||||
expect(result.content).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"text": "Console output:
|
||||
[log] 'Page title:' 'Example Domain'
|
||||
|
||||
[return value] { url: 'https://example.com/', title: 'Example Domain' }",
|
||||
"type": "text",
|
||||
},
|
||||
]
|
||||
`)
|
||||
expect(result.content).toBeDefined()
|
||||
}, 30000)
|
||||
|
||||
it('should show extension as connected for pages created via newPage()', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// Create a page via MCP (which uses context.newPage())
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const newPage = await context.newPage();
|
||||
state.testPage = newPage;
|
||||
await newPage.goto('https://example.com/mcp-test');
|
||||
return newPage.url();
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Get extension state to verify the page is marked as connected
|
||||
const extensionState = await serviceWorker.evaluate(async () => {
|
||||
const state = globalThis.getExtensionState()
|
||||
const tabs = await chrome.tabs.query({})
|
||||
const testTab = tabs.find((t: any) => t.url?.includes('mcp-test'))
|
||||
return {
|
||||
connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
|
||||
tabId: testTab?.id,
|
||||
tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
|
||||
connectionState: state.connectionState
|
||||
}
|
||||
})
|
||||
|
||||
expect(extensionState.connected).toBe(true)
|
||||
expect(extensionState.tabInfo?.state).toBe('connected')
|
||||
expect(extensionState.connectionState).toBe('connected')
|
||||
|
||||
// Clean up
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
if (state.testPage) {
|
||||
await state.testPage.close();
|
||||
delete state.testPage;
|
||||
}
|
||||
`,
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
it('should get accessibility snapshot of hacker news', async () => {
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const newPage = await context.newPage();
|
||||
state.page = newPage;
|
||||
if (!state.pages) state.pages = [];
|
||||
state.pages.push(newPage);
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.page.goto('https://news.ycombinator.com/item?id=1', { waitUntil: 'domcontentloaded' });
|
||||
const snapshot = await state.page._snapshotForAI();
|
||||
return snapshot;
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const initialData =
|
||||
typeof result === 'object' && result.content?.[0]?.text
|
||||
? tryJsonParse(result.content[0].text)
|
||||
: result
|
||||
await expect(initialData).toMatchFileSnapshot(
|
||||
'snapshots/hacker-news-initial-accessibility.md',
|
||||
)
|
||||
expect(result.content).toBeDefined()
|
||||
expect(initialData).toContain('table')
|
||||
expect(initialData).toContain('Hacker News')
|
||||
}, 30000)
|
||||
|
||||
it('should get accessibility snapshot of shadcn UI', async () => {
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const newPage = await context.newPage();
|
||||
state.page = newPage;
|
||||
if (!state.pages) state.pages = [];
|
||||
state.pages.push(newPage);
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const snapshot = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.page.goto('https://ui.shadcn.com/', { waitUntil: 'domcontentloaded' });
|
||||
const snapshot = await state.page._snapshotForAI();
|
||||
return snapshot;
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const data =
|
||||
typeof snapshot === 'object' && snapshot.content?.[0]?.text
|
||||
? tryJsonParse(snapshot.content[0].text)
|
||||
: snapshot
|
||||
await expect(data).toMatchFileSnapshot('snapshots/shadcn-ui-accessibility.md')
|
||||
expect(snapshot.content).toBeDefined()
|
||||
expect(data).toContain('shadcn')
|
||||
}, 30000)
|
||||
|
||||
it('should close all created pages', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
if (state.pages && state.pages.length > 0) {
|
||||
for (const page of state.pages) {
|
||||
await page.close();
|
||||
}
|
||||
const closedCount = state.pages.length;
|
||||
state.pages = [];
|
||||
return { closedCount };
|
||||
}
|
||||
return { closedCount: 0 };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
it('should capture browser console logs with getLatestLogs', async () => {
|
||||
// Ensure clean state and clear any existing logs
|
||||
const resetResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
// Clear any existing logs from previous tests
|
||||
clearAllLogs();
|
||||
console.log('Cleared all existing logs');
|
||||
|
||||
// Verify connection is working
|
||||
const pages = context.pages();
|
||||
console.log('Current pages count:', pages.length);
|
||||
|
||||
return { success: true, pagesCount: pages.length };
|
||||
`,
|
||||
},
|
||||
})
|
||||
console.log('Cleanup result:', resetResult)
|
||||
|
||||
// Create a new page for this test
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const newPage = await context.newPage();
|
||||
state.testLogPage = newPage;
|
||||
await newPage.goto('about:blank');
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Generate some console logs in the browser
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.testLogPage.evaluate(() => {
|
||||
console.log('Test log 12345');
|
||||
console.error('Test error 67890');
|
||||
console.warn('Test warning 11111');
|
||||
console.log('Test log 2 with', { data: 'object' });
|
||||
});
|
||||
// Wait for logs to be captured
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Test getting all logs
|
||||
const allLogsResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs();
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const output = (allLogsResult as any).content[0].text
|
||||
expect(output).toContain('[log] Test log 12345')
|
||||
expect(output).toContain('[error] Test error 67890')
|
||||
expect(output).toContain('[warning] Test warning 11111')
|
||||
|
||||
// Test filtering by search string
|
||||
const errorLogsResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ search: 'error' });
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const errorOutput = (errorLogsResult as any).content[0].text
|
||||
expect(errorOutput).toContain('[error] Test error 67890')
|
||||
// With context lines (5 above/below), nearby logs are also included
|
||||
expect(errorOutput).toContain('[log] Test log 12345')
|
||||
|
||||
// Test that logs are cleared on page reload
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
// First add a log before reload
|
||||
await state.testLogPage.evaluate(() => {
|
||||
console.log('Before reload 99999');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the log exists
|
||||
const beforeReloadResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ page: state.testLogPage });
|
||||
console.log('Logs before reload:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const beforeReloadOutput = (beforeReloadResult as any).content[0].text
|
||||
expect(beforeReloadOutput).toContain('[log] Before reload 99999')
|
||||
|
||||
// Reload the page
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.testLogPage.reload();
|
||||
await state.testLogPage.evaluate(() => {
|
||||
console.log('After reload 88888');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Check logs after reload - old logs should be gone
|
||||
const afterReloadResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ page: state.testLogPage });
|
||||
console.log('Logs after reload:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const afterReloadOutput = (afterReloadResult as any).content[0].text
|
||||
expect(afterReloadOutput).toContain('[log] After reload 88888')
|
||||
expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
|
||||
|
||||
// Clean up
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.testLogPage.close();
|
||||
delete state.testLogPage;
|
||||
`,
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
it('should keep logs separate between different pages', async () => {
|
||||
// Clear any existing logs from previous tests
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
clearAllLogs();
|
||||
console.log('Cleared all existing logs for second log test');
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Create two pages
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
state.pageA = await context.newPage();
|
||||
state.pageB = await context.newPage();
|
||||
await state.pageA.goto('about:blank');
|
||||
await state.pageB.goto('about:blank');
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Generate logs in page A
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.pageA.evaluate(() => {
|
||||
console.log('PageA log 11111');
|
||||
console.error('PageA error 22222');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Generate logs in page B
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.pageB.evaluate(() => {
|
||||
console.log('PageB log 33333');
|
||||
console.error('PageB error 44444');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Check logs for page A - should only have page A logs
|
||||
const pageALogsResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ page: state.pageA });
|
||||
console.log('Page A logs:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const pageAOutput = (pageALogsResult as any).content[0].text
|
||||
expect(pageAOutput).toContain('[log] PageA log 11111')
|
||||
expect(pageAOutput).toContain('[error] PageA error 22222')
|
||||
expect(pageAOutput).not.toContain('PageB')
|
||||
|
||||
// Check logs for page B - should only have page B logs
|
||||
const pageBLogsResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ page: state.pageB });
|
||||
console.log('Page B logs:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const pageBOutput = (pageBLogsResult as any).content[0].text
|
||||
expect(pageBOutput).toContain('[log] PageB log 33333')
|
||||
expect(pageBOutput).toContain('[error] PageB error 44444')
|
||||
expect(pageBOutput).not.toContain('PageA')
|
||||
|
||||
// Check all logs - should have logs from both pages
|
||||
const allLogsResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs();
|
||||
console.log('All logs:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const allOutput = (allLogsResult as any).content[0].text
|
||||
expect(allOutput).toContain('[log] PageA log 11111')
|
||||
expect(allOutput).toContain('[log] PageB log 33333')
|
||||
|
||||
// Test that reloading page A clears only page A logs
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.pageA.reload();
|
||||
await state.pageA.evaluate(() => {
|
||||
console.log('PageA after reload 55555');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Check page A logs - should only have new log
|
||||
const pageAAfterReloadResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ page: state.pageA });
|
||||
console.log('Page A logs after reload:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
|
||||
expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
|
||||
expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
|
||||
|
||||
// Check page B logs - should still have original logs
|
||||
const pageBAfterAReloadResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs({ page: state.pageB });
|
||||
console.log('Page B logs after A reload:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text
|
||||
expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333')
|
||||
expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444')
|
||||
|
||||
// Test that logs are deleted when page is closed
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
// Close page A
|
||||
await state.pageA.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Check all logs - page A logs should be gone
|
||||
const logsAfterCloseResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const logs = await getLatestLogs();
|
||||
console.log('All logs after closing page A:', logs.length);
|
||||
logs.forEach(log => console.log(log));
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
|
||||
expect(logsAfterCloseOutput).not.toContain('PageA')
|
||||
expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
|
||||
|
||||
// Clean up remaining page
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
await state.pageB.close();
|
||||
delete state.pageA;
|
||||
delete state.pageB;
|
||||
`,
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
it('should preserve system color scheme instead of forcing light mode', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://example.com')
|
||||
await page.bringToFront()
|
||||
|
||||
const colorSchemeBefore = await page.evaluate(() => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
})
|
||||
console.log('Color scheme before MCP connection:', colorSchemeBefore)
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const pages = context.pages();
|
||||
const urls = pages.map(p => p.url());
|
||||
const targetPage = pages.find(p => p.url().includes('example.com'));
|
||||
if (!targetPage) {
|
||||
return { error: 'Page not found', urls };
|
||||
}
|
||||
const isDark = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches);
|
||||
return { matchesDark: isDark, matchesLight: isLight };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Color scheme after MCP connection:', result.content)
|
||||
|
||||
expect(result.content).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"text": "[return value] { matchesDark: false, matchesLight: true }",
|
||||
"type": "text",
|
||||
},
|
||||
]
|
||||
`)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should get clean HTML with getCleanHTML', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<head>
|
||||
<style>.hidden { display: none; }</style>
|
||||
<script>console.log('test')</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" data-testid="main">
|
||||
<h1>Hello World</h1>
|
||||
<button id="btn" aria-label="Click me">Submit</button>
|
||||
<a href="/about" title="About page">About</a>
|
||||
<input type="text" placeholder="Enter name" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
// Test basic getCleanHTML
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('Hello World')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const html = await getCleanHTML({ locator: testPage.locator('body') });
|
||||
return html;
|
||||
`,
|
||||
timeout: 15000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isError).toBeFalsy()
|
||||
const text = (result.content as any)[0]?.text || ''
|
||||
|
||||
// Inline snapshot of cleaned HTML
|
||||
expect(text).toMatchInlineSnapshot(`
|
||||
"[return value] '<div data-testid="main">\\n' +
|
||||
' <h1>Hello World</h1>\\n' +
|
||||
' <button aria-label="Click me">Submit</button>\\n' +
|
||||
' <a href="/about" title="About page">About</a>\\n' +
|
||||
' <input type="text" placeholder="Enter name">\\n' +
|
||||
'</div>\\n'"
|
||||
`)
|
||||
|
||||
// Should NOT contain script/style tags (they're removed)
|
||||
expect(text).not.toContain('<script')
|
||||
expect(text).not.toContain('<style')
|
||||
expect(text).not.toContain('console.log')
|
||||
|
||||
// Test search functionality
|
||||
const searchResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('Hello World')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const html = await getCleanHTML({ locator: testPage, search: /button/i });
|
||||
return html;
|
||||
`,
|
||||
timeout: 15000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(searchResult.isError).toBeFalsy()
|
||||
const searchText = (searchResult.content as any)[0]?.text || ''
|
||||
expect(searchText).toContain('button')
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should handle default page being closed and switch to another available page', async () => {
|
||||
// This test verifies that when the default `page` in MCP scope is closed,
|
||||
// the MCP automatically switches to another available page instead of failing
|
||||
// with cryptic "page closed" errors.
|
||||
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// 1. Disconnect everything to start fresh
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.disconnectEverything()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 2. Create first page and enable extension
|
||||
const page1 = await browserContext.newPage()
|
||||
await page1.goto('https://example.com/first-page')
|
||||
await page1.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 3. Reset MCP to ensure page1 becomes the default page (only page available)
|
||||
const resetResult = await client.callTool({
|
||||
name: 'reset',
|
||||
arguments: {},
|
||||
})
|
||||
expect((resetResult as any).content[0].text).toContain('Connection reset successfully')
|
||||
|
||||
// 4. Verify initial page is accessible via default `page`
|
||||
const initialResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const url = page.url();
|
||||
console.log('Initial page URL:', url);
|
||||
return { url };
|
||||
`,
|
||||
},
|
||||
})
|
||||
expect((initialResult as any).content[0].text).toContain('first-page')
|
||||
|
||||
// 5. Create second page and enable extension
|
||||
const page2 = await browserContext.newPage()
|
||||
await page2.goto('https://example.com/second-page')
|
||||
await page2.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 6. Close the first page (which is the default `page` in MCP scope)
|
||||
await page1.close()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 7. Execute code via MCP - should NOT fail with "page closed" error
|
||||
// Instead, it should automatically switch to the second page
|
||||
const afterCloseResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const url = page.url();
|
||||
console.log('Page URL after close:', url);
|
||||
const title = await page.title();
|
||||
return { url, title };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
// Should succeed and return the second page's info
|
||||
expect((afterCloseResult as any).isError).toBeFalsy()
|
||||
const output = (afterCloseResult as any).content[0].text
|
||||
expect(output).toContain('second-page')
|
||||
expect(output).not.toContain('page closed')
|
||||
expect(output).not.toContain('Target closed')
|
||||
|
||||
// Cleanup
|
||||
await page2.close()
|
||||
}, 60000)
|
||||
})
|
||||
@@ -0,0 +1,448 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { getCdpUrl } from './utils.js'
|
||||
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, createSimpleServer } from './test-utils.js'
|
||||
import './test-declarations.js'
|
||||
|
||||
const TEST_PORT = 19992
|
||||
|
||||
describe('MCP Navigation Tests', () => {
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-nav-test-', toggleExtension: true })
|
||||
}, 600000)
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestContext(testCtx)
|
||||
testCtx = null
|
||||
})
|
||||
|
||||
const getBrowserContext = () => {
|
||||
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||
return testCtx.browserContext
|
||||
}
|
||||
|
||||
it('should be usable after toggle with valid URL', async () => {
|
||||
// Validates the extension waits for a non-empty URL before attaching.
|
||||
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const context = browser.contexts()[0]
|
||||
|
||||
const server = await createSimpleServer({
|
||||
routes: {
|
||||
'/': '<!doctype html><html><body>ok</body></html>',
|
||||
},
|
||||
})
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
try {
|
||||
await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
|
||||
await page.bringToFront()
|
||||
|
||||
const pagePromise = context.waitForEvent('page', { timeout: 5000 })
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
const targetPage = await pagePromise
|
||||
console.log('Page URL when event fired:', targetPage.url())
|
||||
|
||||
expect(targetPage.url()).not.toBe('')
|
||||
expect(targetPage.url()).not.toBe(':')
|
||||
expect(targetPage.url()).toContain(server.baseUrl)
|
||||
|
||||
const result = await targetPage.evaluate(() => window.location.href)
|
||||
expect(result).toContain(server.baseUrl)
|
||||
} finally {
|
||||
await browser.close()
|
||||
await page.close()
|
||||
await server.close()
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
it('should expose iframe frames when connecting to an existing page over CDP', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const childServer = await createSimpleServer({
|
||||
routes: {
|
||||
'/child.html': '<!doctype html><html><body>child</body></html>',
|
||||
},
|
||||
})
|
||||
const childUrl = `${childServer.baseUrl}/child.html`
|
||||
|
||||
const parentServer = await createSimpleServer({
|
||||
routes: {
|
||||
'/': `<!doctype html><html><body><iframe src="${childUrl}"></iframe></body></html>`,
|
||||
},
|
||||
})
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
try {
|
||||
await withTimeout({
|
||||
promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }),
|
||||
timeoutMs: 6000,
|
||||
errorMessage: 'Timed out loading parent page for iframe test',
|
||||
})
|
||||
await withTimeout({
|
||||
promise: page.frameLocator('iframe').locator('body').waitFor({ timeout: 5000 }),
|
||||
timeoutMs: 6000,
|
||||
errorMessage: 'Timed out waiting for iframe to attach in parent page',
|
||||
})
|
||||
expect(page.frames().map((frame) => frame.url())).toContain(childUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
await withTimeout({
|
||||
promise: serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
}),
|
||||
timeoutMs: 5000,
|
||||
errorMessage: 'Timed out toggling extension for iframe test',
|
||||
})
|
||||
await new Promise((r) => { setTimeout(r, 400) })
|
||||
|
||||
const browser = await withTimeout({
|
||||
promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
|
||||
timeoutMs: 5000,
|
||||
errorMessage: 'Timed out connecting over CDP for iframe test',
|
||||
})
|
||||
const context = browser.contexts()[0]
|
||||
const cdpPage = context.pages().find((candidate) => {
|
||||
return candidate.url().startsWith(parentServer.baseUrl)
|
||||
})
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const frames = cdpPage!.frames()
|
||||
const childFrame = frames.find((frame) => {
|
||||
return frame.url() === childUrl
|
||||
})
|
||||
|
||||
expect(frames.length).toBe(2)
|
||||
expect(childFrame).toBeDefined()
|
||||
|
||||
await withTimeout({
|
||||
promise: browser.close(),
|
||||
timeoutMs: 5000,
|
||||
errorMessage: 'Timed out closing CDP browser for iframe test',
|
||||
})
|
||||
} finally {
|
||||
await withTimeout({
|
||||
promise: page.close(),
|
||||
timeoutMs: 5000,
|
||||
errorMessage: 'Timed out closing page for iframe test',
|
||||
})
|
||||
await Promise.all([
|
||||
parentServer.close(),
|
||||
childServer.close(),
|
||||
])
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
it('should have non-empty URLs when connecting to already-loaded pages', async () => {
|
||||
const _browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(_browserContext)
|
||||
|
||||
const page = await _browserContext.newPage()
|
||||
await page.goto('https://discord.com/login', { waitUntil: 'load' })
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const context = browser.contexts()[0]
|
||||
|
||||
const pages = context.pages()
|
||||
console.log('All page URLs:', pages.map(p => p.url()))
|
||||
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
for (const p of pages) {
|
||||
expect(p.url()).not.toBe('')
|
||||
expect(p.url()).not.toBe(':')
|
||||
expect(p.url()).not.toBeUndefined()
|
||||
}
|
||||
|
||||
const discordPage = pages.find(p => p.url().includes('discord.com'))
|
||||
expect(discordPage).toBeDefined()
|
||||
|
||||
const result = await discordPage!.evaluate(() => window.location.href)
|
||||
expect(result).toContain('discord.com')
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should navigate to notion without hanging', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
const initialUrl = 'https://example.com/notion-repro'
|
||||
await page.goto(initialUrl)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url() === initialUrl)
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 })
|
||||
|
||||
const currentUrl = cdpPage!.url()
|
||||
const responseUrl = response?.url() ?? ''
|
||||
expect(responseUrl).toMatch(/notion\.(so|com)/)
|
||||
expect(currentUrl).toMatch(/notion\.(so|com)/)
|
||||
expect(await cdpPage!.evaluate(() => document.readyState)).not.toBe('loading')
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should navigate to youtube without hanging', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('about:blank')
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('about:'))
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'load', timeout: 5000 })
|
||||
const currentUrl = cdpPage!.url()
|
||||
const responseUrl = response?.url() ?? ''
|
||||
|
||||
expect(responseUrl).toContain('youtube')
|
||||
expect(currentUrl).toContain('youtube')
|
||||
expect(await cdpPage!.evaluate(() => document.readyState)).not.toBe('loading')
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should maintain correct page.url() with iframe-heavy pages', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<head><title>Iframe Test Page</title></head>
|
||||
<body>
|
||||
<h1>Iframe Heavy Page</h1>
|
||||
<iframe src="about:blank" id="frame1"></iframe>
|
||||
<iframe src="about:blank" id="frame2"></iframe>
|
||||
<iframe src="about:blank" id="frame3"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const pages = browser.contexts()[0].pages()
|
||||
let iframePage
|
||||
for (const p of pages) {
|
||||
const html = await p.content()
|
||||
if (html.includes('Iframe Heavy Page')) {
|
||||
iframePage = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(iframePage).toBeDefined()
|
||||
expect(iframePage?.url()).toContain('about:')
|
||||
|
||||
await browser.close()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
|
||||
await page.close()
|
||||
}, 30000)
|
||||
|
||||
it('should work with stagehand', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.disconnectEverything()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const targetUrl = 'https://example.com/'
|
||||
|
||||
const enableResult = await serviceWorker.evaluate(async (url) => {
|
||||
const tab = await chrome.tabs.create({ url, active: true })
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
return await globalThis.toggleExtensionForActiveTab()
|
||||
}, targetUrl)
|
||||
|
||||
console.log('Extension enabled:', enableResult)
|
||||
expect(enableResult.isConnected).toBe(true)
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const { Stagehand } = await import('@browserbasehq/stagehand')
|
||||
|
||||
const stagehand = new Stagehand({
|
||||
env: 'LOCAL',
|
||||
verbose: 1,
|
||||
disablePino: true,
|
||||
localBrowserLaunchOptions: {
|
||||
cdpUrl: getCdpUrl({ port: TEST_PORT }),
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Initializing Stagehand...')
|
||||
await stagehand.init()
|
||||
console.log('Stagehand initialized')
|
||||
|
||||
const context = stagehand.context
|
||||
expect(context).toBeDefined()
|
||||
|
||||
const pages = context.pages()
|
||||
console.log('Stagehand pages:', pages.length, pages.map(p => p.url()))
|
||||
|
||||
const stagehandPage = pages.find(p => p.url().includes('example.com'))
|
||||
expect(stagehandPage).toBeDefined()
|
||||
|
||||
const url = stagehandPage!.url()
|
||||
console.log('Stagehand page URL:', url)
|
||||
expect(url).toContain('example.com')
|
||||
|
||||
await stagehand.close()
|
||||
}, 60000)
|
||||
|
||||
it('should expose CDP discovery endpoints /json/version and /json/list', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://example.com')
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
|
||||
// Test /json/version
|
||||
const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
|
||||
expect(versionRes.status).toBe(200)
|
||||
const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string }
|
||||
expect(versionJson).toMatchObject({
|
||||
'Browser': expect.stringContaining('Playwriter/'),
|
||||
'Protocol-Version': '1.3',
|
||||
'webSocketDebuggerUrl': expect.stringContaining('ws://'),
|
||||
})
|
||||
expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
|
||||
|
||||
// Test /json/version/ (trailing slash)
|
||||
const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`)
|
||||
expect(versionSlashRes.status).toBe(200)
|
||||
|
||||
// Test /json/list
|
||||
const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
|
||||
expect(listRes.status).toBe(200)
|
||||
const listJson = await listRes.json() as Array<{ url?: string }>
|
||||
expect(Array.isArray(listJson)).toBe(true)
|
||||
expect(listJson.length).toBeGreaterThan(0)
|
||||
|
||||
const examplePage = listJson.find((t) => t.url?.includes('example.com'))
|
||||
expect(examplePage).toBeDefined()
|
||||
expect(examplePage).toMatchObject({
|
||||
id: expect.any(String),
|
||||
type: 'page',
|
||||
url: expect.stringContaining('example.com'),
|
||||
webSocketDebuggerUrl: expect.stringContaining('ws://'),
|
||||
})
|
||||
|
||||
// Test /json (alias for /json/list)
|
||||
const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`)
|
||||
expect(jsonRes.status).toBe(200)
|
||||
const jsonData = await jsonRes.json()
|
||||
expect(Array.isArray(jsonData)).toBe(true)
|
||||
|
||||
// Test PUT method (Chrome 66+ prefers PUT)
|
||||
const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' })
|
||||
expect(putRes.status).toBe(200)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
// Skip: chrome.tabCapture.getMediaStreamId() requires activeTab permission
|
||||
it.skip('should record screen with navigation using chrome.tabCapture', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
const path = await import('node:path')
|
||||
const fs = await import('node:fs')
|
||||
|
||||
const recordingPage = await browserContext.newPage()
|
||||
await recordingPage.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' })
|
||||
await recordingPage.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
|
||||
const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4')
|
||||
if (!fs.existsSync(path.dirname(outputPath))) {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
}
|
||||
|
||||
const { startRecording, stopRecording, isRecording } = await import('./screen-recording.js')
|
||||
|
||||
const startResult = await startRecording({
|
||||
page: recordingPage,
|
||||
outputPath,
|
||||
frameRate: 30,
|
||||
audio: false,
|
||||
videoBitsPerSecond: 1500000,
|
||||
relayPort: TEST_PORT,
|
||||
})
|
||||
expect(startResult.isRecording).toBe(true)
|
||||
|
||||
await recordingPage.locator('.titleline a').first().click()
|
||||
await recordingPage.waitForLoadState('domcontentloaded')
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
await recordingPage.goBack()
|
||||
await recordingPage.waitForLoadState('domcontentloaded')
|
||||
|
||||
const status = await isRecording({ page: recordingPage, relayPort: TEST_PORT })
|
||||
expect(status.isRecording).toBe(true)
|
||||
|
||||
const stopResult = await stopRecording({ page: recordingPage, relayPort: TEST_PORT })
|
||||
expect(stopResult.path).toBe(outputPath)
|
||||
expect(stopResult.size).toBeGreaterThan(10000)
|
||||
expect(fs.existsSync(outputPath)).toBe(true)
|
||||
|
||||
await recordingPage.close()
|
||||
fs.unlinkSync(outputPath)
|
||||
}, 60000)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
||||
import { createMCPClient } from './mcp-client.js'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import { imageSize } from 'image-size'
|
||||
import { getCdpUrl } from './utils.js'
|
||||
import { getCDPSessionForPage } from './cdp-session.js'
|
||||
import type { CDPCommand } from './cdp-types.js'
|
||||
import { screenshotWithAccessibilityLabels } from './aria-snapshot.js'
|
||||
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, js } from './test-utils.js'
|
||||
import './test-declarations.js'
|
||||
|
||||
const TEST_PORT = 19991
|
||||
|
||||
describe('MCP Snapshot & Screenshot Tests', () => {
|
||||
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
|
||||
let cleanup: (() => Promise<void>) | null = null
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-snap-test-', toggleExtension: true })
|
||||
|
||||
const result = await createMCPClient({ port: TEST_PORT })
|
||||
client = result.client
|
||||
cleanup = result.cleanup
|
||||
}, 600000)
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestContext(testCtx, cleanup)
|
||||
cleanup = null
|
||||
testCtx = null
|
||||
})
|
||||
|
||||
const getBrowserContext = () => {
|
||||
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||
return testCtx.browserContext
|
||||
}
|
||||
|
||||
it('should capture screenshot correctly', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://example.com/')
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const capturedCommands: CDPCommand[] = []
|
||||
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
|
||||
if (command.method === 'Page.captureScreenshot') {
|
||||
capturedCommands.push(command)
|
||||
}
|
||||
}
|
||||
testCtx!.relayServer.on('cdp:command', commandHandler)
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
|
||||
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const viewportSize = cdpPage!.viewportSize()
|
||||
console.log('Viewport size:', viewportSize)
|
||||
|
||||
const viewportScreenshot = await cdpPage!.screenshot()
|
||||
expect(viewportScreenshot).toBeDefined()
|
||||
|
||||
const viewportDimensions = imageSize(viewportScreenshot)
|
||||
console.log('Viewport screenshot dimensions:', viewportDimensions)
|
||||
expect(viewportDimensions.width).toBeGreaterThan(0)
|
||||
expect(viewportDimensions.height).toBeGreaterThan(0)
|
||||
if (viewportSize) {
|
||||
expect(viewportDimensions.width).toBe(viewportSize.width)
|
||||
expect(viewportDimensions.height).toBe(viewportSize.height)
|
||||
}
|
||||
|
||||
const fullPageScreenshot = await cdpPage!.screenshot({ fullPage: true })
|
||||
expect(fullPageScreenshot).toBeDefined()
|
||||
|
||||
const fullPageDimensions = imageSize(fullPageScreenshot)
|
||||
console.log('Full page screenshot dimensions:', fullPageDimensions)
|
||||
expect(fullPageDimensions.width).toBeGreaterThan(0)
|
||||
expect(fullPageDimensions.height).toBeGreaterThan(0)
|
||||
expect(fullPageDimensions.width).toBeGreaterThanOrEqual(viewportDimensions.width!)
|
||||
|
||||
testCtx!.relayServer.off('cdp:command', commandHandler)
|
||||
|
||||
expect(capturedCommands.length).toBe(2)
|
||||
expect(capturedCommands.map(c => ({
|
||||
method: c.method,
|
||||
params: c.params
|
||||
}))).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"method": "Page.captureScreenshot",
|
||||
"params": {
|
||||
"captureBeyondViewport": false,
|
||||
"clip": {
|
||||
"height": 720,
|
||||
"scale": 1,
|
||||
"width": 1280,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"format": "png",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "Page.captureScreenshot",
|
||||
"params": {
|
||||
"captureBeyondViewport": false,
|
||||
"clip": {
|
||||
"height": 528,
|
||||
"scale": 1,
|
||||
"width": 1280,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"format": "png",
|
||||
},
|
||||
},
|
||||
]
|
||||
`)
|
||||
|
||||
const screenshotPath = path.join(os.tmpdir(), 'playwriter-test-screenshot.png')
|
||||
fs.writeFileSync(screenshotPath, viewportScreenshot)
|
||||
console.log('Screenshot saved to:', screenshotPath)
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should capture element screenshot with correct coordinates', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const target = { x: 200, y: 150, width: 300, height: 100 }
|
||||
const scrolledTarget = { x: 100, y: 1500, width: 200, height: 80 }
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; height: 2000px; }
|
||||
#target {
|
||||
position: absolute;
|
||||
top: ${target.y}px;
|
||||
left: ${target.x}px;
|
||||
width: ${target.width}px;
|
||||
height: ${target.height}px;
|
||||
background: red;
|
||||
}
|
||||
#scrolled-target {
|
||||
position: absolute;
|
||||
top: ${scrolledTarget.y}px;
|
||||
left: ${scrolledTarget.x}px;
|
||||
width: ${scrolledTarget.width}px;
|
||||
height: ${scrolledTarget.height}px;
|
||||
background: blue;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="target">Target Element</div>
|
||||
<div id="scrolled-target">Scrolled Target</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const capturedCommands: CDPCommand[] = []
|
||||
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
|
||||
if (command.method === 'Page.captureScreenshot') {
|
||||
capturedCommands.push(command)
|
||||
}
|
||||
}
|
||||
testCtx!.relayServer.on('cdp:command', commandHandler)
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
let cdpPage
|
||||
for (const p of browser.contexts()[0].pages()) {
|
||||
const html = await p.content()
|
||||
if (html.includes('scrolled-target')) {
|
||||
cdpPage = p
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
await cdpPage!.locator('#target').screenshot()
|
||||
|
||||
await cdpPage!.locator('#scrolled-target').screenshot()
|
||||
|
||||
testCtx!.relayServer.off('cdp:command', commandHandler)
|
||||
|
||||
expect(capturedCommands.length).toBe(2)
|
||||
|
||||
const targetCmd = capturedCommands[0]
|
||||
expect(targetCmd.method).toBe('Page.captureScreenshot')
|
||||
const targetClip = (targetCmd.params as any).clip
|
||||
expect(targetClip.x).toBe(target.x)
|
||||
expect(targetClip.y).toBe(target.y)
|
||||
expect(targetClip.width).toBe(target.width)
|
||||
expect(targetClip.height).toBe(target.height)
|
||||
|
||||
const scrolledCmd = capturedCommands[1]
|
||||
expect(scrolledCmd.method).toBe('Page.captureScreenshot')
|
||||
const scrolledClip = (scrolledCmd.params as any).clip
|
||||
expect(scrolledClip.x).toBe(scrolledTarget.x)
|
||||
expect(scrolledClip.y).toBe(scrolledTarget.y)
|
||||
expect(scrolledClip.width).toBe(scrolledTarget.width)
|
||||
expect(scrolledClip.height).toBe(scrolledTarget.height)
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should get locator string for element using getLocatorStringForElement', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button id="test-btn">Click Me</button>
|
||||
<input type="text" placeholder="Enter name" />
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('test-btn')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const btn = testPage.locator('#test-btn');
|
||||
const locatorString = await getLocatorStringForElement(btn);
|
||||
console.log('Locator string:', locatorString);
|
||||
const locatorFromString = eval('testPage.' + locatorString);
|
||||
const count = await locatorFromString.count();
|
||||
console.log('Locator count:', count);
|
||||
const text = await locatorFromString.textContent();
|
||||
console.log('Locator text:', text);
|
||||
`,
|
||||
timeout: 30000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isError).toBeFalsy()
|
||||
const text = (result.content as any)[0]?.text || ''
|
||||
expect(text).toContain('Locator string:')
|
||||
expect(text).toContain("getByRole('button', { name: 'Click Me' })")
|
||||
expect(text).toContain('Locator count:')
|
||||
expect(text).toContain('Locator text:')
|
||||
expect(text).toContain('Click Me')
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should get styles for element using getStylesForLocator', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #333; }
|
||||
.container { padding: 20px; margin: 10px; }
|
||||
#main-btn { background-color: blue; color: white; border-radius: 4px; }
|
||||
.btn { padding: 8px 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<button id="main-btn" class="btn" style="font-weight: bold;">Click Me</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
const stylesResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('main-btn')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const btn = testPage.locator('#main-btn');
|
||||
const styles = await getStylesForLocator({ locator: btn });
|
||||
return styles;
|
||||
`,
|
||||
timeout: 30000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(stylesResult.isError).toBeFalsy()
|
||||
const stylesText = (stylesResult.content as any)[0]?.text || ''
|
||||
expect(stylesText).toMatchInlineSnapshot(`
|
||||
"[return value] {
|
||||
element: 'button#main-btn.btn',
|
||||
inlineStyle: { 'font-weight': 'bold' },
|
||||
rules: [
|
||||
{
|
||||
selector: '.btn',
|
||||
source: null,
|
||||
origin: 'regular',
|
||||
declarations: {
|
||||
padding: '8px 16px',
|
||||
'padding-top': '8px',
|
||||
'padding-right': '16px',
|
||||
'padding-bottom': '8px',
|
||||
'padding-left': '16px'
|
||||
},
|
||||
inheritedFrom: null
|
||||
},
|
||||
{
|
||||
selector: '#main-btn',
|
||||
source: null,
|
||||
origin: 'regular',
|
||||
declarations: {
|
||||
'background-color': 'blue',
|
||||
color: 'white',
|
||||
'border-radius': '4px',
|
||||
'border-top-left-radius': '4px',
|
||||
'border-top-right-radius': '4px',
|
||||
'border-bottom-right-radius': '4px',
|
||||
'border-bottom-left-radius': '4px'
|
||||
},
|
||||
inheritedFrom: null
|
||||
},
|
||||
{
|
||||
selector: '.container',
|
||||
source: null,
|
||||
origin: 'regular',
|
||||
declarations: {
|
||||
padding: '20px',
|
||||
margin: '10px',
|
||||
'padding-top': '20px',
|
||||
'padding-right': '20px',
|
||||
'padding-bottom': '20px',
|
||||
'padding-left': '20px',
|
||||
'margin-top': '10px',
|
||||
'margin-right': '10px',
|
||||
'margin-bottom': '10px',
|
||||
'margin-left': '10px'
|
||||
},
|
||||
inheritedFrom: 'ancestor[1]'
|
||||
},
|
||||
{
|
||||
selector: 'body',
|
||||
source: null,
|
||||
origin: 'regular',
|
||||
declarations: { 'font-family': 'Arial, sans-serif', color: 'rgb(51, 51, 51)' },
|
||||
inheritedFrom: 'ancestor[2]'
|
||||
}
|
||||
]
|
||||
}"
|
||||
`)
|
||||
|
||||
const formattedResult = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('main-btn')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
const btn = testPage.locator('#main-btn');
|
||||
const styles = await getStylesForLocator({ locator: btn });
|
||||
return formatStylesAsText(styles);
|
||||
`,
|
||||
timeout: 30000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(formattedResult.isError).toBeFalsy()
|
||||
const formattedText = (formattedResult.content as any)[0]?.text || ''
|
||||
expect(formattedText).toMatchInlineSnapshot(`
|
||||
"[return value] 'Element: button#main-btn.btn\\n' +
|
||||
'\\n' +
|
||||
'Inline styles:\\n' +
|
||||
' font-weight: bold\\n' +
|
||||
'\\n' +
|
||||
'Matched rules:\\n' +
|
||||
' .btn {\\n' +
|
||||
' padding: 8px 16px;\\n' +
|
||||
' padding-top: 8px;\\n' +
|
||||
' padding-right: 16px;\\n' +
|
||||
' padding-bottom: 8px;\\n' +
|
||||
' padding-left: 16px;\\n' +
|
||||
' }\\n' +
|
||||
' #main-btn {\\n' +
|
||||
' background-color: blue;\\n' +
|
||||
' color: white;\\n' +
|
||||
' border-radius: 4px;\\n' +
|
||||
' border-top-left-radius: 4px;\\n' +
|
||||
' border-top-right-radius: 4px;\\n' +
|
||||
' border-bottom-right-radius: 4px;\\n' +
|
||||
' border-bottom-left-radius: 4px;\\n' +
|
||||
' }\\n' +
|
||||
'\\n' +
|
||||
'Inherited from ancestor[1]:\\n' +
|
||||
' .container {\\n' +
|
||||
' padding: 20px;\\n' +
|
||||
' margin: 10px;\\n' +
|
||||
' padding-top: 20px;\\n' +
|
||||
' padding-right: 20px;\\n' +
|
||||
' padding-bottom: 20px;\\n' +
|
||||
' padding-left: 20px;\\n' +
|
||||
' margin-top: 10px;\\n' +
|
||||
' margin-right: 10px;\\n' +
|
||||
' margin-bottom: 10px;\\n' +
|
||||
' margin-left: 10px;\\n' +
|
||||
' }\\n' +
|
||||
'\\n' +
|
||||
'Inherited from ancestor[2]:\\n' +
|
||||
' body {\\n' +
|
||||
' font-family: Arial, sans-serif;\\n' +
|
||||
' color: rgb(51, 51, 51);\\n' +
|
||||
' }\\n'"
|
||||
`)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should return correct layout metrics via CDP', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://example.com/')
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
||||
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
||||
|
||||
const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics')
|
||||
|
||||
const normalized = {
|
||||
cssLayoutViewport: layoutMetrics.cssLayoutViewport,
|
||||
cssVisualViewport: layoutMetrics.cssVisualViewport,
|
||||
layoutViewport: layoutMetrics.layoutViewport,
|
||||
visualViewport: layoutMetrics.visualViewport,
|
||||
devicePixelRatio: layoutMetrics.cssVisualViewport.clientWidth > 0
|
||||
? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth
|
||||
: 1,
|
||||
}
|
||||
|
||||
expect(normalized).toMatchInlineSnapshot(`
|
||||
{
|
||||
"cssLayoutViewport": {
|
||||
"clientHeight": 720,
|
||||
"clientWidth": 1280,
|
||||
"pageX": 0,
|
||||
"pageY": 0,
|
||||
},
|
||||
"cssVisualViewport": {
|
||||
"clientHeight": 720,
|
||||
"clientWidth": 1280,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"pageX": 0,
|
||||
"pageY": 0,
|
||||
"scale": 1,
|
||||
"zoom": 1,
|
||||
},
|
||||
"devicePixelRatio": 1,
|
||||
"layoutViewport": {
|
||||
"clientHeight": 720,
|
||||
"clientWidth": 1280,
|
||||
"pageX": 0,
|
||||
"pageY": 0,
|
||||
},
|
||||
"visualViewport": {
|
||||
"clientHeight": 720,
|
||||
"clientWidth": 1280,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"pageX": 0,
|
||||
"pageY": 0,
|
||||
"scale": 1,
|
||||
"zoom": 1,
|
||||
},
|
||||
}
|
||||
`)
|
||||
|
||||
const windowDpr = await cdpPage!.evaluate(() => (globalThis as any).devicePixelRatio)
|
||||
console.log('window.devicePixelRatio:', windowDpr)
|
||||
expect(windowDpr).toBe(1)
|
||||
|
||||
cdpSession.close()
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should support getCDPSession through the relay', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://example.com/')
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
||||
const cdpClient = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
||||
|
||||
const layoutMetrics = await cdpClient.send('Page.getLayoutMetrics')
|
||||
expect(layoutMetrics.cssVisualViewport).toBeDefined()
|
||||
expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
|
||||
|
||||
cdpClient.close()
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should get aria ref for locator using getAriaSnapshot', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button id="submit-btn">Submit Form</button>
|
||||
<a href="/about">About Us</a>
|
||||
<input type="text" placeholder="Enter your name" />
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
let cdpPage
|
||||
for (const p of browser.contexts()[0].pages()) {
|
||||
const html = await p.content()
|
||||
if (html.includes('submit-btn')) {
|
||||
cdpPage = p
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(cdpPage).toBeDefined()
|
||||
|
||||
const { getAriaSnapshot } = await import('./aria-snapshot.js')
|
||||
|
||||
const ariaResult = await getAriaSnapshot({ page: cdpPage! })
|
||||
|
||||
expect(ariaResult.snapshot).toBeDefined()
|
||||
expect(ariaResult.snapshot.length).toBeGreaterThan(0)
|
||||
expect(ariaResult.snapshot).toContain('Submit Form')
|
||||
|
||||
expect(ariaResult.refToElement.size).toBeGreaterThan(0)
|
||||
console.log('RefToElement map size:', ariaResult.refToElement.size)
|
||||
console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
|
||||
|
||||
const btnViaAriaRef = cdpPage!.locator('aria-ref=e2')
|
||||
const btnTextViaRef = await btnViaAriaRef.textContent()
|
||||
console.log('Button text via aria-ref=e2:', btnTextViaRef)
|
||||
expect(btnTextViaRef).toBe('Submit Form')
|
||||
|
||||
const submitBtn = cdpPage!.locator('#submit-btn')
|
||||
const btnAriaRef = await ariaResult.getRefForLocator(submitBtn)
|
||||
console.log('Button ariaRef:', btnAriaRef)
|
||||
expect(btnAriaRef).toBeDefined()
|
||||
expect(btnAriaRef?.role).toBe('button')
|
||||
expect(btnAriaRef?.name).toBe('Submit Form')
|
||||
expect(btnAriaRef?.ref).toMatch(/^e\d+$/)
|
||||
|
||||
const btnFromRef = cdpPage!.locator(`aria-ref=${btnAriaRef?.ref}`)
|
||||
const btnText = await btnFromRef.textContent()
|
||||
expect(btnText).toBe('Submit Form')
|
||||
|
||||
const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn)
|
||||
console.log('Button ref string:', btnRefStr)
|
||||
expect(btnRefStr).toBe(btnAriaRef?.ref)
|
||||
|
||||
const aboutLink = cdpPage!.locator('a')
|
||||
const linkAriaRef = await ariaResult.getRefForLocator(aboutLink)
|
||||
console.log('Link ariaRef:', linkAriaRef)
|
||||
expect(linkAriaRef).toBeDefined()
|
||||
expect(linkAriaRef?.role).toBe('link')
|
||||
expect(linkAriaRef?.name).toBe('About Us')
|
||||
|
||||
const linkFromRef = cdpPage!.locator(`aria-ref=${linkAriaRef?.ref}`)
|
||||
const linkText = await linkFromRef.textContent()
|
||||
expect(linkText).toBe('About Us')
|
||||
|
||||
const inputField = cdpPage!.locator('input')
|
||||
const inputAriaRef = await ariaResult.getRefForLocator(inputField)
|
||||
console.log('Input ariaRef:', inputAriaRef)
|
||||
expect(inputAriaRef).toBeDefined()
|
||||
expect(inputAriaRef?.role).toBe('textbox')
|
||||
|
||||
const batchRefs = await ariaResult.getRefsForLocators([submitBtn, aboutLink, inputField])
|
||||
console.log('Batch refs:', batchRefs)
|
||||
expect(batchRefs).toHaveLength(3)
|
||||
expect(batchRefs[0]?.ref).toBe(btnAriaRef?.ref)
|
||||
expect(batchRefs[1]?.ref).toBe(linkAriaRef?.ref)
|
||||
expect(batchRefs[2]?.ref).toBe(inputAriaRef?.ref)
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should show aria ref labels on real pages and save screenshots', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const { showAriaRefLabels, hideAriaRefLabels } = await import('./aria-snapshot.js')
|
||||
|
||||
// Create assets folder for screenshots
|
||||
const assetsDir = path.join(path.dirname(new URL(import.meta.url).pathname), 'assets')
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
fs.mkdirSync(assetsDir, { recursive: true })
|
||||
}
|
||||
|
||||
const testPages = [
|
||||
{ name: 'hacker-news', url: 'https://news.ycombinator.com/' },
|
||||
{ name: 'google', url: 'https://www.google.com/' },
|
||||
{ name: 'github', url: 'https://github.com/' },
|
||||
]
|
||||
|
||||
const pages = await Promise.all(
|
||||
testPages.map(async ({ name, url }) => {
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
return { name, url, page }
|
||||
})
|
||||
)
|
||||
|
||||
for (const { page } of pages) {
|
||||
await page.bringToFront()
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
|
||||
await Promise.all(
|
||||
pages.map(async ({ name, url, page }) => {
|
||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes(new URL(url).hostname))
|
||||
|
||||
if (!cdpPage) {
|
||||
console.log(`Could not find CDP page for ${name}, skipping...`)
|
||||
return
|
||||
}
|
||||
|
||||
const { snapshot, labelCount } = await showAriaRefLabels({ page: cdpPage })
|
||||
console.log(`${name}: ${labelCount} labels shown`)
|
||||
expect(labelCount).toBeGreaterThan(0)
|
||||
|
||||
const screenshot = await cdpPage.screenshot({ type: 'png', fullPage: false })
|
||||
const screenshotPath = path.join(assetsDir, `aria-labels-${name}.png`)
|
||||
fs.writeFileSync(screenshotPath, screenshot)
|
||||
console.log(`Screenshot saved: ${screenshotPath}`)
|
||||
|
||||
const snapshotPath = path.join(assetsDir, `aria-labels-${name}-snapshot.txt`)
|
||||
fs.writeFileSync(snapshotPath, snapshot)
|
||||
|
||||
const labelElements = await cdpPage.evaluate(() =>
|
||||
document.querySelectorAll('.__pw_label__').length
|
||||
)
|
||||
expect(labelElements).toBe(labelCount)
|
||||
|
||||
await hideAriaRefLabels({ page: cdpPage })
|
||||
|
||||
const labelsAfterHide = await cdpPage.evaluate(() =>
|
||||
document.getElementById('__playwriter_labels__')
|
||||
)
|
||||
expect(labelsAfterHide).toBeNull()
|
||||
|
||||
await page.close()
|
||||
})
|
||||
)
|
||||
|
||||
await browser.close()
|
||||
console.log(`Screenshots saved to: ${assetsDir}`)
|
||||
}, 120000)
|
||||
|
||||
it('should take screenshot with accessibility labels via MCP execute tool', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #e8f4f8;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.controls {
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
.grid-marker {
|
||||
position: absolute;
|
||||
background: rgba(255, 100, 100, 0.3);
|
||||
border: 1px solid #ff6464;
|
||||
font-size: 10px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.h-marker {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
.v-marker {
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="controls">
|
||||
<button id="submit-btn">Submit Form</button>
|
||||
<a href="/about">About Us</a>
|
||||
<input type="text" placeholder="Enter your name" />
|
||||
</div>
|
||||
<!-- Horizontal markers every 200px -->
|
||||
<div class="grid-marker h-marker" style="top: 200px;">200px</div>
|
||||
<div class="grid-marker h-marker" style="top: 400px;">400px</div>
|
||||
<div class="grid-marker h-marker" style="top: 600px;">600px</div>
|
||||
<!-- Vertical markers every 200px -->
|
||||
<div class="grid-marker v-marker" style="left: 200px;">200</div>
|
||||
<div class="grid-marker v-marker" style="left: 400px;">400</div>
|
||||
<div class="grid-marker v-marker" style="left: 600px;">600</div>
|
||||
<div class="grid-marker v-marker" style="left: 800px;">800</div>
|
||||
<div class="grid-marker v-marker" style="left: 1000px;">1000</div>
|
||||
<div class="grid-marker v-marker" style="left: 1200px;">1200</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('submit-btn')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
await screenshotWithAccessibilityLabels({ page: testPage });
|
||||
`,
|
||||
timeout: 15000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isError).toBeFalsy()
|
||||
|
||||
const content = result.content as any[]
|
||||
expect(content.length).toBe(2)
|
||||
|
||||
const textContent = content.find(c => c.type === 'text')
|
||||
expect(textContent).toBeDefined()
|
||||
expect(textContent.text).toContain('Screenshot saved to:')
|
||||
expect(textContent.text).toContain('.jpg')
|
||||
expect(textContent.text).toContain('Labels shown:')
|
||||
expect(textContent.text).toContain('Accessibility snapshot:')
|
||||
expect(textContent.text).toContain('Submit Form')
|
||||
|
||||
const imageContent = content.find(c => c.type === 'image')
|
||||
expect(imageContent).toBeDefined()
|
||||
expect(imageContent.mimeType).toBe('image/jpeg')
|
||||
expect(imageContent.data).toBeDefined()
|
||||
expect(imageContent.data.length).toBeGreaterThan(100)
|
||||
|
||||
const buffer = Buffer.from(imageContent.data, 'base64')
|
||||
const dimensions = imageSize(buffer)
|
||||
|
||||
const viewport = await page.evaluate(() => ({
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
outerWidth: window.outerWidth,
|
||||
outerHeight: window.outerHeight,
|
||||
}))
|
||||
console.log('Screenshot dimensions:', dimensions.width, 'x', dimensions.height)
|
||||
console.log('Window viewport:', viewport)
|
||||
|
||||
expect(dimensions.type).toBe('jpg')
|
||||
expect(dimensions.width).toBeGreaterThan(0)
|
||||
expect(dimensions.height).toBeGreaterThan(0)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionState } from 'mcp-extension/src/types.js'
|
||||
|
||||
declare global {
|
||||
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
|
||||
var getExtensionState: () => ExtensionState;
|
||||
var disconnectEverything: () => Promise<void>;
|
||||
|
||||
// Browser globals used in evaluate() calls
|
||||
var window: any;
|
||||
var document: any;
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -286,3 +286,85 @@ export async function withTimeout<T>({ promise, timeoutMs, errorMessage }: { pro
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Tagged template for inline JS code strings used in MCP execute calls */
|
||||
export function js(strings: TemplateStringsArray, ...values: unknown[]): string {
|
||||
return strings.reduce(
|
||||
(result, str, i) => result + str + (values[i] || ''),
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
export function tryJsonParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
export type SimpleServer = {
|
||||
baseUrl: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Minimal local HTTP server for tests that need cross-origin iframes or custom routes */
|
||||
export async function createSimpleServer({ routes }: { routes: Record<string, string> }): Promise<SimpleServer> {
|
||||
const openSockets: Set<net.Socket> = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url || '/'
|
||||
const body = routes[url]
|
||||
if (!body) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(body)
|
||||
})
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
openSockets.add(socket)
|
||||
socket.on('close', () => {
|
||||
openSockets.delete(socket)
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
throw new Error('Failed to start test server')
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => {
|
||||
for (const socket of openSockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user