playwriter 0.0.15 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/cdp-session.d.ts +21 -0
  2. package/dist/cdp-session.d.ts.map +1 -0
  3. package/dist/cdp-session.js +114 -0
  4. package/dist/cdp-session.js.map +1 -0
  5. package/dist/cdp-types.d.ts +15 -0
  6. package/dist/cdp-types.d.ts.map +1 -1
  7. package/dist/cdp-types.js.map +1 -1
  8. package/dist/create-logger.d.ts +9 -0
  9. package/dist/create-logger.d.ts.map +1 -0
  10. package/dist/create-logger.js +43 -0
  11. package/dist/create-logger.js.map +1 -0
  12. package/dist/extension/cdp-relay.d.ts +7 -3
  13. package/dist/extension/cdp-relay.d.ts.map +1 -1
  14. package/dist/extension/cdp-relay.js +46 -13
  15. package/dist/extension/cdp-relay.js.map +1 -1
  16. package/dist/mcp.js +52 -27
  17. package/dist/mcp.js.map +1 -1
  18. package/dist/mcp.test.d.ts.map +1 -1
  19. package/dist/mcp.test.js +625 -185
  20. package/dist/mcp.test.js.map +1 -1
  21. package/dist/prompt.md +36 -8
  22. package/dist/selector-generator.js +331 -0
  23. package/dist/start-relay-server.d.ts +1 -3
  24. package/dist/start-relay-server.d.ts.map +1 -1
  25. package/dist/start-relay-server.js +3 -16
  26. package/dist/start-relay-server.js.map +1 -1
  27. package/dist/utils.d.ts +3 -0
  28. package/dist/utils.d.ts.map +1 -1
  29. package/dist/utils.js +34 -0
  30. package/dist/utils.js.map +1 -1
  31. package/dist/wait-for-page-load.d.ts +16 -0
  32. package/dist/wait-for-page-load.d.ts.map +1 -0
  33. package/dist/wait-for-page-load.js +114 -0
  34. package/dist/wait-for-page-load.js.map +1 -0
  35. package/package.json +4 -2
  36. package/src/cdp-session.ts +142 -0
  37. package/src/cdp-types.ts +6 -0
  38. package/src/create-logger.ts +56 -0
  39. package/src/debugger.md +453 -0
  40. package/src/extension/cdp-relay.ts +57 -15
  41. package/src/mcp.test.ts +743 -191
  42. package/src/mcp.ts +63 -29
  43. package/src/prompt.md +36 -8
  44. package/src/snapshots/shadcn-ui-accessibility.md +94 -91
  45. package/src/start-relay-server.ts +3 -20
  46. package/src/utils.ts +43 -0
  47. package/src/wait-for-page-load.ts +162 -0
package/dist/mcp.test.js CHANGED
@@ -7,7 +7,10 @@ import path from 'node:path';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import { getCdpUrl } from './utils.js';
10
- import { spawn } from 'node:child_process';
10
+ import { imageSize } from 'image-size';
11
+ import { getCDPSessionForPage } from './cdp-session.js';
12
+ import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js';
13
+ import { createFileLogger } from './create-logger.js';
11
14
  const execAsync = promisify(exec);
12
15
  async function getExtensionServiceWorker(context) {
13
16
  let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'));
@@ -17,6 +20,15 @@ async function getExtensionServiceWorker(context) {
17
20
  predicate: (sw) => sw.url().startsWith('chrome-extension://')
18
21
  });
19
22
  }
23
+ for (let i = 0; i < 50; i++) {
24
+ const isReady = await serviceWorker.evaluate(() => {
25
+ // @ts-ignore
26
+ return typeof globalThis.toggleExtensionForActiveTab === 'function';
27
+ });
28
+ if (isReady)
29
+ break;
30
+ await new Promise(r => setTimeout(r, 100));
31
+ }
20
32
  return serviceWorker;
21
33
  }
22
34
  function js(strings, ...values) {
@@ -36,98 +48,94 @@ async function killProcessOnPort(port) {
36
48
  // No process running on port or already killed
37
49
  }
38
50
  }
51
+ async function setupTestContext({ tempDirPrefix }) {
52
+ await killProcessOnPort(19988);
53
+ console.log('Building extension...');
54
+ await execAsync('TESTING=1 pnpm build', { cwd: '../extension' });
55
+ console.log('Extension built');
56
+ const localLogPath = path.join(process.cwd(), 'relay-server.log');
57
+ const logger = createFileLogger({ logFilePath: localLogPath });
58
+ const relayServer = await startPlayWriterCDPRelayServer({ port: 19988, logger });
59
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix));
60
+ const extensionPath = path.resolve('../extension/dist');
61
+ const browserContext = await chromium.launchPersistentContext(userDataDir, {
62
+ channel: 'chromium',
63
+ headless: !process.env.HEADFUL,
64
+ args: [
65
+ `--disable-extensions-except=${extensionPath}`,
66
+ `--load-extension=${extensionPath}`,
67
+ ],
68
+ });
69
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
70
+ const page = await browserContext.newPage();
71
+ await page.goto('about:blank');
72
+ await serviceWorker.evaluate(async () => {
73
+ await globalThis.toggleExtensionForActiveTab();
74
+ });
75
+ return { browserContext, userDataDir, relayServer };
76
+ }
77
+ async function cleanupTestContext(ctx, cleanup) {
78
+ if (ctx?.browserContext) {
79
+ await ctx.browserContext.close();
80
+ }
81
+ if (ctx?.relayServer) {
82
+ ctx.relayServer.close();
83
+ }
84
+ if (ctx?.userDataDir) {
85
+ try {
86
+ fs.rmSync(ctx.userDataDir, { recursive: true, force: true });
87
+ }
88
+ catch (e) {
89
+ console.error('Failed to cleanup user data dir:', e);
90
+ }
91
+ }
92
+ if (cleanup) {
93
+ await cleanup();
94
+ }
95
+ }
39
96
  describe('MCP Server Tests', () => {
40
97
  let client;
41
98
  let cleanup = null;
42
- let browserContext = null;
43
- let userDataDir;
44
- let relayServerProcess;
99
+ let testCtx = null;
45
100
  beforeAll(async () => {
46
- await killProcessOnPort(19988);
47
- // Build extension
48
- console.log('Building extension...');
49
- await execAsync('TESTING=1 pnpm build', { cwd: '../extension' });
50
- console.log('Extension built');
51
- // Start Relay Server manually
52
- relayServerProcess = spawn('pnpm', ['tsx', 'src/start-relay-server.ts'], {
53
- cwd: process.cwd(),
54
- stdio: 'inherit'
55
- });
56
- // Wait for port 19988 to be ready
57
- await new Promise((resolve, reject) => {
58
- let retries = 0;
59
- const interval = setInterval(async () => {
60
- try {
61
- const { stdout } = await execAsync('lsof -ti:19988');
62
- if (stdout.trim()) {
63
- clearInterval(interval);
64
- resolve();
65
- }
66
- }
67
- catch {
68
- // ignore
69
- }
70
- retries++;
71
- if (retries > 30) {
72
- clearInterval(interval);
73
- reject(new Error('Relay server failed to start'));
74
- }
75
- }, 1000);
76
- });
101
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' });
77
102
  const result = await createMCPClient();
78
103
  client = result.client;
79
104
  cleanup = result.cleanup;
80
- userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-test-'));
81
- const extensionPath = path.resolve('../extension/dist');
82
- browserContext = await chromium.launchPersistentContext(userDataDir, {
83
- channel: 'chromium', // <- this opts into new headless
84
- headless: !process.env.HEADFUL,
85
- args: [
86
- `--disable-extensions-except=${extensionPath}`,
87
- `--load-extension=${extensionPath}`,
88
- ],
89
- });
90
- // Wait for service worker and connect
105
+ }, 600000);
106
+ afterAll(async () => {
107
+ await cleanupTestContext(testCtx, cleanup);
108
+ cleanup = null;
109
+ testCtx = null;
110
+ });
111
+ const getBrowserContext = () => {
112
+ if (!testCtx?.browserContext)
113
+ throw new Error('Browser not initialized');
114
+ return testCtx.browserContext;
115
+ };
116
+ it('should inject script via addScriptTag through CDP relay', async () => {
117
+ const browserContext = getBrowserContext();
91
118
  const serviceWorker = await getExtensionServiceWorker(browserContext);
92
- // Wait for extension to initialize global functions
93
- for (let i = 0; i < 50; i++) {
94
- const isReady = await serviceWorker.evaluate(() => {
95
- // @ts-ignore
96
- return typeof globalThis.toggleExtensionForActiveTab === 'function';
97
- });
98
- if (isReady)
99
- break;
100
- await new Promise(r => setTimeout(r, 100));
101
- }
102
- // Create a page to attach to
103
119
  const page = await browserContext.newPage();
104
- await page.goto('about:blank');
105
- // Connect the tab
120
+ await page.setContent('<html><body><button id="btn">Click</button></body></html>');
121
+ await page.bringToFront();
106
122
  await serviceWorker.evaluate(async () => {
107
123
  await globalThis.toggleExtensionForActiveTab();
108
124
  });
109
- }, 600000); // 10 minutes timeout
110
- afterAll(async () => {
111
- if (browserContext) {
112
- await browserContext.close();
113
- }
114
- if (relayServerProcess) {
115
- relayServerProcess.kill();
116
- }
117
- await killProcessOnPort(19988);
118
- if (userDataDir) {
119
- try {
120
- fs.rmSync(userDataDir, { recursive: true, force: true });
121
- }
122
- catch (e) {
123
- console.error('Failed to cleanup user data dir:', e);
124
- }
125
- }
126
- if (cleanup) {
127
- await cleanup();
128
- cleanup = null;
129
- }
130
- });
125
+ await new Promise(r => setTimeout(r, 500));
126
+ const browser = await chromium.connectOverCDP(getCdpUrl());
127
+ const cdpPage = browser.contexts()[0].pages().find(p => {
128
+ return p.url().startsWith('about:');
129
+ });
130
+ expect(cdpPage).toBeDefined();
131
+ const hasGlobalBefore = await cdpPage.evaluate(() => !!globalThis.__testGlobal);
132
+ expect(hasGlobalBefore).toBe(false);
133
+ await cdpPage.addScriptTag({ content: 'globalThis.__testGlobal = { foo: "bar" };' });
134
+ const hasGlobalAfter = await cdpPage.evaluate(() => globalThis.__testGlobal);
135
+ expect(hasGlobalAfter).toEqual({ foo: 'bar' });
136
+ await browser.close();
137
+ await page.close();
138
+ }, 60000);
131
139
  it('should execute code and capture console output', async () => {
132
140
  await client.callTool({
133
141
  name: 'execute',
@@ -169,8 +177,7 @@ describe('MCP Server Tests', () => {
169
177
  expect(result.content).toBeDefined();
170
178
  }, 30000);
171
179
  it('should show extension as connected for pages created via newPage()', async () => {
172
- if (!browserContext)
173
- throw new Error('Browser not initialized');
180
+ const browserContext = getBrowserContext();
174
181
  const serviceWorker = await getExtensionServiceWorker(browserContext);
175
182
  // Create a page via MCP (which uses context.newPage())
176
183
  await client.callTool({
@@ -290,9 +297,7 @@ describe('MCP Server Tests', () => {
290
297
  });
291
298
  });
292
299
  it('should handle new pages and toggling with new connections', async () => {
293
- if (!browserContext)
294
- throw new Error('Browser not initialized');
295
- // Find the correct service worker by URL
300
+ const browserContext = getBrowserContext();
296
301
  const serviceWorker = await getExtensionServiceWorker(browserContext);
297
302
  // 1. Create a new page
298
303
  const page = await browserContext.newPage();
@@ -356,8 +361,7 @@ describe('MCP Server Tests', () => {
356
361
  await page.close();
357
362
  });
358
363
  it('should handle new pages and toggling with persistent connection', async () => {
359
- if (!browserContext)
360
- throw new Error('Browser not initialized');
364
+ const browserContext = getBrowserContext();
361
365
  const serviceWorker = await getExtensionServiceWorker(browserContext);
362
366
  // Connect once
363
367
  const directBrowser = await chromium.connectOverCDP(getCdpUrl());
@@ -420,8 +424,7 @@ describe('MCP Server Tests', () => {
420
424
  await directBrowser.close();
421
425
  });
422
426
  it('should maintain connection across reloads and navigation', async () => {
423
- if (!browserContext)
424
- throw new Error('Browser not initialized');
427
+ const browserContext = getBrowserContext();
425
428
  const serviceWorker = await getExtensionServiceWorker(browserContext);
426
429
  // 1. Setup page
427
430
  const page = await browserContext.newPage();
@@ -459,8 +462,7 @@ describe('MCP Server Tests', () => {
459
462
  await page.close();
460
463
  });
461
464
  it('should support multiple concurrent tabs', async () => {
462
- if (!browserContext)
463
- throw new Error('Browser not initialized');
465
+ const browserContext = getBrowserContext();
464
466
  const serviceWorker = await getExtensionServiceWorker(browserContext);
465
467
  await new Promise(resolve => setTimeout(resolve, 500));
466
468
  // Tab A
@@ -511,83 +513,8 @@ describe('MCP Server Tests', () => {
511
513
  expect(results).toMatchInlineSnapshot(`
512
514
  [
513
515
  {
514
- "title": "🎄 Twelve Days of Shell 🎄",
515
- "url": "https://12days.cmdchallenge.com/",
516
- },
517
- {
518
- "title": "Example Domain",
519
- "url": "https://example.com/tab-a",
520
- },
521
- {
522
- "title": "Example Domain",
523
- "url": "https://example.com/tab-b",
524
- },
525
- ]
526
- `);
527
- // Verify execution on both pages
528
- const pageA_CDP = pages.find(p => p.url().includes('tab-a'));
529
- const pageB_CDP = pages.find(p => p.url().includes('tab-b'));
530
- expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20);
531
- expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40);
532
- await browser.close();
533
- await pageA.close();
534
- await pageB.close();
535
- });
536
- it('should support multiple concurrent tabs', async () => {
537
- if (!browserContext)
538
- throw new Error('Browser not initialized');
539
- const serviceWorker = await getExtensionServiceWorker(browserContext);
540
- await new Promise(resolve => setTimeout(resolve, 500));
541
- // Tab A
542
- const pageA = await browserContext.newPage();
543
- await pageA.goto('https://example.com/tab-a');
544
- await pageA.bringToFront();
545
- await new Promise(resolve => setTimeout(resolve, 500));
546
- await serviceWorker.evaluate(async () => {
547
- await globalThis.toggleExtensionForActiveTab();
548
- });
549
- // Tab B
550
- const pageB = await browserContext.newPage();
551
- await pageB.goto('https://example.com/tab-b');
552
- await pageB.bringToFront();
553
- await new Promise(resolve => setTimeout(resolve, 500));
554
- await serviceWorker.evaluate(async () => {
555
- await globalThis.toggleExtensionForActiveTab();
556
- });
557
- // Get target IDs for both
558
- const targetIds = await serviceWorker.evaluate(async () => {
559
- const state = globalThis.getExtensionState();
560
- const chrome = globalThis.chrome;
561
- const tabs = await chrome.tabs.query({});
562
- const tabA = tabs.find((t) => t.url?.includes('tab-a'));
563
- const tabB = tabs.find((t) => t.url?.includes('tab-b'));
564
- return {
565
- idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
566
- idB: state.tabs.get(tabB?.id ?? -1)?.targetId
567
- };
568
- });
569
- expect(targetIds).toMatchInlineSnapshot({
570
- idA: expect.any(String),
571
- idB: expect.any(String)
572
- }, `
573
- {
574
- "idA": Any<String>,
575
- "idB": Any<String>,
576
- }
577
- `);
578
- expect(targetIds.idA).not.toBe(targetIds.idB);
579
- // Verify independent connections
580
- const browser = await chromium.connectOverCDP(getCdpUrl());
581
- const pages = browser.contexts()[0].pages();
582
- const results = await Promise.all(pages.map(async (p) => ({
583
- url: p.url(),
584
- title: await p.title()
585
- })));
586
- expect(results).toMatchInlineSnapshot(`
587
- [
588
- {
589
- "title": "🎄 Twelve Days of Shell 🎄",
590
- "url": "https://12days.cmdchallenge.com/",
516
+ "title": "",
517
+ "url": "about:blank",
591
518
  },
592
519
  {
593
520
  "title": "Example Domain",
@@ -609,10 +536,8 @@ describe('MCP Server Tests', () => {
609
536
  await pageB.close();
610
537
  });
611
538
  it('should show correct url when enabling extension after navigation', async () => {
612
- if (!browserContext)
613
- throw new Error('Browser not initialized');
539
+ const browserContext = getBrowserContext();
614
540
  const serviceWorker = await getExtensionServiceWorker(browserContext);
615
- // 1. Open a new page (extension not yet enabled for it)
616
541
  const page = await browserContext.newPage();
617
542
  const targetUrl = 'https://example.com/late-enable';
618
543
  await page.goto(targetUrl);
@@ -634,10 +559,8 @@ describe('MCP Server Tests', () => {
634
559
  await page.close();
635
560
  });
636
561
  it('should be able to reconnect after disconnecting everything', async () => {
637
- if (!browserContext)
638
- throw new Error('Browser not initialized');
562
+ const browserContext = getBrowserContext();
639
563
  const serviceWorker = await getExtensionServiceWorker(browserContext);
640
- // 1. Use the existing about:blank page from beforeAll
641
564
  const pages = await browserContext.pages();
642
565
  expect(pages.length).toBeGreaterThan(0);
643
566
  const page = pages[0];
@@ -1062,8 +985,7 @@ describe('MCP Server Tests', () => {
1062
985
  });
1063
986
  }, 30000);
1064
987
  it('should maintain correct page.url() with service worker pages', async () => {
1065
- if (!browserContext)
1066
- throw new Error('Browser not initialized');
988
+ const browserContext = getBrowserContext();
1067
989
  const serviceWorker = await getExtensionServiceWorker(browserContext);
1068
990
  const page = await browserContext.newPage();
1069
991
  const targetUrl = 'https://x.com';
@@ -1083,8 +1005,7 @@ describe('MCP Server Tests', () => {
1083
1005
  await page.close();
1084
1006
  }, 30000);
1085
1007
  it('should maintain correct page.url() after repeated connections', async () => {
1086
- if (!browserContext)
1087
- throw new Error('Browser not initialized');
1008
+ const browserContext = getBrowserContext();
1088
1009
  const serviceWorker = await getExtensionServiceWorker(browserContext);
1089
1010
  const page = await browserContext.newPage();
1090
1011
  const targetUrl = 'https://example.com/repeated-test';
@@ -1105,8 +1026,7 @@ describe('MCP Server Tests', () => {
1105
1026
  await page.close();
1106
1027
  }, 30000);
1107
1028
  it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
1108
- if (!browserContext)
1109
- throw new Error('Browser not initialized');
1029
+ const browserContext = getBrowserContext();
1110
1030
  const serviceWorker = await getExtensionServiceWorker(browserContext);
1111
1031
  const page = await browserContext.newPage();
1112
1032
  const targetUrl = 'https://example.com/concurrent-test';
@@ -1138,8 +1058,7 @@ describe('MCP Server Tests', () => {
1138
1058
  await page.close();
1139
1059
  }, 30000);
1140
1060
  it('should maintain correct page.url() with iframe-heavy pages', async () => {
1141
- if (!browserContext)
1142
- throw new Error('Browser not initialized');
1061
+ const browserContext = getBrowserContext();
1143
1062
  const serviceWorker = await getExtensionServiceWorker(browserContext);
1144
1063
  const page = await browserContext.newPage();
1145
1064
  const targetUrl = 'https://www.youtube.com';
@@ -1160,9 +1079,306 @@ describe('MCP Server Tests', () => {
1160
1079
  }
1161
1080
  await page.close();
1162
1081
  }, 60000);
1082
+ it('should capture screenshot correctly', async () => {
1083
+ const browserContext = getBrowserContext();
1084
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1085
+ const page = await browserContext.newPage();
1086
+ await page.goto('https://example.com/');
1087
+ await page.bringToFront();
1088
+ await serviceWorker.evaluate(async () => {
1089
+ await globalThis.toggleExtensionForActiveTab();
1090
+ });
1091
+ await new Promise(r => setTimeout(r, 500));
1092
+ const capturedCommands = [];
1093
+ const commandHandler = ({ command }) => {
1094
+ if (command.method === 'Page.captureScreenshot') {
1095
+ capturedCommands.push(command);
1096
+ }
1097
+ };
1098
+ testCtx.relayServer.on('cdp:command', commandHandler);
1099
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1100
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'));
1101
+ expect(cdpPage).toBeDefined();
1102
+ const viewportSize = cdpPage.viewportSize();
1103
+ console.log('Viewport size:', viewportSize);
1104
+ const viewportScreenshot = await cdpPage.screenshot();
1105
+ expect(viewportScreenshot).toBeDefined();
1106
+ const viewportDimensions = imageSize(viewportScreenshot);
1107
+ console.log('Viewport screenshot dimensions:', viewportDimensions);
1108
+ expect(viewportDimensions.width).toBeGreaterThan(0);
1109
+ expect(viewportDimensions.height).toBeGreaterThan(0);
1110
+ if (viewportSize) {
1111
+ expect(viewportDimensions.width).toBe(viewportSize.width);
1112
+ expect(viewportDimensions.height).toBe(viewportSize.height);
1113
+ }
1114
+ const fullPageScreenshot = await cdpPage.screenshot({ fullPage: true });
1115
+ expect(fullPageScreenshot).toBeDefined();
1116
+ const fullPageDimensions = imageSize(fullPageScreenshot);
1117
+ console.log('Full page screenshot dimensions:', fullPageDimensions);
1118
+ expect(fullPageDimensions.width).toBeGreaterThan(0);
1119
+ expect(fullPageDimensions.height).toBeGreaterThan(0);
1120
+ expect(fullPageDimensions.width).toBeGreaterThanOrEqual(viewportDimensions.width);
1121
+ testCtx.relayServer.off('cdp:command', commandHandler);
1122
+ expect(capturedCommands.length).toBe(2);
1123
+ expect(capturedCommands.map(c => ({
1124
+ method: c.method,
1125
+ params: c.params
1126
+ }))).toMatchInlineSnapshot(`
1127
+ [
1128
+ {
1129
+ "method": "Page.captureScreenshot",
1130
+ "params": {
1131
+ "captureBeyondViewport": false,
1132
+ "clip": {
1133
+ "height": 720,
1134
+ "scale": 1,
1135
+ "width": 1280,
1136
+ "x": 0,
1137
+ "y": 0,
1138
+ },
1139
+ "format": "png",
1140
+ },
1141
+ },
1142
+ {
1143
+ "method": "Page.captureScreenshot",
1144
+ "params": {
1145
+ "captureBeyondViewport": false,
1146
+ "clip": {
1147
+ "height": 528,
1148
+ "scale": 1,
1149
+ "width": 1280,
1150
+ "x": 0,
1151
+ "y": 0,
1152
+ },
1153
+ "format": "png",
1154
+ },
1155
+ },
1156
+ ]
1157
+ `);
1158
+ const screenshotPath = path.join(os.tmpdir(), 'playwriter-test-screenshot.png');
1159
+ fs.writeFileSync(screenshotPath, viewportScreenshot);
1160
+ console.log('Screenshot saved to:', screenshotPath);
1161
+ await browser.close();
1162
+ await page.close();
1163
+ }, 60000);
1164
+ it('should capture element screenshot with correct coordinates', async () => {
1165
+ const browserContext = getBrowserContext();
1166
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1167
+ const target = { x: 200, y: 150, width: 300, height: 100 };
1168
+ const scrolledTarget = { x: 100, y: 1500, width: 200, height: 80 };
1169
+ const page = await browserContext.newPage();
1170
+ await page.setContent(`
1171
+ <html>
1172
+ <head>
1173
+ <style>
1174
+ body { margin: 0; padding: 0; height: 2000px; }
1175
+ #target {
1176
+ position: absolute;
1177
+ top: ${target.y}px;
1178
+ left: ${target.x}px;
1179
+ width: ${target.width}px;
1180
+ height: ${target.height}px;
1181
+ background: red;
1182
+ }
1183
+ #scrolled-target {
1184
+ position: absolute;
1185
+ top: ${scrolledTarget.y}px;
1186
+ left: ${scrolledTarget.x}px;
1187
+ width: ${scrolledTarget.width}px;
1188
+ height: ${scrolledTarget.height}px;
1189
+ background: blue;
1190
+ }
1191
+ </style>
1192
+ </head>
1193
+ <body>
1194
+ <div id="target">Target Element</div>
1195
+ <div id="scrolled-target">Scrolled Target</div>
1196
+ </body>
1197
+ </html>
1198
+ `);
1199
+ await page.bringToFront();
1200
+ await serviceWorker.evaluate(async () => {
1201
+ await globalThis.toggleExtensionForActiveTab();
1202
+ });
1203
+ await new Promise(r => setTimeout(r, 500));
1204
+ const capturedCommands = [];
1205
+ const commandHandler = ({ command }) => {
1206
+ if (command.method === 'Page.captureScreenshot') {
1207
+ capturedCommands.push(command);
1208
+ }
1209
+ };
1210
+ testCtx.relayServer.on('cdp:command', commandHandler);
1211
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1212
+ let cdpPage;
1213
+ for (const p of browser.contexts()[0].pages()) {
1214
+ const html = await p.content();
1215
+ if (html.includes('scrolled-target')) {
1216
+ cdpPage = p;
1217
+ break;
1218
+ }
1219
+ }
1220
+ expect(cdpPage).toBeDefined();
1221
+ await cdpPage.locator('#target').screenshot();
1222
+ await cdpPage.locator('#scrolled-target').screenshot();
1223
+ testCtx.relayServer.off('cdp:command', commandHandler);
1224
+ expect(capturedCommands.length).toBe(2);
1225
+ const targetCmd = capturedCommands[0];
1226
+ expect(targetCmd.method).toBe('Page.captureScreenshot');
1227
+ const targetClip = targetCmd.params.clip;
1228
+ expect(targetClip.x).toBe(target.x);
1229
+ expect(targetClip.y).toBe(target.y);
1230
+ expect(targetClip.width).toBe(target.width);
1231
+ expect(targetClip.height).toBe(target.height);
1232
+ const scrolledCmd = capturedCommands[1];
1233
+ expect(scrolledCmd.method).toBe('Page.captureScreenshot');
1234
+ const scrolledClip = scrolledCmd.params.clip;
1235
+ expect(scrolledClip.x).toBe(scrolledTarget.x);
1236
+ expect(scrolledClip.y).toBe(scrolledTarget.y);
1237
+ expect(scrolledClip.width).toBe(scrolledTarget.width);
1238
+ expect(scrolledClip.height).toBe(scrolledTarget.height);
1239
+ await browser.close();
1240
+ await page.close();
1241
+ }, 60000);
1242
+ it('should get locator string for element using getLocatorStringForElement', async () => {
1243
+ const browserContext = getBrowserContext();
1244
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1245
+ const page = await browserContext.newPage();
1246
+ await page.setContent(`
1247
+ <html>
1248
+ <body>
1249
+ <button id="test-btn">Click Me</button>
1250
+ <input type="text" placeholder="Enter name" />
1251
+ </body>
1252
+ </html>
1253
+ `);
1254
+ await page.bringToFront();
1255
+ await serviceWorker.evaluate(async () => {
1256
+ await globalThis.toggleExtensionForActiveTab();
1257
+ });
1258
+ await new Promise(r => setTimeout(r, 500));
1259
+ const result = await client.callTool({
1260
+ name: 'execute',
1261
+ arguments: {
1262
+ code: js `
1263
+ let testPage;
1264
+ for (const p of context.pages()) {
1265
+ const html = await p.content();
1266
+ if (html.includes('test-btn')) { testPage = p; break; }
1267
+ }
1268
+ if (!testPage) throw new Error('Test page not found');
1269
+ const btn = testPage.locator('#test-btn');
1270
+ const locatorString = await getLocatorStringForElement(btn);
1271
+ console.log('Locator string:', locatorString);
1272
+ const locatorFromString = eval('testPage.' + locatorString);
1273
+ const count = await locatorFromString.count();
1274
+ console.log('Locator count:', count);
1275
+ const text = await locatorFromString.textContent();
1276
+ console.log('Locator text:', text);
1277
+ `,
1278
+ timeout: 30000,
1279
+ },
1280
+ });
1281
+ expect(result.isError).toBeFalsy();
1282
+ const text = result.content[0]?.text || '';
1283
+ expect(text).toContain('Locator string:');
1284
+ expect(text).toContain("getByRole('button', { name: 'Click Me' })");
1285
+ expect(text).toContain('Locator count: 1');
1286
+ expect(text).toContain('Locator text: Click Me');
1287
+ await page.close();
1288
+ }, 60000);
1289
+ it('should return correct layout metrics via CDP', async () => {
1290
+ const browserContext = getBrowserContext();
1291
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1292
+ const page = await browserContext.newPage();
1293
+ await page.goto('https://example.com/');
1294
+ await page.bringToFront();
1295
+ await serviceWorker.evaluate(async () => {
1296
+ await globalThis.toggleExtensionForActiveTab();
1297
+ });
1298
+ await new Promise(r => setTimeout(r, 500));
1299
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1300
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'));
1301
+ expect(cdpPage).toBeDefined();
1302
+ const wsUrl = getCdpUrl();
1303
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage, wsUrl });
1304
+ const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics');
1305
+ const normalized = {
1306
+ cssLayoutViewport: layoutMetrics.cssLayoutViewport,
1307
+ cssVisualViewport: layoutMetrics.cssVisualViewport,
1308
+ layoutViewport: layoutMetrics.layoutViewport,
1309
+ visualViewport: layoutMetrics.visualViewport,
1310
+ devicePixelRatio: layoutMetrics.cssVisualViewport.clientWidth > 0
1311
+ ? layoutMetrics.visualViewport.clientWidth / layoutMetrics.cssVisualViewport.clientWidth
1312
+ : 1,
1313
+ };
1314
+ expect(normalized).toMatchInlineSnapshot(`
1315
+ {
1316
+ "cssLayoutViewport": {
1317
+ "clientHeight": 720,
1318
+ "clientWidth": 1280,
1319
+ "pageX": 0,
1320
+ "pageY": 0,
1321
+ },
1322
+ "cssVisualViewport": {
1323
+ "clientHeight": 720,
1324
+ "clientWidth": 1280,
1325
+ "offsetX": 0,
1326
+ "offsetY": 0,
1327
+ "pageX": 0,
1328
+ "pageY": 0,
1329
+ "scale": 1,
1330
+ "zoom": 1,
1331
+ },
1332
+ "devicePixelRatio": 1,
1333
+ "layoutViewport": {
1334
+ "clientHeight": 720,
1335
+ "clientWidth": 1280,
1336
+ "pageX": 0,
1337
+ "pageY": 0,
1338
+ },
1339
+ "visualViewport": {
1340
+ "clientHeight": 720,
1341
+ "clientWidth": 1280,
1342
+ "offsetX": 0,
1343
+ "offsetY": 0,
1344
+ "pageX": 0,
1345
+ "pageY": 0,
1346
+ "scale": 1,
1347
+ "zoom": 1,
1348
+ },
1349
+ }
1350
+ `);
1351
+ const windowDpr = await cdpPage.evaluate(() => globalThis.devicePixelRatio);
1352
+ console.log('window.devicePixelRatio:', windowDpr);
1353
+ expect(windowDpr).toBe(1);
1354
+ cdpSession.detach();
1355
+ await browser.close();
1356
+ await page.close();
1357
+ }, 60000);
1358
+ it('should support getCDPSession through the relay', async () => {
1359
+ const browserContext = getBrowserContext();
1360
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1361
+ const page = await browserContext.newPage();
1362
+ await page.goto('https://example.com/');
1363
+ await page.bringToFront();
1364
+ await serviceWorker.evaluate(async () => {
1365
+ await globalThis.toggleExtensionForActiveTab();
1366
+ });
1367
+ await new Promise(r => setTimeout(r, 500));
1368
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1369
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'));
1370
+ expect(cdpPage).toBeDefined();
1371
+ const wsUrl = getCdpUrl();
1372
+ const client = await getCDPSessionForPage({ page: cdpPage, wsUrl });
1373
+ const layoutMetrics = await client.send('Page.getLayoutMetrics');
1374
+ expect(layoutMetrics.cssVisualViewport).toBeDefined();
1375
+ expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0);
1376
+ client.detach();
1377
+ await browser.close();
1378
+ await page.close();
1379
+ }, 60000);
1163
1380
  it('should work with stagehand', async () => {
1164
- if (!browserContext)
1165
- throw new Error('Browser not initialized');
1381
+ const browserContext = getBrowserContext();
1166
1382
  const serviceWorker = await getExtensionServiceWorker(browserContext);
1167
1383
  await serviceWorker.evaluate(async () => {
1168
1384
  await globalThis.disconnectEverything();
@@ -1210,4 +1426,228 @@ function tryJsonParse(str) {
1210
1426
  return str;
1211
1427
  }
1212
1428
  }
1429
+ describe('CDP Session Tests', () => {
1430
+ let testCtx = null;
1431
+ beforeAll(async () => {
1432
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-cdp-test-' });
1433
+ }, 600000);
1434
+ afterAll(async () => {
1435
+ await cleanupTestContext(testCtx);
1436
+ testCtx = null;
1437
+ });
1438
+ const getBrowserContext = () => {
1439
+ if (!testCtx?.browserContext)
1440
+ throw new Error('Browser not initialized');
1441
+ return testCtx.browserContext;
1442
+ };
1443
+ it('should enable debugger and pause on debugger statement via CDP session', async () => {
1444
+ const browserContext = getBrowserContext();
1445
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1446
+ const page = await browserContext.newPage();
1447
+ await page.goto('https://example.com/');
1448
+ await page.bringToFront();
1449
+ await serviceWorker.evaluate(async () => {
1450
+ await globalThis.toggleExtensionForActiveTab();
1451
+ });
1452
+ await new Promise(r => setTimeout(r, 500));
1453
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1454
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'));
1455
+ expect(cdpPage).toBeDefined();
1456
+ const wsUrl = getCdpUrl();
1457
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage, wsUrl });
1458
+ await cdpSession.send('Debugger.enable');
1459
+ const pausedPromise = new Promise((resolve) => {
1460
+ cdpSession.on('Debugger.paused', (params) => {
1461
+ resolve(params);
1462
+ });
1463
+ });
1464
+ cdpPage.evaluate(`
1465
+ (function testFunction() {
1466
+ const localVar = 'hello';
1467
+ const numberVar = 42;
1468
+ const objVar = { key: 'value', nested: { a: 1 } };
1469
+ debugger;
1470
+ return localVar + numberVar;
1471
+ })()
1472
+ `);
1473
+ const pausedEvent = await Promise.race([
1474
+ pausedPromise,
1475
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
1476
+ ]);
1477
+ const stackTrace = pausedEvent.callFrames.map(frame => ({
1478
+ functionName: frame.functionName || '(anonymous)',
1479
+ lineNumber: frame.location.lineNumber,
1480
+ columnNumber: frame.location.columnNumber,
1481
+ }));
1482
+ expect({
1483
+ reason: pausedEvent.reason,
1484
+ stackTrace: stackTrace.slice(0, 3),
1485
+ }).toMatchInlineSnapshot(`
1486
+ {
1487
+ "reason": "other",
1488
+ "stackTrace": [
1489
+ {
1490
+ "columnNumber": 16,
1491
+ "functionName": "testFunction",
1492
+ "lineNumber": 4,
1493
+ },
1494
+ {
1495
+ "columnNumber": 14,
1496
+ "functionName": "(anonymous)",
1497
+ "lineNumber": 6,
1498
+ },
1499
+ {
1500
+ "columnNumber": 29,
1501
+ "functionName": "evaluate",
1502
+ "lineNumber": 289,
1503
+ },
1504
+ ],
1505
+ }
1506
+ `);
1507
+ const topFrame = pausedEvent.callFrames[0];
1508
+ const scopeChain = topFrame.scopeChain;
1509
+ const localScope = scopeChain.find(s => s.type === 'local');
1510
+ const localVars = {};
1511
+ if (localScope?.object.objectId) {
1512
+ const propsResult = await cdpSession.send('Runtime.getProperties', {
1513
+ objectId: localScope.object.objectId,
1514
+ ownProperties: true,
1515
+ });
1516
+ for (const prop of propsResult.result) {
1517
+ if (prop.value) {
1518
+ localVars[prop.name] = prop.value.type === 'object'
1519
+ ? `[object ${prop.value.className || prop.value.subtype || 'Object'}]`
1520
+ : prop.value.value;
1521
+ }
1522
+ }
1523
+ }
1524
+ expect({
1525
+ scopeTypes: scopeChain.map(s => s.type),
1526
+ localVariables: localVars,
1527
+ }).toMatchInlineSnapshot(`
1528
+ {
1529
+ "localVariables": {
1530
+ "localVar": "hello",
1531
+ "numberVar": 42,
1532
+ "objVar": "[object Object]",
1533
+ },
1534
+ "scopeTypes": [
1535
+ "local",
1536
+ "global",
1537
+ ],
1538
+ }
1539
+ `);
1540
+ const evalResult = await cdpSession.send('Debugger.evaluateOnCallFrame', {
1541
+ callFrameId: topFrame.callFrameId,
1542
+ expression: 'localVar + " world " + numberVar',
1543
+ });
1544
+ expect({
1545
+ evaluatedExpression: 'localVar + " world " + numberVar',
1546
+ result: evalResult.result.value,
1547
+ type: evalResult.result.type,
1548
+ }).toMatchInlineSnapshot(`
1549
+ {
1550
+ "evaluatedExpression": "localVar + " world " + numberVar",
1551
+ "result": "hello world 42",
1552
+ "type": "string",
1553
+ }
1554
+ `);
1555
+ await cdpSession.send('Debugger.resume');
1556
+ await cdpSession.send('Debugger.disable');
1557
+ cdpSession.detach();
1558
+ await browser.close();
1559
+ await page.close();
1560
+ }, 60000);
1561
+ it('should profile JavaScript execution using CDP Profiler', async () => {
1562
+ const browserContext = getBrowserContext();
1563
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1564
+ const page = await browserContext.newPage();
1565
+ await page.goto('https://example.com/');
1566
+ await page.bringToFront();
1567
+ await serviceWorker.evaluate(async () => {
1568
+ await globalThis.toggleExtensionForActiveTab();
1569
+ });
1570
+ await new Promise(r => setTimeout(r, 500));
1571
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1572
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'));
1573
+ expect(cdpPage).toBeDefined();
1574
+ const wsUrl = getCdpUrl();
1575
+ const cdpSession = await getCDPSessionForPage({ page: cdpPage, wsUrl });
1576
+ await cdpSession.send('Profiler.enable');
1577
+ await cdpSession.send('Profiler.start');
1578
+ await cdpPage.evaluate(`
1579
+ (() => {
1580
+ function fibonacci(n) {
1581
+ if (n <= 1) return n
1582
+ return fibonacci(n - 1) + fibonacci(n - 2)
1583
+ }
1584
+ for (let i = 0; i < 5; i++) {
1585
+ fibonacci(20)
1586
+ }
1587
+ for (let i = 0; i < 1000; i++) {
1588
+ document.querySelectorAll('*')
1589
+ }
1590
+ })()
1591
+ `);
1592
+ const stopResult = await cdpSession.send('Profiler.stop');
1593
+ const profile = stopResult.profile;
1594
+ const functionNames = profile.nodes
1595
+ .map(n => n.callFrame.functionName)
1596
+ .filter(name => name && name.length > 0)
1597
+ .slice(0, 10);
1598
+ expect({
1599
+ hasNodes: profile.nodes.length > 0,
1600
+ nodeCount: profile.nodes.length,
1601
+ durationMicroseconds: profile.endTime - profile.startTime,
1602
+ sampleFunctionNames: functionNames,
1603
+ }).toMatchInlineSnapshot(`
1604
+ {
1605
+ "durationMicroseconds": 11057,
1606
+ "hasNodes": true,
1607
+ "nodeCount": 4,
1608
+ "sampleFunctionNames": [
1609
+ "(root)",
1610
+ "(program)",
1611
+ "(idle)",
1612
+ "fibonacci",
1613
+ ],
1614
+ }
1615
+ `);
1616
+ await cdpSession.send('Profiler.disable');
1617
+ cdpSession.detach();
1618
+ await browser.close();
1619
+ await page.close();
1620
+ }, 60000);
1621
+ it('should click at correct coordinates on high-DPI simulation', async () => {
1622
+ const browserContext = getBrowserContext();
1623
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1624
+ const page = await browserContext.newPage();
1625
+ await page.goto('https://example.com/');
1626
+ await page.bringToFront();
1627
+ await serviceWorker.evaluate(async () => {
1628
+ await globalThis.toggleExtensionForActiveTab();
1629
+ });
1630
+ await new Promise(r => setTimeout(r, 500));
1631
+ const browser = await chromium.connectOverCDP(getCdpUrl());
1632
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'));
1633
+ expect(cdpPage).toBeDefined();
1634
+ const h1Bounds = await cdpPage.locator('h1').boundingBox();
1635
+ expect(h1Bounds).toBeDefined();
1636
+ console.log('H1 bounding box:', h1Bounds);
1637
+ await cdpPage.evaluate(() => {
1638
+ window.clickedAt = null;
1639
+ document.addEventListener('click', (e) => {
1640
+ window.clickedAt = { x: e.clientX, y: e.clientY };
1641
+ });
1642
+ });
1643
+ await cdpPage.locator('h1').click();
1644
+ const clickedAt = await cdpPage.evaluate(() => window.clickedAt);
1645
+ console.log('Clicked at:', clickedAt);
1646
+ expect(clickedAt).toBeDefined();
1647
+ expect(clickedAt.x).toBeGreaterThan(0);
1648
+ expect(clickedAt.y).toBeGreaterThan(0);
1649
+ await browser.close();
1650
+ await page.close();
1651
+ }, 60000);
1652
+ });
1213
1653
  //# sourceMappingURL=mcp.test.js.map