playwriter 0.0.33 → 0.0.37

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 (65) hide show
  1. package/dist/aria-snapshot.d.ts +68 -0
  2. package/dist/aria-snapshot.d.ts.map +1 -0
  3. package/dist/aria-snapshot.js +359 -0
  4. package/dist/aria-snapshot.js.map +1 -0
  5. package/dist/cdp-relay.d.ts.map +1 -1
  6. package/dist/cdp-relay.js +95 -5
  7. package/dist/cdp-relay.js.map +1 -1
  8. package/dist/cdp-session.d.ts +24 -3
  9. package/dist/cdp-session.d.ts.map +1 -1
  10. package/dist/cdp-session.js +23 -0
  11. package/dist/cdp-session.js.map +1 -1
  12. package/dist/debugger-api.md +4 -3
  13. package/dist/debugger.d.ts +4 -3
  14. package/dist/debugger.d.ts.map +1 -1
  15. package/dist/debugger.js +3 -1
  16. package/dist/debugger.js.map +1 -1
  17. package/dist/editor-api.md +2 -2
  18. package/dist/editor.d.ts +2 -2
  19. package/dist/editor.d.ts.map +1 -1
  20. package/dist/editor.js +1 -0
  21. package/dist/editor.js.map +1 -1
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp.d.ts.map +1 -1
  27. package/dist/mcp.js +151 -14
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/mcp.test.js +340 -5
  30. package/dist/mcp.test.js.map +1 -1
  31. package/dist/protocol.d.ts +12 -1
  32. package/dist/protocol.d.ts.map +1 -1
  33. package/dist/react-source.d.ts +3 -3
  34. package/dist/react-source.d.ts.map +1 -1
  35. package/dist/react-source.js +3 -1
  36. package/dist/react-source.js.map +1 -1
  37. package/dist/scoped-fs.d.ts +94 -0
  38. package/dist/scoped-fs.d.ts.map +1 -0
  39. package/dist/scoped-fs.js +356 -0
  40. package/dist/scoped-fs.js.map +1 -0
  41. package/dist/styles-api.md +3 -3
  42. package/dist/styles.d.ts +3 -3
  43. package/dist/styles.d.ts.map +1 -1
  44. package/dist/styles.js +3 -1
  45. package/dist/styles.js.map +1 -1
  46. package/package.json +13 -13
  47. package/src/aria-snapshot.ts +446 -0
  48. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  49. package/src/assets/aria-labels-github.png +0 -0
  50. package/src/assets/aria-labels-google-snapshot.txt +110 -0
  51. package/src/assets/aria-labels-google.png +0 -0
  52. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  53. package/src/assets/aria-labels-hacker-news.png +0 -0
  54. package/src/cdp-relay.ts +103 -5
  55. package/src/cdp-session.ts +50 -3
  56. package/src/debugger.ts +6 -4
  57. package/src/editor.ts +4 -3
  58. package/src/index.ts +8 -0
  59. package/src/mcp.test.ts +424 -5
  60. package/src/mcp.ts +242 -66
  61. package/src/prompt.md +209 -167
  62. package/src/protocol.ts +14 -1
  63. package/src/react-source.ts +5 -3
  64. package/src/scoped-fs.ts +411 -0
  65. package/src/styles.ts +5 -3
package/dist/mcp.test.js CHANGED
@@ -167,8 +167,8 @@ describe('MCP Server Tests', () => {
167
167
 
168
168
  Return value:
169
169
  {
170
- \"url\": \"https://example.com/\",
171
- \"title\": \"Example Domain\"
170
+ "url": "https://example.com/",
171
+ "title": "Example Domain"
172
172
  }",
173
173
  "type": "text",
174
174
  },
@@ -557,7 +557,7 @@ describe('MCP Server Tests', () => {
557
557
  expect(cdpPage?.url()).toBe(targetUrl);
558
558
  await browser.close();
559
559
  await page.close();
560
- });
560
+ }, 60000);
561
561
  it('should be able to reconnect after disconnecting everything', async () => {
562
562
  const browserContext = getBrowserContext();
563
563
  const serviceWorker = await getExtensionServiceWorker(browserContext);
@@ -676,6 +676,73 @@ describe('MCP Server Tests', () => {
676
676
  // Clean up - navigate page back to about:blank to not interfere with other tests
677
677
  await page.goto('about:blank');
678
678
  });
679
+ it('should auto-reconnect MCP after extension WebSocket reconnects', async () => {
680
+ // This test verifies that the MCP automatically reconnects when the browser
681
+ // disconnects (e.g., when the extension WebSocket reconnects and the relay
682
+ // server closes all playwright clients). The fix adds browser.on('disconnected')
683
+ // handler that clears state.isConnected, so ensureConnection() creates a new connection.
684
+ const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext);
685
+ // 1. Create a test page and enable extension
686
+ const page = await testCtx.browserContext.newPage();
687
+ await page.goto('https://example.com/auto-reconnect-test');
688
+ await page.waitForLoadState('domcontentloaded');
689
+ await page.bringToFront();
690
+ const initialEnable = await serviceWorker.evaluate(async () => {
691
+ return await globalThis.toggleExtensionForActiveTab();
692
+ });
693
+ expect(initialEnable.isConnected).toBe(true);
694
+ await new Promise(resolve => setTimeout(resolve, 100));
695
+ // 2. Verify MCP can execute commands
696
+ const beforeResult = await client.callTool({
697
+ name: 'execute',
698
+ arguments: {
699
+ code: js `
700
+ const pages = context.pages();
701
+ const testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
702
+ return { pagesCount: pages.length, foundTestPage: !!testPage };
703
+ `,
704
+ },
705
+ });
706
+ const beforeOutput = beforeResult.content[0].text;
707
+ expect(beforeOutput).toContain('foundTestPage');
708
+ expect(beforeOutput).toContain('true');
709
+ // 3. Simulate extension WebSocket reconnection
710
+ // This causes relay server to close all playwright client WebSockets
711
+ await serviceWorker.evaluate(async () => {
712
+ await globalThis.disconnectEverything();
713
+ });
714
+ await new Promise(resolve => setTimeout(resolve, 100));
715
+ // Re-enable extension (simulates extension reconnecting)
716
+ await page.bringToFront();
717
+ const reconnectResult = await serviceWorker.evaluate(async () => {
718
+ return await globalThis.toggleExtensionForActiveTab();
719
+ });
720
+ expect(reconnectResult.isConnected).toBe(true);
721
+ await new Promise(resolve => setTimeout(resolve, 100));
722
+ // 4. Execute command WITHOUT calling resetPlaywright()
723
+ // The browser.on('disconnected') handler should have cleared state.isConnected,
724
+ // causing ensureConnection() to automatically create a new connection
725
+ const afterResult = await client.callTool({
726
+ name: 'execute',
727
+ arguments: {
728
+ code: js `
729
+ const pages = context.pages();
730
+ const testPage = pages.find(p => p.url().includes('auto-reconnect-test'));
731
+ return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() };
732
+ `,
733
+ },
734
+ });
735
+ const afterOutput = afterResult.content[0].text;
736
+ // The command should succeed and find our test page
737
+ expect(afterOutput).toContain('foundTestPage');
738
+ expect(afterOutput).toContain('true');
739
+ expect(afterOutput).toContain('auto-reconnect-test');
740
+ // Should NOT contain error about extension not connected
741
+ expect(afterOutput).not.toContain('Extension not connected');
742
+ expect(afterResult.isError).not.toBe(true);
743
+ // Clean up
744
+ await page.goto('about:blank');
745
+ });
679
746
  it('should capture browser console logs with getLatestLogs', async () => {
680
747
  // Ensure clean state and clear any existing logs
681
748
  const resetResult = await client.callTool({
@@ -1057,6 +1124,73 @@ describe('MCP Server Tests', () => {
1057
1124
  await cdpBrowser.close();
1058
1125
  await page.close();
1059
1126
  }, 30000);
1127
+ it('should be usable after toggle with valid URL', async () => {
1128
+ // This test validates the extension properly waits for valid URLs before
1129
+ // sending Target.attachedToTarget. Uses Discord - a heavy React SPA.
1130
+ //
1131
+ // We use waitForEvent('page') to wait for Playwright to process the event.
1132
+ // The KEY assertion is that when the event fires, the URL is VALID (not empty).
1133
+ // Before the fix: event fired with empty URL -> page broken forever
1134
+ // After the fix: event fires with valid URL -> page works immediately
1135
+ const _browserContext = getBrowserContext();
1136
+ const serviceWorker = await getExtensionServiceWorker(_browserContext);
1137
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }));
1138
+ const context = browser.contexts()[0];
1139
+ const page = await _browserContext.newPage();
1140
+ await page.goto('https://discord.com/login');
1141
+ await page.bringToFront();
1142
+ // Set up listener BEFORE toggle
1143
+ const pagePromise = context.waitForEvent('page', { timeout: 10000 });
1144
+ // Toggle extension - extension waits for valid URL before sending event
1145
+ await serviceWorker.evaluate(async () => {
1146
+ await globalThis.toggleExtensionForActiveTab();
1147
+ });
1148
+ // Wait for page event
1149
+ const targetPage = await pagePromise;
1150
+ console.log('Page URL when event fired:', targetPage.url());
1151
+ // KEY ASSERTION: URL must NOT be empty - this is what the extension fix guarantees
1152
+ expect(targetPage.url()).not.toBe('');
1153
+ expect(targetPage.url()).not.toBe(':');
1154
+ expect(targetPage.url()).toContain('discord.com');
1155
+ // evaluate() works immediately - no waiting needed
1156
+ const result = await targetPage.evaluate(() => window.location.href);
1157
+ expect(result).toContain('discord.com');
1158
+ await browser.close();
1159
+ await page.close();
1160
+ }, 60000);
1161
+ it('should have non-empty URLs when connecting to already-loaded pages', async () => {
1162
+ // This test validates that when we connect to a browser with already-loaded pages,
1163
+ // all pages have non-empty URLs. Empty URLs break Playwright permanently.
1164
+ const _browserContext = getBrowserContext();
1165
+ const serviceWorker = await getExtensionServiceWorker(_browserContext);
1166
+ // Create and fully load a heavy page BEFORE connecting
1167
+ const page = await _browserContext.newPage();
1168
+ await page.goto('https://discord.com/login', { waitUntil: 'load' });
1169
+ await page.bringToFront();
1170
+ // Toggle extension to attach to the loaded page
1171
+ await serviceWorker.evaluate(async () => {
1172
+ await globalThis.toggleExtensionForActiveTab();
1173
+ });
1174
+ // NOW connect via CDP - page should already be attached
1175
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }));
1176
+ const context = browser.contexts()[0];
1177
+ // Get all pages and verify NONE have empty URLs
1178
+ const pages = context.pages();
1179
+ console.log('All page URLs:', pages.map(p => p.url()));
1180
+ expect(pages.length).toBeGreaterThan(0);
1181
+ for (const p of pages) {
1182
+ expect(p.url()).not.toBe('');
1183
+ expect(p.url()).not.toBe(':');
1184
+ expect(p.url()).not.toBeUndefined();
1185
+ }
1186
+ // Find Discord page and verify it works
1187
+ const discordPage = pages.find(p => p.url().includes('discord.com'));
1188
+ expect(discordPage).toBeDefined();
1189
+ const result = await discordPage.evaluate(() => window.location.href);
1190
+ expect(result).toContain('discord.com');
1191
+ await browser.close();
1192
+ await page.close();
1193
+ }, 60000);
1060
1194
  it('should maintain correct page.url() with iframe-heavy pages', async () => {
1061
1195
  const browserContext = getBrowserContext();
1062
1196
  const serviceWorker = await getExtensionServiceWorker(browserContext);
@@ -1647,8 +1781,8 @@ describe('MCP Server Tests', () => {
1647
1781
  {
1648
1782
  "text": "Return value:
1649
1783
  {
1650
- "error": "Page not found",
1651
- "urls": []
1784
+ \"matchesDark\": false,
1785
+ \"matchesLight\": true
1652
1786
  }",
1653
1787
  "type": "text",
1654
1788
  },
@@ -1656,6 +1790,154 @@ describe('MCP Server Tests', () => {
1656
1790
  `);
1657
1791
  await page.close();
1658
1792
  }, 60000);
1793
+ it('should get aria ref for locator using getAriaSnapshot', async () => {
1794
+ const browserContext = getBrowserContext();
1795
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1796
+ const page = await browserContext.newPage();
1797
+ await page.setContent(`
1798
+ <html>
1799
+ <body>
1800
+ <button id="submit-btn">Submit Form</button>
1801
+ <a href="/about">About Us</a>
1802
+ <input type="text" placeholder="Enter your name" />
1803
+ </body>
1804
+ </html>
1805
+ `);
1806
+ await page.bringToFront();
1807
+ await serviceWorker.evaluate(async () => {
1808
+ await globalThis.toggleExtensionForActiveTab();
1809
+ });
1810
+ await new Promise(r => setTimeout(r, 400));
1811
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }));
1812
+ let cdpPage;
1813
+ for (const p of browser.contexts()[0].pages()) {
1814
+ const html = await p.content();
1815
+ if (html.includes('submit-btn')) {
1816
+ cdpPage = p;
1817
+ break;
1818
+ }
1819
+ }
1820
+ expect(cdpPage).toBeDefined();
1821
+ const { getAriaSnapshot } = await import('./aria-snapshot.js');
1822
+ // Get aria snapshot and verify we can get refs
1823
+ const ariaResult = await getAriaSnapshot({ page: cdpPage });
1824
+ expect(ariaResult.snapshot).toBeDefined();
1825
+ expect(ariaResult.snapshot.length).toBeGreaterThan(0);
1826
+ expect(ariaResult.snapshot).toContain('Submit Form');
1827
+ // Verify refToElement map is populated
1828
+ expect(ariaResult.refToElement.size).toBeGreaterThan(0);
1829
+ console.log('RefToElement map size:', ariaResult.refToElement.size);
1830
+ console.log('RefToElement entries:', [...ariaResult.refToElement.entries()]);
1831
+ // Verify we can select elements using aria-ref selectors
1832
+ const btnViaAriaRef = cdpPage.locator('aria-ref=e2');
1833
+ const btnTextViaRef = await btnViaAriaRef.textContent();
1834
+ console.log('Button text via aria-ref=e2:', btnTextViaRef);
1835
+ expect(btnTextViaRef).toBe('Submit Form');
1836
+ // Get ref for the submit button using getRefForLocator
1837
+ const submitBtn = cdpPage.locator('#submit-btn');
1838
+ const btnAriaRef = await ariaResult.getRefForLocator(submitBtn);
1839
+ console.log('Button ariaRef:', btnAriaRef);
1840
+ expect(btnAriaRef).toBeDefined();
1841
+ expect(btnAriaRef?.role).toBe('button');
1842
+ expect(btnAriaRef?.name).toBe('Submit Form');
1843
+ expect(btnAriaRef?.ref).toMatch(/^e\d+$/);
1844
+ // Verify the ref matches what we can use to select
1845
+ const btnFromRef = cdpPage.locator(`aria-ref=${btnAriaRef?.ref}`);
1846
+ const btnText = await btnFromRef.textContent();
1847
+ expect(btnText).toBe('Submit Form');
1848
+ // Test getRefStringForLocator
1849
+ const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn);
1850
+ console.log('Button ref string:', btnRefStr);
1851
+ expect(btnRefStr).toBe(btnAriaRef?.ref);
1852
+ // Test link
1853
+ const aboutLink = cdpPage.locator('a');
1854
+ const linkAriaRef = await ariaResult.getRefForLocator(aboutLink);
1855
+ console.log('Link ariaRef:', linkAriaRef);
1856
+ expect(linkAriaRef).toBeDefined();
1857
+ expect(linkAriaRef?.role).toBe('link');
1858
+ expect(linkAriaRef?.name).toBe('About Us');
1859
+ // Verify the link ref works
1860
+ const linkFromRef = cdpPage.locator(`aria-ref=${linkAriaRef?.ref}`);
1861
+ const linkText = await linkFromRef.textContent();
1862
+ expect(linkText).toBe('About Us');
1863
+ // Test input field
1864
+ const inputField = cdpPage.locator('input');
1865
+ const inputAriaRef = await ariaResult.getRefForLocator(inputField);
1866
+ console.log('Input ariaRef:', inputAriaRef);
1867
+ expect(inputAriaRef).toBeDefined();
1868
+ expect(inputAriaRef?.role).toBe('textbox');
1869
+ // Test batch getRefsForLocators - single evaluate call for multiple elements
1870
+ const batchRefs = await ariaResult.getRefsForLocators([submitBtn, aboutLink, inputField]);
1871
+ console.log('Batch refs:', batchRefs);
1872
+ expect(batchRefs).toHaveLength(3);
1873
+ expect(batchRefs[0]?.ref).toBe(btnAriaRef?.ref);
1874
+ expect(batchRefs[1]?.ref).toBe(linkAriaRef?.ref);
1875
+ expect(batchRefs[2]?.ref).toBe(inputAriaRef?.ref);
1876
+ await browser.close();
1877
+ await page.close();
1878
+ }, 60000);
1879
+ it('should show aria ref labels on real pages and save screenshots', async () => {
1880
+ const browserContext = getBrowserContext();
1881
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
1882
+ const { showAriaRefLabels, hideAriaRefLabels } = await import('./aria-snapshot.js');
1883
+ const fs = await import('node:fs');
1884
+ const path = await import('node:path');
1885
+ // Create assets folder for screenshots
1886
+ const assetsDir = path.join(path.dirname(new URL(import.meta.url).pathname), 'assets');
1887
+ if (!fs.existsSync(assetsDir)) {
1888
+ fs.mkdirSync(assetsDir, { recursive: true });
1889
+ }
1890
+ const testPages = [
1891
+ { name: 'hacker-news', url: 'https://news.ycombinator.com/' },
1892
+ { name: 'google', url: 'https://www.google.com/' },
1893
+ { name: 'github', url: 'https://github.com/' },
1894
+ ];
1895
+ // Create all pages and enable extension for each
1896
+ const pages = await Promise.all(testPages.map(async ({ name, url }) => {
1897
+ const page = await browserContext.newPage();
1898
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
1899
+ return { name, url, page };
1900
+ }));
1901
+ // Enable extension for each tab (must be done sequentially as it uses active tab)
1902
+ for (const { page } of pages) {
1903
+ await page.bringToFront();
1904
+ await serviceWorker.evaluate(async () => {
1905
+ await globalThis.toggleExtensionForActiveTab();
1906
+ });
1907
+ }
1908
+ // Connect CDP and process all pages concurrently
1909
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }));
1910
+ await Promise.all(pages.map(async ({ name, url, page }) => {
1911
+ const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes(new URL(url).hostname));
1912
+ if (!cdpPage) {
1913
+ console.log(`Could not find CDP page for ${name}, skipping...`);
1914
+ return;
1915
+ }
1916
+ // Show aria ref labels
1917
+ const { snapshot, labelCount } = await showAriaRefLabels({ page: cdpPage });
1918
+ console.log(`${name}: ${labelCount} labels shown`);
1919
+ expect(labelCount).toBeGreaterThan(0);
1920
+ // Take screenshot with labels visible
1921
+ const screenshot = await cdpPage.screenshot({ type: 'png', fullPage: false });
1922
+ const screenshotPath = path.join(assetsDir, `aria-labels-${name}.png`);
1923
+ fs.writeFileSync(screenshotPath, screenshot);
1924
+ console.log(`Screenshot saved: ${screenshotPath}`);
1925
+ // Save snapshot text for reference
1926
+ const snapshotPath = path.join(assetsDir, `aria-labels-${name}-snapshot.txt`);
1927
+ fs.writeFileSync(snapshotPath, snapshot);
1928
+ // Verify labels are in DOM
1929
+ const labelElements = await cdpPage.evaluate(() => document.querySelectorAll('.__pw_label__').length);
1930
+ expect(labelElements).toBe(labelCount);
1931
+ // Cleanup
1932
+ await hideAriaRefLabels({ page: cdpPage });
1933
+ // Verify labels removed
1934
+ const labelsAfterHide = await cdpPage.evaluate(() => document.getElementById('__playwriter_labels__'));
1935
+ expect(labelsAfterHide).toBeNull();
1936
+ await page.close();
1937
+ }));
1938
+ await browser.close();
1939
+ console.log(`Screenshots saved to: ${assetsDir}`);
1940
+ }, 120000);
1659
1941
  });
1660
1942
  function tryJsonParse(str) {
1661
1943
  try {
@@ -2397,4 +2679,57 @@ describe('CDP Session Tests', () => {
2397
2679
  await page.close();
2398
2680
  }, 60000);
2399
2681
  });
2682
+ describe('Auto-enable Tests', () => {
2683
+ let testCtx = null;
2684
+ // Set env var before any setup runs
2685
+ process.env.PLAYWRITER_AUTO_ENABLE = '1';
2686
+ beforeAll(async () => {
2687
+ testCtx = await setupTestContext({ tempDirPrefix: 'pw-auto-test-' });
2688
+ // Disconnect all tabs to start with a clean state
2689
+ const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext);
2690
+ await serviceWorker.evaluate(async () => {
2691
+ await globalThis.disconnectEverything();
2692
+ });
2693
+ await new Promise(r => setTimeout(r, 100));
2694
+ }, 600000);
2695
+ afterAll(async () => {
2696
+ delete process.env.PLAYWRITER_AUTO_ENABLE;
2697
+ await cleanupTestContext(testCtx);
2698
+ testCtx = null;
2699
+ });
2700
+ const getBrowserContext = () => {
2701
+ if (!testCtx?.browserContext)
2702
+ throw new Error('Browser not initialized');
2703
+ return testCtx.browserContext;
2704
+ };
2705
+ it('should auto-create a tab when Playwright connects and no tabs exist', async () => {
2706
+ const browserContext = getBrowserContext();
2707
+ const serviceWorker = await getExtensionServiceWorker(browserContext);
2708
+ // Verify no tabs are connected
2709
+ const tabCountBefore = await serviceWorker.evaluate(() => {
2710
+ const state = globalThis.getExtensionState();
2711
+ return state.tabs.size;
2712
+ });
2713
+ expect(tabCountBefore).toBe(0);
2714
+ // Connect Playwright - this should trigger auto-create
2715
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }));
2716
+ // Verify a page was auto-created
2717
+ const pages = browser.contexts()[0].pages();
2718
+ expect(pages.length).toBeGreaterThan(0);
2719
+ expect(pages.length).toBe(1);
2720
+ const autoCreatedPage = pages[0];
2721
+ expect(autoCreatedPage.url()).toBe('about:blank');
2722
+ // Verify extension state shows the tab as connected
2723
+ const tabCountAfter = await serviceWorker.evaluate(() => {
2724
+ const state = globalThis.getExtensionState();
2725
+ return state.tabs.size;
2726
+ });
2727
+ expect(tabCountAfter).toBe(1);
2728
+ // Verify we can interact with the auto-created page
2729
+ await autoCreatedPage.setContent('<h1>Auto-created page</h1>');
2730
+ const title = await autoCreatedPage.locator('h1').textContent();
2731
+ expect(title).toBe('Auto-created page');
2732
+ await browser.close();
2733
+ }, 60000);
2734
+ });
2400
2735
  //# sourceMappingURL=mcp.test.js.map