playwriter 0.0.63 → 0.0.89

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 (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. package/src/wait-for-page-load.ts +6 -1
@@ -1,116 +1,304 @@
1
1
  import { createMCPClient } from './mcp-client.js'
2
2
  import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
+ import { chromium } from '@xmorse/playwright-core'
3
4
  import { getCDPSessionForPage } from './cdp-session.js'
4
- import { getCdpUrl } from './utils.js'
5
- import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, js, tryJsonParse } from './test-utils.js'
5
+ import { getCdpUrl, LOG_CDP_FILE_PATH } from './utils.js'
6
+ import fs from 'node:fs'
7
+ import {
8
+ setupTestContext,
9
+ cleanupTestContext,
10
+ getExtensionServiceWorker,
11
+ type TestContext,
12
+ withTimeout,
13
+ js,
14
+ tryJsonParse,
15
+ createSimpleServer,
16
+ } from './test-utils.js'
6
17
  import './test-declarations.js'
7
18
 
8
19
  const TEST_PORT = 19987
9
20
 
10
21
  describe('Relay Core Tests', () => {
11
- let client: Awaited<ReturnType<typeof createMCPClient>>['client']
12
- let cleanup: (() => Promise<void>) | null = null
13
- let testCtx: TestContext | null = null
22
+ let client: Awaited<ReturnType<typeof createMCPClient>>['client']
23
+ let cleanup: (() => Promise<void>) | null = null
24
+ let testCtx: TestContext | null = null
25
+
26
+ beforeAll(async () => {
27
+ testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true })
28
+
29
+ const result = await createMCPClient({ port: TEST_PORT })
30
+ client = result.client
31
+ cleanup = result.cleanup
32
+ }, 600000)
33
+
34
+ afterAll(async () => {
35
+ await cleanupTestContext(testCtx, cleanup)
36
+ cleanup = null
37
+ testCtx = null
38
+ })
39
+
40
+ const getBrowserContext = () => {
41
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
42
+ return testCtx.browserContext
43
+ }
44
+
45
+ const ensureConnectedTabForExecute = async (): Promise<void> => {
46
+ const browserContext = getBrowserContext()
47
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
48
+ const connectedTabCount = await serviceWorker.evaluate(async () => {
49
+ const state = globalThis.getExtensionState()
50
+ return state.tabs.size
51
+ })
52
+ if (connectedTabCount > 0) {
53
+ return
54
+ }
14
55
 
15
- beforeAll(async () => {
16
- testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true })
56
+ const page = await browserContext.newPage()
57
+ await page.goto('about:blank')
58
+ await page.bringToFront()
17
59
 
18
- const result = await createMCPClient({ port: TEST_PORT })
19
- client = result.client
20
- cleanup = result.cleanup
21
- }, 600000)
60
+ await serviceWorker.evaluate(async () => {
61
+ await globalThis.toggleExtensionForActiveTab()
62
+ })
22
63
 
23
- afterAll(async () => {
24
- await cleanupTestContext(testCtx, cleanup)
25
- cleanup = null
26
- testCtx = null
64
+ await new Promise((r) => {
65
+ setTimeout(r, 100)
66
+ })
67
+ }
68
+
69
+ it('should inject script via addScriptTag through CDP relay', async () => {
70
+ const browserContext = getBrowserContext()
71
+ const serviceWorker = await withTimeout({
72
+ promise: getExtensionServiceWorker(browserContext),
73
+ timeoutMs: 5000,
74
+ errorMessage: 'Timed out waiting for extension service worker for iframe test',
27
75
  })
28
76
 
29
- const getBrowserContext = () => {
30
- if (!testCtx?.browserContext) throw new Error('Browser not initialized')
31
- return testCtx.browserContext
32
- }
77
+ const page = await browserContext.newPage()
78
+ const html = '<html><body><button id="btn">Click</button></body></html>'
79
+ const dataUrl = `data:text/html,${encodeURIComponent(html)}`
80
+ await page.goto(dataUrl)
81
+ await page.bringToFront()
82
+
83
+ await withTimeout({
84
+ promise: serviceWorker.evaluate(async () => {
85
+ await globalThis.toggleExtensionForActiveTab()
86
+ }),
87
+ timeoutMs: 10000,
88
+ errorMessage: 'Timed out toggling extension for active tab',
89
+ })
90
+ await new Promise((r) => {
91
+ setTimeout(r, 100)
92
+ })
33
93
 
34
- it('should inject script via addScriptTag through CDP relay', async () => {
35
- const browserContext = getBrowserContext()
36
- const serviceWorker = await withTimeout({
37
- promise: getExtensionServiceWorker(browserContext),
38
- timeoutMs: 5000,
39
- errorMessage: 'Timed out waiting for extension service worker for iframe test',
40
- })
94
+ const cdpSession = await withTimeout({
95
+ promise: getCDPSessionForPage({ page }),
96
+ timeoutMs: 10000,
97
+ errorMessage: 'Timed out creating CDP session for page',
98
+ })
41
99
 
42
- const page = await browserContext.newPage()
43
- const html = '<html><body><button id="btn">Click</button></body></html>'
44
- const dataUrl = `data:text/html,${encodeURIComponent(html)}`
45
- await page.goto(dataUrl)
46
- await page.bringToFront()
47
-
48
- await withTimeout({
49
- promise: serviceWorker.evaluate(async () => {
50
- await globalThis.toggleExtensionForActiveTab()
51
- }),
52
- timeoutMs: 10000,
53
- errorMessage: 'Timed out toggling extension for active tab',
54
- })
55
- await new Promise((r) => { setTimeout(r, 100) })
100
+ const hasGlobalBefore = await page.evaluate(() => {
101
+ return Boolean((globalThis as { __testGlobal?: unknown }).__testGlobal)
102
+ })
103
+ expect(hasGlobalBefore).toBe(false)
56
104
 
57
- const cdpSession = await withTimeout({
58
- promise: getCDPSessionForPage({ page }),
59
- timeoutMs: 10000,
60
- errorMessage: 'Timed out creating CDP session for page',
105
+ await withTimeout({
106
+ promise: (async () => {
107
+ await cdpSession.send('Page.enable')
108
+ await cdpSession.send('Page.addScriptToEvaluateOnNewDocument', {
109
+ source: 'globalThis.__testGlobal = { foo: "bar" }',
61
110
  })
111
+ await page.reload({ waitUntil: 'domcontentloaded' })
112
+ })(),
113
+ timeoutMs: 10000,
114
+ errorMessage: 'Timed out injecting script via CDP session',
115
+ })
62
116
 
63
- const hasGlobalBefore = await page.evaluate(() => {
64
- return Boolean((globalThis as { __testGlobal?: unknown }).__testGlobal)
65
- })
66
- expect(hasGlobalBefore).toBe(false)
67
-
68
- await withTimeout({
69
- promise: (async () => {
70
- await cdpSession.send('Page.enable')
71
- await cdpSession.send('Page.addScriptToEvaluateOnNewDocument', {
72
- source: 'globalThis.__testGlobal = { foo: "bar" }',
73
- })
74
- await page.reload({ waitUntil: 'domcontentloaded' })
75
- })(),
76
- timeoutMs: 10000,
77
- errorMessage: 'Timed out injecting script via CDP session',
78
- })
117
+ const hasGlobalAfter = await page.evaluate(() => {
118
+ return (globalThis as { __testGlobal?: unknown }).__testGlobal
119
+ })
120
+ expect(hasGlobalAfter).toEqual({ foo: 'bar' })
121
+
122
+ await cdpSession.detach()
123
+ await page.close()
124
+ }, 60000)
125
+
126
+ it('should emit download events for both Browser and Page domains in extension mode', async () => {
127
+ const browserContext = getBrowserContext()
128
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
129
+ const logFilePath = LOG_CDP_FILE_PATH
130
+ const logLineCountBefore = fs.existsSync(logFilePath)
131
+ ? fs
132
+ .readFileSync(logFilePath, 'utf-8')
133
+ .split('\n')
134
+ .filter((line) => {
135
+ return line.trim().length > 0
136
+ }).length
137
+ : 0
138
+
139
+ const server = await createSimpleServer({
140
+ routes: {
141
+ '/': `<!doctype html>
142
+ <html>
143
+ <body>
144
+ <button id="download-button">Download</button>
145
+ <script>
146
+ const button = document.getElementById('download-button');
147
+ button.addEventListener('click', () => {
148
+ const blob = new Blob(['playwriter-download-test'], { type: 'text/plain' });
149
+ const url = URL.createObjectURL(blob);
150
+ const anchor = document.createElement('a');
151
+ anchor.href = url;
152
+ anchor.download = 'playwriter-download-test.txt';
153
+ document.body.appendChild(anchor);
154
+ anchor.click();
155
+ anchor.remove();
156
+ setTimeout(() => {
157
+ URL.revokeObjectURL(url);
158
+ }, 1000);
159
+ });
160
+ </script>
161
+ </body>
162
+ </html>`,
163
+ },
164
+ })
79
165
 
80
- const hasGlobalAfter = await page.evaluate(() => {
81
- return (globalThis as { __testGlobal?: unknown }).__testGlobal
82
- })
83
- expect(hasGlobalAfter).toEqual({ foo: 'bar' })
166
+ const page = await browserContext.newPage()
167
+ await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
168
+ await page.bringToFront()
84
169
 
85
- await cdpSession.detach()
86
- await page.close()
87
- }, 60000)
170
+ await serviceWorker.evaluate(async () => {
171
+ await globalThis.toggleExtensionForActiveTab()
172
+ })
88
173
 
89
- it('should execute code and capture console output', async () => {
90
- await client.callTool({
91
- name: 'execute',
92
- arguments: {
93
- code: js`
174
+ const directBrowser = await withTimeout({
175
+ promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
176
+ timeoutMs: 10000,
177
+ errorMessage: 'Timed out connecting over CDP for download reproduction test',
178
+ })
179
+
180
+ const connectedPage = directBrowser
181
+ .contexts()[0]
182
+ .pages()
183
+ .find((candidatePage) => {
184
+ return candidatePage.url() === server.baseUrl + '/'
185
+ })
186
+ if (!connectedPage) {
187
+ throw new Error('Connected page not found for download reproduction test')
188
+ }
189
+
190
+ const downloadResult = await Promise.all([
191
+ connectedPage.waitForEvent('download', { timeout: 3000 }).then(
192
+ (download) => {
193
+ return { timedOut: false, suggestedFilename: download.suggestedFilename() }
194
+ },
195
+ (error: Error) => {
196
+ return { timedOut: true, errorMessage: error.message }
197
+ },
198
+ ),
199
+ connectedPage.click('#download-button'),
200
+ ])
201
+
202
+ expect(downloadResult[0]).toMatchInlineSnapshot(`
203
+ {
204
+ "suggestedFilename": "playwriter-download-test.txt",
205
+ "timedOut": false,
206
+ }
207
+ `)
208
+
209
+ await directBrowser.close()
210
+ await page.close()
211
+ await server.close()
212
+
213
+ const logLinesAfter = fs
214
+ .readFileSync(logFilePath, 'utf-8')
215
+ .split('\n')
216
+ .filter((line) => {
217
+ return line.trim().length > 0
218
+ })
219
+ .slice(logLineCountBefore)
220
+
221
+ const newEntries = logLinesAfter
222
+ .map((line) => {
223
+ return tryJsonParse(line)
224
+ })
225
+ .filter((entry): entry is { direction: string; message: { method?: string } } => {
226
+ return Boolean(entry && typeof entry === 'object' && 'direction' in entry && 'message' in entry)
227
+ })
228
+
229
+ const methods = newEntries
230
+ .map((entry) => {
231
+ return {
232
+ direction: entry.direction,
233
+ method: typeof entry.message?.method === 'string' ? entry.message.method : 'response',
234
+ }
235
+ })
236
+ .filter((entry) => {
237
+ return (
238
+ entry.method.includes('download') ||
239
+ entry.method === 'Browser.setDownloadBehavior' ||
240
+ entry.method === 'Page.setDownloadBehavior'
241
+ )
242
+ })
243
+
244
+ const summary = {
245
+ hasBrowserSetDownloadBehavior: methods.some((entry) => {
246
+ return entry.direction === 'from-playwright' && entry.method === 'Browser.setDownloadBehavior'
247
+ }),
248
+ hasPageSetDownloadBehavior: methods.some((entry) => {
249
+ return entry.direction === 'to-extension' && entry.method === 'Page.setDownloadBehavior'
250
+ }),
251
+ hasPageDownloadWillBegin: methods.some((entry) => {
252
+ return entry.method === 'Page.downloadWillBegin'
253
+ }),
254
+ hasPageDownloadProgress: methods.some((entry) => {
255
+ return entry.method === 'Page.downloadProgress'
256
+ }),
257
+ hasBrowserDownloadWillBegin: methods.some((entry) => {
258
+ return entry.method === 'Browser.downloadWillBegin'
259
+ }),
260
+ hasBrowserDownloadProgress: methods.some((entry) => {
261
+ return entry.method === 'Browser.downloadProgress'
262
+ }),
263
+ }
264
+
265
+ expect(summary).toMatchInlineSnapshot(`
266
+ {
267
+ "hasBrowserDownloadProgress": false,
268
+ "hasBrowserDownloadWillBegin": false,
269
+ "hasBrowserSetDownloadBehavior": true,
270
+ "hasPageDownloadProgress": false,
271
+ "hasPageDownloadWillBegin": false,
272
+ "hasPageSetDownloadBehavior": true,
273
+ }
274
+ `)
275
+ }, 120000)
276
+
277
+ it('should execute code and capture console output', async () => {
278
+ await client.callTool({
279
+ name: 'execute',
280
+ arguments: {
281
+ code: js`
94
282
  const newPage = await context.newPage();
95
283
  state.page = newPage;
96
284
  if (!state.pages) state.pages = [];
97
285
  state.pages.push(newPage);
98
286
  `,
99
- },
100
- })
287
+ },
288
+ })
101
289
 
102
- const result = await client.callTool({
103
- name: 'execute',
104
- arguments: {
105
- code: js`
290
+ const result = await client.callTool({
291
+ name: 'execute',
292
+ arguments: {
293
+ code: js`
106
294
  await state.page.goto('https://example.com');
107
295
  const title = await state.page.title();
108
296
  console.log('Page title:', title);
109
297
  return { url: state.page.url(), title };
110
298
  `,
111
- },
112
- })
113
- expect(result.content).toMatchInlineSnapshot(`
299
+ },
300
+ })
301
+ expect(result.content).toMatchInlineSnapshot(`
114
302
  [
115
303
  {
116
304
  "text": "Console output:
@@ -121,138 +309,244 @@ describe('Relay Core Tests', () => {
121
309
  },
122
310
  ]
123
311
  `)
124
- expect(result.content).toBeDefined()
125
- }, 30000)
126
-
127
- it('should show extension as connected for pages created via newPage()', async () => {
128
- const browserContext = getBrowserContext()
129
- const serviceWorker = await getExtensionServiceWorker(browserContext)
312
+ expect(result.content).toBeDefined()
313
+ }, 30000)
314
+
315
+ // Repro test for https://github.com/remorses/playwriter/issues/66.
316
+ // Current limitation: extension-mode routing does not support root-session
317
+ // Storage.getCookies in playwriter. MUST use Network.getCookies via page CDP
318
+ // session instead (see test below), so this repro stays skipped.
319
+ it.skip('should reproduce page.route failure in MCP execute path (issue #66)', async () => {
320
+ const server = await createSimpleServer({
321
+ routes: {
322
+ '/': '<!doctype html><html><body>route issue repro</body></html>',
323
+ '/api/data': '{"ok":true}',
324
+ },
325
+ })
130
326
 
131
- // Create a page via MCP (which uses context.newPage())
327
+ try {
328
+ const result = await client.callTool({
329
+ name: 'execute',
330
+ arguments: {
331
+ code: js`
332
+ const newPage = await context.newPage();
333
+ state.issue66Page = newPage;
334
+ await newPage.goto('${server.baseUrl}', { waitUntil: 'domcontentloaded' });
335
+
336
+ let routeFetchError = null;
337
+ await newPage.route('**/api/**', async (route) => {
338
+ try {
339
+ const response = await route.fetch();
340
+ await route.fulfill({ response });
341
+ } catch (error) {
342
+ routeFetchError = error instanceof Error ? error.message : String(error);
343
+ await route.abort();
344
+ }
345
+ });
346
+
347
+ await newPage.evaluate(async () => {
348
+ await fetch('/api/data').catch(() => null);
349
+ });
350
+
351
+ return { routeFetchError };
352
+ `,
353
+ },
354
+ })
355
+
356
+ const resultWithContent = result as { content?: unknown }
357
+ const content = Array.isArray(resultWithContent.content) ? resultWithContent.content : []
358
+ const firstContent = content[0]
359
+ const output =
360
+ typeof firstContent === 'object' && firstContent !== null && 'text' in firstContent
361
+ ? String((firstContent as { text?: unknown }).text ?? '')
362
+ : ''
363
+ expect(output).toContain('routeFetchError')
364
+ expect(output).toContain('Storage.getCookies')
365
+ expect(output).toContain('No tab found for method Storage.getCookies')
366
+ } finally {
367
+ try {
132
368
  await client.callTool({
133
- name: 'execute',
134
- arguments: {
135
- code: js`
369
+ name: 'execute',
370
+ arguments: {
371
+ code: js`
372
+ if (state.issue66Page && !state.issue66Page.isClosed()) {
373
+ await state.issue66Page.close();
374
+ }
375
+ delete state.issue66Page;
376
+ `,
377
+ },
378
+ })
379
+ } catch {
380
+ // Ignore cleanup failure if MCP disconnected due to the repro.
381
+ }
382
+ await server.close()
383
+ }
384
+ }, 30000)
385
+
386
+ it('should read cookies via Network.getCookies through page CDP session', async () => {
387
+ const browserContext = getBrowserContext()
388
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
389
+
390
+ const server = await createSimpleServer({
391
+ routes: {
392
+ '/': '<!doctype html><html><body>cookies test</body></html>',
393
+ },
394
+ })
395
+
396
+ const page = await browserContext.newPage()
397
+ try {
398
+ await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
399
+ await page.bringToFront()
400
+
401
+ await serviceWorker.evaluate(async () => {
402
+ await globalThis.toggleExtensionForActiveTab()
403
+ })
404
+
405
+ await new Promise((r) => {
406
+ setTimeout(r, 200)
407
+ })
408
+
409
+ await page.evaluate(() => {
410
+ document.cookie = 'issue66=ok; path=/'
411
+ })
412
+
413
+ const cdpSession = await getCDPSessionForPage({ page })
414
+ const cookiesResult = await cdpSession.send('Network.getCookies', { urls: [page.url()] })
415
+ const cookie = cookiesResult.cookies.find((value) => {
416
+ return value.name === 'issue66'
417
+ })
418
+ expect(cookie?.value).toBe('ok')
419
+ } finally {
420
+ await page.close()
421
+ await server.close()
422
+ }
423
+ }, 30000)
424
+
425
+ it('should show extension as connected for pages created via newPage()', async () => {
426
+ const browserContext = getBrowserContext()
427
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
428
+
429
+ // Create a page via MCP (which uses context.newPage())
430
+ await client.callTool({
431
+ name: 'execute',
432
+ arguments: {
433
+ code: js`
136
434
  const newPage = await context.newPage();
137
435
  state.testPage = newPage;
138
436
  await newPage.goto('https://example.com/mcp-test');
139
437
  return newPage.url();
140
438
  `,
141
- },
142
- })
439
+ },
440
+ })
143
441
 
144
- // Get extension state to verify the page is marked as connected
145
- const extensionState = await serviceWorker.evaluate(async () => {
146
- const state = globalThis.getExtensionState()
147
- const tabs = await chrome.tabs.query({})
148
- const testTab = tabs.find((t: any) => t.url?.includes('mcp-test'))
149
- return {
150
- connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
151
- tabId: testTab?.id,
152
- tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
153
- connectionState: state.connectionState
154
- }
155
- })
442
+ // Get extension state to verify the page is marked as connected
443
+ const extensionState = await serviceWorker.evaluate(async () => {
444
+ const state = globalThis.getExtensionState()
445
+ const tabs = await chrome.tabs.query({})
446
+ const testTab = tabs.find((t: any) => t.url?.includes('mcp-test'))
447
+ return {
448
+ connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
449
+ tabId: testTab?.id,
450
+ tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
451
+ connectionState: state.connectionState,
452
+ }
453
+ })
156
454
 
157
- expect(extensionState.connected).toBe(true)
158
- expect(extensionState.tabInfo?.state).toBe('connected')
159
- expect(extensionState.connectionState).toBe('connected')
455
+ expect(extensionState.connected).toBe(true)
456
+ expect(extensionState.tabInfo?.state).toBe('connected')
457
+ expect(extensionState.connectionState).toBe('connected')
160
458
 
161
- // Clean up
162
- await client.callTool({
163
- name: 'execute',
164
- arguments: {
165
- code: js`
459
+ // Clean up
460
+ await client.callTool({
461
+ name: 'execute',
462
+ arguments: {
463
+ code: js`
166
464
  if (state.testPage) {
167
465
  await state.testPage.close();
168
466
  delete state.testPage;
169
467
  }
170
468
  `,
171
- },
172
- })
173
- }, 30000)
174
-
175
- const accessibilitySnapshotTestCases = [
176
- {
177
- name: 'hacker-news',
178
- url: 'https://news.ycombinator.com/item?id=1',
179
- expectedContent: ['role=link', 'Hacker News'],
180
- },
181
- {
182
- name: 'shadcn-ui',
183
- url: 'https://ui.shadcn.com/',
184
- expectedContent: ['shadcn'],
185
- },
186
- ]
187
-
188
- for (const testCase of accessibilitySnapshotTestCases) {
189
- it(`should get accessibility snapshot of ${testCase.name}`, async () => {
190
- await client.callTool({
191
- name: 'execute',
192
- arguments: {
193
- code: js`
469
+ },
470
+ })
471
+ }, 30000)
472
+
473
+ const snapshotTestCases = [
474
+ {
475
+ name: 'hacker-news',
476
+ url: 'https://news.ycombinator.com/item?id=1',
477
+ expectedContent: ['role=link', 'Hacker News'],
478
+ },
479
+ {
480
+ name: 'shadcn-ui',
481
+ url: 'https://ui.shadcn.com/',
482
+ expectedContent: ['shadcn'],
483
+ },
484
+ ]
485
+
486
+ for (const testCase of snapshotTestCases) {
487
+ it(`should get accessibility snapshot of ${testCase.name}`, async () => {
488
+ await client.callTool({
489
+ name: 'execute',
490
+ arguments: {
491
+ code: js`
194
492
  const newPage = await context.newPage();
195
493
  state.page = newPage;
196
494
  if (!state.pages) state.pages = [];
197
495
  state.pages.push(newPage);
198
496
  `,
199
- },
200
- })
201
-
202
- // Capture interactiveOnly=true snapshot (default)
203
- const interactiveResult = await client.callTool({
204
- name: 'execute',
205
- arguments: {
206
- code: js`
497
+ },
498
+ })
499
+
500
+ // Capture interactiveOnly=true snapshot (default)
501
+ const interactiveResult = await client.callTool({
502
+ name: 'execute',
503
+ arguments: {
504
+ code: js`
207
505
  await state.page.goto('${testCase.url}', { waitUntil: 'domcontentloaded' });
208
- const snapshot = await accessibilitySnapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: true });
209
- return snapshot;
506
+ const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: true });
507
+ return snap;
210
508
  `,
211
- },
212
- })
213
-
214
- const interactiveData =
215
- typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text
216
- ? tryJsonParse(interactiveResult.content[0].text)
217
- : interactiveResult
218
- await expect(interactiveData).toMatchFileSnapshot(
219
- `snapshots/${testCase.name}-accessibility-interactive.md`,
220
- )
221
- expect(interactiveResult.content).toBeDefined()
222
- for (const expected of testCase.expectedContent) {
223
- expect(interactiveData).toContain(expected)
224
- }
225
-
226
- // Capture interactiveOnly=false snapshot (full tree)
227
- const fullResult = await client.callTool({
228
- name: 'execute',
229
- arguments: {
230
- code: js`
231
- const snapshot = await accessibilitySnapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: false });
232
- return snapshot;
509
+ },
510
+ })
511
+
512
+ const interactiveData =
513
+ typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text
514
+ ? tryJsonParse(interactiveResult.content[0].text)
515
+ : interactiveResult
516
+ await expect(interactiveData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-interactive.md`)
517
+ expect(interactiveResult.content).toBeDefined()
518
+ for (const expected of testCase.expectedContent) {
519
+ expect(interactiveData).toContain(expected)
520
+ }
521
+
522
+ // Capture interactiveOnly=false snapshot (full tree)
523
+ const fullResult = await client.callTool({
524
+ name: 'execute',
525
+ arguments: {
526
+ code: js`
527
+ const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: false });
528
+ return snap;
233
529
  `,
234
- },
235
- })
236
-
237
- const fullData =
238
- typeof fullResult === 'object' && fullResult.content?.[0]?.text
239
- ? tryJsonParse(fullResult.content[0].text)
240
- : fullResult
241
- await expect(fullData).toMatchFileSnapshot(
242
- `snapshots/${testCase.name}-accessibility-full.md`,
243
- )
244
- expect(fullResult.content).toBeDefined()
245
- for (const expected of testCase.expectedContent) {
246
- expect(fullData).toContain(expected)
247
- }
248
- }, 60000)
249
- }
530
+ },
531
+ })
532
+
533
+ const fullData =
534
+ typeof fullResult === 'object' && fullResult.content?.[0]?.text
535
+ ? tryJsonParse(fullResult.content[0].text)
536
+ : fullResult
537
+ await expect(fullData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-full.md`)
538
+ expect(fullResult.content).toBeDefined()
539
+ for (const expected of testCase.expectedContent) {
540
+ expect(fullData).toContain(expected)
541
+ }
542
+ }, 60000)
543
+ }
250
544
 
251
- it('should close all created pages', async () => {
252
- const result = await client.callTool({
253
- name: 'execute',
254
- arguments: {
255
- code: js`
545
+ it('should close all created pages', async () => {
546
+ const result = await client.callTool({
547
+ name: 'execute',
548
+ arguments: {
549
+ code: js`
256
550
  if (state.pages && state.pages.length > 0) {
257
551
  for (const page of state.pages) {
258
552
  await page.close();
@@ -263,17 +557,16 @@ describe('Relay Core Tests', () => {
263
557
  }
264
558
  return { closedCount: 0 };
265
559
  `,
266
- },
267
- })
268
-
560
+ },
269
561
  })
270
-
271
- it('should capture browser console logs with getLatestLogs', async () => {
272
- // Ensure clean state and clear any existing logs
273
- const resetResult = await client.callTool({
274
- name: 'execute',
275
- arguments: {
276
- code: js`
562
+ })
563
+
564
+ it('should capture browser console logs with getLatestLogs', async () => {
565
+ // Ensure clean state and clear any existing logs
566
+ const resetResult = await client.callTool({
567
+ name: 'execute',
568
+ arguments: {
569
+ code: js`
277
570
  // Clear any existing logs from previous tests
278
571
  clearAllLogs();
279
572
  console.log('Cleared all existing logs');
@@ -284,27 +577,27 @@ describe('Relay Core Tests', () => {
284
577
 
285
578
  return { success: true, pagesCount: pages.length };
286
579
  `,
287
- },
288
- })
289
- console.log('Cleanup result:', resetResult)
580
+ },
581
+ })
582
+ console.log('Cleanup result:', resetResult)
290
583
 
291
- // Create a new page for this test
292
- await client.callTool({
293
- name: 'execute',
294
- arguments: {
295
- code: js`
584
+ // Create a new page for this test
585
+ await client.callTool({
586
+ name: 'execute',
587
+ arguments: {
588
+ code: js`
296
589
  const newPage = await context.newPage();
297
590
  state.testLogPage = newPage;
298
591
  await newPage.goto('about:blank');
299
592
  `,
300
- },
301
- })
593
+ },
594
+ })
302
595
 
303
- // Generate some console logs in the browser
304
- await client.callTool({
305
- name: 'execute',
306
- arguments: {
307
- code: js`
596
+ // Generate some console logs in the browser
597
+ await client.callTool({
598
+ name: 'execute',
599
+ arguments: {
600
+ code: js`
308
601
  await state.testLogPage.evaluate(() => {
309
602
  console.log('Test log 12345');
310
603
  console.error('Test error 67890');
@@ -314,325 +607,403 @@ describe('Relay Core Tests', () => {
314
607
  // Wait for logs to be captured
315
608
  await new Promise(resolve => setTimeout(resolve, 100));
316
609
  `,
317
- },
318
- })
610
+ },
611
+ })
319
612
 
320
- // Test getting all logs
321
- const allLogsResult = await client.callTool({
322
- name: 'execute',
323
- arguments: {
324
- code: js`
613
+ // Test getting all logs
614
+ const allLogsResult = await client.callTool({
615
+ name: 'execute',
616
+ arguments: {
617
+ code: js`
325
618
  const logs = await getLatestLogs();
326
619
  logs.forEach(log => console.log(log));
327
620
  `,
328
- },
329
- })
621
+ },
622
+ })
330
623
 
331
- const output = (allLogsResult as any).content[0].text
332
- expect(output).toContain('[log] Test log 12345')
333
- expect(output).toContain('[error] Test error 67890')
334
- expect(output).toContain('[warning] Test warning 11111')
624
+ const output = (allLogsResult as any).content[0].text
625
+ expect(output).toContain('[log] Test log 12345')
626
+ expect(output).toContain('[error] Test error 67890')
627
+ expect(output).toContain('[warning] Test warning 11111')
335
628
 
336
- // Test filtering by search string
337
- const errorLogsResult = await client.callTool({
338
- name: 'execute',
339
- arguments: {
340
- code: js`
629
+ // Test filtering by search string
630
+ const errorLogsResult = await client.callTool({
631
+ name: 'execute',
632
+ arguments: {
633
+ code: js`
341
634
  const logs = await getLatestLogs({ search: 'error' });
342
635
  logs.forEach(log => console.log(log));
343
636
  `,
344
- },
345
- })
637
+ },
638
+ })
346
639
 
347
- const errorOutput = (errorLogsResult as any).content[0].text
348
- expect(errorOutput).toContain('[error] Test error 67890')
349
- // With context lines (5 above/below), nearby logs are also included
350
- expect(errorOutput).toContain('[log] Test log 12345')
640
+ const errorOutput = (errorLogsResult as any).content[0].text
641
+ expect(errorOutput).toContain('[error] Test error 67890')
642
+ // With context lines (5 above/below), nearby logs are also included
643
+ expect(errorOutput).toContain('[log] Test log 12345')
351
644
 
352
- // Test that logs are cleared on page reload
353
- await client.callTool({
354
- name: 'execute',
355
- arguments: {
356
- code: js`
645
+ // Test that logs are cleared on page reload
646
+ await client.callTool({
647
+ name: 'execute',
648
+ arguments: {
649
+ code: js`
357
650
  // First add a log before reload
358
651
  await state.testLogPage.evaluate(() => {
359
652
  console.log('Before reload 99999');
360
653
  });
361
654
  await new Promise(resolve => setTimeout(resolve, 100));
362
655
  `,
363
- },
364
- })
656
+ },
657
+ })
365
658
 
366
- // Verify the log exists
367
- const beforeReloadResult = await client.callTool({
368
- name: 'execute',
369
- arguments: {
370
- code: js`
659
+ // Verify the log exists
660
+ const beforeReloadResult = await client.callTool({
661
+ name: 'execute',
662
+ arguments: {
663
+ code: js`
371
664
  const logs = await getLatestLogs({ page: state.testLogPage });
372
665
  console.log('Logs before reload:', logs.length);
373
666
  logs.forEach(log => console.log(log));
374
667
  `,
375
- },
376
- })
668
+ },
669
+ })
377
670
 
378
- const beforeReloadOutput = (beforeReloadResult as any).content[0].text
379
- expect(beforeReloadOutput).toContain('[log] Before reload 99999')
671
+ const beforeReloadOutput = (beforeReloadResult as any).content[0].text
672
+ expect(beforeReloadOutput).toContain('[log] Before reload 99999')
380
673
 
381
- // Reload the page
382
- await client.callTool({
383
- name: 'execute',
384
- arguments: {
385
- code: js`
674
+ // Reload the page
675
+ await client.callTool({
676
+ name: 'execute',
677
+ arguments: {
678
+ code: js`
386
679
  await state.testLogPage.reload();
387
680
  await state.testLogPage.evaluate(() => {
388
681
  console.log('After reload 88888');
389
682
  });
390
683
  await new Promise(resolve => setTimeout(resolve, 100));
391
684
  `,
392
- },
393
- })
685
+ },
686
+ })
394
687
 
395
- // Check logs after reload - old logs should be gone
396
- const afterReloadResult = await client.callTool({
397
- name: 'execute',
398
- arguments: {
399
- code: js`
688
+ // Check logs after reload - old logs should be gone
689
+ const afterReloadResult = await client.callTool({
690
+ name: 'execute',
691
+ arguments: {
692
+ code: js`
400
693
  const logs = await getLatestLogs({ page: state.testLogPage });
401
694
  console.log('Logs after reload:', logs.length);
402
695
  logs.forEach(log => console.log(log));
403
696
  `,
404
- },
405
- })
697
+ },
698
+ })
406
699
 
407
- const afterReloadOutput = (afterReloadResult as any).content[0].text
408
- expect(afterReloadOutput).toContain('[log] After reload 88888')
409
- expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
700
+ const afterReloadOutput = (afterReloadResult as any).content[0].text
701
+ expect(afterReloadOutput).toContain('[log] After reload 88888')
702
+ expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
410
703
 
411
- // Clean up
412
- await client.callTool({
413
- name: 'execute',
414
- arguments: {
415
- code: js`
704
+ // Clean up
705
+ await client.callTool({
706
+ name: 'execute',
707
+ arguments: {
708
+ code: js`
416
709
  await state.testLogPage.close();
417
710
  delete state.testLogPage;
418
711
  `,
419
- },
420
- })
421
- }, 30000)
422
-
423
- it('should keep logs separate between different pages', async () => {
424
- // Clear any existing logs from previous tests
425
- await client.callTool({
426
- name: 'execute',
427
- arguments: {
428
- code: js`
712
+ },
713
+ })
714
+ }, 30000)
715
+
716
+ it('should keep logs separate between different pages', async () => {
717
+ // Clear any existing logs from previous tests
718
+ await client.callTool({
719
+ name: 'execute',
720
+ arguments: {
721
+ code: js`
429
722
  clearAllLogs();
430
723
  console.log('Cleared all existing logs for second log test');
431
724
  `,
432
- },
433
- })
725
+ },
726
+ })
434
727
 
435
- // Create two pages
436
- await client.callTool({
437
- name: 'execute',
438
- arguments: {
439
- code: js`
728
+ // Create two pages
729
+ await client.callTool({
730
+ name: 'execute',
731
+ arguments: {
732
+ code: js`
440
733
  state.pageA = await context.newPage();
441
734
  state.pageB = await context.newPage();
442
735
  await state.pageA.goto('about:blank');
443
736
  await state.pageB.goto('about:blank');
444
737
  `,
445
- },
446
- })
738
+ },
739
+ })
447
740
 
448
- // Generate logs in page A
449
- await client.callTool({
450
- name: 'execute',
451
- arguments: {
452
- code: js`
741
+ // Generate logs in page A
742
+ await client.callTool({
743
+ name: 'execute',
744
+ arguments: {
745
+ code: js`
453
746
  await state.pageA.evaluate(() => {
454
747
  console.log('PageA log 11111');
455
748
  console.error('PageA error 22222');
456
749
  });
457
750
  await new Promise(resolve => setTimeout(resolve, 100));
458
751
  `,
459
- },
460
- })
752
+ },
753
+ })
461
754
 
462
- // Generate logs in page B
463
- await client.callTool({
464
- name: 'execute',
465
- arguments: {
466
- code: js`
755
+ // Generate logs in page B
756
+ await client.callTool({
757
+ name: 'execute',
758
+ arguments: {
759
+ code: js`
467
760
  await state.pageB.evaluate(() => {
468
761
  console.log('PageB log 33333');
469
762
  console.error('PageB error 44444');
470
763
  });
471
764
  await new Promise(resolve => setTimeout(resolve, 100));
472
765
  `,
473
- },
474
- })
766
+ },
767
+ })
475
768
 
476
- // Check logs for page A - should only have page A logs
477
- const pageALogsResult = await client.callTool({
478
- name: 'execute',
479
- arguments: {
480
- code: js`
769
+ // Check logs for page A - should only have page A logs
770
+ const pageALogsResult = await client.callTool({
771
+ name: 'execute',
772
+ arguments: {
773
+ code: js`
481
774
  const logs = await getLatestLogs({ page: state.pageA });
482
775
  console.log('Page A logs:', logs.length);
483
776
  logs.forEach(log => console.log(log));
484
777
  `,
485
- },
486
- })
778
+ },
779
+ })
487
780
 
488
- const pageAOutput = (pageALogsResult as any).content[0].text
489
- expect(pageAOutput).toContain('[log] PageA log 11111')
490
- expect(pageAOutput).toContain('[error] PageA error 22222')
491
- expect(pageAOutput).not.toContain('PageB')
781
+ const pageAOutput = (pageALogsResult as any).content[0].text
782
+ expect(pageAOutput).toContain('[log] PageA log 11111')
783
+ expect(pageAOutput).toContain('[error] PageA error 22222')
784
+ expect(pageAOutput).not.toContain('PageB')
492
785
 
493
- // Check logs for page B - should only have page B logs
494
- const pageBLogsResult = await client.callTool({
495
- name: 'execute',
496
- arguments: {
497
- code: js`
786
+ // Check logs for page B - should only have page B logs
787
+ const pageBLogsResult = await client.callTool({
788
+ name: 'execute',
789
+ arguments: {
790
+ code: js`
498
791
  const logs = await getLatestLogs({ page: state.pageB });
499
792
  console.log('Page B logs:', logs.length);
500
793
  logs.forEach(log => console.log(log));
501
794
  `,
502
- },
503
- })
795
+ },
796
+ })
504
797
 
505
- const pageBOutput = (pageBLogsResult as any).content[0].text
506
- expect(pageBOutput).toContain('[log] PageB log 33333')
507
- expect(pageBOutput).toContain('[error] PageB error 44444')
508
- expect(pageBOutput).not.toContain('PageA')
798
+ const pageBOutput = (pageBLogsResult as any).content[0].text
799
+ expect(pageBOutput).toContain('[log] PageB log 33333')
800
+ expect(pageBOutput).toContain('[error] PageB error 44444')
801
+ expect(pageBOutput).not.toContain('PageA')
509
802
 
510
- // Check all logs - should have logs from both pages
511
- const allLogsResult = await client.callTool({
512
- name: 'execute',
513
- arguments: {
514
- code: js`
803
+ // Check all logs - should have logs from both pages
804
+ const allLogsResult = await client.callTool({
805
+ name: 'execute',
806
+ arguments: {
807
+ code: js`
515
808
  const logs = await getLatestLogs();
516
809
  console.log('All logs:', logs.length);
517
810
  logs.forEach(log => console.log(log));
518
811
  `,
519
- },
520
- })
812
+ },
813
+ })
521
814
 
522
- const allOutput = (allLogsResult as any).content[0].text
523
- expect(allOutput).toContain('[log] PageA log 11111')
524
- expect(allOutput).toContain('[log] PageB log 33333')
815
+ const allOutput = (allLogsResult as any).content[0].text
816
+ expect(allOutput).toContain('[log] PageA log 11111')
817
+ expect(allOutput).toContain('[log] PageB log 33333')
525
818
 
526
- // Test that reloading page A clears only page A logs
527
- await client.callTool({
528
- name: 'execute',
529
- arguments: {
530
- code: js`
819
+ // Test that reloading page A clears only page A logs
820
+ await client.callTool({
821
+ name: 'execute',
822
+ arguments: {
823
+ code: js`
531
824
  await state.pageA.reload();
532
825
  await state.pageA.evaluate(() => {
533
826
  console.log('PageA after reload 55555');
534
827
  });
535
828
  await new Promise(resolve => setTimeout(resolve, 100));
536
829
  `,
537
- },
538
- })
830
+ },
831
+ })
539
832
 
540
- // Check page A logs - should only have new log
541
- const pageAAfterReloadResult = await client.callTool({
542
- name: 'execute',
543
- arguments: {
544
- code: js`
833
+ // Check page A logs - should only have new log
834
+ const pageAAfterReloadResult = await client.callTool({
835
+ name: 'execute',
836
+ arguments: {
837
+ code: js`
545
838
  const logs = await getLatestLogs({ page: state.pageA });
546
839
  console.log('Page A logs after reload:', logs.length);
547
840
  logs.forEach(log => console.log(log));
548
841
  `,
549
- },
550
- })
842
+ },
843
+ })
551
844
 
552
- const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
553
- expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
554
- expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
845
+ const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
846
+ expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
847
+ expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
555
848
 
556
- // Check page B logs - should still have original logs
557
- const pageBAfterAReloadResult = await client.callTool({
558
- name: 'execute',
559
- arguments: {
560
- code: js`
849
+ // Check page B logs - should still have original logs
850
+ const pageBAfterAReloadResult = await client.callTool({
851
+ name: 'execute',
852
+ arguments: {
853
+ code: js`
561
854
  const logs = await getLatestLogs({ page: state.pageB });
562
855
  console.log('Page B logs after A reload:', logs.length);
563
856
  logs.forEach(log => console.log(log));
564
857
  `,
565
- },
566
- })
858
+ },
859
+ })
567
860
 
568
- const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text
569
- expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333')
570
- expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444')
861
+ const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text
862
+ expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333')
863
+ expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444')
571
864
 
572
- // Test that logs are deleted when page is closed
573
- await client.callTool({
574
- name: 'execute',
575
- arguments: {
576
- code: js`
865
+ // Test that logs are deleted when page is closed
866
+ await client.callTool({
867
+ name: 'execute',
868
+ arguments: {
869
+ code: js`
577
870
  // Close page A
578
871
  await state.pageA.close();
579
872
  await new Promise(resolve => setTimeout(resolve, 100));
580
873
  `,
581
- },
582
- })
874
+ },
875
+ })
583
876
 
584
- // Check all logs - page A logs should be gone
585
- const logsAfterCloseResult = await client.callTool({
586
- name: 'execute',
587
- arguments: {
588
- code: js`
877
+ // Check all logs - page A logs should be gone
878
+ const logsAfterCloseResult = await client.callTool({
879
+ name: 'execute',
880
+ arguments: {
881
+ code: js`
589
882
  const logs = await getLatestLogs();
590
883
  console.log('All logs after closing page A:', logs.length);
591
884
  logs.forEach(log => console.log(log));
592
885
  `,
593
- },
594
- })
886
+ },
887
+ })
595
888
 
596
- const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
597
- expect(logsAfterCloseOutput).not.toContain('PageA')
598
- expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
889
+ const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
890
+ expect(logsAfterCloseOutput).not.toContain('PageA')
891
+ expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
599
892
 
600
- // Clean up remaining page
601
- await client.callTool({
602
- name: 'execute',
603
- arguments: {
604
- code: js`
893
+ // Clean up remaining page
894
+ await client.callTool({
895
+ name: 'execute',
896
+ arguments: {
897
+ code: js`
605
898
  await state.pageB.close();
606
899
  delete state.pageA;
607
900
  delete state.pageB;
608
901
  `,
609
- },
610
- })
611
- }, 30000)
612
-
613
- // right now our extension always forces light mode because of a playwright cdp bug
614
- it.todo('should preserve system color scheme instead of forcing light mode', async () => {
615
- const browserContext = getBrowserContext()
616
- const serviceWorker = await getExtensionServiceWorker(browserContext)
617
-
618
- const page = await browserContext.newPage()
619
- await page.goto('https://example.com')
620
- await page.bringToFront()
621
-
622
- const colorSchemeBefore = await page.evaluate(() => {
623
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
624
- })
625
- console.log('Color scheme before MCP connection:', colorSchemeBefore)
902
+ },
903
+ })
904
+ }, 30000)
905
+
906
+ it('should capture console logs from cross-origin iframes', async () => {
907
+ // Two servers on different ports = different origins
908
+ const iframeServer = await createSimpleServer({
909
+ routes: {
910
+ '/iframe.html': `<!doctype html><html><body>
911
+ <script>
912
+ console.log('iframe-log-ALPHA');
913
+ console.error('iframe-error-BETA');
914
+ console.warn('iframe-warn-GAMMA');
915
+ </script>
916
+ <p>cross-origin iframe</p>
917
+ </body></html>`,
918
+ },
919
+ })
626
920
 
627
- await serviceWorker.evaluate(async () => {
628
- await globalThis.toggleExtensionForActiveTab()
629
- })
630
- await new Promise(r => setTimeout(r, 100))
921
+ const parentServer = await createSimpleServer({
922
+ routes: {
923
+ '/': `<!doctype html><html><body>
924
+ <script>console.log('parent-log-DELTA');</script>
925
+ <iframe src="${iframeServer.baseUrl}/iframe.html"></iframe>
926
+ </body></html>`,
927
+ },
928
+ })
631
929
 
632
- const result = await client.callTool({
633
- name: 'execute',
634
- arguments: {
635
- code: js`
930
+ try {
931
+ // Clear logs and navigate to the parent page with cross-origin iframe
932
+ await client.callTool({
933
+ name: 'execute',
934
+ arguments: {
935
+ code: js`
936
+ clearAllLogs();
937
+ state.iframePage = await context.newPage();
938
+ await state.iframePage.goto('${parentServer.baseUrl}', { waitUntil: 'networkidle' });
939
+ // Wait for iframe to load and logs to be captured
940
+ await state.iframePage.frameLocator('iframe').locator('p').waitFor({ timeout: 5000 });
941
+ await new Promise(resolve => setTimeout(resolve, 500));
942
+ `,
943
+ },
944
+ })
945
+
946
+ // Retrieve logs and verify both parent and iframe logs are captured
947
+ const logsResult = await client.callTool({
948
+ name: 'execute',
949
+ arguments: {
950
+ code: js`
951
+ const logs = await getLatestLogs({ page: state.iframePage });
952
+ console.log('Cross-origin iframe logs count:', logs.length);
953
+ logs.forEach(log => console.log(log));
954
+ `,
955
+ },
956
+ })
957
+
958
+ const output = (logsResult as any).content[0].text
959
+ // Parent page log
960
+ expect(output).toContain('parent-log-DELTA')
961
+ // Cross-origin iframe logs
962
+ expect(output).toContain('iframe-log-ALPHA')
963
+ expect(output).toContain('iframe-error-BETA')
964
+ expect(output).toContain('iframe-warn-GAMMA')
965
+
966
+ // Clean up
967
+ await client.callTool({
968
+ name: 'execute',
969
+ arguments: {
970
+ code: js`
971
+ await state.iframePage.close();
972
+ delete state.iframePage;
973
+ `,
974
+ },
975
+ })
976
+ } finally {
977
+ await Promise.all([parentServer.close(), iframeServer.close()])
978
+ }
979
+ }, 60000)
980
+
981
+ it(
982
+ 'should preserve system color scheme instead of forcing light mode',
983
+ async () => {
984
+ const browserContext = getBrowserContext()
985
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
986
+
987
+ const page = await browserContext.newPage()
988
+ await page.goto('https://example.com')
989
+ await page.bringToFront()
990
+
991
+ // test-utils launches with colorScheme: 'dark', so before MCP connection
992
+ // the browser should report dark mode
993
+ const colorSchemeBefore = await page.evaluate(() => {
994
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
995
+ })
996
+ expect(colorSchemeBefore).toBe('dark')
997
+
998
+ await serviceWorker.evaluate(async () => {
999
+ await globalThis.toggleExtensionForActiveTab()
1000
+ })
1001
+ await new Promise((r) => setTimeout(r, 500))
1002
+
1003
+ const result = await client.callTool({
1004
+ name: 'execute',
1005
+ arguments: {
1006
+ code: js`
636
1007
  const pages = context.pages();
637
1008
  const urls = pages.map(p => p.url());
638
1009
  const targetPage = pages.find(p => p.url().includes('example.com'));
@@ -643,29 +1014,34 @@ describe('Relay Core Tests', () => {
643
1014
  const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches);
644
1015
  return { matchesDark: isDark, matchesLight: isLight };
645
1016
  `,
646
- },
647
- })
648
-
649
- console.log('Color scheme after MCP connection:', result.content)
650
-
651
- expect(result.content).toMatchInlineSnapshot(`
652
- [
653
- {
654
- "text": "[return value] { error: 'Page not found', urls: [ 'about:blank' ] }",
655
- "type": "text",
656
- },
657
- ]
658
- `)
659
-
660
- await page.close()
661
- }, 60000)
662
-
663
- it('should get clean HTML with getCleanHTML', async () => {
664
- const browserContext = getBrowserContext()
665
- const serviceWorker = await getExtensionServiceWorker(browserContext)
666
-
667
- const page = await browserContext.newPage()
668
- await page.setContent(`
1017
+ },
1018
+ })
1019
+
1020
+ console.log('Color scheme after MCP connection:', result.content)
1021
+
1022
+ // After MCP connection, color scheme should NOT be forced to light.
1023
+ // The page.ts default is now 'no-override', so the browser's actual
1024
+ // color scheme (dark, from test-utils launch config) should be preserved.
1025
+ expect(result.content).toMatchInlineSnapshot(`
1026
+ [
1027
+ {
1028
+ "text": "[return value] { matchesDark: true, matchesLight: false }",
1029
+ "type": "text",
1030
+ },
1031
+ ]
1032
+ `)
1033
+
1034
+ await page.close()
1035
+ },
1036
+ 60000,
1037
+ )
1038
+
1039
+ it('should get clean HTML with getCleanHTML', async () => {
1040
+ const browserContext = getBrowserContext()
1041
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
1042
+
1043
+ const page = await browserContext.newPage()
1044
+ await page.setContent(`
669
1045
  <html>
670
1046
  <head>
671
1047
  <style>.hidden { display: none; }</style>
@@ -681,18 +1057,18 @@ describe('Relay Core Tests', () => {
681
1057
  </body>
682
1058
  </html>
683
1059
  `)
684
- await page.bringToFront()
1060
+ await page.bringToFront()
685
1061
 
686
- await serviceWorker.evaluate(async () => {
687
- await globalThis.toggleExtensionForActiveTab()
688
- })
689
- await new Promise(r => setTimeout(r, 400))
1062
+ await serviceWorker.evaluate(async () => {
1063
+ await globalThis.toggleExtensionForActiveTab()
1064
+ })
1065
+ await new Promise((r) => setTimeout(r, 400))
690
1066
 
691
- // Test basic getCleanHTML
692
- const result = await client.callTool({
693
- name: 'execute',
694
- arguments: {
695
- code: js`
1067
+ // Test basic getCleanHTML
1068
+ const result = await client.callTool({
1069
+ name: 'execute',
1070
+ arguments: {
1071
+ code: js`
696
1072
  let testPage;
697
1073
  for (const p of context.pages()) {
698
1074
  const html = await p.content();
@@ -702,15 +1078,15 @@ describe('Relay Core Tests', () => {
702
1078
  const html = await getCleanHTML({ locator: testPage.locator('body') });
703
1079
  return html;
704
1080
  `,
705
- timeout: 15000,
706
- },
707
- })
1081
+ timeout: 15000,
1082
+ },
1083
+ })
708
1084
 
709
- expect(result.isError).toBeFalsy()
710
- const text = (result.content as any)[0]?.text || ''
1085
+ expect(result.isError).toBeFalsy()
1086
+ const text = (result.content as any)[0]?.text || ''
711
1087
 
712
- // Inline snapshot of cleaned HTML
713
- expect(text).toMatchInlineSnapshot(`
1088
+ // Inline snapshot of cleaned HTML
1089
+ expect(text).toMatchInlineSnapshot(`
714
1090
  "[return value] <div data-testid="main">
715
1091
  <h1>Hello World</h1>
716
1092
  <button aria-label="Click me">Submit</button>
@@ -719,16 +1095,16 @@ describe('Relay Core Tests', () => {
719
1095
  </div>"
720
1096
  `)
721
1097
 
722
- // Should NOT contain script/style tags (they're removed)
723
- expect(text).not.toContain('<script')
724
- expect(text).not.toContain('<style')
725
- expect(text).not.toContain('console.log')
1098
+ // Should NOT contain script/style tags (they're removed)
1099
+ expect(text).not.toContain('<script')
1100
+ expect(text).not.toContain('<style')
1101
+ expect(text).not.toContain('console.log')
726
1102
 
727
- // Test search functionality
728
- const searchResult = await client.callTool({
729
- name: 'execute',
730
- arguments: {
731
- code: js`
1103
+ // Test search functionality
1104
+ const searchResult = await client.callTool({
1105
+ name: 'execute',
1106
+ arguments: {
1107
+ code: js`
732
1108
  let testPage;
733
1109
  for (const p of context.pages()) {
734
1110
  const html = await p.content();
@@ -738,24 +1114,24 @@ describe('Relay Core Tests', () => {
738
1114
  const html = await getCleanHTML({ locator: testPage, search: /button/i });
739
1115
  return html;
740
1116
  `,
741
- timeout: 15000,
742
- },
743
- })
1117
+ timeout: 15000,
1118
+ },
1119
+ })
744
1120
 
745
- expect(searchResult.isError).toBeFalsy()
746
- const searchText = (searchResult.content as any)[0]?.text || ''
747
- expect(searchText).toContain('button')
1121
+ expect(searchResult.isError).toBeFalsy()
1122
+ const searchText = (searchResult.content as any)[0]?.text || ''
1123
+ expect(searchText).toContain('button')
748
1124
 
749
- await page.close()
750
- }, 60000)
1125
+ await page.close()
1126
+ }, 60000)
751
1127
 
752
- it('should extract page content as markdown with getPageMarkdown', async () => {
753
- const browserContext = getBrowserContext()
754
- const serviceWorker = await getExtensionServiceWorker(browserContext)
1128
+ it('should extract page content as markdown with getPageMarkdown', async () => {
1129
+ const browserContext = getBrowserContext()
1130
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
755
1131
 
756
- const page = await browserContext.newPage()
757
- // Create a realistic article-like page structure
758
- await page.setContent(`
1132
+ const page = await browserContext.newPage()
1133
+ // Create a realistic article-like page structure
1134
+ await page.setContent(`
759
1135
  <html>
760
1136
  <head>
761
1137
  <title>Test Article Title</title>
@@ -782,18 +1158,18 @@ describe('Relay Core Tests', () => {
782
1158
  </body>
783
1159
  </html>
784
1160
  `)
785
- await page.bringToFront()
1161
+ await page.bringToFront()
786
1162
 
787
- await serviceWorker.evaluate(async () => {
788
- await globalThis.toggleExtensionForActiveTab()
789
- })
790
- await new Promise(r => setTimeout(r, 400))
1163
+ await serviceWorker.evaluate(async () => {
1164
+ await globalThis.toggleExtensionForActiveTab()
1165
+ })
1166
+ await new Promise((r) => setTimeout(r, 400))
791
1167
 
792
- // Test basic getPageMarkdown
793
- const result = await client.callTool({
794
- name: 'execute',
795
- arguments: {
796
- code: js`
1168
+ // Test basic getPageMarkdown
1169
+ const result = await client.callTool({
1170
+ name: 'execute',
1171
+ arguments: {
1172
+ code: js`
797
1173
  let testPage;
798
1174
  for (const p of context.pages()) {
799
1175
  const html = await p.content();
@@ -803,30 +1179,30 @@ describe('Relay Core Tests', () => {
803
1179
  const content = await getPageMarkdown({ page: testPage });
804
1180
  console.log(content);
805
1181
  `,
806
- timeout: 15000,
807
- },
808
- })
1182
+ timeout: 15000,
1183
+ },
1184
+ })
809
1185
 
810
- expect(result.isError).toBeFalsy()
811
- const text = (result.content as any)[0]?.text || ''
1186
+ expect(result.isError).toBeFalsy()
1187
+ const text = (result.content as any)[0]?.text || ''
812
1188
 
813
- // Snapshot the full output
814
- await expect(text).toMatchFileSnapshot('./snapshots/page-markdown-output.txt')
1189
+ // Snapshot the full output
1190
+ await expect(text).toMatchFileSnapshot('./snapshots/page-markdown-output.txt')
815
1191
 
816
- // Should contain article content
817
- expect(text).toContain('Test Article Title')
818
- expect(text).toContain('first paragraph')
819
- expect(text).toContain('second paragraph')
1192
+ // Should contain article content
1193
+ expect(text).toContain('Test Article Title')
1194
+ expect(text).toContain('first paragraph')
1195
+ expect(text).toContain('second paragraph')
820
1196
 
821
- // Should NOT contain script/style content
822
- expect(text).not.toContain('analytics')
823
- expect(text).not.toContain('background: blue')
1197
+ // Should NOT contain script/style content
1198
+ expect(text).not.toContain('analytics')
1199
+ expect(text).not.toContain('background: blue')
824
1200
 
825
- // Test search functionality
826
- const searchResult = await client.callTool({
827
- name: 'execute',
828
- arguments: {
829
- code: js`
1201
+ // Test search functionality
1202
+ const searchResult = await client.callTool({
1203
+ name: 'execute',
1204
+ arguments: {
1205
+ code: js`
830
1206
  let testPage;
831
1207
  for (const p of context.pages()) {
832
1208
  const html = await p.content();
@@ -836,97 +1212,260 @@ describe('Relay Core Tests', () => {
836
1212
  const content = await getPageMarkdown({ page: testPage, search: /important/i, showDiffSinceLastCall: false });
837
1213
  return content;
838
1214
  `,
839
- timeout: 15000,
840
- },
841
- })
1215
+ timeout: 15000,
1216
+ },
1217
+ })
842
1218
 
843
- expect(searchResult.isError).toBeFalsy()
844
- const searchText = (searchResult.content as any)[0]?.text || ''
845
- expect(searchText).toContain('important')
1219
+ expect(searchResult.isError).toBeFalsy()
1220
+ const searchText = (searchResult.content as any)[0]?.text || ''
1221
+ expect(searchText).toContain('important')
846
1222
 
847
- await page.close()
848
- }, 60000)
1223
+ await page.close()
1224
+ }, 60000)
849
1225
 
850
- it('should handle default page being closed and switch to another available page', async () => {
851
- // This test verifies that when the default `page` in MCP scope is closed,
852
- // the MCP automatically switches to another available page instead of failing
853
- // with cryptic "page closed" errors.
1226
+ it('should handle default page being closed and switch to another available page', async () => {
1227
+ // This test verifies that when the default `page` in MCP scope is closed,
1228
+ // the MCP automatically switches to another available page instead of failing
1229
+ // with cryptic "page closed" errors.
854
1230
 
855
- const browserContext = getBrowserContext()
856
- const serviceWorker = await getExtensionServiceWorker(browserContext)
1231
+ const browserContext = getBrowserContext()
1232
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
857
1233
 
858
- // 1. Disconnect everything to start fresh
859
- await serviceWorker.evaluate(async () => {
860
- await globalThis.disconnectEverything()
861
- })
862
- await new Promise(r => setTimeout(r, 100))
1234
+ // 1. Disconnect everything to start fresh
1235
+ await serviceWorker.evaluate(async () => {
1236
+ await globalThis.disconnectEverything()
1237
+ })
1238
+ await new Promise((r) => setTimeout(r, 100))
863
1239
 
864
- // 2. Create first page and enable extension
865
- const page1 = await browserContext.newPage()
866
- await page1.goto('https://example.com/first-page')
867
- await page1.bringToFront()
1240
+ // 2. Create first page and enable extension
1241
+ const page1 = await browserContext.newPage()
1242
+ await page1.goto('https://example.com/first-page')
1243
+ await page1.bringToFront()
868
1244
 
869
- await serviceWorker.evaluate(async () => {
870
- await globalThis.toggleExtensionForActiveTab()
871
- })
872
- await new Promise(r => setTimeout(r, 100))
1245
+ await serviceWorker.evaluate(async () => {
1246
+ await globalThis.toggleExtensionForActiveTab()
1247
+ })
1248
+ await new Promise((r) => setTimeout(r, 100))
873
1249
 
874
- // 3. Reset MCP to ensure page1 becomes the default page (only page available)
875
- const resetResult = await client.callTool({
876
- name: 'reset',
877
- arguments: {},
878
- })
879
- expect((resetResult as any).content[0].text).toContain('Connection reset successfully')
1250
+ // 3. Reset MCP to ensure page1 becomes the default page (only page available)
1251
+ const resetResult = await client.callTool({
1252
+ name: 'reset',
1253
+ arguments: {},
1254
+ })
1255
+ expect((resetResult as any).content[0].text).toContain('Connection reset successfully')
880
1256
 
881
- // 4. Verify initial page is accessible via default `page`
882
- const initialResult = await client.callTool({
883
- name: 'execute',
884
- arguments: {
885
- code: js`
1257
+ // 4. Verify initial page is accessible via default `page`
1258
+ const initialResult = await client.callTool({
1259
+ name: 'execute',
1260
+ arguments: {
1261
+ code: js`
886
1262
  const url = page.url();
887
1263
  console.log('Initial page URL:', url);
888
1264
  return { url };
889
1265
  `,
890
- },
891
- })
892
- expect((initialResult as any).content[0].text).toContain('first-page')
1266
+ },
1267
+ })
1268
+ expect((initialResult as any).content[0].text).toContain('first-page')
893
1269
 
894
- // 5. Create second page and enable extension
895
- const page2 = await browserContext.newPage()
896
- await page2.goto('https://example.com/second-page')
897
- await page2.bringToFront()
1270
+ // 5. Create second page and enable extension
1271
+ const page2 = await browserContext.newPage()
1272
+ await page2.goto('https://example.com/second-page')
1273
+ await page2.bringToFront()
898
1274
 
899
- await serviceWorker.evaluate(async () => {
900
- await globalThis.toggleExtensionForActiveTab()
901
- })
902
- await new Promise(r => setTimeout(r, 100))
903
-
904
- // 6. Close the first page (which is the default `page` in MCP scope)
905
- await page1.close()
906
- await new Promise(r => setTimeout(r, 100))
907
-
908
- // 7. Execute code via MCP - should NOT fail with "page closed" error
909
- // Instead, it should automatically switch to the second page
910
- const afterCloseResult = await client.callTool({
911
- name: 'execute',
912
- arguments: {
913
- code: js`
1275
+ await serviceWorker.evaluate(async () => {
1276
+ await globalThis.toggleExtensionForActiveTab()
1277
+ })
1278
+ await new Promise((r) => setTimeout(r, 100))
1279
+
1280
+ // 6. Close the first page (which is the default `page` in MCP scope)
1281
+ await page1.close()
1282
+ await new Promise((r) => setTimeout(r, 100))
1283
+
1284
+ // 7. Execute code via MCP - should NOT fail with "page closed" error
1285
+ // Instead, it should automatically switch to the second page
1286
+ const afterCloseResult = await client.callTool({
1287
+ name: 'execute',
1288
+ arguments: {
1289
+ code: js`
914
1290
  const url = page.url();
915
1291
  console.log('Page URL after close:', url);
916
1292
  const title = await page.title();
917
1293
  return { url, title };
918
1294
  `,
919
- },
920
- })
1295
+ },
1296
+ })
921
1297
 
922
- // Should succeed and return the second page's info
923
- expect((afterCloseResult as any).isError).toBeFalsy()
924
- const output = (afterCloseResult as any).content[0].text
925
- expect(output).toContain('second-page')
926
- expect(output).not.toContain('page closed')
927
- expect(output).not.toContain('Target closed')
1298
+ // Should succeed and return the second page's info
1299
+ expect((afterCloseResult as any).isError).toBeFalsy()
1300
+ const output = (afterCloseResult as any).content[0].text
1301
+ expect(output).toContain('second-page')
1302
+ expect(output).not.toContain('page closed')
1303
+ expect(output).not.toContain('Target closed')
1304
+
1305
+ // Cleanup
1306
+ await page2.close()
1307
+ }, 60000)
1308
+
1309
+ it('should show descriptive error when clicking a hidden element', async () => {
1310
+ await ensureConnectedTabForExecute()
1311
+
1312
+ // Create a fresh page and set content with a collapsed details element
1313
+ await client.callTool({
1314
+ name: 'execute',
1315
+ arguments: {
1316
+ code: js`
1317
+ state.errorTestPage = await context.newPage();
1318
+ await state.errorTestPage.setContent(\`
1319
+ <details>
1320
+ <summary>Toggle</summary>
1321
+ <button id="hidden-btn">Hidden Button</button>
1322
+ </details>
1323
+ \`);
1324
+ `,
1325
+ },
1326
+ })
1327
+ const result = await client.callTool({
1328
+ name: 'execute',
1329
+ arguments: {
1330
+ code: js`
1331
+ await state.errorTestPage.click('#hidden-btn', { timeout: 100 });
1332
+ `,
1333
+ },
1334
+ })
1335
+ expect(result).toMatchInlineSnapshot(`
1336
+ {
1337
+ "content": [
1338
+ {
1339
+ "text": "
1340
+ Error executing code: page.click: Timeout 100ms exceeded. Element is not visible — it may be hidden by CSS, inside a collapsed <details>, inactive tab, or closed accordion. Try: interact with the page to reveal it first, or use { force: true } to skip visibility checks
1341
+ Call log:
1342
+  - waiting for locator('#hidden-btn')
1343
+  - locator resolved to <button id="hidden-btn">Hidden Button</button>
1344
+  - attempting click action
1345
+  2 × waiting for element to be visible, enabled and stable
1346
+  - element is not visible
1347
+  - retrying click action
1348
+  - waiting 20ms
1349
+  - waiting for element to be visible, enabled and stable
1350
+  - element is not visible
1351
+  - retrying click action
1352
+  - waiting 100ms
1353
+ ",
1354
+ "type": "text",
1355
+ },
1356
+ ],
1357
+ "isError": true,
1358
+ }
1359
+ `)
1360
+ // Cleanup
1361
+ await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
1362
+ }, 30000)
1363
+
1364
+ it('should show descriptive error when clicking an element covered by another', async () => {
1365
+ await ensureConnectedTabForExecute()
1366
+
1367
+ await client.callTool({
1368
+ name: 'execute',
1369
+ arguments: {
1370
+ code: js`
1371
+ state.errorTestPage = await context.newPage();
1372
+ await state.errorTestPage.setContent(\`
1373
+ <div style="position:relative">
1374
+ <button id="covered-btn" style="position:absolute;top:0;left:0">Covered</button>
1375
+ <div id="overlay" style="position:absolute;top:0;left:0;width:200px;height:200px;background:red;z-index:10">Overlay</div>
1376
+ </div>
1377
+ \`);
1378
+ `,
1379
+ },
1380
+ })
1381
+ const result = await client.callTool({
1382
+ name: 'execute',
1383
+ arguments: {
1384
+ code: js`
1385
+ await state.errorTestPage.click('#covered-btn', { timeout: 100 });
1386
+ `,
1387
+ },
1388
+ })
1389
+ expect(result).toMatchInlineSnapshot(`
1390
+ {
1391
+ "content": [
1392
+ {
1393
+ "text": "
1394
+ Error executing code: page.click: Timeout 100ms exceeded. <div id="overlay">Overlay</div> intercepts pointer events
1395
+ Call log:
1396
+  - waiting for locator('#covered-btn')
1397
+  - locator resolved to <button id="covered-btn">Covered</button>
1398
+  - attempting click action
1399
+  2 × waiting for element to be visible, enabled and stable
1400
+  - element is visible, enabled and stable
1401
+  - scrolling into view if needed
1402
+  - done scrolling
1403
+  - <div id="overlay">Overlay</div> intercepts pointer events
1404
+  - retrying click action
1405
+  - waiting 20ms
1406
+  - waiting for element to be visible, enabled and stable
1407
+  - element is visible, enabled and stable
1408
+  - scrolling into view if needed
1409
+  - done scrolling
1410
+  - <div id="overlay">Overlay</div> intercepts pointer events
1411
+  - retrying click action
1412
+  - waiting 100ms
1413
+ ",
1414
+ "type": "text",
1415
+ },
1416
+ ],
1417
+ "isError": true,
1418
+ }
1419
+ `)
1420
+ await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
1421
+ }, 30000)
1422
+
1423
+ it('should show descriptive error when clicking a display:none element', async () => {
1424
+ await ensureConnectedTabForExecute()
1425
+
1426
+ await client.callTool({
1427
+ name: 'execute',
1428
+ arguments: {
1429
+ code: js`
1430
+ state.errorTestPage = await context.newPage();
1431
+ await state.errorTestPage.setContent('<button id="invisible" style="display:none">Invisible</button>');
1432
+ `,
1433
+ },
1434
+ })
1435
+ const result = await client.callTool({
1436
+ name: 'execute',
1437
+ arguments: {
1438
+ code: js`
1439
+ await state.errorTestPage.click('#invisible', { timeout: 100 });
1440
+ `,
1441
+ },
1442
+ })
1443
+ expect(result).toMatchInlineSnapshot(`
1444
+ {
1445
+ "content": [
1446
+ {
1447
+ "text": "
1448
+ Error executing code: page.click: Timeout 100ms exceeded. Element is not visible — it may be hidden by CSS, inside a collapsed <details>, inactive tab, or closed accordion. Try: interact with the page to reveal it first, or use { force: true } to skip visibility checks
1449
+ Call log:
1450
+  - waiting for locator('#invisible')
1451
+  - locator resolved to <button id="invisible">Invisible</button>
1452
+  - attempting click action
1453
+  2 × waiting for element to be visible, enabled and stable
1454
+  - element is not visible
1455
+  - retrying click action
1456
+  - waiting 20ms
1457
+  - waiting for element to be visible, enabled and stable
1458
+  - element is not visible
1459
+  - retrying click action
1460
+  - waiting 100ms
1461
+ ",
1462
+ "type": "text",
1463
+ },
1464
+ ],
1465
+ "isError": true,
1466
+ }
1467
+ `)
1468
+ await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
1469
+ }, 30000)
928
1470
 
929
- // Cleanup
930
- await page2.close()
931
- }, 60000)
932
1471
  })