3794 lines
138 KiB
TypeScript
3794 lines
138 KiB
TypeScript
import { createMCPClient } from './mcp-client.js'
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
import { exec } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import { chromium, BrowserContext } from 'playwright-core'
|
|
import path from 'node:path'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import { getCdpUrl } from './utils.js'
|
|
import type { ExtensionState } from 'mcp-extension/src/types.js'
|
|
import type { Protocol } from 'devtools-protocol'
|
|
import { imageSize } from 'image-size'
|
|
import { getCDPSessionForPage } from './cdp-session.js'
|
|
import { Debugger } from './debugger.js'
|
|
import { Editor } from './editor.js'
|
|
import { startPlayWriterCDPRelayServer, type RelayServer } from './cdp-relay.js'
|
|
import { createFileLogger } from './create-logger.js'
|
|
import type { CDPCommand } from './cdp-types.js'
|
|
import { killPortProcess } from 'kill-port-process'
|
|
|
|
declare const window: any
|
|
declare const document: any
|
|
|
|
const TEST_PORT = 19987
|
|
|
|
const execAsync = promisify(exec)
|
|
|
|
async function getExtensionServiceWorker(context: BrowserContext) {
|
|
let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'))
|
|
let serviceWorker = serviceWorkers[0]
|
|
if (!serviceWorker) {
|
|
serviceWorker = await context.waitForEvent('serviceworker', {
|
|
predicate: (sw) => sw.url().startsWith('chrome-extension://')
|
|
})
|
|
}
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
const isReady = await serviceWorker.evaluate(() => {
|
|
// @ts-ignore
|
|
return typeof globalThis.toggleExtensionForActiveTab === 'function'
|
|
})
|
|
if (isReady) break
|
|
await new Promise(r => setTimeout(r, 100))
|
|
}
|
|
|
|
return serviceWorker
|
|
}
|
|
|
|
function js(strings: TemplateStringsArray, ...values: any[]): string {
|
|
return strings.reduce(
|
|
(result, str, i) => result + str + (values[i] || ''),
|
|
'',
|
|
)
|
|
}
|
|
|
|
async function killProcessOnPort(port: number): Promise<void> {
|
|
try {
|
|
await killPortProcess(port)
|
|
console.log(`Killed processes on port ${port}`)
|
|
} catch (err) {
|
|
console.error('Error killing process on port:', err)
|
|
}
|
|
}
|
|
|
|
interface TestContext {
|
|
browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
|
|
userDataDir: string
|
|
relayServer: RelayServer
|
|
}
|
|
|
|
async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
|
|
await killProcessOnPort(TEST_PORT)
|
|
|
|
console.log('Building extension...')
|
|
await execAsync(`TESTING=1 PLAYWRITER_PORT=${TEST_PORT} pnpm build`, { cwd: '../extension' })
|
|
console.log('Extension built')
|
|
|
|
const localLogPath = path.join(process.cwd(), 'relay-server.log')
|
|
const logger = createFileLogger({ logFilePath: localLogPath })
|
|
const relayServer = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger })
|
|
|
|
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix))
|
|
const extensionPath = path.resolve('../extension/dist')
|
|
|
|
const browserContext = await chromium.launchPersistentContext(userDataDir, {
|
|
channel: 'chromium',
|
|
headless: !process.env.HEADFUL,
|
|
colorScheme: 'dark',
|
|
args: [
|
|
`--disable-extensions-except=${extensionPath}`,
|
|
`--load-extension=${extensionPath}`,
|
|
],
|
|
})
|
|
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
const page = await browserContext.newPage()
|
|
await page.goto('about:blank')
|
|
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
|
|
return { browserContext, userDataDir, relayServer }
|
|
}
|
|
|
|
async function cleanupTestContext(ctx: TestContext | null, cleanup?: (() => Promise<void>) | null): Promise<void> {
|
|
if (ctx?.browserContext) {
|
|
await ctx.browserContext.close()
|
|
}
|
|
if (ctx?.relayServer) {
|
|
ctx.relayServer.close()
|
|
}
|
|
|
|
if (ctx?.userDataDir) {
|
|
try {
|
|
fs.rmSync(ctx.userDataDir, { recursive: true, force: true })
|
|
} catch (e) {
|
|
console.error('Failed to cleanup user data dir:', e)
|
|
}
|
|
}
|
|
if (cleanup) {
|
|
await cleanup()
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
|
|
var getExtensionState: () => ExtensionState;
|
|
var disconnectEverything: () => Promise<void>;
|
|
}
|
|
|
|
describe('MCP Server Tests', () => {
|
|
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
|
|
let cleanup: (() => Promise<void>) | null = null
|
|
let testCtx: TestContext | null = null
|
|
|
|
beforeAll(async () => {
|
|
testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' })
|
|
|
|
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 getExtensionServiceWorker(browserContext)
|
|
|
|
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 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
|
|
// Since it's a new page, extension is not connected yet
|
|
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()
|
|
|
|
// Find our page
|
|
let foundPage = pages.find(p => p.url() === testUrl)
|
|
expect(foundPage).toBeDefined()
|
|
expect(foundPage?.url()).toBe(testUrl)
|
|
|
|
// Verify execution works
|
|
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. Try to connect/use the page.
|
|
// connecting to relay will succeed, but listing pages should NOT show our page
|
|
|
|
// Connect to relay again
|
|
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 }))
|
|
// Wait a bit for targets to populate
|
|
await new Promise(r => setTimeout(r, 100))
|
|
|
|
contexts = directBrowser.contexts()
|
|
// pages() might need a moment if target attached event comes in
|
|
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)
|
|
|
|
// Verify execution works again
|
|
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)
|
|
|
|
// Connect once
|
|
const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
// Wait a bit for connection and initial target discovery
|
|
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)
|
|
|
|
// Verify execution works
|
|
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)
|
|
|
|
// Verify execution works again
|
|
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()
|
|
|
|
// Verify execution
|
|
expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2)
|
|
|
|
// 4. Reload
|
|
// We use a loop to check if it's still connected because reload might cause temporary disconnect/reconnect events
|
|
// that Playwright handles natively if the session ID stays valid.
|
|
await connectedPage?.reload()
|
|
await connectedPage?.waitForLoadState('domcontentloaded')
|
|
expect(await connectedPage?.title()).toBe('Example Domain')
|
|
|
|
// Verify execution after reload
|
|
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')
|
|
|
|
// Verify execution after navigation
|
|
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()
|
|
|
|
// Wait for load
|
|
await page.waitForLoadState('domcontentloaded')
|
|
|
|
// 2. Enable extension for this page
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
|
|
// 3. Verify via CDP that the correct URL is shown
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
// Wait for sync
|
|
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)
|
|
|
|
// Wait for extension to fully connect
|
|
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()
|
|
})
|
|
|
|
// Wait for disconnect to complete
|
|
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)
|
|
// After disconnect, there are no pages - so we expect an error
|
|
expect((afterDisconnect as any).isError).toBe(true)
|
|
expect(afterDisconnectOutput).toContain('No browser tabs have Playwriter enabled')
|
|
|
|
// 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)
|
|
|
|
// Wait for extension to fully reconnect and relay server to be ready
|
|
console.log('Waiting for reconnection to stabilize...')
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
// 5. Reset the MCP client's playwright connection since it was closed by disconnectEverything
|
|
// Must use the reset tool since execute requires a valid page
|
|
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 - navigate page back to about:blank to not interfere with other tests
|
|
await page.goto('about:blank')
|
|
})
|
|
|
|
it('should auto-reconnect MCP after extension WebSocket reconnects', async () => {
|
|
// This test verifies that the MCP automatically reconnects when the browser
|
|
// disconnects (e.g., when the extension WebSocket reconnects and the relay
|
|
// server closes all playwright clients). The fix adds browser.on('disconnected')
|
|
// handler that clears state.isConnected, so ensureConnection() creates a new connection.
|
|
|
|
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`
|
|
const pages = context.pages();
|
|
const testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
|
|
return { pagesCount: pages.length, foundTestPage: !!testPage };
|
|
`,
|
|
},
|
|
})
|
|
const beforeOutput = (beforeResult as any).content[0].text
|
|
expect(beforeOutput).toContain('foundTestPage')
|
|
expect(beforeOutput).toContain('true')
|
|
|
|
// 3. Simulate extension WebSocket reconnection
|
|
// This causes relay server to close all playwright client WebSockets
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything()
|
|
})
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
// Re-enable extension (simulates extension reconnecting)
|
|
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()
|
|
// The browser.on('disconnected') handler should have cleared state.isConnected,
|
|
// causing ensureConnection() to automatically create a new connection
|
|
const afterResult = await client.callTool({
|
|
name: 'execute',
|
|
arguments: {
|
|
code: js`
|
|
const pages = context.pages();
|
|
const testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
|
|
return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() };
|
|
`,
|
|
},
|
|
})
|
|
|
|
const afterOutput = (afterResult as any).content[0].text
|
|
// The command should succeed and find our test page
|
|
expect(afterOutput).toContain('foundTestPage')
|
|
expect(afterOutput).toContain('true')
|
|
expect(afterOutput).toContain('auto-reconnect-test')
|
|
// Should NOT contain error about extension not connected
|
|
expect(afterOutput).not.toContain('Extension not connected')
|
|
expect((afterResult as any).isError).not.toBe(true)
|
|
|
|
// Clean up
|
|
await page.goto('about:blank')
|
|
})
|
|
|
|
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 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 pages = browser.contexts()[0].pages()
|
|
const testPage = pages.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 pages = browser.contexts()[0].pages()
|
|
const testPage = pages.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)
|
|
|
|
it('should be usable after toggle with valid URL', async () => {
|
|
// This test validates the extension properly waits for valid URLs before
|
|
// sending Target.attachedToTarget. Uses Discord - a heavy React SPA.
|
|
//
|
|
// We use waitForEvent('page') to wait for Playwright to process the event.
|
|
// The KEY assertion is that when the event fires, the URL is VALID (not empty).
|
|
// Before the fix: event fired with empty URL -> page broken forever
|
|
// After the fix: event fires with valid URL -> page works immediately
|
|
|
|
const _browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(_browserContext)
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
const context = browser.contexts()[0]
|
|
|
|
const page = await _browserContext.newPage()
|
|
await page.goto('https://discord.com/login')
|
|
await page.bringToFront()
|
|
|
|
// Set up listener BEFORE toggle
|
|
const pagePromise = context.waitForEvent('page', { timeout: 10000 })
|
|
|
|
// Toggle extension - extension waits for valid URL before sending event
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
|
|
// Wait for page event
|
|
const targetPage = await pagePromise
|
|
console.log('Page URL when event fired:', targetPage.url())
|
|
|
|
// KEY ASSERTION: URL must NOT be empty - this is what the extension fix guarantees
|
|
expect(targetPage.url()).not.toBe('')
|
|
expect(targetPage.url()).not.toBe(':')
|
|
expect(targetPage.url()).toContain('discord.com')
|
|
|
|
// evaluate() works immediately - no waiting needed
|
|
const result = await targetPage.evaluate(() => window.location.href)
|
|
expect(result).toContain('discord.com')
|
|
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should have non-empty URLs when connecting to already-loaded pages', async () => {
|
|
// This test validates that when we connect to a browser with already-loaded pages,
|
|
// all pages have non-empty URLs. Empty URLs break Playwright permanently.
|
|
|
|
const _browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(_browserContext)
|
|
|
|
// Create and fully load a heavy page BEFORE connecting
|
|
const page = await _browserContext.newPage()
|
|
await page.goto('https://discord.com/login', { waitUntil: 'load' })
|
|
await page.bringToFront()
|
|
|
|
// Toggle extension to attach to the loaded page
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
|
|
// NOW connect via CDP - page should already be attached
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
const context = browser.contexts()[0]
|
|
|
|
// Get all pages and verify NONE have empty URLs
|
|
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()
|
|
}
|
|
|
|
// Find Discord page and verify it works
|
|
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 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 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: 1')
|
|
expect(text).toContain('Locator text: 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
|
|
|
|
Inline styles:
|
|
font-weight: bold
|
|
|
|
Matched rules:
|
|
.btn {
|
|
padding: 8px 16px;
|
|
padding-top: 8px;
|
|
padding-right: 16px;
|
|
padding-bottom: 8px;
|
|
padding-left: 16px;
|
|
}
|
|
#main-btn {
|
|
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;
|
|
}
|
|
|
|
Inherited from ancestor[1]:
|
|
.container {
|
|
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;
|
|
}
|
|
|
|
Inherited from ancestor[2]:
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
color: rgb(51, 51, 51);
|
|
}"
|
|
`)
|
|
|
|
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 client = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
|
|
const layoutMetrics = await client.send('Page.getLayoutMetrics')
|
|
expect(layoutMetrics.cssVisualViewport).toBeDefined()
|
|
expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
|
|
|
|
client.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
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
|
|
// console.log('Stagehand context:', 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 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 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')
|
|
|
|
// Get aria snapshot and verify we can get refs
|
|
const ariaResult = await getAriaSnapshot({ page: cdpPage! })
|
|
|
|
expect(ariaResult.snapshot).toBeDefined()
|
|
expect(ariaResult.snapshot.length).toBeGreaterThan(0)
|
|
expect(ariaResult.snapshot).toContain('Submit Form')
|
|
|
|
// Verify refToElement map is populated
|
|
expect(ariaResult.refToElement.size).toBeGreaterThan(0)
|
|
console.log('RefToElement map size:', ariaResult.refToElement.size)
|
|
console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
|
|
|
|
// Verify we can select elements using aria-ref selectors
|
|
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')
|
|
|
|
// Get ref for the submit button using getRefForLocator
|
|
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+$/)
|
|
|
|
// Verify the ref matches what we can use to select
|
|
const btnFromRef = cdpPage!.locator(`aria-ref=${btnAriaRef?.ref}`)
|
|
const btnText = await btnFromRef.textContent()
|
|
expect(btnText).toBe('Submit Form')
|
|
|
|
// Test getRefStringForLocator
|
|
const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn)
|
|
console.log('Button ref string:', btnRefStr)
|
|
expect(btnRefStr).toBe(btnAriaRef?.ref)
|
|
|
|
// Test link
|
|
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')
|
|
|
|
// Verify the link ref works
|
|
const linkFromRef = cdpPage!.locator(`aria-ref=${linkAriaRef?.ref}`)
|
|
const linkText = await linkFromRef.textContent()
|
|
expect(linkText).toBe('About Us')
|
|
|
|
// Test input field
|
|
const inputField = cdpPage!.locator('input')
|
|
const inputAriaRef = await ariaResult.getRefForLocator(inputField)
|
|
console.log('Input ariaRef:', inputAriaRef)
|
|
expect(inputAriaRef).toBeDefined()
|
|
expect(inputAriaRef?.role).toBe('textbox')
|
|
|
|
// Test batch getRefsForLocators - single evaluate call for multiple elements
|
|
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')
|
|
const fs = await import('node:fs')
|
|
const path = await import('node:path')
|
|
|
|
// 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/' },
|
|
]
|
|
|
|
// Create all pages and enable extension for each
|
|
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 }
|
|
})
|
|
)
|
|
|
|
// Enable extension for each tab (must be done sequentially as it uses active tab)
|
|
for (const { page } of pages) {
|
|
await page.bringToFront()
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
}
|
|
|
|
// Connect CDP and process all pages concurrently
|
|
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
|
|
}
|
|
|
|
// Show aria ref labels
|
|
const { snapshot, labelCount } = await showAriaRefLabels({ page: cdpPage })
|
|
console.log(`${name}: ${labelCount} labels shown`)
|
|
expect(labelCount).toBeGreaterThan(0)
|
|
|
|
// Take screenshot with labels visible
|
|
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}`)
|
|
|
|
// Save snapshot text for reference
|
|
const snapshotPath = path.join(assetsDir, `aria-labels-${name}-snapshot.txt`)
|
|
fs.writeFileSync(snapshotPath, snapshot)
|
|
|
|
// Verify labels are in DOM
|
|
const labelElements = await cdpPage.evaluate(() =>
|
|
document.querySelectorAll('.__pw_label__').length
|
|
)
|
|
expect(labelElements).toBe(labelCount)
|
|
|
|
// Cleanup
|
|
await hideAriaRefLabels({ page: cdpPage })
|
|
|
|
// Verify labels removed
|
|
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))
|
|
|
|
// Take screenshot with accessibility labels via MCP
|
|
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()
|
|
|
|
// Verify response has both text and image content
|
|
const content = result.content as any[]
|
|
expect(content.length).toBe(2)
|
|
|
|
// Check text content
|
|
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')
|
|
|
|
// Check image content
|
|
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) // base64 data should be substantial
|
|
|
|
// Verify the image is valid JPEG by checking base64
|
|
const buffer = Buffer.from(imageContent.data, 'base64')
|
|
const dimensions = imageSize(buffer)
|
|
|
|
// Get actual viewport size from page
|
|
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)
|
|
|
|
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">
|
|
<h1>Hello World</h1>
|
|
<button aria-label="Click me">Submit</button>
|
|
<a href="/about" title="About page">About</a>
|
|
<input type="text" placeholder="Enter name">
|
|
</div>"
|
|
`)
|
|
|
|
// 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)
|
|
|
|
it('should expose CDP discovery endpoints /json/version and /json/list', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
// Enable extension on a page
|
|
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 - Playwright uses this)
|
|
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)
|
|
|
|
// Find the example.com page (there may be other pages like about:blank)
|
|
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)
|
|
|
|
})
|
|
|
|
|
|
function tryJsonParse(str: string) {
|
|
try {
|
|
return JSON.parse(str)
|
|
} catch {
|
|
return str
|
|
}
|
|
}
|
|
|
|
describe('CDP Session Tests', () => {
|
|
let testCtx: TestContext | null = null
|
|
|
|
beforeAll(async () => {
|
|
testCtx = await setupTestContext({ tempDirPrefix: 'pw-cdp-test-' })
|
|
|
|
const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext)
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything()
|
|
})
|
|
await new Promise(r => setTimeout(r, 100))
|
|
}, 600000)
|
|
|
|
afterAll(async () => {
|
|
await cleanupTestContext(testCtx)
|
|
testCtx = null
|
|
})
|
|
|
|
const getBrowserContext = () => {
|
|
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
|
return testCtx.browserContext
|
|
}
|
|
|
|
it('should use Debugger class to set breakpoints and inspect variables', 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 dbg = new Debugger({ cdp: cdpSession })
|
|
|
|
await dbg.enable()
|
|
|
|
expect(dbg.isPaused()).toBe(false)
|
|
|
|
const pausedPromise = new Promise<void>((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => {
|
|
resolve()
|
|
})
|
|
})
|
|
|
|
cdpPage!.evaluate(`
|
|
(function testFunction() {
|
|
const localVar = 'hello';
|
|
const numberVar = 42;
|
|
debugger;
|
|
return localVar + numberVar;
|
|
})()
|
|
`)
|
|
|
|
await Promise.race([
|
|
pausedPromise,
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
|
|
])
|
|
|
|
expect(dbg.isPaused()).toBe(true)
|
|
|
|
const location = await dbg.getLocation()
|
|
expect(location.callstack[0].functionName).toBe('testFunction')
|
|
expect(location.sourceContext).toContain('debugger')
|
|
|
|
const vars = await dbg.inspectLocalVariables()
|
|
expect(vars).toMatchInlineSnapshot(`
|
|
{
|
|
"localVar": "hello",
|
|
"numberVar": 42,
|
|
}
|
|
`)
|
|
|
|
const evalResult = await dbg.evaluate({ expression: 'localVar + " world"' })
|
|
expect(evalResult.value).toBe('hello world')
|
|
|
|
await dbg.resume()
|
|
await new Promise(r => setTimeout(r, 100))
|
|
expect(dbg.isPaused()).toBe(false)
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should list scripts with Debugger class', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
const page = await browserContext.newPage()
|
|
await page.setContent(`
|
|
<html>
|
|
<head>
|
|
<script src="data:text/javascript,function testScript() { return 42; }"></script>
|
|
</head>
|
|
<body><h1>Script Test</h1></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 }))
|
|
let cdpPage
|
|
for (const p of browser.contexts()[0].pages()) {
|
|
const html = await p.content()
|
|
if (html.includes('Script Test')) {
|
|
cdpPage = p
|
|
break
|
|
}
|
|
}
|
|
expect(cdpPage).toBeDefined()
|
|
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
const dbg = new Debugger({ cdp: cdpSession })
|
|
|
|
const scripts = await dbg.listScripts()
|
|
expect(scripts.length).toBeGreaterThan(0)
|
|
expect(scripts[0]).toHaveProperty('scriptId')
|
|
expect(scripts[0]).toHaveProperty('url')
|
|
|
|
const dataScripts = await dbg.listScripts({ search: 'data:' })
|
|
expect(dataScripts.length).toBeGreaterThan(0)
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should manage breakpoints with Debugger class', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
const page = await browserContext.newPage()
|
|
await page.setContent(`
|
|
<html>
|
|
<head>
|
|
<script src="data:text/javascript,function testFunc() { return 42; }"></script>
|
|
</head>
|
|
<body></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 }))
|
|
let cdpPage
|
|
for (const p of browser.contexts()[0].pages()) {
|
|
const html = await p.content()
|
|
if (html.includes('testFunc')) {
|
|
cdpPage = p
|
|
break
|
|
}
|
|
}
|
|
expect(cdpPage).toBeDefined()
|
|
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
const dbg = new Debugger({ cdp: cdpSession })
|
|
|
|
await dbg.enable()
|
|
|
|
expect(dbg.listBreakpoints()).toHaveLength(0)
|
|
|
|
const bpId = await dbg.setBreakpoint({ file: 'https://example.com/test.js', line: 1 })
|
|
expect(typeof bpId).toBe('string')
|
|
expect(dbg.listBreakpoints()).toHaveLength(1)
|
|
expect(dbg.listBreakpoints()[0]).toMatchObject({
|
|
id: bpId,
|
|
file: 'https://example.com/test.js',
|
|
line: 1,
|
|
})
|
|
|
|
await dbg.deleteBreakpoint({ breakpointId: bpId })
|
|
expect(dbg.listBreakpoints()).toHaveLength(0)
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should step through code with Debugger class', 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 dbg = new Debugger({ cdp: cdpSession })
|
|
|
|
await dbg.enable()
|
|
|
|
const pausedPromise = new Promise<void>((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve())
|
|
})
|
|
|
|
cdpPage!.evaluate(`
|
|
(function outer() {
|
|
function inner() {
|
|
const x = 1;
|
|
debugger;
|
|
const y = 2;
|
|
return x + y;
|
|
}
|
|
const result = inner();
|
|
return result;
|
|
})()
|
|
`)
|
|
|
|
await pausedPromise
|
|
expect(dbg.isPaused()).toBe(true)
|
|
|
|
const location1 = await dbg.getLocation()
|
|
expect(location1.callstack.length).toBeGreaterThanOrEqual(2)
|
|
expect(location1.callstack[0].functionName).toBe('inner')
|
|
expect(location1.callstack[1].functionName).toBe('outer')
|
|
|
|
const stepOverPromise = new Promise<void>((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve())
|
|
})
|
|
await dbg.stepOver()
|
|
await stepOverPromise
|
|
|
|
const location2 = await dbg.getLocation()
|
|
expect(location2.lineNumber).toBeGreaterThan(location1.lineNumber)
|
|
|
|
const stepOutPromise = new Promise<void>((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve())
|
|
})
|
|
await dbg.stepOut()
|
|
await stepOutPromise
|
|
|
|
const location3 = await dbg.getLocation()
|
|
expect(location3.callstack[0].functionName).toBe('outer')
|
|
|
|
await dbg.resume()
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should profile JavaScript execution using CDP Profiler', 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 })
|
|
await cdpSession.send('Profiler.enable')
|
|
await cdpSession.send('Profiler.start')
|
|
|
|
await cdpPage!.evaluate(`
|
|
(() => {
|
|
function fibonacci(n) {
|
|
if (n <= 1) return n
|
|
return fibonacci(n - 1) + fibonacci(n - 2)
|
|
}
|
|
for (let i = 0; i < 5; i++) {
|
|
fibonacci(20)
|
|
}
|
|
for (let i = 0; i < 1000; i++) {
|
|
document.querySelectorAll('*')
|
|
}
|
|
})()
|
|
`)
|
|
|
|
const stopResult = await cdpSession.send('Profiler.stop')
|
|
const profile = stopResult.profile
|
|
|
|
const functionNames = profile.nodes
|
|
.map(n => n.callFrame.functionName)
|
|
.filter(name => name && name.length > 0)
|
|
.slice(0, 10)
|
|
|
|
expect(profile.nodes.length).toBeGreaterThan(0)
|
|
expect(profile.endTime - profile.startTime).toBeGreaterThan(0)
|
|
expect(functionNames.every((name) => typeof name === 'string')).toBe(true)
|
|
|
|
await cdpSession.send('Profiler.disable')
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should update Target.getTargets URL after page navigation', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
// Clear any existing connected tabs from previous tests
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything()
|
|
})
|
|
|
|
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 initialTargets = await cdpSession.send('Target.getTargets')
|
|
const initialPageTarget = initialTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('example.com'))
|
|
expect(initialPageTarget?.url).toBe('https://example.com/')
|
|
|
|
await cdpPage!.goto('https://example.org/', { waitUntil: 'domcontentloaded' })
|
|
await new Promise(r => setTimeout(r, 100))
|
|
|
|
const afterNavTargets = await cdpSession.send('Target.getTargets')
|
|
const allPageTargets = afterNavTargets.targetInfos.filter(t => t.type === 'page')
|
|
|
|
const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank')
|
|
expect(aboutBlankTargets).toHaveLength(0)
|
|
|
|
const exampleComTargets = allPageTargets.filter(t => t.url.includes('example.com'))
|
|
expect(exampleComTargets).toHaveLength(0)
|
|
|
|
const exampleOrgTargets = allPageTargets.filter(t => t.url.includes('example.org'))
|
|
expect(exampleOrgTargets).toHaveLength(1)
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should return correct targets for multiple pages via Target.getTargets', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
// Clear any existing connected tabs from previous tests
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything()
|
|
})
|
|
|
|
const page1 = await browserContext.newPage()
|
|
await page1.goto('https://example.com/')
|
|
await page1.bringToFront()
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
|
|
const page2 = await browserContext.newPage()
|
|
await page2.goto('https://example.org/')
|
|
await page2.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 { targetInfos } = await cdpSession.send('Target.getTargets')
|
|
const allPageTargets = targetInfos.filter(t => t.type === 'page')
|
|
|
|
const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank')
|
|
expect(aboutBlankTargets).toHaveLength(0)
|
|
|
|
const pageTargets = allPageTargets
|
|
.map(t => ({ type: t.type, url: t.url }))
|
|
.sort((a, b) => a.url.localeCompare(b.url))
|
|
|
|
expect(pageTargets).toMatchInlineSnapshot(`
|
|
[
|
|
{
|
|
"type": "page",
|
|
"url": "https://example.com/",
|
|
},
|
|
{
|
|
"type": "page",
|
|
"url": "https://example.org/",
|
|
},
|
|
]
|
|
`)
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page1.close()
|
|
await page2.close()
|
|
}, 60000)
|
|
|
|
it('should create CDP session for page after navigation', 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))
|
|
|
|
await page.goto('https://example.org/', { waitUntil: 'domcontentloaded' })
|
|
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.org'))
|
|
expect(cdpPage).toBeDefined()
|
|
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
|
|
const evalResult = await cdpSession.send('Runtime.evaluate', {
|
|
expression: 'document.title',
|
|
returnByValue: true,
|
|
})
|
|
expect(evalResult.result.value).toContain('Example Domain')
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should maintain CDP session functionality after page URL change', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
const page = await browserContext.newPage()
|
|
const initialUrl = 'https://example.com/'
|
|
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().includes('example.com'))
|
|
expect(cdpPage).toBeDefined()
|
|
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
|
|
const initialEvalResult = await cdpSession.send('Runtime.evaluate', {
|
|
expression: 'document.title',
|
|
returnByValue: true,
|
|
})
|
|
expect(initialEvalResult.result.value).toBe('Example Domain')
|
|
|
|
const newUrl = 'https://example.org/'
|
|
await cdpPage!.goto(newUrl, { waitUntil: 'domcontentloaded' })
|
|
|
|
expect(cdpPage!.url()).toBe(newUrl)
|
|
|
|
const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics')
|
|
expect(layoutMetrics.cssVisualViewport).toBeDefined()
|
|
expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
|
|
|
|
const afterNavEvalResult = await cdpSession.send('Runtime.evaluate', {
|
|
expression: 'document.title',
|
|
returnByValue: true,
|
|
})
|
|
expect(afterNavEvalResult.result.value).toContain('Example Domain')
|
|
|
|
const locationResult = await cdpSession.send('Runtime.evaluate', {
|
|
expression: 'window.location.href',
|
|
returnByValue: true,
|
|
})
|
|
expect(locationResult.result.value).toBe(newUrl)
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should pause on all exceptions with setPauseOnExceptions', 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 dbg = new Debugger({ cdp: cdpSession })
|
|
|
|
await dbg.enable()
|
|
await dbg.setPauseOnExceptions({ state: 'all' })
|
|
|
|
const pausedPromise = new Promise<void>((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve())
|
|
})
|
|
|
|
cdpPage!.evaluate(`
|
|
(function() {
|
|
try {
|
|
throw new Error('Caught test error');
|
|
} catch (e) {
|
|
// caught but should still pause with state 'all'
|
|
}
|
|
})()
|
|
`).catch(() => {})
|
|
|
|
await Promise.race([
|
|
pausedPromise,
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
|
|
])
|
|
|
|
expect(dbg.isPaused()).toBe(true)
|
|
|
|
const location = await dbg.getLocation()
|
|
expect(location.sourceContext).toContain('throw')
|
|
|
|
await dbg.resume()
|
|
|
|
await dbg.setPauseOnExceptions({ state: 'none' })
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should inspect local and global variables with inline snapshots', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
const page = await browserContext.newPage()
|
|
await page.setContent(`
|
|
<html>
|
|
<head>
|
|
<script>
|
|
const GLOBAL_CONFIG = 'production';
|
|
function runTest() {
|
|
const userName = 'Alice';
|
|
const userAge = 25;
|
|
const settings = { theme: 'dark', lang: 'en' };
|
|
const scores = [10, 20, 30];
|
|
debugger;
|
|
return userName;
|
|
}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<button onclick="runTest()">Run</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 }))
|
|
let cdpPage
|
|
for (const p of browser.contexts()[0].pages()) {
|
|
const html = await p.content()
|
|
if (html.includes('runTest')) {
|
|
cdpPage = p
|
|
break
|
|
}
|
|
}
|
|
expect(cdpPage).toBeDefined()
|
|
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
const dbg = new Debugger({ cdp: cdpSession })
|
|
|
|
await dbg.enable()
|
|
|
|
const globalVars = await dbg.inspectGlobalVariables()
|
|
expect(globalVars).toMatchInlineSnapshot(`
|
|
[
|
|
"GLOBAL_CONFIG",
|
|
]
|
|
`)
|
|
|
|
const pausedPromise = new Promise<void>((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve())
|
|
})
|
|
|
|
cdpPage!.evaluate('runTest()')
|
|
|
|
await pausedPromise
|
|
expect(dbg.isPaused()).toBe(true)
|
|
|
|
const localVars = await dbg.inspectLocalVariables()
|
|
expect(localVars).toMatchInlineSnapshot(`
|
|
{
|
|
"GLOBAL_CONFIG": "production",
|
|
"scores": "[array]",
|
|
"settings": "[object]",
|
|
"userAge": 25,
|
|
"userName": "Alice",
|
|
}
|
|
`)
|
|
|
|
await dbg.resume()
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should click at correct coordinates on high-DPI simulation', 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 h1Bounds = await cdpPage!.locator('h1').boundingBox()
|
|
expect(h1Bounds).toBeDefined()
|
|
console.log('H1 bounding box:', h1Bounds)
|
|
|
|
await cdpPage!.evaluate(() => {
|
|
(window as any).clickedAt = null;
|
|
document.addEventListener('click', (e) => {
|
|
(window as any).clickedAt = { x: e.clientX, y: e.clientY };
|
|
});
|
|
})
|
|
|
|
await cdpPage!.locator('h1').click()
|
|
|
|
const clickedAt = await cdpPage!.evaluate(() => (window as any).clickedAt)
|
|
console.log('Clicked at:', clickedAt)
|
|
|
|
expect(clickedAt).toBeDefined()
|
|
expect(clickedAt.x).toBeGreaterThan(0)
|
|
expect(clickedAt.y).toBeGreaterThan(0)
|
|
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should use Editor class to list, read, and edit scripts', 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 editor = new Editor({ cdp: cdpSession })
|
|
|
|
await editor.enable()
|
|
|
|
await cdpPage!.addScriptTag({
|
|
content: `
|
|
function greetUser(name) {
|
|
console.log('Hello, ' + name);
|
|
return 'Hello, ' + name;
|
|
}
|
|
`,
|
|
})
|
|
await new Promise(r => setTimeout(r, 100))
|
|
const scripts = await editor.list()
|
|
expect(scripts.length).toBeGreaterThan(0)
|
|
|
|
const matches = await editor.grep({ regex: /greetUser/ })
|
|
expect(matches.length).toBeGreaterThan(0)
|
|
|
|
const match = matches[0]
|
|
const { content, totalLines } = await editor.read({ url: match.url })
|
|
expect(content).toContain('greetUser')
|
|
expect(totalLines).toBeGreaterThan(0)
|
|
|
|
await editor.edit({
|
|
url: match.url,
|
|
oldString: "console.log('Hello, ' + name);",
|
|
newString: "console.log('Hello, ' + name); console.log('EDITOR_TEST_MARKER');",
|
|
})
|
|
|
|
const consoleLogs: string[] = []
|
|
cdpPage!.on('console', msg => {
|
|
consoleLogs.push(msg.text())
|
|
})
|
|
|
|
await cdpPage!.evaluate(() => {
|
|
(window as any).greetUser('World')
|
|
})
|
|
await new Promise(r => setTimeout(r, 100))
|
|
|
|
expect(consoleLogs).toContain('Hello, World')
|
|
expect(consoleLogs).toContain('EDITOR_TEST_MARKER')
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('editor can list, read, and edit CSS stylesheets', 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 editor = new Editor({ cdp: cdpSession })
|
|
|
|
await editor.enable()
|
|
|
|
await cdpPage!.addStyleTag({
|
|
content: `
|
|
.editor-test-element {
|
|
color: rgb(255, 0, 0);
|
|
background-color: rgb(0, 0, 255);
|
|
}
|
|
`,
|
|
})
|
|
await new Promise(r => setTimeout(r, 100))
|
|
const stylesheets = await editor.list({ pattern: /inline-css:/ })
|
|
expect(stylesheets.length).toBeGreaterThan(0)
|
|
|
|
const cssMatches = await editor.grep({ regex: /editor-test-element/, pattern: /inline-css:/ })
|
|
expect(cssMatches.length).toBeGreaterThan(0)
|
|
|
|
const cssMatch = cssMatches[0]
|
|
const { content, totalLines } = await editor.read({ url: cssMatch.url })
|
|
expect(content).toContain('editor-test-element')
|
|
expect(content).toContain('rgb(255, 0, 0)')
|
|
expect(totalLines).toBeGreaterThan(0)
|
|
|
|
await cdpPage!.evaluate(() => {
|
|
const el = document.createElement('div')
|
|
el.className = 'editor-test-element'
|
|
el.id = 'test-div'
|
|
el.textContent = 'Test'
|
|
document.body.appendChild(el)
|
|
})
|
|
|
|
const colorBefore = await cdpPage!.evaluate(() => {
|
|
const el = document.getElementById('test-div')!
|
|
return window.getComputedStyle(el).color
|
|
})
|
|
expect(colorBefore).toBe('rgb(255, 0, 0)')
|
|
|
|
await editor.edit({
|
|
url: cssMatch.url,
|
|
oldString: 'color: rgb(255, 0, 0);',
|
|
newString: 'color: rgb(0, 255, 0);',
|
|
})
|
|
|
|
const colorAfter = await cdpPage!.evaluate(() => {
|
|
const el = document.getElementById('test-div')!
|
|
return window.getComputedStyle(el).color
|
|
})
|
|
expect(colorAfter).toBe('rgb(0, 255, 0)')
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should inject bippy and find React fiber with getReactSource', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
const page = await browserContext.newPage()
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script>
|
|
function MyComponent() {
|
|
return React.createElement('button', { id: 'react-btn' }, 'Click me');
|
|
}
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(React.createElement(MyComponent));
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`)
|
|
await page.bringToFront()
|
|
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
await new Promise(r => setTimeout(r, 500))
|
|
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
const pages = browser.contexts()[0].pages()
|
|
const cdpPage = pages.find(p => p.url().startsWith('about:'))
|
|
expect(cdpPage).toBeDefined()
|
|
|
|
const btn = cdpPage!.locator('#react-btn')
|
|
const btnCount = await btn.count()
|
|
expect(btnCount).toBe(1)
|
|
|
|
const hasBippyBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
|
|
expect(hasBippyBefore).toBe(false)
|
|
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
|
|
const { getReactSource } = await import('./react-source.js')
|
|
const source = await getReactSource({ locator: btn, cdp: cdpSession })
|
|
|
|
const hasBippyAfter = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
|
|
expect(hasBippyAfter).toBe(true)
|
|
|
|
const hasFiber = await btn.evaluate((el) => {
|
|
const bippy = (globalThis as any).__bippy
|
|
const fiber = bippy.getFiberFromHostInstance(el)
|
|
return !!fiber
|
|
})
|
|
expect(hasFiber).toBe(true)
|
|
|
|
const componentName = await btn.evaluate((el) => {
|
|
const bippy = (globalThis as any).__bippy
|
|
const fiber = bippy.getFiberFromHostInstance(el)
|
|
let current = fiber
|
|
while (current) {
|
|
if (bippy.isCompositeFiber(current)) {
|
|
return bippy.getDisplayName(current.type)
|
|
}
|
|
current = current.return
|
|
}
|
|
return null
|
|
})
|
|
expect(componentName).toBe('MyComponent')
|
|
|
|
console.log('Component name from fiber:', componentName)
|
|
console.log('Source location (null for UMD React, works on local dev servers with JSX transform):', source)
|
|
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
})
|
|
|
|
describe('Service Worker Target Tests', () => {
|
|
let testCtx: TestContext | null = null
|
|
|
|
beforeAll(async () => {
|
|
testCtx = await setupTestContext({ tempDirPrefix: 'pw-sw-test-' })
|
|
}, 600000)
|
|
|
|
afterAll(async () => {
|
|
await cleanupTestContext(testCtx)
|
|
testCtx = null
|
|
})
|
|
|
|
const getBrowserContext = () => {
|
|
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
|
return testCtx.browserContext
|
|
}
|
|
|
|
it('should not expose service worker targets to Playwright (issue #14)', async () => {
|
|
// This test verifies that service worker targets are NOT forwarded to Playwright.
|
|
// Issue #14: pages with service workers cause problems because Playwright tries to
|
|
// initialize service worker sessions with Runtime.enable/Network.enable, which
|
|
// times out waiting for executionContextCreated (service workers don't have main frames).
|
|
//
|
|
// The fix is to filter out non-page targets (service_worker, worker, etc.) in the
|
|
// server so Playwright never sees them.
|
|
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
// web.dev has a service worker - navigate there
|
|
const page = await browserContext.newPage()
|
|
await page.goto('https://web.dev/', { waitUntil: 'load' })
|
|
await page.bringToFront()
|
|
|
|
// Attach extension to the page
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab()
|
|
})
|
|
await new Promise(r => setTimeout(r, 500))
|
|
|
|
// Connect via Playwright CDP - this triggers Target.setAutoAttach
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
const context = browser.contexts()[0]
|
|
|
|
// Get all targets/pages that Playwright knows about
|
|
const pages = context.pages()
|
|
|
|
// All pages should be actual pages, not service workers or workers
|
|
for (const p of pages) {
|
|
const url = p.url()
|
|
console.log('Page URL:', url)
|
|
// Service workers would have URLs like https://web.dev/sw.js
|
|
expect(url).not.toMatch(/sw\.js$/i)
|
|
expect(url).not.toMatch(/service.?worker/i)
|
|
}
|
|
|
|
// Verify we can interact with the main page
|
|
const targetPage = pages.find(p => p.url().includes('web.dev'))
|
|
expect(targetPage).toBeDefined()
|
|
|
|
const title = await targetPage!.title()
|
|
expect(title).toBeTruthy()
|
|
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
|
|
it('should allow reading response bodies after re-enabling Network buffering', async () => {
|
|
// By default, the relay sets maxTotalBufferSize: 0 to fix SSE streaming.
|
|
// This test verifies that agents can re-enable buffering to read response bodies
|
|
// using Playwright's response.body() API after re-enabling Network buffering via CDP.
|
|
|
|
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()
|
|
|
|
// Get CDP session to re-enable buffering (must use getCDPSessionForPage, not newCDPSession)
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
|
|
|
// Re-enable Network domain with buffering to allow response.body() to work
|
|
await cdpSession.send('Network.disable')
|
|
await cdpSession.send('Network.enable', {
|
|
maxTotalBufferSize: 10000000,
|
|
maxResourceBufferSize: 5000000
|
|
})
|
|
|
|
// Use Playwright's response API to capture and read response body
|
|
const [response] = await Promise.all([
|
|
cdpPage!.waitForResponse(resp => resp.url() === 'https://example.com/'),
|
|
cdpPage!.goto('https://example.com/')
|
|
])
|
|
|
|
// Now response.body() should work because we re-enabled buffering
|
|
const body = await response.text()
|
|
|
|
expect(body).toBeDefined()
|
|
expect(body).toContain('Example Domain')
|
|
expect(body).toContain('</html>')
|
|
|
|
cdpSession.close()
|
|
await browser.close()
|
|
await page.close()
|
|
}, 60000)
|
|
})
|
|
|
|
describe('Auto-enable Tests', () => {
|
|
let testCtx: TestContext | null = null
|
|
|
|
// Set env var before any setup runs
|
|
process.env.PLAYWRITER_AUTO_ENABLE = '1'
|
|
|
|
beforeAll(async () => {
|
|
testCtx = await setupTestContext({ tempDirPrefix: 'pw-auto-test-' })
|
|
|
|
// Disconnect all tabs to start with a clean state
|
|
const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext)
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything()
|
|
})
|
|
await new Promise(r => setTimeout(r, 100))
|
|
}, 600000)
|
|
|
|
afterAll(async () => {
|
|
delete process.env.PLAYWRITER_AUTO_ENABLE
|
|
await cleanupTestContext(testCtx)
|
|
testCtx = null
|
|
})
|
|
|
|
const getBrowserContext = () => {
|
|
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
|
return testCtx.browserContext
|
|
}
|
|
|
|
it('should auto-create a tab when Playwright connects and no tabs exist', async () => {
|
|
const browserContext = getBrowserContext()
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
|
|
// Ensure clean state - disconnect any tabs from previous tests or setup
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything()
|
|
})
|
|
await new Promise(r => setTimeout(r, 100))
|
|
|
|
// Verify no tabs are connected
|
|
const tabCountBefore = await serviceWorker.evaluate(() => {
|
|
const state = globalThis.getExtensionState()
|
|
return state.tabs.size
|
|
})
|
|
expect(tabCountBefore).toBe(0)
|
|
|
|
// Connect Playwright - this should trigger auto-create
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
|
|
|
// Verify a page was auto-created
|
|
const pages = browser.contexts()[0].pages()
|
|
expect(pages.length).toBeGreaterThan(0)
|
|
expect(pages.length).toBe(1)
|
|
|
|
const autoCreatedPage = pages[0]
|
|
expect(autoCreatedPage.url()).toBe('about:blank')
|
|
|
|
// Verify extension state shows the tab as connected
|
|
const tabCountAfter = await serviceWorker.evaluate(() => {
|
|
const state = globalThis.getExtensionState()
|
|
return state.tabs.size
|
|
})
|
|
expect(tabCountAfter).toBe(1)
|
|
|
|
// Verify we can interact with the auto-created page
|
|
await autoCreatedPage.setContent('<h1>Auto-created page</h1>')
|
|
const title = await autoCreatedPage.locator('h1').textContent()
|
|
expect(title).toBe('Auto-created page')
|
|
|
|
await browser.close()
|
|
}, 60000)
|
|
})
|