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:
Tommy D. Rossi
2026-01-27 19:46:38 +01:00
parent 7809be6328
commit f36647a674
8 changed files with 4127 additions and 4130 deletions
+633
View File
@@ -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)
})
+806
View File
@@ -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)
})
+448
View File
@@ -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
+867
View File
@@ -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
+13
View File
@@ -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 {}
+82
View File
@@ -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()
})
})
},
}
}