playwriter 0.0.63 → 0.0.80

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 (216) hide show
  1. package/dist/aria-snapshot.d.ts +41 -3
  2. package/dist/aria-snapshot.d.ts.map +1 -1
  3. package/dist/aria-snapshot.js +131 -54
  4. package/dist/aria-snapshot.js.map +1 -1
  5. package/dist/aria-snapshot.test.js +5 -2
  6. package/dist/aria-snapshot.test.js.map +1 -1
  7. package/dist/aria-snapshot.unit.test.js +83 -41
  8. package/dist/aria-snapshot.unit.test.js.map +1 -1
  9. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  13. package/dist/bippy.js +1 -1
  14. package/dist/cdp-log.d.ts +1 -1
  15. package/dist/cdp-log.d.ts.map +1 -1
  16. package/dist/cdp-log.js +1 -1
  17. package/dist/cdp-log.js.map +1 -1
  18. package/dist/cdp-relay.d.ts.map +1 -1
  19. package/dist/cdp-relay.js +408 -298
  20. package/dist/cdp-relay.js.map +1 -1
  21. package/dist/cdp-session.d.ts.map +1 -1
  22. package/dist/cdp-session.js.map +1 -1
  23. package/dist/cdp-types.d.ts.map +1 -1
  24. package/dist/cdp-types.js +7 -7
  25. package/dist/cdp-types.js.map +1 -1
  26. package/dist/clean-html.d.ts.map +1 -1
  27. package/dist/clean-html.js +4 -5
  28. package/dist/clean-html.js.map +1 -1
  29. package/dist/cli.js +45 -27
  30. package/dist/cli.js.map +1 -1
  31. package/dist/create-logger.d.ts.map +1 -1
  32. package/dist/create-logger.js +3 -1
  33. package/dist/create-logger.js.map +1 -1
  34. package/dist/debugger-examples-types.d.ts.map +1 -1
  35. package/dist/debugger.d.ts.map +1 -1
  36. package/dist/debugger.js +1 -3
  37. package/dist/debugger.js.map +1 -1
  38. package/dist/diff-utils.d.ts.map +1 -1
  39. package/dist/diff-utils.js +1 -4
  40. package/dist/diff-utils.js.map +1 -1
  41. package/dist/editor-api.md +12 -2
  42. package/dist/editor-examples.d.ts +1 -1
  43. package/dist/editor-examples.d.ts.map +1 -1
  44. package/dist/editor-examples.js +1 -1
  45. package/dist/editor-examples.js.map +1 -1
  46. package/dist/editor.d.ts +1 -1
  47. package/dist/editor.d.ts.map +1 -1
  48. package/dist/editor.js +1 -1
  49. package/dist/editor.js.map +1 -1
  50. package/dist/executor.d.ts +26 -3
  51. package/dist/executor.d.ts.map +1 -1
  52. package/dist/executor.js +295 -64
  53. package/dist/executor.js.map +1 -1
  54. package/dist/executor.unit.test.js +38 -1
  55. package/dist/executor.unit.test.js.map +1 -1
  56. package/dist/extension-connection.test.js +139 -36
  57. package/dist/extension-connection.test.js.map +1 -1
  58. package/dist/ffmpeg.d.ts +148 -0
  59. package/dist/ffmpeg.d.ts.map +1 -0
  60. package/dist/ffmpeg.js +523 -0
  61. package/dist/ffmpeg.js.map +1 -0
  62. package/dist/ghost-browser.d.ts.map +1 -1
  63. package/dist/ghost-browser.js.map +1 -1
  64. package/dist/ghost-cursor-client.js +281 -0
  65. package/dist/ghost-cursor.d.ts +27 -0
  66. package/dist/ghost-cursor.d.ts.map +1 -0
  67. package/dist/ghost-cursor.js +63 -0
  68. package/dist/ghost-cursor.js.map +1 -0
  69. package/dist/htmlrewrite.d.ts.map +1 -1
  70. package/dist/htmlrewrite.js +17 -55
  71. package/dist/htmlrewrite.js.map +1 -1
  72. package/dist/htmlrewrite.test.js.map +1 -1
  73. package/dist/kill-port.d.ts.map +1 -1
  74. package/dist/kill-port.js +1 -3
  75. package/dist/kill-port.js.map +1 -1
  76. package/dist/locator-selector.test.d.ts +2 -0
  77. package/dist/locator-selector.test.d.ts.map +1 -0
  78. package/dist/locator-selector.test.js +96 -0
  79. package/dist/locator-selector.test.js.map +1 -0
  80. package/dist/mcp-client.js.map +1 -1
  81. package/dist/mcp.d.ts.map +1 -1
  82. package/dist/mcp.js +8 -3
  83. package/dist/mcp.js.map +1 -1
  84. package/dist/on-mouse-action.test.d.ts +2 -0
  85. package/dist/on-mouse-action.test.d.ts.map +1 -0
  86. package/dist/on-mouse-action.test.js +155 -0
  87. package/dist/on-mouse-action.test.js.map +1 -0
  88. package/dist/page-markdown.js +4 -4
  89. package/dist/page-markdown.js.map +1 -1
  90. package/dist/prompt.md +594 -255
  91. package/dist/protocol.d.ts +4 -0
  92. package/dist/protocol.d.ts.map +1 -1
  93. package/dist/readability.js +1 -1
  94. package/dist/recording-ghost-cursor.d.ts +41 -0
  95. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  96. package/dist/recording-ghost-cursor.js +79 -0
  97. package/dist/recording-ghost-cursor.js.map +1 -0
  98. package/dist/recording-relay.d.ts.map +1 -1
  99. package/dist/recording-relay.js +8 -8
  100. package/dist/recording-relay.js.map +1 -1
  101. package/dist/relay-client.d.ts +17 -4
  102. package/dist/relay-client.d.ts.map +1 -1
  103. package/dist/relay-client.js +44 -10
  104. package/dist/relay-client.js.map +1 -1
  105. package/dist/relay-core.test.d.ts.map +1 -1
  106. package/dist/relay-core.test.js +187 -26
  107. package/dist/relay-core.test.js.map +1 -1
  108. package/dist/relay-navigation.test.d.ts.map +1 -1
  109. package/dist/relay-navigation.test.js +54 -31
  110. package/dist/relay-navigation.test.js.map +1 -1
  111. package/dist/relay-session.test.d.ts.map +1 -1
  112. package/dist/relay-session.test.js +113 -65
  113. package/dist/relay-session.test.js.map +1 -1
  114. package/dist/relay-state.d.ts +158 -0
  115. package/dist/relay-state.d.ts.map +1 -0
  116. package/dist/relay-state.js +306 -0
  117. package/dist/relay-state.js.map +1 -0
  118. package/dist/relay-state.test.d.ts +2 -0
  119. package/dist/relay-state.test.d.ts.map +1 -0
  120. package/dist/relay-state.test.js +472 -0
  121. package/dist/relay-state.test.js.map +1 -0
  122. package/dist/scoped-fs.d.ts.map +1 -1
  123. package/dist/scoped-fs.js.map +1 -1
  124. package/dist/screen-recording.d.ts +42 -4
  125. package/dist/screen-recording.d.ts.map +1 -1
  126. package/dist/screen-recording.js +88 -13
  127. package/dist/screen-recording.js.map +1 -1
  128. package/dist/selector-generator.js +1 -1
  129. package/dist/snapshot-tools.test.js +71 -28
  130. package/dist/snapshot-tools.test.js.map +1 -1
  131. package/dist/start-relay-server.d.ts +1 -1
  132. package/dist/start-relay-server.d.ts.map +1 -1
  133. package/dist/start-relay-server.js +1 -1
  134. package/dist/start-relay-server.js.map +1 -1
  135. package/dist/styles-api.md +8 -1
  136. package/dist/styles-examples.d.ts +1 -1
  137. package/dist/styles-examples.d.ts.map +1 -1
  138. package/dist/styles-examples.js +1 -1
  139. package/dist/styles-examples.js.map +1 -1
  140. package/dist/styles.d.ts.map +1 -1
  141. package/dist/styles.js +1 -3
  142. package/dist/styles.js.map +1 -1
  143. package/dist/test-declarations.d.ts.map +1 -1
  144. package/dist/test-utils.d.ts +1 -1
  145. package/dist/test-utils.d.ts.map +1 -1
  146. package/dist/test-utils.js +7 -5
  147. package/dist/test-utils.js.map +1 -1
  148. package/dist/utils.d.ts.map +1 -1
  149. package/dist/utils.js.map +1 -1
  150. package/dist/wait-for-page-load.d.ts.map +1 -1
  151. package/dist/wait-for-page-load.js +1 -1
  152. package/dist/wait-for-page-load.js.map +1 -1
  153. package/package.json +4 -3
  154. package/src/a11y-client.ts +5 -4
  155. package/src/aria-snapshot.test.ts +5 -2
  156. package/src/aria-snapshot.ts +303 -116
  157. package/src/aria-snapshot.unit.test.ts +199 -141
  158. package/src/aria-snapshots/github-raw.txt +1 -1
  159. package/src/aria-snapshots/hackernews-interactive.txt +240 -240
  160. package/src/aria-snapshots/hackernews-raw.txt +270 -270
  161. package/src/assets/aria-labels-example.png +0 -0
  162. package/src/assets/aria-labels-github.png +0 -0
  163. package/src/assets/aria-labels-hacker-news.png +0 -0
  164. package/src/assets/aria-labels-old-reddit.png +0 -0
  165. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  166. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  167. package/src/cdp-log.ts +4 -1
  168. package/src/cdp-relay.ts +949 -737
  169. package/src/cdp-session.ts +12 -3
  170. package/src/cdp-types.ts +51 -51
  171. package/src/clean-html.ts +4 -5
  172. package/src/cli.ts +82 -55
  173. package/src/create-logger.ts +5 -3
  174. package/src/debugger-examples-types.ts +4 -1
  175. package/src/debugger.ts +1 -5
  176. package/src/diff-utils.ts +2 -5
  177. package/src/editor-examples.ts +11 -1
  178. package/src/editor.ts +10 -2
  179. package/src/executor.ts +372 -73
  180. package/src/executor.unit.test.ts +48 -1
  181. package/src/extension-connection.test.ts +612 -488
  182. package/src/ffmpeg.ts +769 -0
  183. package/src/ghost-browser.ts +4 -6
  184. package/src/ghost-cursor-client.ts +368 -0
  185. package/src/ghost-cursor.ts +110 -0
  186. package/src/htmlrewrite.test.ts +6 -2
  187. package/src/htmlrewrite.ts +348 -386
  188. package/src/kill-port.ts +1 -3
  189. package/src/locator-selector.test.ts +115 -0
  190. package/src/mcp-client.ts +1 -1
  191. package/src/mcp.ts +21 -15
  192. package/src/on-mouse-action.test.ts +196 -0
  193. package/src/page-markdown.ts +7 -7
  194. package/src/protocol.ts +73 -57
  195. package/src/recording-ghost-cursor.ts +107 -0
  196. package/src/recording-relay.ts +20 -12
  197. package/src/relay-client.ts +84 -17
  198. package/src/relay-core.test.ts +761 -583
  199. package/src/relay-navigation.test.ts +517 -484
  200. package/src/relay-session.test.ts +984 -929
  201. package/src/relay-state.test.ts +570 -0
  202. package/src/relay-state.ts +497 -0
  203. package/src/resource.md +21 -49
  204. package/src/scoped-fs.ts +9 -3
  205. package/src/screen-recording.ts +175 -31
  206. package/src/skill.md +619 -271
  207. package/src/snapshot-tools.test.ts +580 -528
  208. package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
  209. package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
  210. package/src/start-relay-server.ts +14 -11
  211. package/src/styles-examples.ts +8 -1
  212. package/src/styles.ts +20 -21
  213. package/src/test-declarations.ts +6 -6
  214. package/src/test-utils.ts +104 -91
  215. package/src/utils.ts +2 -1
  216. package/src/wait-for-page-load.ts +6 -1
@@ -2,115 +2,126 @@ import { createMCPClient } from './mcp-client.js'
2
2
  import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
3
  import { getCDPSessionForPage } from './cdp-session.js'
4
4
  import { getCdpUrl } from './utils.js'
5
- import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, js, tryJsonParse } from './test-utils.js'
5
+ import {
6
+ setupTestContext,
7
+ cleanupTestContext,
8
+ getExtensionServiceWorker,
9
+ type TestContext,
10
+ withTimeout,
11
+ js,
12
+ tryJsonParse,
13
+ createSimpleServer,
14
+ } from './test-utils.js'
6
15
  import './test-declarations.js'
7
16
 
8
17
  const TEST_PORT = 19987
9
18
 
10
19
  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
14
-
15
- beforeAll(async () => {
16
- testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true })
17
-
18
- const result = await createMCPClient({ port: TEST_PORT })
19
- client = result.client
20
- cleanup = result.cleanup
21
- }, 600000)
22
-
23
- afterAll(async () => {
24
- await cleanupTestContext(testCtx, cleanup)
25
- cleanup = null
26
- testCtx = null
20
+ let client: Awaited<ReturnType<typeof createMCPClient>>['client']
21
+ let cleanup: (() => Promise<void>) | null = null
22
+ let testCtx: TestContext | null = null
23
+
24
+ beforeAll(async () => {
25
+ testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-test-', toggleExtension: true })
26
+
27
+ const result = await createMCPClient({ port: TEST_PORT })
28
+ client = result.client
29
+ cleanup = result.cleanup
30
+ }, 600000)
31
+
32
+ afterAll(async () => {
33
+ await cleanupTestContext(testCtx, cleanup)
34
+ cleanup = null
35
+ testCtx = null
36
+ })
37
+
38
+ const getBrowserContext = () => {
39
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
40
+ return testCtx.browserContext
41
+ }
42
+
43
+ it('should inject script via addScriptTag through CDP relay', async () => {
44
+ const browserContext = getBrowserContext()
45
+ const serviceWorker = await withTimeout({
46
+ promise: getExtensionServiceWorker(browserContext),
47
+ timeoutMs: 5000,
48
+ errorMessage: 'Timed out waiting for extension service worker for iframe test',
27
49
  })
28
50
 
29
- const getBrowserContext = () => {
30
- if (!testCtx?.browserContext) throw new Error('Browser not initialized')
31
- return testCtx.browserContext
32
- }
33
-
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
- })
51
+ const page = await browserContext.newPage()
52
+ const html = '<html><body><button id="btn">Click</button></body></html>'
53
+ const dataUrl = `data:text/html,${encodeURIComponent(html)}`
54
+ await page.goto(dataUrl)
55
+ await page.bringToFront()
56
+
57
+ await withTimeout({
58
+ promise: serviceWorker.evaluate(async () => {
59
+ await globalThis.toggleExtensionForActiveTab()
60
+ }),
61
+ timeoutMs: 10000,
62
+ errorMessage: 'Timed out toggling extension for active tab',
63
+ })
64
+ await new Promise((r) => {
65
+ setTimeout(r, 100)
66
+ })
41
67
 
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) })
68
+ const cdpSession = await withTimeout({
69
+ promise: getCDPSessionForPage({ page }),
70
+ timeoutMs: 10000,
71
+ errorMessage: 'Timed out creating CDP session for page',
72
+ })
56
73
 
57
- const cdpSession = await withTimeout({
58
- promise: getCDPSessionForPage({ page }),
59
- timeoutMs: 10000,
60
- errorMessage: 'Timed out creating CDP session for page',
61
- })
74
+ const hasGlobalBefore = await page.evaluate(() => {
75
+ return Boolean((globalThis as { __testGlobal?: unknown }).__testGlobal)
76
+ })
77
+ expect(hasGlobalBefore).toBe(false)
62
78
 
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',
79
+ await withTimeout({
80
+ promise: (async () => {
81
+ await cdpSession.send('Page.enable')
82
+ await cdpSession.send('Page.addScriptToEvaluateOnNewDocument', {
83
+ source: 'globalThis.__testGlobal = { foo: "bar" }',
78
84
  })
85
+ await page.reload({ waitUntil: 'domcontentloaded' })
86
+ })(),
87
+ timeoutMs: 10000,
88
+ errorMessage: 'Timed out injecting script via CDP session',
89
+ })
79
90
 
80
- const hasGlobalAfter = await page.evaluate(() => {
81
- return (globalThis as { __testGlobal?: unknown }).__testGlobal
82
- })
83
- expect(hasGlobalAfter).toEqual({ foo: 'bar' })
91
+ const hasGlobalAfter = await page.evaluate(() => {
92
+ return (globalThis as { __testGlobal?: unknown }).__testGlobal
93
+ })
94
+ expect(hasGlobalAfter).toEqual({ foo: 'bar' })
84
95
 
85
- await cdpSession.detach()
86
- await page.close()
87
- }, 60000)
96
+ await cdpSession.detach()
97
+ await page.close()
98
+ }, 60000)
88
99
 
89
- it('should execute code and capture console output', async () => {
90
- await client.callTool({
91
- name: 'execute',
92
- arguments: {
93
- code: js`
100
+ it('should execute code and capture console output', async () => {
101
+ await client.callTool({
102
+ name: 'execute',
103
+ arguments: {
104
+ code: js`
94
105
  const newPage = await context.newPage();
95
106
  state.page = newPage;
96
107
  if (!state.pages) state.pages = [];
97
108
  state.pages.push(newPage);
98
109
  `,
99
- },
100
- })
110
+ },
111
+ })
101
112
 
102
- const result = await client.callTool({
103
- name: 'execute',
104
- arguments: {
105
- code: js`
113
+ const result = await client.callTool({
114
+ name: 'execute',
115
+ arguments: {
116
+ code: js`
106
117
  await state.page.goto('https://example.com');
107
118
  const title = await state.page.title();
108
119
  console.log('Page title:', title);
109
120
  return { url: state.page.url(), title };
110
121
  `,
111
- },
112
- })
113
- expect(result.content).toMatchInlineSnapshot(`
122
+ },
123
+ })
124
+ expect(result.content).toMatchInlineSnapshot(`
114
125
  [
115
126
  {
116
127
  "text": "Console output:
@@ -121,138 +132,134 @@ describe('Relay Core Tests', () => {
121
132
  },
122
133
  ]
123
134
  `)
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)
130
-
131
- // Create a page via MCP (which uses context.newPage())
132
- await client.callTool({
133
- name: 'execute',
134
- arguments: {
135
- code: js`
135
+ expect(result.content).toBeDefined()
136
+ }, 30000)
137
+
138
+ it('should show extension as connected for pages created via newPage()', async () => {
139
+ const browserContext = getBrowserContext()
140
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
141
+
142
+ // Create a page via MCP (which uses context.newPage())
143
+ await client.callTool({
144
+ name: 'execute',
145
+ arguments: {
146
+ code: js`
136
147
  const newPage = await context.newPage();
137
148
  state.testPage = newPage;
138
149
  await newPage.goto('https://example.com/mcp-test');
139
150
  return newPage.url();
140
151
  `,
141
- },
142
- })
152
+ },
153
+ })
143
154
 
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
- })
155
+ // Get extension state to verify the page is marked as connected
156
+ const extensionState = await serviceWorker.evaluate(async () => {
157
+ const state = globalThis.getExtensionState()
158
+ const tabs = await chrome.tabs.query({})
159
+ const testTab = tabs.find((t: any) => t.url?.includes('mcp-test'))
160
+ return {
161
+ connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id),
162
+ tabId: testTab?.id,
163
+ tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null,
164
+ connectionState: state.connectionState,
165
+ }
166
+ })
156
167
 
157
- expect(extensionState.connected).toBe(true)
158
- expect(extensionState.tabInfo?.state).toBe('connected')
159
- expect(extensionState.connectionState).toBe('connected')
168
+ expect(extensionState.connected).toBe(true)
169
+ expect(extensionState.tabInfo?.state).toBe('connected')
170
+ expect(extensionState.connectionState).toBe('connected')
160
171
 
161
- // Clean up
162
- await client.callTool({
163
- name: 'execute',
164
- arguments: {
165
- code: js`
172
+ // Clean up
173
+ await client.callTool({
174
+ name: 'execute',
175
+ arguments: {
176
+ code: js`
166
177
  if (state.testPage) {
167
178
  await state.testPage.close();
168
179
  delete state.testPage;
169
180
  }
170
181
  `,
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`
182
+ },
183
+ })
184
+ }, 30000)
185
+
186
+ const snapshotTestCases = [
187
+ {
188
+ name: 'hacker-news',
189
+ url: 'https://news.ycombinator.com/item?id=1',
190
+ expectedContent: ['role=link', 'Hacker News'],
191
+ },
192
+ {
193
+ name: 'shadcn-ui',
194
+ url: 'https://ui.shadcn.com/',
195
+ expectedContent: ['shadcn'],
196
+ },
197
+ ]
198
+
199
+ for (const testCase of snapshotTestCases) {
200
+ it(`should get accessibility snapshot of ${testCase.name}`, async () => {
201
+ await client.callTool({
202
+ name: 'execute',
203
+ arguments: {
204
+ code: js`
194
205
  const newPage = await context.newPage();
195
206
  state.page = newPage;
196
207
  if (!state.pages) state.pages = [];
197
208
  state.pages.push(newPage);
198
209
  `,
199
- },
200
- })
201
-
202
- // Capture interactiveOnly=true snapshot (default)
203
- const interactiveResult = await client.callTool({
204
- name: 'execute',
205
- arguments: {
206
- code: js`
210
+ },
211
+ })
212
+
213
+ // Capture interactiveOnly=true snapshot (default)
214
+ const interactiveResult = await client.callTool({
215
+ name: 'execute',
216
+ arguments: {
217
+ code: js`
207
218
  await state.page.goto('${testCase.url}', { waitUntil: 'domcontentloaded' });
208
- const snapshot = await accessibilitySnapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: true });
209
- return snapshot;
219
+ const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: true });
220
+ return snap;
210
221
  `,
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;
222
+ },
223
+ })
224
+
225
+ const interactiveData =
226
+ typeof interactiveResult === 'object' && interactiveResult.content?.[0]?.text
227
+ ? tryJsonParse(interactiveResult.content[0].text)
228
+ : interactiveResult
229
+ await expect(interactiveData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-interactive.md`)
230
+ expect(interactiveResult.content).toBeDefined()
231
+ for (const expected of testCase.expectedContent) {
232
+ expect(interactiveData).toContain(expected)
233
+ }
234
+
235
+ // Capture interactiveOnly=false snapshot (full tree)
236
+ const fullResult = await client.callTool({
237
+ name: 'execute',
238
+ arguments: {
239
+ code: js`
240
+ const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: false });
241
+ return snap;
233
242
  `,
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
- }
243
+ },
244
+ })
245
+
246
+ const fullData =
247
+ typeof fullResult === 'object' && fullResult.content?.[0]?.text
248
+ ? tryJsonParse(fullResult.content[0].text)
249
+ : fullResult
250
+ await expect(fullData).toMatchFileSnapshot(`snapshots/${testCase.name}-accessibility-full.md`)
251
+ expect(fullResult.content).toBeDefined()
252
+ for (const expected of testCase.expectedContent) {
253
+ expect(fullData).toContain(expected)
254
+ }
255
+ }, 60000)
256
+ }
250
257
 
251
- it('should close all created pages', async () => {
252
- const result = await client.callTool({
253
- name: 'execute',
254
- arguments: {
255
- code: js`
258
+ it('should close all created pages', async () => {
259
+ const result = await client.callTool({
260
+ name: 'execute',
261
+ arguments: {
262
+ code: js`
256
263
  if (state.pages && state.pages.length > 0) {
257
264
  for (const page of state.pages) {
258
265
  await page.close();
@@ -263,17 +270,16 @@ describe('Relay Core Tests', () => {
263
270
  }
264
271
  return { closedCount: 0 };
265
272
  `,
266
- },
267
- })
268
-
273
+ },
269
274
  })
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`
275
+ })
276
+
277
+ it('should capture browser console logs with getLatestLogs', async () => {
278
+ // Ensure clean state and clear any existing logs
279
+ const resetResult = await client.callTool({
280
+ name: 'execute',
281
+ arguments: {
282
+ code: js`
277
283
  // Clear any existing logs from previous tests
278
284
  clearAllLogs();
279
285
  console.log('Cleared all existing logs');
@@ -284,27 +290,27 @@ describe('Relay Core Tests', () => {
284
290
 
285
291
  return { success: true, pagesCount: pages.length };
286
292
  `,
287
- },
288
- })
289
- console.log('Cleanup result:', resetResult)
293
+ },
294
+ })
295
+ console.log('Cleanup result:', resetResult)
290
296
 
291
- // Create a new page for this test
292
- await client.callTool({
293
- name: 'execute',
294
- arguments: {
295
- code: js`
297
+ // Create a new page for this test
298
+ await client.callTool({
299
+ name: 'execute',
300
+ arguments: {
301
+ code: js`
296
302
  const newPage = await context.newPage();
297
303
  state.testLogPage = newPage;
298
304
  await newPage.goto('about:blank');
299
305
  `,
300
- },
301
- })
306
+ },
307
+ })
302
308
 
303
- // Generate some console logs in the browser
304
- await client.callTool({
305
- name: 'execute',
306
- arguments: {
307
- code: js`
309
+ // Generate some console logs in the browser
310
+ await client.callTool({
311
+ name: 'execute',
312
+ arguments: {
313
+ code: js`
308
314
  await state.testLogPage.evaluate(() => {
309
315
  console.log('Test log 12345');
310
316
  console.error('Test error 67890');
@@ -314,325 +320,403 @@ describe('Relay Core Tests', () => {
314
320
  // Wait for logs to be captured
315
321
  await new Promise(resolve => setTimeout(resolve, 100));
316
322
  `,
317
- },
318
- })
323
+ },
324
+ })
319
325
 
320
- // Test getting all logs
321
- const allLogsResult = await client.callTool({
322
- name: 'execute',
323
- arguments: {
324
- code: js`
326
+ // Test getting all logs
327
+ const allLogsResult = await client.callTool({
328
+ name: 'execute',
329
+ arguments: {
330
+ code: js`
325
331
  const logs = await getLatestLogs();
326
332
  logs.forEach(log => console.log(log));
327
333
  `,
328
- },
329
- })
334
+ },
335
+ })
330
336
 
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')
337
+ const output = (allLogsResult as any).content[0].text
338
+ expect(output).toContain('[log] Test log 12345')
339
+ expect(output).toContain('[error] Test error 67890')
340
+ expect(output).toContain('[warning] Test warning 11111')
335
341
 
336
- // Test filtering by search string
337
- const errorLogsResult = await client.callTool({
338
- name: 'execute',
339
- arguments: {
340
- code: js`
342
+ // Test filtering by search string
343
+ const errorLogsResult = await client.callTool({
344
+ name: 'execute',
345
+ arguments: {
346
+ code: js`
341
347
  const logs = await getLatestLogs({ search: 'error' });
342
348
  logs.forEach(log => console.log(log));
343
349
  `,
344
- },
345
- })
350
+ },
351
+ })
346
352
 
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')
353
+ const errorOutput = (errorLogsResult as any).content[0].text
354
+ expect(errorOutput).toContain('[error] Test error 67890')
355
+ // With context lines (5 above/below), nearby logs are also included
356
+ expect(errorOutput).toContain('[log] Test log 12345')
351
357
 
352
- // Test that logs are cleared on page reload
353
- await client.callTool({
354
- name: 'execute',
355
- arguments: {
356
- code: js`
358
+ // Test that logs are cleared on page reload
359
+ await client.callTool({
360
+ name: 'execute',
361
+ arguments: {
362
+ code: js`
357
363
  // First add a log before reload
358
364
  await state.testLogPage.evaluate(() => {
359
365
  console.log('Before reload 99999');
360
366
  });
361
367
  await new Promise(resolve => setTimeout(resolve, 100));
362
368
  `,
363
- },
364
- })
369
+ },
370
+ })
365
371
 
366
- // Verify the log exists
367
- const beforeReloadResult = await client.callTool({
368
- name: 'execute',
369
- arguments: {
370
- code: js`
372
+ // Verify the log exists
373
+ const beforeReloadResult = await client.callTool({
374
+ name: 'execute',
375
+ arguments: {
376
+ code: js`
371
377
  const logs = await getLatestLogs({ page: state.testLogPage });
372
378
  console.log('Logs before reload:', logs.length);
373
379
  logs.forEach(log => console.log(log));
374
380
  `,
375
- },
376
- })
381
+ },
382
+ })
377
383
 
378
- const beforeReloadOutput = (beforeReloadResult as any).content[0].text
379
- expect(beforeReloadOutput).toContain('[log] Before reload 99999')
384
+ const beforeReloadOutput = (beforeReloadResult as any).content[0].text
385
+ expect(beforeReloadOutput).toContain('[log] Before reload 99999')
380
386
 
381
- // Reload the page
382
- await client.callTool({
383
- name: 'execute',
384
- arguments: {
385
- code: js`
387
+ // Reload the page
388
+ await client.callTool({
389
+ name: 'execute',
390
+ arguments: {
391
+ code: js`
386
392
  await state.testLogPage.reload();
387
393
  await state.testLogPage.evaluate(() => {
388
394
  console.log('After reload 88888');
389
395
  });
390
396
  await new Promise(resolve => setTimeout(resolve, 100));
391
397
  `,
392
- },
393
- })
398
+ },
399
+ })
394
400
 
395
- // Check logs after reload - old logs should be gone
396
- const afterReloadResult = await client.callTool({
397
- name: 'execute',
398
- arguments: {
399
- code: js`
401
+ // Check logs after reload - old logs should be gone
402
+ const afterReloadResult = await client.callTool({
403
+ name: 'execute',
404
+ arguments: {
405
+ code: js`
400
406
  const logs = await getLatestLogs({ page: state.testLogPage });
401
407
  console.log('Logs after reload:', logs.length);
402
408
  logs.forEach(log => console.log(log));
403
409
  `,
404
- },
405
- })
410
+ },
411
+ })
406
412
 
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')
413
+ const afterReloadOutput = (afterReloadResult as any).content[0].text
414
+ expect(afterReloadOutput).toContain('[log] After reload 88888')
415
+ expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
410
416
 
411
- // Clean up
412
- await client.callTool({
413
- name: 'execute',
414
- arguments: {
415
- code: js`
417
+ // Clean up
418
+ await client.callTool({
419
+ name: 'execute',
420
+ arguments: {
421
+ code: js`
416
422
  await state.testLogPage.close();
417
423
  delete state.testLogPage;
418
424
  `,
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`
425
+ },
426
+ })
427
+ }, 30000)
428
+
429
+ it('should keep logs separate between different pages', async () => {
430
+ // Clear any existing logs from previous tests
431
+ await client.callTool({
432
+ name: 'execute',
433
+ arguments: {
434
+ code: js`
429
435
  clearAllLogs();
430
436
  console.log('Cleared all existing logs for second log test');
431
437
  `,
432
- },
433
- })
438
+ },
439
+ })
434
440
 
435
- // Create two pages
436
- await client.callTool({
437
- name: 'execute',
438
- arguments: {
439
- code: js`
441
+ // Create two pages
442
+ await client.callTool({
443
+ name: 'execute',
444
+ arguments: {
445
+ code: js`
440
446
  state.pageA = await context.newPage();
441
447
  state.pageB = await context.newPage();
442
448
  await state.pageA.goto('about:blank');
443
449
  await state.pageB.goto('about:blank');
444
450
  `,
445
- },
446
- })
451
+ },
452
+ })
447
453
 
448
- // Generate logs in page A
449
- await client.callTool({
450
- name: 'execute',
451
- arguments: {
452
- code: js`
454
+ // Generate logs in page A
455
+ await client.callTool({
456
+ name: 'execute',
457
+ arguments: {
458
+ code: js`
453
459
  await state.pageA.evaluate(() => {
454
460
  console.log('PageA log 11111');
455
461
  console.error('PageA error 22222');
456
462
  });
457
463
  await new Promise(resolve => setTimeout(resolve, 100));
458
464
  `,
459
- },
460
- })
465
+ },
466
+ })
461
467
 
462
- // Generate logs in page B
463
- await client.callTool({
464
- name: 'execute',
465
- arguments: {
466
- code: js`
468
+ // Generate logs in page B
469
+ await client.callTool({
470
+ name: 'execute',
471
+ arguments: {
472
+ code: js`
467
473
  await state.pageB.evaluate(() => {
468
474
  console.log('PageB log 33333');
469
475
  console.error('PageB error 44444');
470
476
  });
471
477
  await new Promise(resolve => setTimeout(resolve, 100));
472
478
  `,
473
- },
474
- })
479
+ },
480
+ })
475
481
 
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`
482
+ // Check logs for page A - should only have page A logs
483
+ const pageALogsResult = await client.callTool({
484
+ name: 'execute',
485
+ arguments: {
486
+ code: js`
481
487
  const logs = await getLatestLogs({ page: state.pageA });
482
488
  console.log('Page A logs:', logs.length);
483
489
  logs.forEach(log => console.log(log));
484
490
  `,
485
- },
486
- })
491
+ },
492
+ })
487
493
 
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')
494
+ const pageAOutput = (pageALogsResult as any).content[0].text
495
+ expect(pageAOutput).toContain('[log] PageA log 11111')
496
+ expect(pageAOutput).toContain('[error] PageA error 22222')
497
+ expect(pageAOutput).not.toContain('PageB')
492
498
 
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`
499
+ // Check logs for page B - should only have page B logs
500
+ const pageBLogsResult = await client.callTool({
501
+ name: 'execute',
502
+ arguments: {
503
+ code: js`
498
504
  const logs = await getLatestLogs({ page: state.pageB });
499
505
  console.log('Page B logs:', logs.length);
500
506
  logs.forEach(log => console.log(log));
501
507
  `,
502
- },
503
- })
508
+ },
509
+ })
504
510
 
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')
511
+ const pageBOutput = (pageBLogsResult as any).content[0].text
512
+ expect(pageBOutput).toContain('[log] PageB log 33333')
513
+ expect(pageBOutput).toContain('[error] PageB error 44444')
514
+ expect(pageBOutput).not.toContain('PageA')
509
515
 
510
- // Check all logs - should have logs from both pages
511
- const allLogsResult = await client.callTool({
512
- name: 'execute',
513
- arguments: {
514
- code: js`
516
+ // Check all logs - should have logs from both pages
517
+ const allLogsResult = await client.callTool({
518
+ name: 'execute',
519
+ arguments: {
520
+ code: js`
515
521
  const logs = await getLatestLogs();
516
522
  console.log('All logs:', logs.length);
517
523
  logs.forEach(log => console.log(log));
518
524
  `,
519
- },
520
- })
525
+ },
526
+ })
521
527
 
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')
528
+ const allOutput = (allLogsResult as any).content[0].text
529
+ expect(allOutput).toContain('[log] PageA log 11111')
530
+ expect(allOutput).toContain('[log] PageB log 33333')
525
531
 
526
- // Test that reloading page A clears only page A logs
527
- await client.callTool({
528
- name: 'execute',
529
- arguments: {
530
- code: js`
532
+ // Test that reloading page A clears only page A logs
533
+ await client.callTool({
534
+ name: 'execute',
535
+ arguments: {
536
+ code: js`
531
537
  await state.pageA.reload();
532
538
  await state.pageA.evaluate(() => {
533
539
  console.log('PageA after reload 55555');
534
540
  });
535
541
  await new Promise(resolve => setTimeout(resolve, 100));
536
542
  `,
537
- },
538
- })
543
+ },
544
+ })
539
545
 
540
- // Check page A logs - should only have new log
541
- const pageAAfterReloadResult = await client.callTool({
542
- name: 'execute',
543
- arguments: {
544
- code: js`
546
+ // Check page A logs - should only have new log
547
+ const pageAAfterReloadResult = await client.callTool({
548
+ name: 'execute',
549
+ arguments: {
550
+ code: js`
545
551
  const logs = await getLatestLogs({ page: state.pageA });
546
552
  console.log('Page A logs after reload:', logs.length);
547
553
  logs.forEach(log => console.log(log));
548
554
  `,
549
- },
550
- })
555
+ },
556
+ })
551
557
 
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')
558
+ const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
559
+ expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
560
+ expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
555
561
 
556
- // Check page B logs - should still have original logs
557
- const pageBAfterAReloadResult = await client.callTool({
558
- name: 'execute',
559
- arguments: {
560
- code: js`
562
+ // Check page B logs - should still have original logs
563
+ const pageBAfterAReloadResult = await client.callTool({
564
+ name: 'execute',
565
+ arguments: {
566
+ code: js`
561
567
  const logs = await getLatestLogs({ page: state.pageB });
562
568
  console.log('Page B logs after A reload:', logs.length);
563
569
  logs.forEach(log => console.log(log));
564
570
  `,
565
- },
566
- })
571
+ },
572
+ })
567
573
 
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')
574
+ const pageBAfterAReloadOutput = (pageBAfterAReloadResult as any).content[0].text
575
+ expect(pageBAfterAReloadOutput).toContain('[log] PageB log 33333')
576
+ expect(pageBAfterAReloadOutput).toContain('[error] PageB error 44444')
571
577
 
572
- // Test that logs are deleted when page is closed
573
- await client.callTool({
574
- name: 'execute',
575
- arguments: {
576
- code: js`
578
+ // Test that logs are deleted when page is closed
579
+ await client.callTool({
580
+ name: 'execute',
581
+ arguments: {
582
+ code: js`
577
583
  // Close page A
578
584
  await state.pageA.close();
579
585
  await new Promise(resolve => setTimeout(resolve, 100));
580
586
  `,
581
- },
582
- })
587
+ },
588
+ })
583
589
 
584
- // Check all logs - page A logs should be gone
585
- const logsAfterCloseResult = await client.callTool({
586
- name: 'execute',
587
- arguments: {
588
- code: js`
590
+ // Check all logs - page A logs should be gone
591
+ const logsAfterCloseResult = await client.callTool({
592
+ name: 'execute',
593
+ arguments: {
594
+ code: js`
589
595
  const logs = await getLatestLogs();
590
596
  console.log('All logs after closing page A:', logs.length);
591
597
  logs.forEach(log => console.log(log));
592
598
  `,
593
- },
594
- })
599
+ },
600
+ })
595
601
 
596
- const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
597
- expect(logsAfterCloseOutput).not.toContain('PageA')
598
- expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
602
+ const logsAfterCloseOutput = (logsAfterCloseResult as any).content[0].text
603
+ expect(logsAfterCloseOutput).not.toContain('PageA')
604
+ expect(logsAfterCloseOutput).toContain('[log] PageB log 33333')
599
605
 
600
- // Clean up remaining page
601
- await client.callTool({
602
- name: 'execute',
603
- arguments: {
604
- code: js`
606
+ // Clean up remaining page
607
+ await client.callTool({
608
+ name: 'execute',
609
+ arguments: {
610
+ code: js`
605
611
  await state.pageB.close();
606
612
  delete state.pageA;
607
613
  delete state.pageB;
608
614
  `,
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)
615
+ },
616
+ })
617
+ }, 30000)
618
+
619
+ it('should capture console logs from cross-origin iframes', async () => {
620
+ // Two servers on different ports = different origins
621
+ const iframeServer = await createSimpleServer({
622
+ routes: {
623
+ '/iframe.html': `<!doctype html><html><body>
624
+ <script>
625
+ console.log('iframe-log-ALPHA');
626
+ console.error('iframe-error-BETA');
627
+ console.warn('iframe-warn-GAMMA');
628
+ </script>
629
+ <p>cross-origin iframe</p>
630
+ </body></html>`,
631
+ },
632
+ })
626
633
 
627
- await serviceWorker.evaluate(async () => {
628
- await globalThis.toggleExtensionForActiveTab()
629
- })
630
- await new Promise(r => setTimeout(r, 100))
634
+ const parentServer = await createSimpleServer({
635
+ routes: {
636
+ '/': `<!doctype html><html><body>
637
+ <script>console.log('parent-log-DELTA');</script>
638
+ <iframe src="${iframeServer.baseUrl}/iframe.html"></iframe>
639
+ </body></html>`,
640
+ },
641
+ })
631
642
 
632
- const result = await client.callTool({
633
- name: 'execute',
634
- arguments: {
635
- code: js`
643
+ try {
644
+ // Clear logs and navigate to the parent page with cross-origin iframe
645
+ await client.callTool({
646
+ name: 'execute',
647
+ arguments: {
648
+ code: js`
649
+ clearAllLogs();
650
+ state.iframePage = await context.newPage();
651
+ await state.iframePage.goto('${parentServer.baseUrl}', { waitUntil: 'networkidle' });
652
+ // Wait for iframe to load and logs to be captured
653
+ await state.iframePage.frameLocator('iframe').locator('p').waitFor({ timeout: 5000 });
654
+ await new Promise(resolve => setTimeout(resolve, 500));
655
+ `,
656
+ },
657
+ })
658
+
659
+ // Retrieve logs and verify both parent and iframe logs are captured
660
+ const logsResult = await client.callTool({
661
+ name: 'execute',
662
+ arguments: {
663
+ code: js`
664
+ const logs = await getLatestLogs({ page: state.iframePage });
665
+ console.log('Cross-origin iframe logs count:', logs.length);
666
+ logs.forEach(log => console.log(log));
667
+ `,
668
+ },
669
+ })
670
+
671
+ const output = (logsResult as any).content[0].text
672
+ // Parent page log
673
+ expect(output).toContain('parent-log-DELTA')
674
+ // Cross-origin iframe logs
675
+ expect(output).toContain('iframe-log-ALPHA')
676
+ expect(output).toContain('iframe-error-BETA')
677
+ expect(output).toContain('iframe-warn-GAMMA')
678
+
679
+ // Clean up
680
+ await client.callTool({
681
+ name: 'execute',
682
+ arguments: {
683
+ code: js`
684
+ await state.iframePage.close();
685
+ delete state.iframePage;
686
+ `,
687
+ },
688
+ })
689
+ } finally {
690
+ await Promise.all([parentServer.close(), iframeServer.close()])
691
+ }
692
+ }, 60000)
693
+
694
+ it(
695
+ 'should preserve system color scheme instead of forcing light mode',
696
+ async () => {
697
+ const browserContext = getBrowserContext()
698
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
699
+
700
+ const page = await browserContext.newPage()
701
+ await page.goto('https://example.com')
702
+ await page.bringToFront()
703
+
704
+ // test-utils launches with colorScheme: 'dark', so before MCP connection
705
+ // the browser should report dark mode
706
+ const colorSchemeBefore = await page.evaluate(() => {
707
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
708
+ })
709
+ expect(colorSchemeBefore).toBe('dark')
710
+
711
+ await serviceWorker.evaluate(async () => {
712
+ await globalThis.toggleExtensionForActiveTab()
713
+ })
714
+ await new Promise((r) => setTimeout(r, 500))
715
+
716
+ const result = await client.callTool({
717
+ name: 'execute',
718
+ arguments: {
719
+ code: js`
636
720
  const pages = context.pages();
637
721
  const urls = pages.map(p => p.url());
638
722
  const targetPage = pages.find(p => p.url().includes('example.com'));
@@ -643,29 +727,34 @@ describe('Relay Core Tests', () => {
643
727
  const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches);
644
728
  return { matchesDark: isDark, matchesLight: isLight };
645
729
  `,
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(`
730
+ },
731
+ })
732
+
733
+ console.log('Color scheme after MCP connection:', result.content)
734
+
735
+ // After MCP connection, color scheme should NOT be forced to light.
736
+ // The page.ts default is now 'no-override', so the browser's actual
737
+ // color scheme (dark, from test-utils launch config) should be preserved.
738
+ expect(result.content).toMatchInlineSnapshot(`
739
+ [
740
+ {
741
+ "text": "[return value] { matchesDark: true, matchesLight: false }",
742
+ "type": "text",
743
+ },
744
+ ]
745
+ `)
746
+
747
+ await page.close()
748
+ },
749
+ 60000,
750
+ )
751
+
752
+ it('should get clean HTML with getCleanHTML', async () => {
753
+ const browserContext = getBrowserContext()
754
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
755
+
756
+ const page = await browserContext.newPage()
757
+ await page.setContent(`
669
758
  <html>
670
759
  <head>
671
760
  <style>.hidden { display: none; }</style>
@@ -681,18 +770,18 @@ describe('Relay Core Tests', () => {
681
770
  </body>
682
771
  </html>
683
772
  `)
684
- await page.bringToFront()
773
+ await page.bringToFront()
685
774
 
686
- await serviceWorker.evaluate(async () => {
687
- await globalThis.toggleExtensionForActiveTab()
688
- })
689
- await new Promise(r => setTimeout(r, 400))
775
+ await serviceWorker.evaluate(async () => {
776
+ await globalThis.toggleExtensionForActiveTab()
777
+ })
778
+ await new Promise((r) => setTimeout(r, 400))
690
779
 
691
- // Test basic getCleanHTML
692
- const result = await client.callTool({
693
- name: 'execute',
694
- arguments: {
695
- code: js`
780
+ // Test basic getCleanHTML
781
+ const result = await client.callTool({
782
+ name: 'execute',
783
+ arguments: {
784
+ code: js`
696
785
  let testPage;
697
786
  for (const p of context.pages()) {
698
787
  const html = await p.content();
@@ -702,15 +791,15 @@ describe('Relay Core Tests', () => {
702
791
  const html = await getCleanHTML({ locator: testPage.locator('body') });
703
792
  return html;
704
793
  `,
705
- timeout: 15000,
706
- },
707
- })
794
+ timeout: 15000,
795
+ },
796
+ })
708
797
 
709
- expect(result.isError).toBeFalsy()
710
- const text = (result.content as any)[0]?.text || ''
798
+ expect(result.isError).toBeFalsy()
799
+ const text = (result.content as any)[0]?.text || ''
711
800
 
712
- // Inline snapshot of cleaned HTML
713
- expect(text).toMatchInlineSnapshot(`
801
+ // Inline snapshot of cleaned HTML
802
+ expect(text).toMatchInlineSnapshot(`
714
803
  "[return value] <div data-testid="main">
715
804
  <h1>Hello World</h1>
716
805
  <button aria-label="Click me">Submit</button>
@@ -719,16 +808,16 @@ describe('Relay Core Tests', () => {
719
808
  </div>"
720
809
  `)
721
810
 
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')
811
+ // Should NOT contain script/style tags (they're removed)
812
+ expect(text).not.toContain('<script')
813
+ expect(text).not.toContain('<style')
814
+ expect(text).not.toContain('console.log')
726
815
 
727
- // Test search functionality
728
- const searchResult = await client.callTool({
729
- name: 'execute',
730
- arguments: {
731
- code: js`
816
+ // Test search functionality
817
+ const searchResult = await client.callTool({
818
+ name: 'execute',
819
+ arguments: {
820
+ code: js`
732
821
  let testPage;
733
822
  for (const p of context.pages()) {
734
823
  const html = await p.content();
@@ -738,24 +827,24 @@ describe('Relay Core Tests', () => {
738
827
  const html = await getCleanHTML({ locator: testPage, search: /button/i });
739
828
  return html;
740
829
  `,
741
- timeout: 15000,
742
- },
743
- })
830
+ timeout: 15000,
831
+ },
832
+ })
744
833
 
745
- expect(searchResult.isError).toBeFalsy()
746
- const searchText = (searchResult.content as any)[0]?.text || ''
747
- expect(searchText).toContain('button')
834
+ expect(searchResult.isError).toBeFalsy()
835
+ const searchText = (searchResult.content as any)[0]?.text || ''
836
+ expect(searchText).toContain('button')
748
837
 
749
- await page.close()
750
- }, 60000)
838
+ await page.close()
839
+ }, 60000)
751
840
 
752
- it('should extract page content as markdown with getPageMarkdown', async () => {
753
- const browserContext = getBrowserContext()
754
- const serviceWorker = await getExtensionServiceWorker(browserContext)
841
+ it('should extract page content as markdown with getPageMarkdown', async () => {
842
+ const browserContext = getBrowserContext()
843
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
755
844
 
756
- const page = await browserContext.newPage()
757
- // Create a realistic article-like page structure
758
- await page.setContent(`
845
+ const page = await browserContext.newPage()
846
+ // Create a realistic article-like page structure
847
+ await page.setContent(`
759
848
  <html>
760
849
  <head>
761
850
  <title>Test Article Title</title>
@@ -782,18 +871,18 @@ describe('Relay Core Tests', () => {
782
871
  </body>
783
872
  </html>
784
873
  `)
785
- await page.bringToFront()
874
+ await page.bringToFront()
786
875
 
787
- await serviceWorker.evaluate(async () => {
788
- await globalThis.toggleExtensionForActiveTab()
789
- })
790
- await new Promise(r => setTimeout(r, 400))
876
+ await serviceWorker.evaluate(async () => {
877
+ await globalThis.toggleExtensionForActiveTab()
878
+ })
879
+ await new Promise((r) => setTimeout(r, 400))
791
880
 
792
- // Test basic getPageMarkdown
793
- const result = await client.callTool({
794
- name: 'execute',
795
- arguments: {
796
- code: js`
881
+ // Test basic getPageMarkdown
882
+ const result = await client.callTool({
883
+ name: 'execute',
884
+ arguments: {
885
+ code: js`
797
886
  let testPage;
798
887
  for (const p of context.pages()) {
799
888
  const html = await p.content();
@@ -803,30 +892,30 @@ describe('Relay Core Tests', () => {
803
892
  const content = await getPageMarkdown({ page: testPage });
804
893
  console.log(content);
805
894
  `,
806
- timeout: 15000,
807
- },
808
- })
895
+ timeout: 15000,
896
+ },
897
+ })
809
898
 
810
- expect(result.isError).toBeFalsy()
811
- const text = (result.content as any)[0]?.text || ''
899
+ expect(result.isError).toBeFalsy()
900
+ const text = (result.content as any)[0]?.text || ''
812
901
 
813
- // Snapshot the full output
814
- await expect(text).toMatchFileSnapshot('./snapshots/page-markdown-output.txt')
902
+ // Snapshot the full output
903
+ await expect(text).toMatchFileSnapshot('./snapshots/page-markdown-output.txt')
815
904
 
816
- // Should contain article content
817
- expect(text).toContain('Test Article Title')
818
- expect(text).toContain('first paragraph')
819
- expect(text).toContain('second paragraph')
905
+ // Should contain article content
906
+ expect(text).toContain('Test Article Title')
907
+ expect(text).toContain('first paragraph')
908
+ expect(text).toContain('second paragraph')
820
909
 
821
- // Should NOT contain script/style content
822
- expect(text).not.toContain('analytics')
823
- expect(text).not.toContain('background: blue')
910
+ // Should NOT contain script/style content
911
+ expect(text).not.toContain('analytics')
912
+ expect(text).not.toContain('background: blue')
824
913
 
825
- // Test search functionality
826
- const searchResult = await client.callTool({
827
- name: 'execute',
828
- arguments: {
829
- code: js`
914
+ // Test search functionality
915
+ const searchResult = await client.callTool({
916
+ name: 'execute',
917
+ arguments: {
918
+ code: js`
830
919
  let testPage;
831
920
  for (const p of context.pages()) {
832
921
  const html = await p.content();
@@ -836,97 +925,186 @@ describe('Relay Core Tests', () => {
836
925
  const content = await getPageMarkdown({ page: testPage, search: /important/i, showDiffSinceLastCall: false });
837
926
  return content;
838
927
  `,
839
- timeout: 15000,
840
- },
841
- })
928
+ timeout: 15000,
929
+ },
930
+ })
842
931
 
843
- expect(searchResult.isError).toBeFalsy()
844
- const searchText = (searchResult.content as any)[0]?.text || ''
845
- expect(searchText).toContain('important')
932
+ expect(searchResult.isError).toBeFalsy()
933
+ const searchText = (searchResult.content as any)[0]?.text || ''
934
+ expect(searchText).toContain('important')
846
935
 
847
- await page.close()
848
- }, 60000)
936
+ await page.close()
937
+ }, 60000)
849
938
 
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.
939
+ it('should handle default page being closed and switch to another available page', async () => {
940
+ // This test verifies that when the default `page` in MCP scope is closed,
941
+ // the MCP automatically switches to another available page instead of failing
942
+ // with cryptic "page closed" errors.
854
943
 
855
- const browserContext = getBrowserContext()
856
- const serviceWorker = await getExtensionServiceWorker(browserContext)
944
+ const browserContext = getBrowserContext()
945
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
857
946
 
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))
947
+ // 1. Disconnect everything to start fresh
948
+ await serviceWorker.evaluate(async () => {
949
+ await globalThis.disconnectEverything()
950
+ })
951
+ await new Promise((r) => setTimeout(r, 100))
863
952
 
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()
953
+ // 2. Create first page and enable extension
954
+ const page1 = await browserContext.newPage()
955
+ await page1.goto('https://example.com/first-page')
956
+ await page1.bringToFront()
868
957
 
869
- await serviceWorker.evaluate(async () => {
870
- await globalThis.toggleExtensionForActiveTab()
871
- })
872
- await new Promise(r => setTimeout(r, 100))
958
+ await serviceWorker.evaluate(async () => {
959
+ await globalThis.toggleExtensionForActiveTab()
960
+ })
961
+ await new Promise((r) => setTimeout(r, 100))
873
962
 
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')
963
+ // 3. Reset MCP to ensure page1 becomes the default page (only page available)
964
+ const resetResult = await client.callTool({
965
+ name: 'reset',
966
+ arguments: {},
967
+ })
968
+ expect((resetResult as any).content[0].text).toContain('Connection reset successfully')
880
969
 
881
- // 4. Verify initial page is accessible via default `page`
882
- const initialResult = await client.callTool({
883
- name: 'execute',
884
- arguments: {
885
- code: js`
970
+ // 4. Verify initial page is accessible via default `page`
971
+ const initialResult = await client.callTool({
972
+ name: 'execute',
973
+ arguments: {
974
+ code: js`
886
975
  const url = page.url();
887
976
  console.log('Initial page URL:', url);
888
977
  return { url };
889
978
  `,
890
- },
891
- })
892
- expect((initialResult as any).content[0].text).toContain('first-page')
979
+ },
980
+ })
981
+ expect((initialResult as any).content[0].text).toContain('first-page')
893
982
 
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()
983
+ // 5. Create second page and enable extension
984
+ const page2 = await browserContext.newPage()
985
+ await page2.goto('https://example.com/second-page')
986
+ await page2.bringToFront()
898
987
 
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`
988
+ await serviceWorker.evaluate(async () => {
989
+ await globalThis.toggleExtensionForActiveTab()
990
+ })
991
+ await new Promise((r) => setTimeout(r, 100))
992
+
993
+ // 6. Close the first page (which is the default `page` in MCP scope)
994
+ await page1.close()
995
+ await new Promise((r) => setTimeout(r, 100))
996
+
997
+ // 7. Execute code via MCP - should NOT fail with "page closed" error
998
+ // Instead, it should automatically switch to the second page
999
+ const afterCloseResult = await client.callTool({
1000
+ name: 'execute',
1001
+ arguments: {
1002
+ code: js`
914
1003
  const url = page.url();
915
1004
  console.log('Page URL after close:', url);
916
1005
  const title = await page.title();
917
1006
  return { url, title };
918
1007
  `,
919
- },
920
- })
1008
+ },
1009
+ })
921
1010
 
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')
1011
+ // Should succeed and return the second page's info
1012
+ expect((afterCloseResult as any).isError).toBeFalsy()
1013
+ const output = (afterCloseResult as any).content[0].text
1014
+ expect(output).toContain('second-page')
1015
+ expect(output).not.toContain('page closed')
1016
+ expect(output).not.toContain('Target closed')
1017
+
1018
+ // Cleanup
1019
+ await page2.close()
1020
+ }, 60000)
1021
+
1022
+ it('should show descriptive error when clicking a hidden element', async () => {
1023
+ // Create a fresh page and set content with a collapsed details element
1024
+ await client.callTool({
1025
+ name: 'execute',
1026
+ arguments: {
1027
+ code: js`
1028
+ state.errorTestPage = await context.newPage();
1029
+ await state.errorTestPage.setContent(\`
1030
+ <details>
1031
+ <summary>Toggle</summary>
1032
+ <button id="hidden-btn">Hidden Button</button>
1033
+ </details>
1034
+ \`);
1035
+ `,
1036
+ },
1037
+ })
1038
+ const result = await client.callTool({
1039
+ name: 'execute',
1040
+ arguments: {
1041
+ code: js`
1042
+ await state.errorTestPage.click('#hidden-btn');
1043
+ `,
1044
+ },
1045
+ })
1046
+ const text = (result as any).content[0].text
1047
+ // Strip stack traces and call logs to only match the descriptive error line
1048
+ const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('not visible') || l.includes('not stable'))
1049
+ expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms 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"`)
1050
+ expect((result as any).isError).toBe(true)
1051
+ // Cleanup
1052
+ await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
1053
+ }, 30000)
1054
+
1055
+ it('should show descriptive error when clicking an element covered by another', async () => {
1056
+ await client.callTool({
1057
+ name: 'execute',
1058
+ arguments: {
1059
+ code: js`
1060
+ state.errorTestPage = await context.newPage();
1061
+ await state.errorTestPage.setContent(\`
1062
+ <div style="position:relative">
1063
+ <button id="covered-btn" style="position:absolute;top:0;left:0">Covered</button>
1064
+ <div id="overlay" style="position:absolute;top:0;left:0;width:200px;height:200px;background:red;z-index:10">Overlay</div>
1065
+ </div>
1066
+ \`);
1067
+ `,
1068
+ },
1069
+ })
1070
+ const result = await client.callTool({
1071
+ name: 'execute',
1072
+ arguments: {
1073
+ code: js`
1074
+ await state.errorTestPage.click('#covered-btn');
1075
+ `,
1076
+ },
1077
+ })
1078
+ const text = (result as any).content[0].text
1079
+ const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('intercepts'))
1080
+ expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms exceeded. <div id="overlay">Overlay</div> intercepts pointer events"`)
1081
+ expect((result as any).isError).toBe(true)
1082
+ await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
1083
+ }, 30000)
1084
+
1085
+ it('should show descriptive error when clicking a display:none element', async () => {
1086
+ await client.callTool({
1087
+ name: 'execute',
1088
+ arguments: {
1089
+ code: js`
1090
+ state.errorTestPage = await context.newPage();
1091
+ await state.errorTestPage.setContent('<button id="invisible" style="display:none">Invisible</button>');
1092
+ `,
1093
+ },
1094
+ })
1095
+ const result = await client.callTool({
1096
+ name: 'execute',
1097
+ arguments: {
1098
+ code: js`
1099
+ await state.errorTestPage.click('#invisible');
1100
+ `,
1101
+ },
1102
+ })
1103
+ const text = (result as any).content[0].text
1104
+ const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('not visible'))
1105
+ expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms 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"`)
1106
+ expect((result as any).isError).toBe(true)
1107
+ await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
1108
+ }, 30000)
928
1109
 
929
- // Cleanup
930
- await page2.close()
931
- }, 60000)
932
1110
  })