Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,100 changes: 342 additions & 758 deletions skills/dev-browser/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions skills/dev-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
"@hono/node-ws": "^1.2.0",
"express": "^4.21.0",
"hono": "^4.11.1",
"playwright": "^1.49.0"
"playwright": "^1.57.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
"vitest": "^2.1.0"
"vitest": "^4.0.16"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.0.0"
Expand Down
28 changes: 28 additions & 0 deletions skills/dev-browser/scripts/start-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,44 @@ try {

console.log("Starting dev browser server...");
const headless = process.env.HEADLESS === "true";

// Parse recording options from environment variables
const recordVideoDir = process.env.RECORD_VIDEO_DIR;
const recordVideoWidth = process.env.RECORD_VIDEO_WIDTH
? parseInt(process.env.RECORD_VIDEO_WIDTH, 10)
: undefined;
const recordVideoHeight = process.env.RECORD_VIDEO_HEIGHT
? parseInt(process.env.RECORD_VIDEO_HEIGHT, 10)
: undefined;

const recordVideo = recordVideoDir
? {
dir: recordVideoDir,
size:
recordVideoWidth && recordVideoHeight
? { width: recordVideoWidth, height: recordVideoHeight }
: undefined,
}
: undefined;

const recordingsDir = process.env.RECORDINGS_DIR || join(__dirname, "..", "recordings");

const server = await serve({
port: 9222,
headless,
profileDir,
recordVideo,
recordingsDir,
});

console.log(`Dev browser server started`);
console.log(` WebSocket: ${server.wsEndpoint}`);
console.log(` Tmp directory: ${tmpDir}`);
console.log(` Profile directory: ${profileDir}`);
console.log(` Recordings directory: ${recordingsDir}`);
if (recordVideo) {
console.log(` Playwright video recording: ${recordVideo.dir}`);
}
console.log(`\nReady`);
console.log(`\nPress Ctrl+C to stop`);

Expand Down
146 changes: 146 additions & 0 deletions skills/dev-browser/scripts/test-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Integration test for video recording functionality.
* Run with: npx tsx scripts/test-recording.ts
*
* Prerequisites:
* - Server must be running: npm run start-server
* - ffmpeg should be installed for video encoding (optional, falls back to frames)
*/

import { connect } from "@/client.js";
import { execSync } from "child_process";
import { existsSync, statSync, rmSync } from "fs";

async function testRecording() {
console.log("=== Video Recording Integration Test ===\n");

console.log("1. Connecting to server...");
const client = await connect();
console.log(" Connected!\n");

console.log("2. Creating test page...");
const page = await client.page("recording-test");
await page.goto("https://example.com");
console.log(" Page created and navigated to example.com\n");

console.log("3. Starting recording...");
await client.startRecording("recording-test");
console.log(" Recording started!\n");

console.log("4. Checking status...");
let status = await client.getRecordingStatus("recording-test");
if (!status.isRecording) {
throw new Error("Recording should be active");
}
console.log(` Status: isRecording=${status.isRecording}, startedAt=${status.startedAt}\n`);

console.log("5. Performing actions (2 seconds)...");
await page.click("body");
await page.evaluate(() => {
// Scroll to trigger some visual changes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).scrollTo(0, 100);
});
await new Promise((r) => setTimeout(r, 2000));

status = await client.getRecordingStatus("recording-test");
console.log(` Captured ${status.frameCount} frames\n`);

if ((status.frameCount ?? 0) < 10) {
console.warn(" Warning: Expected more frames. CDP screencast may not be capturing.");
}

console.log("6. Testing duplicate start rejection...");
try {
await client.startRecording("recording-test");
throw new Error("Should have rejected duplicate start");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("already in progress")) {
console.log(" Correctly rejected duplicate start\n");
} else {
throw e;
}
}

console.log("7. Stopping recording...");
const { videoPath, durationMs, frameCount } = await client.stopRecording("recording-test");
console.log(` Video path: ${videoPath}`);
console.log(` Duration: ${durationMs}ms`);
console.log(` Frames: ${frameCount}\n`);

console.log("8. Validating output...");
if (!existsSync(videoPath)) {
throw new Error(`Output not found: ${videoPath}`);
}

const stats = statSync(videoPath);
const isDirectory = stats.isDirectory();
const size = isDirectory ? 0 : stats.size;

if (isDirectory) {
console.log(` Output is a frames directory (ffmpeg not available)`);
} else {
console.log(` File size: ${(size / 1024).toFixed(1)}KB`);

if (size < 100) {
throw new Error(`Output too small: ${size} bytes`);
}

// Try to validate with ffprobe if available
console.log("\n9. Checking video with ffprobe...");
try {
const probe = execSync(`ffprobe -v error -show_format "${videoPath}"`, {
encoding: "utf-8",
});
if (probe.includes("format_name=")) {
console.log(" Video format validated with ffprobe");
}
} catch {
console.log(" ffprobe not available, skipping codec validation");
}
}

console.log("\n10. Testing stop without start rejection...");
try {
await client.stopRecording("recording-test");
throw new Error("Should have rejected stop without start");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("No recording in progress")) {
console.log(" Correctly rejected stop without start\n");
} else {
throw e;
}
}

console.log("11. Cleaning up...");
// Clean up the test video/frames
try {
rmSync(videoPath, { recursive: true });
console.log(" Removed test output\n");
} catch {
console.log(" Could not remove test output (may need manual cleanup)\n");
}

// Close the test page
await client.close("recording-test");
await client.disconnect();

console.log("=================================");
console.log("✅ ALL CHECKS PASSED");
console.log("=================================\n");

console.log("Summary:");
console.log(` - Recording started/stopped successfully`);
console.log(` - Captured ${frameCount} frames in ${durationMs}ms`);
console.log(` - Error cases handled correctly`);
console.log(` - Output: ${videoPath}`);
}

testRecording().catch((e) => {
console.error("\n=================================");
console.error("❌ TEST FAILED:", e.message);
console.error("=================================\n");
process.exit(1);
});
88 changes: 88 additions & 0 deletions skills/dev-browser/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import type {
ListPagesResponse,
ServerInfoResponse,
ViewportSize,
RecordingOptions,
StartRecordingRequest,
StartRecordingResponse,
StopRecordingResponse,
RecordingStatusResponse,
GetVideoPathResponse,
} from "./types";
import { getSnapshotScript } from "./snapshot/browser-script";

Expand Down Expand Up @@ -242,6 +248,25 @@ export interface DevBrowserClient {
* Get server information including mode and extension connection status.
*/
getServerInfo: () => Promise<ServerInfo>;
/**
* Start recording a page using CDP Screencast.
* Captures frames until stopRecording is called.
*/
startRecording: (name: string, options?: RecordingOptions) => Promise<void>;
/**
* Stop recording and get the video file path.
*/
stopRecording: (name: string) => Promise<{ videoPath: string; durationMs: number; frameCount: number }>;
/**
* Check if a page is currently being recorded.
*/
getRecordingStatus: (name: string) => Promise<RecordingStatusResponse>;
/**
* Get the Playwright recordVideo path for a page.
* Only works if server was started with recordVideo option.
* Video is only available after page is closed.
*/
getVideoPath: (name: string) => Promise<string | null>;
}

export async function connect(serverUrl = "http://localhost:9222"): Promise<DevBrowserClient> {
Expand Down Expand Up @@ -470,5 +495,68 @@ export async function connect(serverUrl = "http://localhost:9222"): Promise<DevB
extensionConnected: info.extensionConnected,
};
},

async startRecording(name: string, options?: RecordingOptions): Promise<void> {
const body: StartRecordingRequest = { options };
const res = await fetch(
`${serverUrl}/pages/${encodeURIComponent(name)}/recording/start`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);

const data = (await res.json()) as StartRecordingResponse;
if (!data.success) {
throw new Error(data.error || `Failed to start recording: ${res.status}`);
}
},

async stopRecording(
name: string
): Promise<{ videoPath: string; durationMs: number; frameCount: number }> {
const res = await fetch(
`${serverUrl}/pages/${encodeURIComponent(name)}/recording/stop`,
{ method: "POST" }
);

const data = (await res.json()) as StopRecordingResponse;
if (!data.success || !data.videoPath) {
throw new Error(data.error || "Failed to stop recording");
}

return {
videoPath: data.videoPath,
durationMs: data.durationMs ?? 0,
frameCount: data.frameCount ?? 0,
};
},

async getRecordingStatus(name: string): Promise<RecordingStatusResponse> {
const res = await fetch(
`${serverUrl}/pages/${encodeURIComponent(name)}/recording/status`
);

if (!res.ok) {
throw new Error(`Failed to get recording status: ${res.status}`);
}

return (await res.json()) as RecordingStatusResponse;
},

async getVideoPath(name: string): Promise<string | null> {
const res = await fetch(
`${serverUrl}/pages/${encodeURIComponent(name)}/video`
);

const data = (await res.json()) as GetVideoPathResponse;

if (data.error) {
throw new Error(data.error);
}

return data.pending ? null : (data.videoPath ?? null);
},
};
}
Loading