refactor: simplify screen recording code
- Fix vite output path (was broken lib/background.mjs, now background.js) - Import recording types from protocol.ts instead of duplicating in background.ts - Remove dead getSessionId() and unused sessionId params from screen-recording.ts - Replace 4 executor wrapper functions with single withRecordingDefaults helper - Fix binary chunk routing with lastRecordingMetadataTabId tracking - Add ExtensionStopRecordingResult type for proper extension/relay separation Saves ~56 lines while improving correctness of multi-tab recording.
This commit is contained in:
@@ -3,7 +3,7 @@ declare const process: { env: { PLAYWRITER_PORT: string } }
|
|||||||
import { createStore } from 'zustand/vanilla'
|
import { createStore } from 'zustand/vanilla'
|
||||||
import type { ExtensionState, ConnectionState, TabState, TabInfo, RecordingInfo } from './types'
|
import type { ExtensionState, ConnectionState, TabState, TabInfo, RecordingInfo } from './types'
|
||||||
import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types'
|
import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types'
|
||||||
import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/protocol'
|
import type { ExtensionCommandMessage, ExtensionResponseMessage, StartRecordingParams, StopRecordingParams, IsRecordingParams, CancelRecordingParams, StartRecordingResult, ExtensionStopRecordingResult, IsRecordingResult, CancelRecordingResult } from 'playwriter/src/protocol'
|
||||||
|
|
||||||
const RELAY_PORT = process.env.PLAYWRITER_PORT
|
const RELAY_PORT = process.env.PLAYWRITER_PORT
|
||||||
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
|
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
|
||||||
@@ -1089,15 +1089,7 @@ function resolveTabIdFromSessionId(sessionId?: string): number | undefined {
|
|||||||
return found?.tabId
|
return found?.tabId
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StartRecordingParams {
|
async function handleStartRecording(params: StartRecordingParams): Promise<StartRecordingResult> {
|
||||||
sessionId?: string
|
|
||||||
frameRate?: number
|
|
||||||
audio?: boolean
|
|
||||||
videoBitsPerSecond?: number
|
|
||||||
audioBitsPerSecond?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStartRecording(params: StartRecordingParams): Promise<{ success: true; tabId: number; startedAt: number } | { success: false; error: string }> {
|
|
||||||
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' }
|
return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' }
|
||||||
@@ -1177,11 +1169,7 @@ async function handleStartRecording(params: StartRecordingParams): Promise<{ suc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StopRecordingParams {
|
async function handleStopRecording(params: StopRecordingParams): Promise<ExtensionStopRecordingResult> {
|
||||||
sessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStopRecording(params: StopRecordingParams): Promise<{ success: true; tabId: number; duration: number } | { success: false; error: string }> {
|
|
||||||
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return { success: false, error: 'No connected tab found' }
|
return { success: false, error: 'No connected tab found' }
|
||||||
@@ -1225,11 +1213,7 @@ async function handleStopRecording(params: StopRecordingParams): Promise<{ succe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IsRecordingParams {
|
async function handleIsRecording(params: IsRecordingParams): Promise<IsRecordingResult> {
|
||||||
sessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleIsRecording(params: IsRecordingParams): Promise<{ isRecording: boolean; tabId?: number; startedAt?: number }> {
|
|
||||||
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return { isRecording: false }
|
return { isRecording: false }
|
||||||
@@ -1257,11 +1241,7 @@ async function handleIsRecording(params: IsRecordingParams): Promise<{ isRecordi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CancelRecordingParams {
|
async function handleCancelRecording(params: CancelRecordingParams): Promise<CancelRecordingResult> {
|
||||||
sessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCancelRecording(params: CancelRecordingParams): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
const tabId = resolveTabIdFromSessionId(params.sessionId)
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return { success: false, error: 'No connected tab found' }
|
return { success: false, error: 'No connected tab found' }
|
||||||
|
|||||||
@@ -58,12 +58,7 @@ export default defineConfig({
|
|||||||
welcome: resolve(__dirname, 'src/welcome.html'),
|
welcome: resolve(__dirname, 'src/welcome.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: (chunkInfo) => {
|
entryFileNames: '[name].js',
|
||||||
if (chunkInfo.name === 'background') {
|
|
||||||
return 'lib/background.mjs';
|
|
||||||
}
|
|
||||||
return '[name].js';
|
|
||||||
},
|
|
||||||
format: 'es',
|
format: 'es',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
resolveStop?: (result: StopRecordingResult) => void
|
resolveStop?: (result: StopRecordingResult) => void
|
||||||
}
|
}
|
||||||
const activeRecordings = new Map<number, ActiveRecording>()
|
const activeRecordings = new Map<number, ActiveRecording>()
|
||||||
|
// Track which tabId just sent recordingData metadata - used to route the next binary chunk
|
||||||
|
let lastRecordingMetadataTabId: number | null = null
|
||||||
|
|
||||||
const playwrightClients = new Map<string, PlaywrightClient>()
|
const playwrightClients = new Map<string, PlaywrightClient>()
|
||||||
let extensionWs: WSContext | null = null
|
let extensionWs: WSContext | null = null
|
||||||
@@ -839,12 +841,20 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
// Handle binary data (recording chunks)
|
// Handle binary data (recording chunks)
|
||||||
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
||||||
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
||||||
// Find the recording that's waiting for data (the one we just received metadata for)
|
// Route to the recording that just sent metadata
|
||||||
// The extension sends metadata first, then binary
|
const tabId = lastRecordingMetadataTabId
|
||||||
for (const recording of activeRecordings.values()) {
|
lastRecordingMetadataTabId = null
|
||||||
recording.chunks.push(buffer)
|
|
||||||
logger?.log(pc.blue(`Received recording chunk for tab ${recording.tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`))
|
if (tabId !== null) {
|
||||||
break // Only add to the first active recording
|
const recording = activeRecordings.get(tabId)
|
||||||
|
if (recording) {
|
||||||
|
recording.chunks.push(buffer)
|
||||||
|
logger?.log(pc.blue(`Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`))
|
||||||
|
} else {
|
||||||
|
logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger?.log(pc.yellow('Received recording chunk without preceding metadata, ignoring'))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -883,6 +893,11 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
const { tabId, final } = (message as any).params
|
const { tabId, final } = (message as any).params
|
||||||
const recording = activeRecordings.get(tabId)
|
const recording = activeRecordings.get(tabId)
|
||||||
|
|
||||||
|
// Track which tab sent this metadata for routing the next binary chunk
|
||||||
|
if (!final) {
|
||||||
|
lastRecordingMetadataTabId = tabId
|
||||||
|
}
|
||||||
|
|
||||||
if (recording && final) {
|
if (recording && final) {
|
||||||
// This is the final marker - write all chunks to file
|
// This is the final marker - write all chunks to file
|
||||||
try {
|
try {
|
||||||
|
|||||||
+11
-33
@@ -591,36 +591,14 @@ export class PlaywrightExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Screen recording functions (via chrome.tabCapture in extension - survives navigation)
|
// Screen recording functions (via chrome.tabCapture in extension - survives navigation)
|
||||||
|
// Recording uses chrome.tabCapture which requires activeTab permission.
|
||||||
|
// This permission is granted when the user clicks the Playwriter extension icon on a tab.
|
||||||
const relayPort = this.cdpConfig.port || 19988
|
const relayPort = this.cdpConfig.port || 19988
|
||||||
|
// Recording will work on any tab where the user has clicked the icon.
|
||||||
const startRecordingFn = async (options: {
|
const withRecordingDefaults = <T extends { page?: Page }, R>(
|
||||||
page?: Page
|
fn: (opts: T & { relayPort: number }) => Promise<R>
|
||||||
frameRate?: number
|
) => {
|
||||||
videoBitsPerSecond?: number
|
return (options: T = {} as T) => fn({ page: options.page || page, relayPort, ...options })
|
||||||
audioBitsPerSecond?: number
|
|
||||||
audio?: boolean
|
|
||||||
outputPath: string
|
|
||||||
}) => {
|
|
||||||
// Recording uses chrome.tabCapture which requires activeTab permission.
|
|
||||||
// This permission is granted when the user clicks the Playwriter extension icon on a tab.
|
|
||||||
// Recording will work on any tab where the user has clicked the icon.
|
|
||||||
return startRecording({
|
|
||||||
page: options.page || page,
|
|
||||||
relayPort,
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopRecordingFn = async (options: { page?: Page } = {}) => {
|
|
||||||
return stopRecording({ page: options.page || page, relayPort })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRecordingFn = async (options: { page?: Page } = {}) => {
|
|
||||||
return isRecording({ page: options.page || page, relayPort })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelRecordingFn = async (options: { page?: Page } = {}) => {
|
|
||||||
return cancelRecording({ page: options.page || page, relayPort })
|
|
||||||
}
|
}
|
||||||
const self = this
|
const self = this
|
||||||
|
|
||||||
@@ -642,10 +620,10 @@ export class PlaywrightExecutor {
|
|||||||
formatStylesAsText,
|
formatStylesAsText,
|
||||||
getReactSource: getReactSourceFn,
|
getReactSource: getReactSourceFn,
|
||||||
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
||||||
startRecording: startRecordingFn,
|
startRecording: withRecordingDefaults(startRecording),
|
||||||
stopRecording: stopRecordingFn,
|
stopRecording: withRecordingDefaults(stopRecording),
|
||||||
isRecording: isRecordingFn,
|
isRecording: withRecordingDefaults(isRecording),
|
||||||
cancelRecording: cancelRecordingFn,
|
cancelRecording: withRecordingDefaults(cancelRecording),
|
||||||
resetPlaywright: async () => {
|
resetPlaywright: async () => {
|
||||||
const { page: newPage, context: newContext } = await self.reset()
|
const { page: newPage, context: newContext } = await self.reset()
|
||||||
vmContextObj.page = newPage
|
vmContextObj.page = newPage
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ export type StartRecordingResult = {
|
|||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result from extension - doesn't include path/size since relay writes the file */
|
||||||
|
export type ExtensionStopRecordingResult = {
|
||||||
|
success: true
|
||||||
|
tabId: number
|
||||||
|
duration: number
|
||||||
|
} | {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final result from relay - includes path/size after file is written */
|
||||||
export type StopRecordingResult = {
|
export type StopRecordingResult = {
|
||||||
success: true
|
success: true
|
||||||
tabId: number
|
tabId: number
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
* Recording happens in the extension context, so it survives page navigation.
|
* Recording happens in the extension context, so it survives page navigation.
|
||||||
*
|
*
|
||||||
* This module communicates with the relay server which forwards commands to the extension.
|
* This module communicates with the relay server which forwards commands to the extension.
|
||||||
|
*
|
||||||
|
* Note: Recording uses the first connected tab. Multi-tab recording support would require
|
||||||
|
* proper sessionId mapping between Playwright pages and extension tabs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Page } from 'playwright-core'
|
import type { Page } from 'playwright-core'
|
||||||
import type {
|
import type {
|
||||||
StartRecordingBody,
|
|
||||||
StopRecordingParams,
|
|
||||||
CancelRecordingParams,
|
|
||||||
StartRecordingResult,
|
StartRecordingResult,
|
||||||
StopRecordingResult,
|
StopRecordingResult,
|
||||||
IsRecordingResult,
|
IsRecordingResult,
|
||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
} from './protocol.js'
|
} from './protocol.js'
|
||||||
|
|
||||||
export interface StartRecordingOptions {
|
export interface StartRecordingOptions {
|
||||||
/** Target page to record */
|
/** Target page to record (currently unused - records first connected tab) */
|
||||||
page: Page
|
page: Page
|
||||||
/** Frame rate (default: 30) */
|
/** Frame rate (default: 30) */
|
||||||
frameRate?: number
|
frameRate?: number
|
||||||
@@ -34,7 +34,7 @@ export interface StartRecordingOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StopRecordingOptions {
|
export interface StopRecordingOptions {
|
||||||
/** Target page that is being recorded */
|
/** Target page that is being recorded (currently unused) */
|
||||||
page: Page
|
page: Page
|
||||||
/** Relay server port (default: 19988) */
|
/** Relay server port (default: 19988) */
|
||||||
relayPort?: number
|
relayPort?: number
|
||||||
@@ -46,20 +46,12 @@ export interface RecordingState {
|
|||||||
tabId?: number
|
tabId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionId(page: Page): string | undefined {
|
|
||||||
// The page's _guid is Playwright-internal and doesn't match the extension's sessionId (pw-tab-X).
|
|
||||||
// For now, we don't pass sessionId and let the extension use the first connected tab.
|
|
||||||
// TODO: Add proper mapping between page and extension tab sessionIds
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start recording the page.
|
* Start recording the page.
|
||||||
* The recording is handled by the extension, so it survives page navigation.
|
* The recording is handled by the extension, so it survives page navigation.
|
||||||
*/
|
*/
|
||||||
export async function startRecording(options: StartRecordingOptions): Promise<RecordingState> {
|
export async function startRecording(options: StartRecordingOptions): Promise<RecordingState> {
|
||||||
const {
|
const {
|
||||||
page,
|
|
||||||
frameRate = 30,
|
frameRate = 30,
|
||||||
videoBitsPerSecond = 2500000,
|
videoBitsPerSecond = 2500000,
|
||||||
audioBitsPerSecond = 128000,
|
audioBitsPerSecond = 128000,
|
||||||
@@ -67,20 +59,11 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|||||||
outputPath,
|
outputPath,
|
||||||
relayPort = 19988,
|
relayPort = 19988,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const sessionId = getSessionId(page)
|
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ frameRate, videoBitsPerSecond, audioBitsPerSecond, audio, outputPath }),
|
||||||
sessionId,
|
|
||||||
frameRate,
|
|
||||||
videoBitsPerSecond,
|
|
||||||
audioBitsPerSecond,
|
|
||||||
audio,
|
|
||||||
outputPath,
|
|
||||||
} satisfies StartRecordingBody),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as StartRecordingResult
|
const result = await response.json() as StartRecordingResult
|
||||||
@@ -101,14 +84,12 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|||||||
* Returns the path to the saved video file.
|
* Returns the path to the saved video file.
|
||||||
*/
|
*/
|
||||||
export async function stopRecording(options: StopRecordingOptions): Promise<{ path: string; duration: number; size: number }> {
|
export async function stopRecording(options: StopRecordingOptions): Promise<{ path: string; duration: number; size: number }> {
|
||||||
const { page, relayPort = 19988 } = options
|
const { relayPort = 19988 } = options
|
||||||
|
|
||||||
const sessionId = getSessionId(page)
|
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId } satisfies StopRecordingParams),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as StopRecordingResult
|
const result = await response.json() as StopRecordingResult
|
||||||
@@ -117,43 +98,31 @@ export async function stopRecording(options: StopRecordingOptions): Promise<{ pa
|
|||||||
throw new Error(`Failed to stop recording: ${result.error}`)
|
throw new Error(`Failed to stop recording: ${result.error}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { path: result.path, duration: result.duration, size: result.size }
|
||||||
path: result.path,
|
|
||||||
duration: result.duration,
|
|
||||||
size: result.size,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if recording is currently active on a page.
|
* Check if recording is currently active.
|
||||||
*/
|
*/
|
||||||
export async function isRecording(options: { page: Page; relayPort?: number }): Promise<RecordingState> {
|
export async function isRecording(options: { page: Page; relayPort?: number }): Promise<RecordingState> {
|
||||||
const { page, relayPort = 19988 } = options
|
const { relayPort = 19988 } = options
|
||||||
|
|
||||||
const sessionId = getSessionId(page)
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/status`)
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/status?sessionId=${sessionId || ''}`)
|
|
||||||
const result = await response.json() as IsRecordingResult
|
const result = await response.json() as IsRecordingResult
|
||||||
|
|
||||||
return {
|
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
|
||||||
isRecording: result.isRecording,
|
|
||||||
startedAt: result.startedAt,
|
|
||||||
tabId: result.tabId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel recording without saving.
|
* Cancel recording without saving.
|
||||||
*/
|
*/
|
||||||
export async function cancelRecording(options: { page: Page; relayPort?: number }): Promise<void> {
|
export async function cancelRecording(options: { page: Page; relayPort?: number }): Promise<void> {
|
||||||
const { page, relayPort = 19988 } = options
|
const { relayPort = 19988 } = options
|
||||||
|
|
||||||
const sessionId = getSessionId(page)
|
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId } satisfies CancelRecordingParams),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as CancelRecordingResult
|
const result = await response.json() as CancelRecordingResult
|
||||||
|
|||||||
Reference in New Issue
Block a user