1085 lines
46 KiB
JavaScript
1085 lines
46 KiB
JavaScript
import { createMCPClient } from './mcp-client.js';
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { chromium } from '@xmorse/playwright-core';
|
|
import { getCdpUrl } from './utils.js';
|
|
import { getCDPSessionForPage } from './cdp-session.js';
|
|
import { Debugger } from './debugger.js';
|
|
import { Editor } from './editor.js';
|
|
import { PlaywrightExecutor } from './executor.js';
|
|
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, createSseServer, safeCloseCDPBrowser, withTimeout, js, } from './test-utils.js';
|
|
import './test-declarations.js';
|
|
const TEST_PORT = 19993;
|
|
// --- CDP Session Tests ---
|
|
describe('CDP Session Tests', () => {
|
|
let testCtx = null;
|
|
beforeAll(async () => {
|
|
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-cdp-test-', toggleExtension: true });
|
|
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();
|
|
const testUrl = 'https://example.com/?test=debugger-variables';
|
|
await page.goto(testUrl);
|
|
await page.bringToFront();
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab();
|
|
});
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT });
|
|
const cdpSession = await getCDPSessionForPage({ page });
|
|
const dbg = new Debugger({ cdp: cdpSession });
|
|
await dbg.enable();
|
|
expect(dbg.isPaused()).toBe(false);
|
|
const pausedPromise = new Promise((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => {
|
|
resolve();
|
|
});
|
|
});
|
|
const evalPromise = cdpSession.send('Runtime.evaluate', {
|
|
expression: `(function testFunction() {
|
|
const localVar = 'hello';
|
|
const numberVar = 42;
|
|
debugger;
|
|
return localVar + numberVar;
|
|
})()`,
|
|
});
|
|
await Promise.race([
|
|
pausedPromise,
|
|
new Promise((_, 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);
|
|
await evalPromise;
|
|
await cdpSession.detach();
|
|
await page.close();
|
|
}, 60000);
|
|
it('should reuse cached CDP session and close on page close', async () => {
|
|
const browserContext = getBrowserContext();
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext);
|
|
const page = await browserContext.newPage();
|
|
const testUrl = 'https://example.com/?test=debugger-step';
|
|
await page.goto(testUrl);
|
|
await page.bringToFront();
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab();
|
|
});
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const executor = new PlaywrightExecutor({
|
|
cdpConfig: { port: TEST_PORT },
|
|
logger: {
|
|
log: () => { },
|
|
error: () => { },
|
|
},
|
|
});
|
|
const result = await executor.execute(js `
|
|
const sessionA = await getCDPSession({ page })
|
|
const sessionB = await getCDPSession({ page })
|
|
await sessionA.send('Runtime.evaluate', { expression: '1 + 1', returnByValue: true })
|
|
const evalResult = await sessionB.send('Runtime.evaluate', { expression: '2 + 2', returnByValue: true })
|
|
return evalResult.result.value
|
|
`);
|
|
expect(result.isError).toBe(false);
|
|
expect(result.text).toContain('[return value] 4');
|
|
await page.close();
|
|
}, 60000);
|
|
it('should list scripts with Debugger class', async () => {
|
|
const browserContext = getBrowserContext();
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext);
|
|
// Use setContent with external script URLs so Debugger.listScripts returns them
|
|
const page = await browserContext.newPage();
|
|
await page.setContent(`
|
|
<html>
|
|
<head>
|
|
<script src="data:text/javascript,function hello() { return 1; }"></script>
|
|
<script src="data:text/javascript,function world() { return 2; }"></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 }));
|
|
const cdpPage = browser
|
|
.contexts()[0]
|
|
.pages()
|
|
.find((p) => p.url().startsWith('about:'));
|
|
expect(cdpPage).toBeDefined();
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT });
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage });
|
|
const dbg = new Debugger({ cdp: cdpSession });
|
|
await dbg.enable();
|
|
const scripts = await dbg.listScripts();
|
|
expect(scripts.length).toBeGreaterThan(0);
|
|
expect(scripts[0]).toHaveProperty('scriptId');
|
|
expect(scripts[0]).toHaveProperty('url');
|
|
await cdpSession.detach();
|
|
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 wsUrl = getCdpUrl({ port: TEST_PORT });
|
|
const cdpSession = await getCDPSessionForPage({ page });
|
|
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);
|
|
await cdpSession.detach();
|
|
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 wsUrl = getCdpUrl({ port: TEST_PORT });
|
|
const cdpSession = await getCDPSessionForPage({ page });
|
|
const dbg = new Debugger({ cdp: cdpSession });
|
|
await dbg.enable();
|
|
const pausedPromise = new Promise((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve());
|
|
});
|
|
const evalPromise = cdpSession.send('Runtime.evaluate', {
|
|
expression: `(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((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((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();
|
|
await evalPromise;
|
|
await cdpSession.detach();
|
|
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();
|
|
const testUrl = 'https://example.com/?test=debugger-profiler';
|
|
await page.goto(testUrl);
|
|
await page.bringToFront();
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab();
|
|
});
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT });
|
|
const cdpSession = await getCDPSessionForPage({ page });
|
|
await cdpSession.send('Profiler.enable');
|
|
await cdpSession.send('Profiler.start');
|
|
await cdpSession.send('Runtime.evaluate', {
|
|
expression: `(() => {
|
|
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');
|
|
await cdpSession.detach();
|
|
await page.close();
|
|
}, 60000);
|
|
it('should update Target.getTargets URL after page navigation', async () => {
|
|
const browserContext = getBrowserContext();
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext);
|
|
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 });
|
|
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);
|
|
await cdpSession.detach();
|
|
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);
|
|
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 });
|
|
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/",
|
|
},
|
|
]
|
|
`);
|
|
await cdpSession.detach();
|
|
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 });
|
|
const evalResult = await cdpSession.send('Runtime.evaluate', {
|
|
expression: 'document.title',
|
|
returnByValue: true,
|
|
});
|
|
expect(evalResult.result.value).toContain('Example Domain');
|
|
await cdpSession.detach();
|
|
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 });
|
|
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);
|
|
await cdpSession.detach();
|
|
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 });
|
|
const dbg = new Debugger({ cdp: cdpSession });
|
|
await dbg.enable();
|
|
await dbg.setPauseOnExceptions({ state: 'all' });
|
|
const pausedPromise = new Promise((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve());
|
|
});
|
|
const evalPromise = cdpSession.send('Runtime.evaluate', {
|
|
expression: `(function() {
|
|
try {
|
|
throw new Error('Caught test error');
|
|
} catch (e) {
|
|
// caught but should still pause with state 'all'
|
|
}
|
|
})()`,
|
|
});
|
|
await Promise.race([
|
|
pausedPromise,
|
|
new Promise((_, 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 evalPromise;
|
|
await dbg.setPauseOnExceptions({ state: 'none' });
|
|
await cdpSession.detach();
|
|
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 });
|
|
const dbg = new Debugger({ cdp: cdpSession });
|
|
await dbg.enable();
|
|
const globalVars = await dbg.inspectGlobalVariables();
|
|
expect(globalVars).toMatchInlineSnapshot(`
|
|
[
|
|
"GLOBAL_CONFIG",
|
|
]
|
|
`);
|
|
const pausedPromise = new Promise((resolve) => {
|
|
cdpSession.on('Debugger.paused', () => resolve());
|
|
});
|
|
// Don't await - we want it to pause at breakpoint
|
|
const evalPromise = cdpPage.evaluate('runTest()').catch(() => {
|
|
// Ignore errors from evaluate when browser closes
|
|
});
|
|
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();
|
|
// Wait for evaluate to complete after resume
|
|
await evalPromise;
|
|
await cdpSession.detach();
|
|
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.clickedAt = null;
|
|
document.addEventListener('click', (e) => {
|
|
;
|
|
window.clickedAt = { x: e.clientX, y: e.clientY };
|
|
});
|
|
});
|
|
await cdpPage.locator('h1').click();
|
|
const clickedAt = await cdpPage.evaluate(() => window.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 });
|
|
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 = [];
|
|
cdpPage.on('console', (msg) => {
|
|
consoleLogs.push(msg.text());
|
|
});
|
|
await cdpPage.evaluate(() => {
|
|
;
|
|
window.greetUser('World');
|
|
});
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
expect(consoleLogs).toContain('Hello, World');
|
|
expect(consoleLogs).toContain('EDITOR_TEST_MARKER');
|
|
await cdpSession.detach();
|
|
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 });
|
|
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)');
|
|
await cdpSession.detach();
|
|
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.__bippy);
|
|
expect(hasBippyBefore).toBe(false);
|
|
const wsUrl = getCdpUrl({ port: TEST_PORT });
|
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage });
|
|
const { getReactSource } = await import('./react-source.js');
|
|
const source = await getReactSource({ locator: btn, cdp: cdpSession });
|
|
const hasBippyAfter = await cdpPage.evaluate(() => !!globalThis.__bippy);
|
|
expect(hasBippyAfter).toBe(true);
|
|
const hasFiber = await btn.evaluate((el) => {
|
|
const bippy = globalThis.__bippy;
|
|
const fiber = bippy.getFiberFromHostInstance(el);
|
|
return !!fiber;
|
|
});
|
|
expect(hasFiber).toBe(true);
|
|
const componentName = await btn.evaluate((el) => {
|
|
const bippy = globalThis.__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);
|
|
});
|
|
// --- Service Worker Target Tests ---
|
|
describe('Service Worker Target Tests', () => {
|
|
let testCtx = null;
|
|
beforeAll(async () => {
|
|
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-sw-test-', toggleExtension: true });
|
|
}, 600000);
|
|
afterAll(async () => {
|
|
await cleanupTestContext(testCtx);
|
|
testCtx = null;
|
|
});
|
|
const getBrowserContext = () => {
|
|
if (!testCtx?.browserContext)
|
|
throw new Error('Browser not initialized');
|
|
return testCtx.browserContext;
|
|
};
|
|
it('should not expose service worker targets to Playwright (issue #14)', async () => {
|
|
const browserContext = getBrowserContext();
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext);
|
|
const page = await browserContext.newPage();
|
|
await page.goto('https://web.dev/', { waitUntil: 'load' });
|
|
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 context = browser.contexts()[0];
|
|
const pages = context.pages();
|
|
for (const p of pages) {
|
|
const url = p.url();
|
|
console.log('Page URL:', url);
|
|
expect(url).not.toMatch(/sw\.js$/i);
|
|
expect(url).not.toMatch(/service.?worker/i);
|
|
}
|
|
const targetPage = pages.find((p) => p.url().includes('web.dev'));
|
|
expect(targetPage).toBeDefined();
|
|
const title = await targetPage.title();
|
|
expect(title).toBeTruthy();
|
|
await safeCloseCDPBrowser(browser);
|
|
await page.close();
|
|
}, 60000);
|
|
it('should allow reading response bodies after re-enabling Network buffering', 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 });
|
|
await cdpSession.send('Network.disable');
|
|
await cdpSession.send('Network.enable', {
|
|
maxTotalBufferSize: 10000000,
|
|
maxResourceBufferSize: 5000000,
|
|
});
|
|
const [response] = await Promise.all([
|
|
cdpPage.waitForResponse((resp) => resp.url() === 'https://example.com/'),
|
|
cdpPage.goto('https://example.com/'),
|
|
]);
|
|
const body = await response.text();
|
|
expect(body).toBeDefined();
|
|
expect(body).toContain('Example Domain');
|
|
expect(body).toContain('</html>');
|
|
await cdpSession.detach();
|
|
await browser.close();
|
|
await page.close();
|
|
}, 60000);
|
|
it('should stream SSE without waiting for response end', async () => {
|
|
const browserContext = getBrowserContext();
|
|
const serviceWorker = await withTimeout({
|
|
promise: getExtensionServiceWorker(browserContext),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'getExtensionServiceWorker timed out',
|
|
});
|
|
const sseServer = await withTimeout({
|
|
promise: createSseServer(),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'createSseServer timed out',
|
|
});
|
|
let page = null;
|
|
let browser = null;
|
|
try {
|
|
page = await withTimeout({
|
|
promise: browserContext.newPage(),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'newPage timed out',
|
|
});
|
|
await withTimeout({
|
|
promise: page.goto(`${sseServer.baseUrl}/`),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'page.goto timed out',
|
|
});
|
|
await page.bringToFront();
|
|
await withTimeout({
|
|
promise: serviceWorker.evaluate(async () => {
|
|
await globalThis.toggleExtensionForActiveTab();
|
|
}),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'toggleExtensionForActiveTab timed out',
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 100);
|
|
});
|
|
browser = await withTimeout({
|
|
promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'connectOverCDP timed out',
|
|
});
|
|
const cdpPage = browser
|
|
.contexts()[0]
|
|
.pages()
|
|
.find((p) => {
|
|
return p.url().startsWith(sseServer.baseUrl);
|
|
});
|
|
expect(cdpPage).toBeDefined();
|
|
await cdpPage.evaluate(() => {
|
|
return window.startSse();
|
|
});
|
|
await withTimeout({
|
|
promise: cdpPage.waitForFunction(() => {
|
|
return window.__sseMessages.length > 0;
|
|
}, { timeout: 5000 }),
|
|
timeoutMs: 7000,
|
|
errorMessage: 'SSE message not received in time',
|
|
});
|
|
const firstMessage = await cdpPage.evaluate(() => {
|
|
return window.__sseMessages[0];
|
|
});
|
|
expect(firstMessage).toBe('hello');
|
|
const sseState = sseServer.getState();
|
|
expect(sseState.connected).toBe(true);
|
|
expect(sseState.finished).toBe(false);
|
|
expect(sseState.closed).toBe(false);
|
|
expect(sseState.writeCount).toBeGreaterThan(0);
|
|
const readyState = await cdpPage.evaluate(() => {
|
|
if (!window.__sseSource) {
|
|
return -1;
|
|
}
|
|
return window.__sseSource.readyState;
|
|
});
|
|
expect(readyState).toBe(1);
|
|
await cdpPage.evaluate(() => {
|
|
window.stopSse();
|
|
});
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 100);
|
|
});
|
|
}
|
|
finally {
|
|
if (browser) {
|
|
await withTimeout({
|
|
promise: browser.close(),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'browser.close timed out',
|
|
});
|
|
}
|
|
if (page) {
|
|
await withTimeout({
|
|
promise: page.close(),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'page.close timed out',
|
|
});
|
|
}
|
|
await withTimeout({
|
|
promise: sseServer.close(),
|
|
timeoutMs: 5000,
|
|
errorMessage: 'sseServer.close timed out',
|
|
});
|
|
}
|
|
}, 60000);
|
|
});
|
|
// --- Auto-enable Tests ---
|
|
describe('Auto-enable Tests', () => {
|
|
let testCtx = null;
|
|
let client;
|
|
let cleanup = null;
|
|
beforeAll(async () => {
|
|
process.env.PLAYWRITER_AUTO_ENABLE = '1';
|
|
testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-auto-test-' });
|
|
const result = await createMCPClient({ port: TEST_PORT });
|
|
client = result.client;
|
|
cleanup = result.cleanup;
|
|
// 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, cleanup);
|
|
cleanup = null;
|
|
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);
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything();
|
|
});
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const tabCountBefore = await serviceWorker.evaluate(() => {
|
|
const state = globalThis.getExtensionState();
|
|
return state.tabs.size;
|
|
});
|
|
expect(tabCountBefore).toBe(0);
|
|
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }));
|
|
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');
|
|
const tabCountAfter = await serviceWorker.evaluate(() => {
|
|
const state = globalThis.getExtensionState();
|
|
return state.tabs.size;
|
|
});
|
|
expect(tabCountAfter).toBe(1);
|
|
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);
|
|
it('should auto-create a page when MCP executes with no connected pages', async () => {
|
|
const browserContext = getBrowserContext();
|
|
const serviceWorker = await getExtensionServiceWorker(browserContext);
|
|
await serviceWorker.evaluate(async () => {
|
|
await globalThis.disconnectEverything();
|
|
});
|
|
await new Promise((r) => {
|
|
setTimeout(r, 100);
|
|
});
|
|
const tabCountBefore = await serviceWorker.evaluate(() => {
|
|
const state = globalThis.getExtensionState();
|
|
return state.tabs.size;
|
|
});
|
|
expect(tabCountBefore).toBe(0);
|
|
const result = await client.callTool({
|
|
name: 'execute',
|
|
arguments: {
|
|
code: js `
|
|
return { pageCount: context.pages().length, url: page.url() };
|
|
`,
|
|
},
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
const text = result.content[0].text;
|
|
expect(text).toContain('pageCount');
|
|
expect(text).toContain('about:blank');
|
|
const tabCountAfter = await serviceWorker.evaluate(() => {
|
|
const state = globalThis.getExtensionState();
|
|
return state.tabs.size;
|
|
});
|
|
expect(tabCountAfter).toBe(1);
|
|
await client.callTool({
|
|
name: 'execute',
|
|
arguments: {
|
|
code: js `
|
|
await page.close();
|
|
return { remaining: context.pages().length };
|
|
`,
|
|
},
|
|
});
|
|
await new Promise((r) => {
|
|
setTimeout(r, 100);
|
|
});
|
|
const afterCloseResult = await client.callTool({
|
|
name: 'execute',
|
|
arguments: {
|
|
code: js `
|
|
return { pageCount: context.pages().length, url: page.url() };
|
|
`,
|
|
},
|
|
});
|
|
expect(afterCloseResult.isError).toBeFalsy();
|
|
const afterCloseText = afterCloseResult.content[0].text;
|
|
expect(afterCloseText).toContain('pageCount');
|
|
expect(afterCloseText).toContain('about:blank');
|
|
}, 60000);
|
|
});
|
|
//# sourceMappingURL=relay-session.test.js.map
|