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
@@ -1,194 +1,194 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest'
2
2
  import { chromium, type Page } from '@xmorse/playwright-core'
3
3
  import { getCdpUrl } from './utils.js'
4
- import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, withTimeout, createSimpleServer } from './test-utils.js'
4
+ import {
5
+ setupTestContext,
6
+ cleanupTestContext,
7
+ getExtensionServiceWorker,
8
+ type TestContext,
9
+ withTimeout,
10
+ createSimpleServer,
11
+ } from './test-utils.js'
5
12
  import './test-declarations.js'
6
13
 
7
14
  const TEST_PORT = 19992
8
15
 
9
16
  describe('Relay Navigation Tests', () => {
10
- let testCtx: TestContext | null = null
17
+ let testCtx: TestContext | null = null
11
18
 
12
- beforeAll(async () => {
13
- testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-nav-test-', toggleExtension: true })
14
- }, 600000)
19
+ beforeAll(async () => {
20
+ testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-nav-test-', toggleExtension: true })
21
+ }, 600000)
15
22
 
16
- afterAll(async () => {
17
- await cleanupTestContext(testCtx)
18
- testCtx = null
19
- })
23
+ afterAll(async () => {
24
+ await cleanupTestContext(testCtx)
25
+ testCtx = null
26
+ })
20
27
 
21
- const getBrowserContext = () => {
22
- if (!testCtx?.browserContext) throw new Error('Browser not initialized')
23
- return testCtx.browserContext
24
- }
28
+ const getBrowserContext = () => {
29
+ if (!testCtx?.browserContext) throw new Error('Browser not initialized')
30
+ return testCtx.browserContext
31
+ }
25
32
 
26
- const waitForStableDocumentReadyState = async ({
27
- page,
28
- timeoutMs,
29
- }: {
30
- page: Page
31
- timeoutMs: number
32
- }) => {
33
- const startTime = Date.now()
34
-
35
- while (Date.now() - startTime < timeoutMs) {
36
- try {
37
- const readyState = await page.evaluate(() => {
38
- return document.readyState
39
- })
40
- if (readyState !== 'loading') {
41
- return
42
- }
43
- } catch (e) {
44
- if (!(e instanceof Error) || !e.message.includes('Execution context was destroyed')) {
45
- throw new Error('Failed while waiting for stable document readyState', { cause: e })
46
- }
47
- }
33
+ const waitForStableDocumentReadyState = async ({ page, timeoutMs }: { page: Page; timeoutMs: number }) => {
34
+ const startTime = Date.now()
48
35
 
49
- await page.waitForTimeout(100)
36
+ while (Date.now() - startTime < timeoutMs) {
37
+ try {
38
+ const readyState = await page.evaluate(() => {
39
+ return document.readyState
40
+ })
41
+ if (readyState !== 'loading') {
42
+ return
43
+ }
44
+ } catch (e) {
45
+ if (!(e instanceof Error) || !e.message.includes('Execution context was destroyed')) {
46
+ throw new Error('Failed while waiting for stable document readyState', { cause: e })
50
47
  }
48
+ }
51
49
 
52
- throw new Error(`Timed out waiting for stable document readyState after ${timeoutMs}ms`)
50
+ await page.waitForTimeout(100)
53
51
  }
54
52
 
55
- it('should be usable after toggle with valid URL', async () => {
56
- // Validates the extension waits for a non-empty URL before attaching.
53
+ throw new Error(`Timed out waiting for stable document readyState after ${timeoutMs}ms`)
54
+ }
57
55
 
58
- const browserContext = getBrowserContext()
59
- const serviceWorker = await getExtensionServiceWorker(browserContext)
60
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
61
- const context = browser.contexts()[0]
56
+ it('should be usable after toggle with valid URL', async () => {
57
+ // Validates the extension waits for a non-empty URL before attaching.
62
58
 
63
- const server = await createSimpleServer({
64
- routes: {
65
- '/': '<!doctype html><html><body>ok</body></html>',
66
- },
67
- })
59
+ const browserContext = getBrowserContext()
60
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
61
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
62
+ const context = browser.contexts()[0]
68
63
 
69
- const page = await browserContext.newPage()
70
- try {
71
- await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
72
- await page.bringToFront()
64
+ const server = await createSimpleServer({
65
+ routes: {
66
+ '/': '<!doctype html><html><body>ok</body></html>',
67
+ },
68
+ })
73
69
 
74
- const pagePromise = context.waitForEvent('page', { timeout: 5000 })
70
+ const page = await browserContext.newPage()
71
+ try {
72
+ await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
73
+ await page.bringToFront()
75
74
 
76
- await serviceWorker.evaluate(async () => {
77
- await globalThis.toggleExtensionForActiveTab()
78
- })
75
+ const pagePromise = context.waitForEvent('page', { timeout: 5000 })
79
76
 
80
- const targetPage = await pagePromise
81
- console.log('Page URL when event fired:', targetPage.url())
77
+ await serviceWorker.evaluate(async () => {
78
+ await globalThis.toggleExtensionForActiveTab()
79
+ })
82
80
 
83
- expect(targetPage.url()).not.toBe('')
84
- expect(targetPage.url()).not.toBe(':')
85
- expect(targetPage.url()).toContain(server.baseUrl)
81
+ const targetPage = await pagePromise
82
+ console.log('Page URL when event fired:', targetPage.url())
86
83
 
87
- const result = await targetPage.evaluate(() => window.location.href)
88
- expect(result).toContain(server.baseUrl)
89
- } finally {
90
- await browser.close()
91
- await page.close()
92
- await server.close()
93
- }
94
- }, 15000)
84
+ expect(targetPage.url()).not.toBe('')
85
+ expect(targetPage.url()).not.toBe(':')
86
+ expect(targetPage.url()).toContain(server.baseUrl)
95
87
 
96
- it('should expose iframe frames when connecting to an existing page over CDP', async () => {
97
- const browserContext = getBrowserContext()
98
- const serviceWorker = await getExtensionServiceWorker(browserContext)
88
+ const result = await targetPage.evaluate(() => window.location.href)
89
+ expect(result).toContain(server.baseUrl)
90
+ } finally {
91
+ await browser.close()
92
+ await page.close()
93
+ await server.close()
94
+ }
95
+ }, 15000)
99
96
 
100
- const childServer = await createSimpleServer({
101
- routes: {
102
- '/child.html': '<!doctype html><html><body>child</body></html>',
103
- },
104
- })
105
- const childUrl = `${childServer.baseUrl}/child.html`
97
+ it('should expose iframe frames when connecting to an existing page over CDP', async () => {
98
+ const browserContext = getBrowserContext()
99
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
106
100
 
107
- const parentServer = await createSimpleServer({
108
- routes: {
109
- '/': `<!doctype html><html><body><iframe src="${childUrl}"></iframe></body></html>`,
110
- },
111
- })
101
+ const childServer = await createSimpleServer({
102
+ routes: {
103
+ '/child.html': '<!doctype html><html><body>child</body></html>',
104
+ },
105
+ })
106
+ const childUrl = `${childServer.baseUrl}/child.html`
112
107
 
113
- const page = await browserContext.newPage()
114
- try {
115
- await withTimeout({
116
- promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }),
117
- timeoutMs: 6000,
118
- errorMessage: 'Timed out loading parent page for iframe test',
119
- })
120
- await withTimeout({
121
- promise: page.frameLocator('iframe').locator('body').waitFor({ timeout: 5000 }),
122
- timeoutMs: 6000,
123
- errorMessage: 'Timed out waiting for iframe to attach in parent page',
124
- })
125
- expect(page.frames().map((frame) => frame.url())).toContain(childUrl)
126
- await page.bringToFront()
127
-
128
- await withTimeout({
129
- promise: serviceWorker.evaluate(async () => {
130
- await globalThis.toggleExtensionForActiveTab()
131
- }),
132
- timeoutMs: 5000,
133
- errorMessage: 'Timed out toggling extension for iframe test',
134
- })
135
- await new Promise((r) => { setTimeout(r, 400) })
136
-
137
- const browser = await withTimeout({
138
- promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
139
- timeoutMs: 5000,
140
- errorMessage: 'Timed out connecting over CDP for iframe test',
141
- })
142
- const context = browser.contexts()[0]
143
- const cdpPage = context.pages().find((candidate) => {
144
- return candidate.url().startsWith(parentServer.baseUrl)
145
- })
146
- expect(cdpPage).toBeDefined()
147
-
148
- const frames = cdpPage!.frames()
149
- const childFrame = frames.find((frame) => {
150
- return frame.url() === childUrl
151
- })
152
-
153
- expect(frames.length).toBe(2)
154
- expect(childFrame).toBeDefined()
155
-
156
- await withTimeout({
157
- promise: browser.close(),
158
- timeoutMs: 5000,
159
- errorMessage: 'Timed out closing CDP browser for iframe test',
160
- })
161
- } finally {
162
- await withTimeout({
163
- promise: page.close(),
164
- timeoutMs: 5000,
165
- errorMessage: 'Timed out closing page for iframe test',
166
- })
167
- await Promise.all([
168
- parentServer.close(),
169
- childServer.close(),
170
- ])
171
- }
172
- }, 60000)
108
+ const parentServer = await createSimpleServer({
109
+ routes: {
110
+ '/': `<!doctype html><html><body><iframe src="${childUrl}"></iframe></body></html>`,
111
+ },
112
+ })
113
+
114
+ const page = await browserContext.newPage()
115
+ try {
116
+ await withTimeout({
117
+ promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }),
118
+ timeoutMs: 6000,
119
+ errorMessage: 'Timed out loading parent page for iframe test',
120
+ })
121
+ await withTimeout({
122
+ promise: page.frameLocator('iframe').locator('body').waitFor({ timeout: 5000 }),
123
+ timeoutMs: 6000,
124
+ errorMessage: 'Timed out waiting for iframe to attach in parent page',
125
+ })
126
+ expect(page.frames().map((frame) => frame.url())).toContain(childUrl)
127
+ await page.bringToFront()
128
+
129
+ await withTimeout({
130
+ promise: serviceWorker.evaluate(async () => {
131
+ await globalThis.toggleExtensionForActiveTab()
132
+ }),
133
+ timeoutMs: 5000,
134
+ errorMessage: 'Timed out toggling extension for iframe test',
135
+ })
136
+ await new Promise((r) => {
137
+ setTimeout(r, 400)
138
+ })
139
+
140
+ const browser = await withTimeout({
141
+ promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
142
+ timeoutMs: 5000,
143
+ errorMessage: 'Timed out connecting over CDP for iframe test',
144
+ })
145
+ const context = browser.contexts()[0]
146
+ const cdpPage = context.pages().find((candidate) => {
147
+ return candidate.url().startsWith(parentServer.baseUrl)
148
+ })
149
+ expect(cdpPage).toBeDefined()
150
+
151
+ const frames = cdpPage!.frames()
152
+ const childFrame = frames.find((frame) => {
153
+ return frame.url() === childUrl
154
+ })
155
+
156
+ expect(frames.length).toBe(2)
157
+ expect(childFrame).toBeDefined()
158
+
159
+ await withTimeout({
160
+ promise: browser.close(),
161
+ timeoutMs: 5000,
162
+ errorMessage: 'Timed out closing CDP browser for iframe test',
163
+ })
164
+ } finally {
165
+ await withTimeout({
166
+ promise: page.close(),
167
+ timeoutMs: 5000,
168
+ errorMessage: 'Timed out closing page for iframe test',
169
+ })
170
+ await Promise.all([parentServer.close(), childServer.close()])
171
+ }
172
+ }, 60000)
173
173
 
174
- it('should resolve locators for cross-origin iframe that starts with empty src', async () => {
175
- const browserContext = getBrowserContext()
176
- const serviceWorker = await getExtensionServiceWorker(browserContext)
174
+ it('should resolve locators for cross-origin iframe that starts with empty src', async () => {
175
+ const browserContext = getBrowserContext()
176
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
177
177
 
178
- const childServer = await createSimpleServer({
179
- routes: {
180
- '/login.html': '<!doctype html><html><body><button id="login-btn">Login</button></body></html>',
181
- '/canvas.html': '<!doctype html><html><body><button id="canvas-btn">Canvas</button></body></html>',
182
- },
183
- })
184
- const loginUrl = `${childServer.baseUrl}/login.html`
185
- const canvasUrl = `${childServer.baseUrl}/canvas.html`
186
-
187
- const parentServer = await createSimpleServer({
188
- routes: {
189
- // Reproduces Framer-like plugin iframes: attached with empty src first,
190
- // then navigated cross-origin after auto-attach is active.
191
- '/': `<!doctype html>
178
+ const childServer = await createSimpleServer({
179
+ routes: {
180
+ '/login.html': '<!doctype html><html><body><button id="login-btn">Login</button></body></html>',
181
+ '/canvas.html': '<!doctype html><html><body><button id="canvas-btn">Canvas</button></body></html>',
182
+ },
183
+ })
184
+ const loginUrl = `${childServer.baseUrl}/login.html`
185
+ const canvasUrl = `${childServer.baseUrl}/canvas.html`
186
+
187
+ const parentServer = await createSimpleServer({
188
+ routes: {
189
+ // Reproduces Framer-like plugin iframes: attached with empty src first,
190
+ // then navigated cross-origin after auto-attach is active.
191
+ '/': `<!doctype html>
192
192
  <html>
193
193
  <body>
194
194
  <iframe id="plugin-frame"></iframe>
@@ -203,194 +203,200 @@ describe('Relay Navigation Tests', () => {
203
203
  </script>
204
204
  </body>
205
205
  </html>`,
206
- },
206
+ },
207
+ })
208
+
209
+ const page = await browserContext.newPage()
210
+ try {
211
+ await withTimeout({
212
+ promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }),
213
+ timeoutMs: 6000,
214
+ errorMessage: 'Timed out loading parent page for empty-src iframe test',
215
+ })
216
+ await page.bringToFront()
217
+
218
+ await withTimeout({
219
+ promise: serviceWorker.evaluate(async () => {
220
+ await globalThis.toggleExtensionForActiveTab()
221
+ }),
222
+ timeoutMs: 5000,
223
+ errorMessage: 'Timed out toggling extension for empty-src iframe test',
224
+ })
225
+
226
+ const browser = await withTimeout({
227
+ promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
228
+ timeoutMs: 5000,
229
+ errorMessage: 'Timed out connecting over CDP for empty-src iframe test',
230
+ })
231
+
232
+ try {
233
+ const context = browser.contexts()[0]
234
+ const cdpPage = context.pages().find((candidate) => {
235
+ return candidate.url().startsWith(parentServer.baseUrl)
207
236
  })
237
+ expect(cdpPage).toBeDefined()
208
238
 
209
- const page = await browserContext.newPage()
210
- try {
211
- await withTimeout({
212
- promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }),
213
- timeoutMs: 6000,
214
- errorMessage: 'Timed out loading parent page for empty-src iframe test',
215
- })
216
- await page.bringToFront()
217
-
218
- await withTimeout({
219
- promise: serviceWorker.evaluate(async () => {
220
- await globalThis.toggleExtensionForActiveTab()
221
- }),
222
- timeoutMs: 5000,
223
- errorMessage: 'Timed out toggling extension for empty-src iframe test',
224
- })
225
-
226
- const browser = await withTimeout({
227
- promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
228
- timeoutMs: 5000,
229
- errorMessage: 'Timed out connecting over CDP for empty-src iframe test',
230
- })
231
-
232
- try {
233
- const context = browser.contexts()[0]
234
- const cdpPage = context.pages().find((candidate) => {
235
- return candidate.url().startsWith(parentServer.baseUrl)
236
- })
237
- expect(cdpPage).toBeDefined()
238
-
239
- await withTimeout({
240
- promise: page.evaluate(() => {
241
- ;(window as Window & { startPluginFlow?: () => void }).startPluginFlow?.()
242
- }),
243
- timeoutMs: 3000,
244
- errorMessage: 'Timed out starting plugin iframe flow',
245
- })
246
-
247
- const pluginFrame = await withTimeout({
248
- promise: (async () => {
249
- for (let attempt = 0; attempt < 40; attempt += 1) {
250
- const frame = cdpPage!.frames().find((candidate) => {
251
- return candidate.url() === loginUrl || candidate.url() === canvasUrl
252
- })
253
- if (frame) {
254
- return frame
255
- }
256
- await cdpPage!.waitForTimeout(100)
257
- }
258
- throw new Error('Plugin frame did not appear with expected URL')
259
- })(),
260
- timeoutMs: 5000,
261
- errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test',
262
- })
263
-
264
- await withTimeout({
265
- promise: pluginFrame.locator('button').first().waitFor({ state: 'attached' }),
266
- timeoutMs: 5000,
267
- errorMessage: 'Timed out waiting for button locator in empty-src iframe test',
268
- })
269
-
270
- const buttonCount = await pluginFrame.locator('button').count()
271
- expect(buttonCount).toBe(1)
272
- } finally {
273
- await withTimeout({
274
- promise: browser.close(),
275
- timeoutMs: 5000,
276
- errorMessage: 'Timed out closing CDP browser for empty-src iframe test',
277
- })
278
- }
279
- } finally {
280
- await withTimeout({
281
- promise: page.close(),
282
- timeoutMs: 5000,
283
- errorMessage: 'Timed out closing page for empty-src iframe test',
284
- })
285
- await Promise.all([
286
- parentServer.close(),
287
- childServer.close(),
288
- ])
289
- }
290
- }, 60000)
239
+ await withTimeout({
240
+ promise: page.evaluate(() => {
241
+ ;(window as Window & { startPluginFlow?: () => void }).startPluginFlow?.()
242
+ }),
243
+ timeoutMs: 3000,
244
+ errorMessage: 'Timed out starting plugin iframe flow',
245
+ })
291
246
 
292
- it('should have non-empty URLs when connecting to already-loaded pages', async () => {
293
- const _browserContext = getBrowserContext()
294
- const serviceWorker = await getExtensionServiceWorker(_browserContext)
247
+ const pluginFrame = await withTimeout({
248
+ promise: (async () => {
249
+ for (let attempt = 0; attempt < 40; attempt += 1) {
250
+ const frame = cdpPage!.frames().find((candidate) => {
251
+ return candidate.url() === loginUrl || candidate.url() === canvasUrl
252
+ })
253
+ if (frame) {
254
+ return frame
255
+ }
256
+ await cdpPage!.waitForTimeout(100)
257
+ }
258
+ throw new Error('Plugin frame did not appear with expected URL')
259
+ })(),
260
+ timeoutMs: 5000,
261
+ errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test',
262
+ })
295
263
 
296
- const page = await _browserContext.newPage()
297
- await page.goto('https://discord.com/login', { waitUntil: 'load' })
298
- await page.bringToFront()
264
+ await withTimeout({
265
+ promise: pluginFrame.locator('button').first().waitFor({ state: 'attached' }),
266
+ timeoutMs: 5000,
267
+ errorMessage: 'Timed out waiting for button locator in empty-src iframe test',
268
+ })
299
269
 
300
- await serviceWorker.evaluate(async () => {
301
- await globalThis.toggleExtensionForActiveTab()
270
+ const buttonCount = await pluginFrame.locator('button').count()
271
+ expect(buttonCount).toBe(1)
272
+ } finally {
273
+ await withTimeout({
274
+ promise: browser.close(),
275
+ timeoutMs: 5000,
276
+ errorMessage: 'Timed out closing CDP browser for empty-src iframe test',
302
277
  })
278
+ }
279
+ } finally {
280
+ await withTimeout({
281
+ promise: page.close(),
282
+ timeoutMs: 5000,
283
+ errorMessage: 'Timed out closing page for empty-src iframe test',
284
+ })
285
+ await Promise.all([parentServer.close(), childServer.close()])
286
+ }
287
+ }, 60000)
303
288
 
304
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
305
- const context = browser.contexts()[0]
289
+ it('should have non-empty URLs when connecting to already-loaded pages', async () => {
290
+ const _browserContext = getBrowserContext()
291
+ const serviceWorker = await getExtensionServiceWorker(_browserContext)
306
292
 
307
- const pages = context.pages()
308
- console.log('All page URLs:', pages.map(p => p.url()))
293
+ const page = await _browserContext.newPage()
294
+ await page.goto('https://discord.com/login', { waitUntil: 'load' })
295
+ await page.bringToFront()
309
296
 
310
- expect(pages.length).toBeGreaterThan(0)
311
- for (const p of pages) {
312
- expect(p.url()).not.toBe('')
313
- expect(p.url()).not.toBe(':')
314
- expect(p.url()).not.toBeUndefined()
315
- }
297
+ await serviceWorker.evaluate(async () => {
298
+ await globalThis.toggleExtensionForActiveTab()
299
+ })
316
300
 
317
- const discordPage = pages.find(p => p.url().includes('discord.com'))
318
- expect(discordPage).toBeDefined()
301
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
302
+ const context = browser.contexts()[0]
319
303
 
320
- const result = await discordPage!.evaluate(() => window.location.href)
321
- expect(result).toContain('discord.com')
304
+ const pages = context.pages()
305
+ console.log(
306
+ 'All page URLs:',
307
+ pages.map((p) => p.url()),
308
+ )
322
309
 
323
- await browser.close()
324
- await page.close()
325
- }, 60000)
310
+ expect(pages.length).toBeGreaterThan(0)
311
+ for (const p of pages) {
312
+ expect(p.url()).not.toBe('')
313
+ expect(p.url()).not.toBe(':')
314
+ expect(p.url()).not.toBeUndefined()
315
+ }
326
316
 
327
- it('should navigate to notion without hanging', async () => {
328
- const browserContext = getBrowserContext()
329
- const serviceWorker = await getExtensionServiceWorker(browserContext)
317
+ const discordPage = pages.find((p) => p.url().includes('discord.com'))
318
+ expect(discordPage).toBeDefined()
330
319
 
331
- const page = await browserContext.newPage()
332
- const initialUrl = 'https://example.com/notion-repro'
333
- await page.goto(initialUrl)
334
- await page.bringToFront()
320
+ const result = await discordPage!.evaluate(() => window.location.href)
321
+ expect(result).toContain('discord.com')
335
322
 
336
- await serviceWorker.evaluate(async () => {
337
- await globalThis.toggleExtensionForActiveTab()
338
- })
323
+ await browser.close()
324
+ await page.close()
325
+ }, 60000)
339
326
 
340
- await new Promise(r => setTimeout(r, 100))
327
+ it('should navigate to notion without hanging', async () => {
328
+ const browserContext = getBrowserContext()
329
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
341
330
 
342
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
343
- const cdpPage = browser.contexts()[0].pages().find(p => p.url() === initialUrl)
344
- expect(cdpPage).toBeDefined()
331
+ const page = await browserContext.newPage()
332
+ const initialUrl = 'https://example.com/notion-repro'
333
+ await page.goto(initialUrl)
334
+ await page.bringToFront()
345
335
 
346
- const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 })
336
+ await serviceWorker.evaluate(async () => {
337
+ await globalThis.toggleExtensionForActiveTab()
338
+ })
347
339
 
348
- const currentUrl = cdpPage!.url()
349
- const responseUrl = response?.url() ?? ''
350
- expect(responseUrl).toMatch(/notion\.(so|com)/)
351
- expect(currentUrl).toMatch(/notion\.(so|com)/)
352
- expect(await cdpPage!.evaluate(() => document.readyState)).not.toBe('loading')
340
+ await new Promise((r) => setTimeout(r, 100))
353
341
 
354
- await browser.close()
355
- await page.close()
356
- }, 60000)
342
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
343
+ const cdpPage = browser
344
+ .contexts()[0]
345
+ .pages()
346
+ .find((p) => p.url() === initialUrl)
347
+ expect(cdpPage).toBeDefined()
357
348
 
358
- it('should navigate to youtube without hanging', async () => {
359
- const browserContext = getBrowserContext()
360
- const serviceWorker = await getExtensionServiceWorker(browserContext)
349
+ const response = await cdpPage!.goto('https://www.notion.so', { waitUntil: 'domcontentloaded', timeout: 20000 })
361
350
 
362
- const page = await browserContext.newPage()
363
- await page.goto('about:blank')
364
- await page.bringToFront()
351
+ const currentUrl = cdpPage!.url()
352
+ const responseUrl = response?.url() ?? ''
353
+ expect(responseUrl).toMatch(/notion\.(so|com)/)
354
+ expect(currentUrl).toMatch(/notion\.(so|com)/)
355
+ expect(await cdpPage!.evaluate(() => document.readyState)).not.toBe('loading')
365
356
 
366
- await serviceWorker.evaluate(async () => {
367
- await globalThis.toggleExtensionForActiveTab()
368
- })
357
+ await browser.close()
358
+ await page.close()
359
+ }, 60000)
369
360
 
370
- await new Promise(r => setTimeout(r, 100))
361
+ it('should navigate to youtube without hanging', async () => {
362
+ const browserContext = getBrowserContext()
363
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
371
364
 
372
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
373
- const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('about:'))
374
- expect(cdpPage).toBeDefined()
365
+ const page = await browserContext.newPage()
366
+ await page.goto('about:blank')
367
+ await page.bringToFront()
368
+
369
+ await serviceWorker.evaluate(async () => {
370
+ await globalThis.toggleExtensionForActiveTab()
371
+ })
375
372
 
376
- const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'domcontentloaded', timeout: 20000 })
377
- const currentUrl = cdpPage!.url()
378
- const responseUrl = response?.url() ?? ''
373
+ await new Promise((r) => setTimeout(r, 100))
379
374
 
380
- expect(responseUrl).toContain('youtube')
381
- expect(currentUrl).toContain('youtube')
382
- await waitForStableDocumentReadyState({ page: cdpPage!, timeoutMs: 5000 })
375
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
376
+ const cdpPage = browser
377
+ .contexts()[0]
378
+ .pages()
379
+ .find((p) => p.url().includes('about:'))
380
+ expect(cdpPage).toBeDefined()
383
381
 
384
- await browser.close()
385
- await page.close()
386
- }, 60000)
382
+ const response = await cdpPage!.goto('https://www.youtube.com', { waitUntil: 'domcontentloaded', timeout: 20000 })
383
+ const currentUrl = cdpPage!.url()
384
+ const responseUrl = response?.url() ?? ''
387
385
 
388
- it('should maintain correct page.url() with iframe-heavy pages', async () => {
389
- const browserContext = getBrowserContext()
390
- const serviceWorker = await getExtensionServiceWorker(browserContext)
386
+ expect(responseUrl).toContain('youtube')
387
+ expect(currentUrl).toContain('youtube')
388
+ await waitForStableDocumentReadyState({ page: cdpPage!, timeoutMs: 5000 })
391
389
 
392
- const page = await browserContext.newPage()
393
- await page.setContent(`
390
+ await browser.close()
391
+ await page.close()
392
+ }, 60000)
393
+
394
+ it('should maintain correct page.url() with iframe-heavy pages', async () => {
395
+ const browserContext = getBrowserContext()
396
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
397
+
398
+ const page = await browserContext.newPage()
399
+ await page.setContent(`
394
400
  <html>
395
401
  <head><title>Iframe Test Page</title></head>
396
402
  <body>
@@ -401,195 +407,222 @@ describe('Relay Navigation Tests', () => {
401
407
  </body>
402
408
  </html>
403
409
  `)
404
- await page.bringToFront()
410
+ await page.bringToFront()
405
411
 
406
- await serviceWorker.evaluate(async () => {
407
- await globalThis.toggleExtensionForActiveTab()
408
- })
412
+ await serviceWorker.evaluate(async () => {
413
+ await globalThis.toggleExtensionForActiveTab()
414
+ })
409
415
 
410
- await new Promise(r => setTimeout(r, 100))
411
-
412
- for (let i = 0; i < 3; i++) {
413
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
414
- const pages = browser.contexts()[0].pages()
415
- let iframePage
416
- for (const p of pages) {
417
- const html = await p.content()
418
- if (html.includes('Iframe Heavy Page')) {
419
- iframePage = p
420
- break
421
- }
422
- }
416
+ await new Promise((r) => setTimeout(r, 100))
417
+
418
+ for (let i = 0; i < 3; i++) {
419
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
420
+ const pages = browser.contexts()[0].pages()
421
+ let iframePage
422
+ for (const p of pages) {
423
+ const html = await p.content()
424
+ if (html.includes('Iframe Heavy Page')) {
425
+ iframePage = p
426
+ break
427
+ }
428
+ }
423
429
 
424
- expect(iframePage).toBeDefined()
425
- expect(iframePage?.url()).toContain('about:')
430
+ expect(iframePage).toBeDefined()
431
+ expect(iframePage?.url()).toContain('about:')
426
432
 
427
- await browser.close()
428
- await new Promise(r => setTimeout(r, 100))
429
- }
433
+ await browser.close()
434
+ await new Promise((r) => setTimeout(r, 100))
435
+ }
430
436
 
431
- await page.close()
432
- }, 30000)
437
+ await page.close()
438
+ }, 30000)
433
439
 
434
- it('should work with stagehand', async () => {
435
- const browserContext = getBrowserContext()
436
- const serviceWorker = await getExtensionServiceWorker(browserContext)
440
+ it('should work with stagehand', async () => {
441
+ const browserContext = getBrowserContext()
442
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
437
443
 
438
- await serviceWorker.evaluate(async () => {
439
- await globalThis.disconnectEverything()
440
- })
441
- await new Promise(r => setTimeout(r, 100))
444
+ await serviceWorker.evaluate(async () => {
445
+ await globalThis.disconnectEverything()
446
+ })
447
+ await new Promise((r) => setTimeout(r, 100))
442
448
 
443
- const targetUrl = 'https://example.com/'
449
+ const targetUrl = 'https://example.com/'
444
450
 
445
- const enableResult = await serviceWorker.evaluate(async (url) => {
446
- const tab = await chrome.tabs.create({ url, active: true })
447
- await new Promise(r => setTimeout(r, 100))
448
- return await globalThis.toggleExtensionForActiveTab()
449
- }, targetUrl)
451
+ const enableResult = await serviceWorker.evaluate(async (url) => {
452
+ const tab = await chrome.tabs.create({ url, active: true })
453
+ await new Promise((r) => setTimeout(r, 100))
454
+ return await globalThis.toggleExtensionForActiveTab()
455
+ }, targetUrl)
450
456
 
451
- console.log('Extension enabled:', enableResult)
452
- expect(enableResult.isConnected).toBe(true)
457
+ console.log('Extension enabled:', enableResult)
458
+ expect(enableResult.isConnected).toBe(true)
453
459
 
454
- await new Promise(r => setTimeout(r, 100))
460
+ await new Promise((r) => setTimeout(r, 100))
455
461
 
456
- const { Stagehand } = await import('@browserbasehq/stagehand')
462
+ const { Stagehand } = await import('@browserbasehq/stagehand')
457
463
 
458
- const stagehand = new Stagehand({
459
- env: 'LOCAL',
460
- verbose: 1,
461
- disablePino: true,
462
- localBrowserLaunchOptions: {
463
- cdpUrl: getCdpUrl({ port: TEST_PORT }),
464
- },
465
- })
464
+ const stagehand = new Stagehand({
465
+ env: 'LOCAL',
466
+ verbose: 1,
467
+ disablePino: true,
468
+ localBrowserLaunchOptions: {
469
+ cdpUrl: getCdpUrl({ port: TEST_PORT }),
470
+ },
471
+ })
466
472
 
467
- console.log('Initializing Stagehand...')
468
- await stagehand.init()
469
- console.log('Stagehand initialized')
473
+ console.log('Initializing Stagehand...')
474
+ await stagehand.init()
475
+ console.log('Stagehand initialized')
470
476
 
471
- const context = stagehand.context
472
- expect(context).toBeDefined()
477
+ const context = stagehand.context
478
+ expect(context).toBeDefined()
473
479
 
474
- const pages = context.pages()
475
- console.log('Stagehand pages:', pages.length, pages.map(p => p.url()))
480
+ const pages = context.pages()
481
+ console.log(
482
+ 'Stagehand pages:',
483
+ pages.length,
484
+ pages.map((p) => p.url()),
485
+ )
476
486
 
477
- const stagehandPage = pages.find(p => p.url().includes('example.com'))
478
- expect(stagehandPage).toBeDefined()
487
+ const stagehandPage = pages.find((p) => p.url().includes('example.com'))
488
+ expect(stagehandPage).toBeDefined()
479
489
 
480
- const url = stagehandPage!.url()
481
- console.log('Stagehand page URL:', url)
482
- expect(url).toContain('example.com')
490
+ const url = stagehandPage!.url()
491
+ console.log('Stagehand page URL:', url)
492
+ expect(url).toContain('example.com')
483
493
 
484
- await stagehand.close()
485
- }, 60000)
494
+ await stagehand.close()
495
+ }, 60000)
486
496
 
487
- it('should expose CDP discovery endpoints /json/version and /json/list', async () => {
488
- const browserContext = getBrowserContext()
489
- const serviceWorker = await getExtensionServiceWorker(browserContext)
497
+ it('should expose CDP discovery endpoints /json/version and /json/list', async () => {
498
+ const browserContext = getBrowserContext()
499
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
490
500
 
491
- const page = await browserContext.newPage()
492
- await page.goto('https://example.com')
493
- await page.bringToFront()
501
+ const page = await browserContext.newPage()
502
+ await page.goto('https://example.com')
503
+ await page.bringToFront()
494
504
 
495
- await serviceWorker.evaluate(async () => {
496
- await globalThis.toggleExtensionForActiveTab()
497
- })
498
- await new Promise(r => setTimeout(r, 200))
499
-
500
- // Test /json/version
501
- const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
502
- expect(versionRes.status).toBe(200)
503
- const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string }
504
- expect(versionJson).toMatchObject({
505
- 'Browser': expect.stringContaining('Playwriter/'),
506
- 'Protocol-Version': '1.3',
507
- 'webSocketDebuggerUrl': expect.stringContaining('ws://'),
508
- })
509
- expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
510
-
511
- // Test /json/version/ (trailing slash)
512
- const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`)
513
- expect(versionSlashRes.status).toBe(200)
514
-
515
- // Test /json/list
516
- const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
517
- expect(listRes.status).toBe(200)
518
- const listJson = await listRes.json() as Array<{ url?: string }>
519
- expect(Array.isArray(listJson)).toBe(true)
520
- expect(listJson.length).toBeGreaterThan(0)
521
-
522
- const examplePage = listJson.find((t) => t.url?.includes('example.com'))
523
- expect(examplePage).toBeDefined()
524
- expect(examplePage).toMatchObject({
525
- id: expect.any(String),
526
- type: 'page',
527
- url: expect.stringContaining('example.com'),
528
- webSocketDebuggerUrl: expect.stringContaining('ws://'),
529
- })
505
+ await serviceWorker.evaluate(async () => {
506
+ await globalThis.toggleExtensionForActiveTab()
507
+ })
508
+ await new Promise((r) => setTimeout(r, 200))
509
+
510
+ // Test /json/version
511
+ const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
512
+ expect(versionRes.status).toBe(200)
513
+ const versionJson = (await versionRes.json()) as { webSocketDebuggerUrl: string }
514
+ expect(versionJson).toMatchObject({
515
+ Browser: expect.stringContaining('Playwriter/'),
516
+ 'Protocol-Version': '1.3',
517
+ webSocketDebuggerUrl: expect.stringContaining('ws://'),
518
+ })
519
+ expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
520
+
521
+ // Test /json/version/ (trailing slash)
522
+ const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`)
523
+ expect(versionSlashRes.status).toBe(200)
524
+
525
+ // Test /json/list
526
+ const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
527
+ expect(listRes.status).toBe(200)
528
+ const listJson = (await listRes.json()) as Array<{ url?: string }>
529
+ expect(Array.isArray(listJson)).toBe(true)
530
+ expect(listJson.length).toBeGreaterThan(0)
531
+
532
+ const examplePage = listJson.find((t) => t.url?.includes('example.com'))
533
+ expect(examplePage).toBeDefined()
534
+ expect(examplePage).toMatchObject({
535
+ id: expect.any(String),
536
+ type: 'page',
537
+ url: expect.stringContaining('example.com'),
538
+ webSocketDebuggerUrl: expect.stringContaining('ws://'),
539
+ })
530
540
 
531
- // Test /json (alias for /json/list)
532
- const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`)
533
- expect(jsonRes.status).toBe(200)
534
- const jsonData = await jsonRes.json()
535
- expect(Array.isArray(jsonData)).toBe(true)
536
-
537
- // Test PUT method (Chrome 66+ prefers PUT)
538
- const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' })
539
- expect(putRes.status).toBe(200)
540
-
541
- await page.close()
542
- }, 60000)
543
-
544
- // Skip: chrome.tabCapture.getMediaStreamId() requires activeTab permission
545
- it.skip('should record screen with navigation using chrome.tabCapture', async () => {
546
- const browserContext = getBrowserContext()
547
- const serviceWorker = await getExtensionServiceWorker(browserContext)
548
- const path = await import('node:path')
549
- const fs = await import('node:fs')
550
-
551
- const recordingPage = await browserContext.newPage()
552
- await recordingPage.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' })
553
- await recordingPage.bringToFront()
554
-
555
- await serviceWorker.evaluate(async () => {
556
- await globalThis.toggleExtensionForActiveTab()
557
- })
558
- await new Promise(r => setTimeout(r, 200))
541
+ // Test /json (alias for /json/list)
542
+ const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`)
543
+ expect(jsonRes.status).toBe(200)
544
+ const jsonData = await jsonRes.json()
545
+ expect(Array.isArray(jsonData)).toBe(true)
559
546
 
560
- const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4')
561
- if (!fs.existsSync(path.dirname(outputPath))) {
562
- fs.mkdirSync(path.dirname(outputPath), { recursive: true })
563
- }
547
+ // Test PUT method (Chrome 66+ prefers PUT)
548
+ const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' })
549
+ expect(putRes.status).toBe(200)
564
550
 
565
- const { startRecording, stopRecording, isRecording } = await import('./screen-recording.js')
566
-
567
- const startResult = await startRecording({
568
- page: recordingPage,
569
- outputPath,
570
- frameRate: 30,
571
- audio: false,
572
- videoBitsPerSecond: 1500000,
573
- relayPort: TEST_PORT,
574
- })
575
- expect(startResult.isRecording).toBe(true)
576
-
577
- await recordingPage.locator('.titleline a').first().click()
578
- await recordingPage.waitForLoadState('domcontentloaded')
579
- await new Promise(r => setTimeout(r, 500))
580
-
581
- await recordingPage.goBack()
582
- await recordingPage.waitForLoadState('domcontentloaded')
583
-
584
- const status = await isRecording({ page: recordingPage, relayPort: TEST_PORT })
585
- expect(status.isRecording).toBe(true)
586
-
587
- const stopResult = await stopRecording({ page: recordingPage, relayPort: TEST_PORT })
588
- expect(stopResult.path).toBe(outputPath)
589
- expect(stopResult.size).toBeGreaterThan(10000)
590
- expect(fs.existsSync(outputPath)).toBe(true)
591
-
592
- await recordingPage.close()
593
- fs.unlinkSync(outputPath)
594
- }, 60000)
551
+ await page.close()
552
+ }, 60000)
553
+
554
+ // Skip: chrome.tabCapture.getMediaStreamId() requires activeTab permission
555
+ it.skip('should record screen with navigation using chrome.tabCapture', async () => {
556
+ const browserContext = getBrowserContext()
557
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
558
+ const path = await import('node:path')
559
+ const fs = await import('node:fs')
560
+
561
+ const recordingPage = await browserContext.newPage()
562
+ await recordingPage.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' })
563
+ await recordingPage.bringToFront()
564
+
565
+ await serviceWorker.evaluate(async () => {
566
+ await globalThis.toggleExtensionForActiveTab()
567
+ })
568
+ await new Promise((r) => setTimeout(r, 200))
569
+
570
+ const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.mp4')
571
+ if (!fs.existsSync(path.dirname(outputPath))) {
572
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
573
+ }
574
+
575
+ const { startRecording, stopRecording, isRecording } = await import('./screen-recording.js')
576
+
577
+ const startResult = await startRecording({
578
+ page: recordingPage,
579
+ outputPath,
580
+ frameRate: 30,
581
+ audio: false,
582
+ videoBitsPerSecond: 1500000,
583
+ relayPort: TEST_PORT,
584
+ })
585
+ expect(startResult.isRecording).toBe(true)
586
+
587
+ await recordingPage.locator('.titleline a').first().click()
588
+ await recordingPage.waitForLoadState('domcontentloaded')
589
+ await new Promise((r) => setTimeout(r, 500))
590
+
591
+ await recordingPage.goBack()
592
+ await recordingPage.waitForLoadState('domcontentloaded')
593
+
594
+ const status = await isRecording({ page: recordingPage, relayPort: TEST_PORT })
595
+ expect(status.isRecording).toBe(true)
596
+
597
+ const stopResult = await stopRecording({ page: recordingPage, relayPort: TEST_PORT })
598
+ expect(stopResult.path).toBe(outputPath)
599
+ expect(stopResult.size).toBeGreaterThan(10000)
600
+ expect(fs.existsSync(outputPath)).toBe(true)
601
+
602
+ // Create a sped-up demo video from the recording.
603
+ // We fake executionTimestamps since this test calls screen-recording
604
+ // directly (not via executor sandbox which tracks them automatically).
605
+ const { createDemoVideo } = await import('./ffmpeg.js')
606
+ const demoPath = await createDemoVideo({
607
+ recordingPath: outputPath,
608
+ durationMs: stopResult.duration,
609
+ executionTimestamps: [
610
+ // Simulate two interactions with an idle gap between them
611
+ { start: 0.5, end: 1.5 },
612
+ { start: 3, end: 4 },
613
+ ],
614
+ speed: 4,
615
+ })
616
+ expect(fs.existsSync(demoPath)).toBe(true)
617
+ expect(demoPath).toContain('-demo')
618
+
619
+ // Verify the demo video is smaller (idle sections were sped up)
620
+ const demoSize = fs.statSync(demoPath).size
621
+ expect(demoSize).toBeGreaterThan(0)
622
+ console.log(`Recording: ${stopResult.size} bytes, Demo: ${demoSize} bytes`)
623
+
624
+ await recordingPage.close()
625
+ fs.unlinkSync(outputPath)
626
+ fs.unlinkSync(demoPath)
627
+ }, 60000)
595
628
  })