playwriter 0.0.63 → 0.0.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. package/src/wait-for-page-load.ts +6 -1
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Unit tests for fitToAspectRatio — verifies viewport shrink-to-fit
3
+ * for common screen sizes and aspect ratios.
4
+ */
5
+ import { describe, test, expect } from 'vitest'
6
+ import { fitToAspectRatio } from './screen-recording.js'
7
+
8
+ describe('fitToAspectRatio', () => {
9
+ test('common sizes → 16:9', () => {
10
+ const ratio = { width: 16, height: 9 }
11
+
12
+ // Already 16:9 — no change
13
+ expect(fitToAspectRatio({ width: 1920, height: 1080 }, ratio)).toMatchInlineSnapshot(`
14
+ {
15
+ "height": 1080,
16
+ "width": 1920,
17
+ }
18
+ `)
19
+ expect(fitToAspectRatio({ width: 1280, height: 720 }, ratio)).toMatchInlineSnapshot(`
20
+ {
21
+ "height": 720,
22
+ "width": 1280,
23
+ }
24
+ `)
25
+
26
+ // 16:10 (MacBook default) — too tall, shrink height
27
+ expect(fitToAspectRatio({ width: 1440, height: 900 }, ratio)).toMatchInlineSnapshot(`
28
+ {
29
+ "height": 810,
30
+ "width": 1440,
31
+ }
32
+ `)
33
+ expect(fitToAspectRatio({ width: 1680, height: 1050 }, ratio)).toMatchInlineSnapshot(`
34
+ {
35
+ "height": 945,
36
+ "width": 1680,
37
+ }
38
+ `)
39
+
40
+ // 4:3 — too tall, shrink height
41
+ expect(fitToAspectRatio({ width: 1024, height: 768 }, ratio)).toMatchInlineSnapshot(`
42
+ {
43
+ "height": 576,
44
+ "width": 1024,
45
+ }
46
+ `)
47
+
48
+ // Ultra-wide 21:9 — too wide, shrink width
49
+ expect(fitToAspectRatio({ width: 2560, height: 1080 }, ratio)).toMatchInlineSnapshot(`
50
+ {
51
+ "height": 1080,
52
+ "width": 1920,
53
+ }
54
+ `)
55
+ expect(fitToAspectRatio({ width: 3440, height: 1440 }, ratio)).toMatchInlineSnapshot(`
56
+ {
57
+ "height": 1440,
58
+ "width": 2560,
59
+ }
60
+ `)
61
+
62
+ // Square — too tall, shrink height
63
+ expect(fitToAspectRatio({ width: 1000, height: 1000 }, ratio)).toMatchInlineSnapshot(`
64
+ {
65
+ "height": 563,
66
+ "width": 1000,
67
+ }
68
+ `)
69
+ })
70
+
71
+ test('custom aspect ratios', () => {
72
+ // 4:3
73
+ expect(fitToAspectRatio({ width: 1920, height: 1080 }, { width: 4, height: 3 })).toMatchInlineSnapshot(`
74
+ {
75
+ "height": 1080,
76
+ "width": 1440,
77
+ }
78
+ `)
79
+
80
+ // 1:1
81
+ expect(fitToAspectRatio({ width: 1920, height: 1080 }, { width: 1, height: 1 })).toMatchInlineSnapshot(`
82
+ {
83
+ "height": 1080,
84
+ "width": 1080,
85
+ }
86
+ `)
87
+
88
+ // 9:16 vertical
89
+ expect(fitToAspectRatio({ width: 1920, height: 1080 }, { width: 9, height: 16 })).toMatchInlineSnapshot(`
90
+ {
91
+ "height": 1080,
92
+ "width": 608,
93
+ }
94
+ `)
95
+ })
96
+
97
+ test('never increases dimensions', () => {
98
+ const ratio = { width: 16, height: 9 }
99
+ const sizes = [
100
+ { width: 800, height: 600 },
101
+ { width: 1440, height: 900 },
102
+ { width: 2560, height: 1080 },
103
+ { width: 1000, height: 1000 },
104
+ ]
105
+ for (const size of sizes) {
106
+ const result = fitToAspectRatio(size, ratio)
107
+ expect(result.width).toBeLessThanOrEqual(size.width)
108
+ expect(result.height).toBeLessThanOrEqual(size.height)
109
+ }
110
+ })
111
+ })
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Screen recording utility for playwriter using chrome.tabCapture.
3
3
  * Recording happens in the extension context, so it survives page navigation.
4
- *
4
+ *
5
5
  * This module communicates with the relay server which forwards commands to the extension.
6
- * SessionId (pw-tab-X format) is used to identify which tab to record.
6
+ * sessionId (pw-tab-* format) is used to identify which tab to record.
7
7
  */
8
8
 
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
- import type { Page } from '@xmorse/playwright-core'
11
+ import type { BrowserContext, Page } from '@xmorse/playwright-core'
12
12
  import type {
13
13
  StartRecordingResult,
14
14
  StopRecordingResult,
@@ -16,19 +16,22 @@ import type {
16
16
  CancelRecordingResult,
17
17
  } from './protocol.js'
18
18
  import { EXTENSION_IDS } from './utils.js'
19
+ import { RecordingGhostCursorController } from './recording-ghost-cursor.js'
19
20
 
20
21
  /**
21
22
  * Generate a shell command to quit and restart Chrome with flags that allow automatic tab capture.
22
23
  * This enables screen recording without user interaction (clicking extension icon).
23
- *
24
+ *
24
25
  * Required flags:
25
26
  * - --allowlisted-extension-id=<id> - grants the extension special privileges (one per extension)
26
27
  * - --auto-accept-this-tab-capture - auto-accepts tab capture permission requests
27
28
  */
28
29
  export function getChromeRestartCommand(): string {
29
30
  const platform = os.platform()
30
- const flags = EXTENSION_IDS.map(id => `--allowlisted-extension-id=${id}`).join(' ') + ' --auto-accept-this-tab-capture'
31
-
31
+ const extensionFlags = EXTENSION_IDS.map((id) => `--allowlisted-extension-id=${id}`).join(' ')
32
+ // --profile-directory=Default skips the profile picker on startup, preventing Chrome from hanging
33
+ const flags = `${extensionFlags} --auto-accept-this-tab-capture --profile-directory=Default`
34
+
32
35
  if (platform === 'darwin') {
33
36
  return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}`
34
37
  }
@@ -39,19 +42,45 @@ export function getChromeRestartCommand(): string {
39
42
  return `pkill chrome; sleep 1; google-chrome ${flags}`
40
43
  }
41
44
 
45
+ const DEFAULT_ASPECT_RATIO = { width: 16, height: 9 }
46
+
47
+ /** Default max recording duration: 15 minutes in milliseconds */
48
+ const DEFAULT_MAX_DURATION_MS = 15 * 60 * 1000
49
+
50
+ /**
51
+ * Compute the largest viewport that fits inside `current` at the target aspect ratio.
52
+ * Never increases width or height beyond current values — only shrinks the
53
+ * dimension that's "too large" relative to the target ratio.
54
+ */
55
+ export function fitToAspectRatio(
56
+ current: { width: number; height: number },
57
+ ratio: { width: number; height: number } = DEFAULT_ASPECT_RATIO,
58
+ ): { width: number; height: number } {
59
+ const targetRatio = ratio.width / ratio.height
60
+ const currentRatio = current.width / current.height
61
+ if (currentRatio > targetRatio) {
62
+ // Too wide — keep height, shrink width
63
+ return { width: Math.round(current.height * targetRatio), height: current.height }
64
+ }
65
+ // Too tall (or already exact) — keep width, shrink height
66
+ return { width: current.width, height: Math.round(current.width / targetRatio) }
67
+ }
68
+
42
69
  /**
43
70
  * Check if an error is related to missing activeTab permission for recording.
44
71
  */
45
72
  function isActiveTabPermissionError(error: string): boolean {
46
- return error.includes('Extension has not been invoked') ||
47
- error.includes('activeTab') ||
48
- error.includes('enable recording')
73
+ return (
74
+ error.includes('Extension has not been invoked') ||
75
+ error.includes('activeTab') ||
76
+ error.includes('enable recording')
77
+ )
49
78
  }
50
79
 
51
80
  export interface StartRecordingOptions {
52
81
  /** Target page to record */
53
82
  page: Page
54
- /** Session ID (pw-tab-X format) to identify which tab to record */
83
+ /** CDP tab session ID (pw-tab-* format) to identify which tab to record */
55
84
  sessionId?: string
56
85
  /** Frame rate (default: 30) */
57
86
  frameRate?: number
@@ -65,12 +94,18 @@ export interface StartRecordingOptions {
65
94
  outputPath: string
66
95
  /** Relay server port (default: 19988) */
67
96
  relayPort?: number
97
+ /** Aspect ratio to fit viewport to before recording (default: { width: 16, height: 9 }).
98
+ * Set to null to skip viewport resizing. */
99
+ aspectRatio?: { width: number; height: number } | null
100
+ /** Max recording duration in ms (default: 15 min = 900000). Auto-stops recording
101
+ * when exceeded to prevent accidentally filling disk. Set to 0 or Infinity to disable. */
102
+ maxDurationMs?: number
68
103
  }
69
104
 
70
105
  export interface StopRecordingOptions {
71
106
  /** Target page that is being recorded */
72
107
  page: Page
73
- /** Session ID (pw-tab-X format) to identify which tab to stop recording */
108
+ /** CDP tab session ID (pw-tab-* format) to identify which tab to stop recording */
74
109
  sessionId?: string
75
110
  /** Relay server port (default: 19988) */
76
111
  relayPort?: number
@@ -82,6 +117,178 @@ export interface RecordingState {
82
117
  tabId?: number
83
118
  }
84
119
 
120
+ export interface ExecutionTimestamp {
121
+ start: number
122
+ end: number
123
+ }
124
+
125
+ interface RecordingTargetOptions {
126
+ page?: Page
127
+ sessionId?: string
128
+ }
129
+
130
+ interface CreateRecordingApiOptions {
131
+ context: BrowserContext
132
+ defaultPage: Page
133
+ relayPort: number
134
+ ghostCursorController: RecordingGhostCursorController
135
+ onStart: () => void
136
+ onFinish: () => void
137
+ getExecutionTimestamps: () => ExecutionTimestamp[]
138
+ }
139
+
140
+ interface StartRecordingWithDefaultsOptions extends Omit<StartRecordingOptions, 'relayPort'> {}
141
+ interface StopRecordingWithDefaultsOptions extends Omit<StopRecordingOptions, 'relayPort'> {}
142
+ interface IsRecordingWithDefaultsOptions {
143
+ page?: Page
144
+ sessionId?: string
145
+ }
146
+ interface CancelRecordingWithDefaultsOptions {
147
+ page?: Page
148
+ sessionId?: string
149
+ }
150
+
151
+ function resolveRecordingTargetPage(options: {
152
+ context: BrowserContext
153
+ defaultPage: Page
154
+ ghostCursorController: RecordingGhostCursorController
155
+ target?: RecordingTargetOptions
156
+ }): Page {
157
+ return options.ghostCursorController.resolveRecordingTargetPage({
158
+ context: options.context,
159
+ defaultPage: options.defaultPage,
160
+ target: options.target,
161
+ })
162
+ }
163
+
164
+ function withRecordingDefaults<T extends { page?: Page; sessionId?: string }, R>(options: {
165
+ relayPort: number
166
+ defaultPage: Page
167
+ fn: (opts: T & { relayPort: number; sessionId?: string }) => Promise<R>
168
+ }): (input?: T) => Promise<R> {
169
+ const { relayPort, defaultPage, fn } = options
170
+ return async (input: T = {} as T) => {
171
+ const targetPage = input.page || defaultPage
172
+ const sessionId = input.sessionId || targetPage.sessionId() || undefined
173
+ return fn({ page: targetPage, sessionId, relayPort, ...input })
174
+ }
175
+ }
176
+
177
+ export function createRecordingApi(options: CreateRecordingApiOptions): {
178
+ start: (opts?: StartRecordingWithDefaultsOptions) => Promise<RecordingState>
179
+ stop: (opts?: StopRecordingWithDefaultsOptions) => Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }>
180
+ isRecording: (opts?: IsRecordingWithDefaultsOptions) => Promise<RecordingState>
181
+ cancel: (opts?: CancelRecordingWithDefaultsOptions) => Promise<void>
182
+ } {
183
+ const { context, defaultPage, relayPort, ghostCursorController, onStart, onFinish, getExecutionTimestamps } = options
184
+
185
+ // Stores the original viewport before aspect-ratio resize so we can restore on stop/cancel
186
+ let preRecordingViewport: { width: number; height: number } | null = null
187
+ // Auto-stop timer to prevent unbounded recordings
188
+ let maxDurationTimer: ReturnType<typeof setTimeout> | null = null
189
+
190
+ const startWithDefaults = withRecordingDefaults<StartRecordingWithDefaultsOptions, RecordingState>({
191
+ relayPort,
192
+ defaultPage,
193
+ fn: startRecording,
194
+ })
195
+ const stopWithDefaults = withRecordingDefaults<StopRecordingWithDefaultsOptions, { path: string; duration: number; size: number }>({
196
+ relayPort,
197
+ defaultPage,
198
+ fn: stopRecording,
199
+ })
200
+ const isRecordingWithDefaults = async (opts: IsRecordingWithDefaultsOptions = {}): Promise<RecordingState> => {
201
+ const targetPage = opts.page || defaultPage
202
+ const sessionId = opts.sessionId || targetPage.sessionId() || undefined
203
+ return isRecording({ page: targetPage, sessionId, relayPort })
204
+ }
205
+
206
+ const cancelWithDefaults = async (opts: CancelRecordingWithDefaultsOptions = {}): Promise<void> => {
207
+ const targetPage = opts.page || defaultPage
208
+ const sessionId = opts.sessionId || targetPage.sessionId() || undefined
209
+ await cancelRecording({ page: targetPage, sessionId, relayPort })
210
+ }
211
+
212
+ const start = async (opts?: StartRecordingWithDefaultsOptions): Promise<RecordingState> => {
213
+ const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
214
+
215
+ // Resize viewport to target aspect ratio (default 16:9) before recording.
216
+ // Only shrinks — never increases width or height beyond current values.
217
+ const aspectRatio = opts?.aspectRatio === undefined ? DEFAULT_ASPECT_RATIO : opts.aspectRatio
218
+ if (aspectRatio) {
219
+ const current = targetPage.viewportSize()
220
+ if (current) {
221
+ const fitted = fitToAspectRatio(current, aspectRatio)
222
+ if (fitted.width !== current.width || fitted.height !== current.height) {
223
+ preRecordingViewport = current
224
+ await targetPage.setViewportSize(fitted)
225
+ }
226
+ }
227
+ }
228
+
229
+ const result = await startWithDefaults(opts)
230
+ onStart()
231
+ await ghostCursorController.enableForRecording({ page: targetPage })
232
+
233
+ // Schedule auto-stop to prevent unbounded recordings filling disk.
234
+ // Default 15 min. Set maxDurationMs to 0 or Infinity to disable.
235
+ const maxMs = opts?.maxDurationMs ?? DEFAULT_MAX_DURATION_MS
236
+ if (maxMs > 0 && maxMs < Infinity) {
237
+ maxDurationTimer = setTimeout(() => {
238
+ maxDurationTimer = null
239
+ stop(opts ? { page: opts.page, sessionId: opts.sessionId } : undefined).catch(() => {})
240
+ }, maxMs)
241
+ }
242
+
243
+ return result
244
+ }
245
+
246
+ const clearMaxDurationTimer = (): void => {
247
+ if (maxDurationTimer) {
248
+ clearTimeout(maxDurationTimer)
249
+ maxDurationTimer = null
250
+ }
251
+ }
252
+
253
+ const restoreViewport = async (targetPage: Page): Promise<void> => {
254
+ if (!preRecordingViewport) {
255
+ return
256
+ }
257
+ const saved = preRecordingViewport
258
+ preRecordingViewport = null
259
+ await targetPage.setViewportSize(saved)
260
+ }
261
+
262
+ const stop = async (
263
+ opts?: StopRecordingWithDefaultsOptions,
264
+ ): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => {
265
+ clearMaxDurationTimer()
266
+ const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
267
+ const result = await stopWithDefaults(opts)
268
+ const executionTimestamps = [...getExecutionTimestamps()]
269
+ onFinish()
270
+ await ghostCursorController.disableForRecording({ page: targetPage })
271
+ await restoreViewport(targetPage)
272
+ return { ...result, executionTimestamps }
273
+ }
274
+
275
+ const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise<void> => {
276
+ clearMaxDurationTimer()
277
+ const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
278
+ await cancelWithDefaults(opts)
279
+ onFinish()
280
+ await ghostCursorController.disableForRecording({ page: targetPage })
281
+ await restoreViewport(targetPage)
282
+ }
283
+
284
+ return {
285
+ start,
286
+ stop,
287
+ isRecording: isRecordingWithDefaults,
288
+ cancel,
289
+ }
290
+ }
291
+
85
292
  /**
86
293
  * Start recording the page.
87
294
  * The recording is handled by the extension, so it survives page navigation.
@@ -96,34 +303,41 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
96
303
  outputPath,
97
304
  relayPort = 19988,
98
305
  } = options
99
-
306
+
100
307
  // Resolve relative paths to absolute using the caller's cwd.
101
308
  // The relay server may have a different cwd, so we must resolve here.
102
309
  const absoluteOutputPath = path.resolve(outputPath)
103
-
310
+
104
311
  const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
105
312
  method: 'POST',
106
313
  headers: { 'Content-Type': 'application/json' },
107
- body: JSON.stringify({ sessionId, frameRate, videoBitsPerSecond, audioBitsPerSecond, audio, outputPath: absoluteOutputPath }),
314
+ body: JSON.stringify({
315
+ sessionId,
316
+ frameRate,
317
+ videoBitsPerSecond,
318
+ audioBitsPerSecond,
319
+ audio,
320
+ outputPath: absoluteOutputPath,
321
+ }),
108
322
  })
109
323
 
110
- const result = await response.json() as StartRecordingResult
324
+ const result = (await response.json()) as StartRecordingResult
111
325
 
112
326
  if (!result.success) {
113
327
  const errorMsg = result.error || 'Unknown error'
114
-
328
+
115
329
  // If the error is about missing activeTab permission, provide helpful guidance
116
330
  if (isActiveTabPermissionError(errorMsg)) {
117
331
  const restartCmd = getChromeRestartCommand()
118
332
  throw new Error(
119
333
  `Failed to start recording: ${errorMsg}\n\n` +
120
- `For automated recording, Chrome must be restarted with special flags.\n` +
121
- `WARNING: This will close all Chrome windows. Save your work first!\n\n` +
122
- ` ${restartCmd}\n\n` +
123
- `Or click the Playwriter extension icon on the tab once to grant permission.`
334
+ `For automated recording, Chrome must be restarted with special flags.\n` +
335
+ `WARNING: This will close all Chrome windows. Save your work first!\n\n` +
336
+ ` ${restartCmd}\n\n` +
337
+ `Or click the Playwriter extension icon on the tab once to grant permission.`,
124
338
  )
125
339
  }
126
-
340
+
127
341
  throw new Error(`Failed to start recording: ${errorMsg}`)
128
342
  }
129
343
 
@@ -138,7 +352,9 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
138
352
  * Stop recording and save to file.
139
353
  * Returns the path to the saved video file.
140
354
  */
141
- export async function stopRecording(options: StopRecordingOptions): Promise<{ path: string; duration: number; size: number }> {
355
+ export async function stopRecording(
356
+ options: StopRecordingOptions,
357
+ ): Promise<{ path: string; duration: number; size: number }> {
142
358
  const { sessionId, relayPort = 19988 } = options
143
359
 
144
360
  const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
@@ -147,7 +363,7 @@ export async function stopRecording(options: StopRecordingOptions): Promise<{ pa
147
363
  body: JSON.stringify({ sessionId }),
148
364
  })
149
365
 
150
- const result = await response.json() as StopRecordingResult
366
+ const result = (await response.json()) as StopRecordingResult
151
367
 
152
368
  if (!result.success) {
153
369
  throw new Error(`Failed to stop recording: ${result.error}`)
@@ -159,14 +375,19 @@ export async function stopRecording(options: StopRecordingOptions): Promise<{ pa
159
375
  /**
160
376
  * Check if recording is currently active.
161
377
  */
162
- export async function isRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<RecordingState> {
378
+ export async function isRecording(options: {
379
+ page: Page
380
+ sessionId?: string
381
+ relayPort?: number
382
+ }): Promise<RecordingState> {
163
383
  const { sessionId, relayPort = 19988 } = options
164
384
 
165
- const url = sessionId
166
- ? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}`
167
- : `http://127.0.0.1:${relayPort}/recording/status`
168
- const response = await fetch(url)
169
- const result = await response.json() as IsRecordingResult
385
+ const url = new URL(`http://127.0.0.1:${relayPort}/recording/status`)
386
+ if (sessionId) {
387
+ url.searchParams.set('sessionId', sessionId)
388
+ }
389
+ const response = await fetch(url.toString())
390
+ const result = (await response.json()) as IsRecordingResult
170
391
 
171
392
  return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
172
393
  }
@@ -174,7 +395,11 @@ export async function isRecording(options: { page: Page; sessionId?: string; rel
174
395
  /**
175
396
  * Cancel recording without saving.
176
397
  */
177
- export async function cancelRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<void> {
398
+ export async function cancelRecording(options: {
399
+ page: Page
400
+ sessionId?: string
401
+ relayPort?: number
402
+ }): Promise<void> {
178
403
  const { sessionId, relayPort = 19988 } = options
179
404
 
180
405
  const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
@@ -183,7 +408,7 @@ export async function cancelRecording(options: { page: Page; sessionId?: string;
183
408
  body: JSON.stringify({ sessionId }),
184
409
  })
185
410
 
186
- const result = await response.json() as CancelRecordingResult
411
+ const result = (await response.json()) as CancelRecordingResult
187
412
 
188
413
  if (!result.success) {
189
414
  throw new Error(`Failed to cancel recording: ${result.error}`)