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.
- package/dist/a11y-client.js +18 -8
- package/dist/aria-snapshot.d.ts +41 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +134 -55
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +5 -2
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/aria-snapshot.unit.test.js +83 -41
- package/dist/aria-snapshot.unit.test.js.map +1 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
- package/dist/bippy.js +1 -1
- package/dist/cdp-log.d.ts +1 -1
- package/dist/cdp-log.d.ts.map +1 -1
- package/dist/cdp-log.js +1 -1
- package/dist/cdp-log.js.map +1 -1
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +492 -298
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cdp-session.d.ts.map +1 -1
- package/dist/cdp-session.js.map +1 -1
- package/dist/cdp-types.d.ts.map +1 -1
- package/dist/cdp-types.js +7 -7
- package/dist/cdp-types.js.map +1 -1
- package/dist/clean-html.d.ts.map +1 -1
- package/dist/clean-html.js +4 -5
- package/dist/clean-html.js.map +1 -1
- package/dist/cli.js +45 -27
- package/dist/cli.js.map +1 -1
- package/dist/create-logger.d.ts.map +1 -1
- package/dist/create-logger.js +3 -1
- package/dist/create-logger.js.map +1 -1
- package/dist/debugger-examples-types.d.ts.map +1 -1
- package/dist/debugger.d.ts.map +1 -1
- package/dist/debugger.js +1 -3
- package/dist/debugger.js.map +1 -1
- package/dist/diff-utils.d.ts.map +1 -1
- package/dist/diff-utils.js +1 -4
- package/dist/diff-utils.js.map +1 -1
- package/dist/editor-api.md +12 -2
- package/dist/editor-examples.d.ts +1 -1
- package/dist/editor-examples.d.ts.map +1 -1
- package/dist/editor-examples.js +1 -1
- package/dist/editor-examples.js.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +1 -1
- package/dist/executor.d.ts +26 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +297 -64
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +38 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension-connection.test.js +139 -36
- package/dist/extension-connection.test.js.map +1 -1
- package/dist/ffmpeg.d.ts +148 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +523 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/ghost-browser.d.ts.map +1 -1
- package/dist/ghost-browser.js.map +1 -1
- package/dist/ghost-cursor-client.js +287 -0
- package/dist/ghost-cursor.d.ts +27 -0
- package/dist/ghost-cursor.d.ts.map +1 -0
- package/dist/ghost-cursor.js +63 -0
- package/dist/ghost-cursor.js.map +1 -0
- package/dist/htmlrewrite.d.ts.map +1 -1
- package/dist/htmlrewrite.js +17 -55
- package/dist/htmlrewrite.js.map +1 -1
- package/dist/htmlrewrite.test.js.map +1 -1
- package/dist/kill-port.d.ts.map +1 -1
- package/dist/kill-port.js +1 -3
- package/dist/kill-port.js.map +1 -1
- package/dist/locator-selector.test.d.ts +2 -0
- package/dist/locator-selector.test.d.ts.map +1 -0
- package/dist/locator-selector.test.js +96 -0
- package/dist/locator-selector.test.js.map +1 -0
- package/dist/mcp-client.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -3
- package/dist/mcp.js.map +1 -1
- package/dist/on-mouse-action.test.d.ts +2 -0
- package/dist/on-mouse-action.test.d.ts.map +1 -0
- package/dist/on-mouse-action.test.js +155 -0
- package/dist/on-mouse-action.test.js.map +1 -0
- package/dist/page-markdown.js +4 -4
- package/dist/page-markdown.js.map +1 -1
- package/dist/prompt.md +450 -377
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/readability.js +16 -2
- package/dist/recording-ghost-cursor.d.ts +41 -0
- package/dist/recording-ghost-cursor.d.ts.map +1 -0
- package/dist/recording-ghost-cursor.js +79 -0
- package/dist/recording-ghost-cursor.js.map +1 -0
- package/dist/recording-relay.d.ts.map +1 -1
- package/dist/recording-relay.js +8 -8
- package/dist/recording-relay.js.map +1 -1
- package/dist/relay-client.d.ts +17 -4
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +45 -11
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +515 -26
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +169 -31
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/relay-session.test.d.ts.map +1 -1
- package/dist/relay-session.test.js +113 -65
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.d.ts +158 -0
- package/dist/relay-state.d.ts.map +1 -0
- package/dist/relay-state.js +306 -0
- package/dist/relay-state.js.map +1 -0
- package/dist/relay-state.test.d.ts +2 -0
- package/dist/relay-state.test.d.ts.map +1 -0
- package/dist/relay-state.test.js +472 -0
- package/dist/relay-state.test.js.map +1 -0
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js.map +1 -1
- package/dist/screen-recording.d.ts +66 -4
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +150 -13
- package/dist/screen-recording.js.map +1 -1
- package/dist/screen-recording.test.d.ts +2 -0
- package/dist/screen-recording.test.d.ts.map +1 -0
- package/dist/screen-recording.test.js +102 -0
- package/dist/screen-recording.test.js.map +1 -0
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +71 -28
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/start-relay-server.d.ts +1 -1
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +1 -1
- package/dist/start-relay-server.js.map +1 -1
- package/dist/styles-api.md +8 -1
- package/dist/styles-examples.d.ts +1 -1
- package/dist/styles-examples.d.ts.map +1 -1
- package/dist/styles-examples.js +1 -1
- package/dist/styles-examples.js.map +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +1 -3
- package/dist/styles.js.map +1 -1
- package/dist/test-declarations.d.ts.map +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +7 -5
- package/dist/test-utils.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/wait-for-page-load.d.ts.map +1 -1
- package/dist/wait-for-page-load.js +1 -1
- package/dist/wait-for-page-load.js.map +1 -1
- package/package.json +4 -3
- package/src/a11y-client.ts +5 -4
- package/src/aria-snapshot.test.ts +5 -2
- package/src/aria-snapshot.ts +306 -117
- package/src/aria-snapshot.unit.test.ts +199 -141
- package/src/aria-snapshots/github-interactive.txt +2 -0
- package/src/aria-snapshots/github-raw.txt +5 -1
- package/src/aria-snapshots/hackernews-interactive.txt +238 -241
- package/src/aria-snapshots/hackernews-raw.txt +265 -269
- package/src/assets/aria-labels-example.png +0 -0
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
- package/src/cdp-log.ts +4 -1
- package/src/cdp-relay.ts +1059 -737
- package/src/cdp-session.ts +12 -3
- package/src/cdp-types.ts +51 -51
- package/src/clean-html.ts +4 -5
- package/src/cli.ts +82 -55
- package/src/create-logger.ts +5 -3
- package/src/debugger-examples-types.ts +4 -1
- package/src/debugger.ts +1 -5
- package/src/diff-utils.ts +2 -5
- package/src/editor-examples.ts +11 -1
- package/src/editor.ts +10 -2
- package/src/executor.ts +374 -73
- package/src/executor.unit.test.ts +48 -1
- package/src/extension-connection.test.ts +612 -488
- package/src/ffmpeg.ts +769 -0
- package/src/ghost-browser.ts +4 -6
- package/src/ghost-cursor-client.ts +369 -0
- package/src/ghost-cursor.ts +110 -0
- package/src/htmlrewrite.test.ts +6 -2
- package/src/htmlrewrite.ts +348 -386
- package/src/kill-port.ts +1 -3
- package/src/locator-selector.test.ts +115 -0
- package/src/mcp-client.ts +1 -1
- package/src/mcp.ts +21 -15
- package/src/on-mouse-action.test.ts +196 -0
- package/src/page-markdown.ts +7 -7
- package/src/protocol.ts +73 -57
- package/src/recording-ghost-cursor.ts +113 -0
- package/src/recording-relay.ts +20 -12
- package/src/relay-client.ts +85 -18
- package/src/relay-core.test.ts +1117 -578
- package/src/relay-navigation.test.ts +648 -483
- package/src/relay-session.test.ts +984 -929
- package/src/relay-state.test.ts +570 -0
- package/src/relay-state.ts +497 -0
- package/src/resource.md +21 -49
- package/src/scoped-fs.ts +9 -3
- package/src/screen-recording.test.ts +111 -0
- package/src/screen-recording.ts +256 -31
- package/src/skill.md +476 -396
- package/src/snapshot-tools.test.ts +580 -528
- package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
- package/src/start-relay-server.ts +14 -11
- package/src/styles-examples.ts +8 -1
- package/src/styles.ts +20 -21
- package/src/test-declarations.ts +6 -6
- package/src/test-utils.ts +104 -91
- package/src/utils.ts +2 -1
- 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
|
+
})
|
package/src/screen-recording.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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({
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
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: {
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
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: {
|
|
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}`)
|