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:
Tommy D. Rossi
2026-01-24 15:48:52 +01:00
parent 3b5427ebe8
commit 8449006e59
6 changed files with 64 additions and 116 deletions
+5 -25
View File
@@ -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' }
+1 -6
View File
@@ -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',
}, },
}, },
+21 -6
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
+15 -46
View File
@@ -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,
@@ -68,19 +60,10 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
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