diff --git a/agents/b2vPlayer.md b/agents/b2vPlayer.md
index 0fabbc2..5786c44 100644
--- a/agents/b2vPlayer.md
+++ b/agents/b2vPlayer.md
@@ -66,7 +66,7 @@ Tier 1 - MVP
The goal of this tier is to have a good looking compact player UI whit e2e test that checks:
-- when user launches player it started with 1x1 layout with a + button, that allows to select what shoul be in this pane - browser or terminal
+- when user launches player it started with 1x1 layout with a + button, that allows to select what should be in this pane - browser or terminal
- user clicks browser, and see a prompt with url and confirmation button.
- user also see a checkbox "open in dedicated browser window instead iframe (TODO)"
- by default the user opens the github page of this project
diff --git a/agents/self-test.md b/agents/self-test.md
new file mode 100644
index 0000000..67bb123
--- /dev/null
+++ b/agents/self-test.md
@@ -0,0 +1,54 @@
+# Player Self-Test
+
+## Running
+
+```bash
+# Fast mode (CI, headless, ~2.5 min)
+pnpm self-test
+
+# Human mode (headed, visible cursor + 1s breathe pauses, ~3 min)
+pnpm self-test:human
+
+# Headed but fast (visible window, no animation delays)
+pnpm self-test -- --headed
+```
+
+`--human` sets `B2V_HUMAN=1` which:
+- Enables human mode (visible cursor animations, 1s breathe pauses between actions)
+- Auto-enables `--headed` (Electron window is visible on screen)
+
+`--headed` alone shows the window but keeps fast mode (no delays).
+
+## Architecture
+
+The self-test **uses the player to test the player**:
+
+1. Playwright launches the outer player (Electron)
+2. Selects `player-self-test` scenario via the picker
+3. Clicks "Play All"
+4. Monitors all steps until completion
+
+The actual test logic lives in `tests/scenarios/player-self-test.scenario.ts` which:
+- Spawns an **inner** player (Player B) as a child process
+- Opens Player B's web UI in the outer player's session
+- Uses `InjectedActor` to drive Player B's UI (cursor visible in-page)
+
+## Test Steps
+
+- actor should split screen horizontally and open the terminal in bottom pane
+- actor launches our demo app in terminal and once it ready actors create a browser page with todo app
+- ensure we can add todos, reorder them and scroll when there are too many todos (looks like we need to inject another actor to work there)
+- after that player closes the terminal, and make sure the todo app doesn't work anymore
+- after that player opens basic ui scenario and play it
+- once it's played it replay it and click stop after first slide, and go through all slides one by one
+- we should ensure we don't have errors in consoles
+
+## Mode Behavior
+
+| Feature | Fast mode | Human mode |
+|---------|-----------|------------|
+| `breathe()` | Always 0ms (instant) | 1000ms pause |
+| Cursor movement | Instant teleport | Smooth wind-mouse animation |
+| Click effects | Instant | 25ms ripple + 300ms after-click |
+| Typing | Instant | 35ms per keystroke |
+| `--headed` | Optional | Auto-enabled |
\ No newline at end of file
diff --git a/apps/player/electron/main.ts b/apps/player/electron/main.ts
index 159bbd0..92f54ec 100644
--- a/apps/player/electron/main.ts
+++ b/apps/player/electron/main.ts
@@ -15,23 +15,60 @@ import { app, BrowserWindow, WebContentsView, ipcMain } from "electron";
console.error(`[electron ${elt()}] Electron imports loaded`);
import path from "node:path";
import { fileURLToPath } from "node:url";
-
import { execSync } from "node:child_process";
+import net from "node:net";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
console.error(`[electron ${elt()}] All imports done`);
-const CDP_PORT = parseInt(process.env.B2V_CDP_PORT ?? "9334", 10);
+const PREFERRED_CDP_PORT = parseInt(process.env.B2V_CDP_PORT ?? "9334", 10);
+
+// Probe if the preferred port is available; if not, find a free one
+function isPortFree(port: number): boolean {
+ try {
+ const srv = net.createServer();
+ srv.unref();
+ srv.on("error", () => { });
+ // Listen synchronously by using a blocking flag trick
+ let free = true;
+ try {
+ execSync(
+ `node -e "const s=require('net').createServer();s.listen(${port},'127.0.0.1',()=>{s.close();process.exit(0)});s.on('error',()=>process.exit(1))"`,
+ { timeout: 2000, stdio: "ignore" },
+ );
+ } catch { free = false; }
+ return free;
+ } catch { return false; }
+}
+
+function findFreePort(preferred: number): number {
+ if (isPortFree(preferred)) return preferred;
+ for (let port = preferred + 1; port < preferred + 65; port++) {
+ if (isPortFree(port)) return port;
+ }
+ return 0; // let OS pick
+}
+
+let CDP_PORT = PREFERRED_CDP_PORT;
// Kill any stale process holding the CDP port from a previous run
try {
- const pids = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: "utf8" }).trim();
+ const pids = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: "utf8", timeout: 3000 }).trim();
for (const pid of pids.split("\n").filter(Boolean)) {
if (pid.trim() === String(process.pid)) continue;
- try { execSync(`kill -9 ${pid.trim()} 2>/dev/null`); } catch { }
+ try { execSync(`kill -9 ${pid.trim()} 2>/dev/null`, { timeout: 2000 }); } catch { }
console.error(`[electron] Killed stale process ${pid.trim()} on CDP port ${CDP_PORT}`);
}
} catch { }
+// Check if port is actually available now; if not, find a free one
+{
+ const actualPort = findFreePort(CDP_PORT);
+ if (actualPort !== CDP_PORT) {
+ console.error(`[electron] CDP port ${CDP_PORT} is busy, using ${actualPort} instead`);
+ CDP_PORT = actualPort;
+ }
+}
+
// Enable CDP so Playwright can connect to WebContentsView pages
app.commandLine.appendSwitch("remote-debugging-port", String(CDP_PORT));
// Disable site isolation so nested iframes (terminal panes) are accessible via CDP
@@ -43,10 +80,22 @@ let scenarioView: WebContentsView | null = null;
const SERVER_PORT = parseInt(process.env.PORT ?? "9521", 10);
+const isEmbedded = process.env.B2V_EMBEDDED === "1";
+
function createMainWindow() {
mainWindow = new BrowserWindow({
- width: 1440,
- height: 900,
+ // When running embedded inside another player (self-test), hide the window
+ // completely. The UI is served via HTTP and rendered in the parent player's
+ // scenario WebContentsView. On macOS, show:false alone can still flash;
+ // we also use off-screen position and minimal size.
+ width: isEmbedded ? 1 : 1440,
+ height: isEmbedded ? 1 : 900,
+ x: isEmbedded ? -10000 : undefined,
+ y: isEmbedded ? -10000 : undefined,
+ show: !isEmbedded,
+ skipTaskbar: isEmbedded,
+ // Prevent embedded window from appearing in Mission Control / Expose
+ ...(isEmbedded ? { type: "toolbar" as any, focusable: false, hasShadow: false } : {}),
title: "b2v Player",
icon: path.join(__dirname, "..", "assets", "icon.png"),
webPreferences: {
@@ -56,6 +105,15 @@ function createMainWindow() {
},
});
+ // For embedded instances: aggressively hide the window.
+ // macOS can show windows during loadURL or other async operations.
+ if (isEmbedded) {
+ mainWindow.hide();
+ mainWindow.setVisibleOnAllWorkspaces(false);
+ // Minimize to ensure it never appears in front of the parent
+ mainWindow.minimize();
+ }
+
mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" as const }));
mainWindow.on("closed", () => {
@@ -187,6 +245,8 @@ app.whenReady().then(async () => {
// Load a minimal splash page immediately. This unblocks Playwright's
// firstWindow() which otherwise waits ~15s for the first navigation.
mainWindow!.loadURL("data:text/html,
Starting…
");
+ // Re-hide after loadURL for embedded instances (macOS can show the window)
+ if (isEmbedded) mainWindow!.hide();
// Import and start the server in-process (~0.5s)
console.error(`[electron ${elt()}] Importing server module...`);
@@ -201,6 +261,8 @@ app.whenReady().then(async () => {
const playerUrl = `http://localhost:${SERVER_PORT}`;
console.error(`[electron ${elt()}] Loading player UI: ${playerUrl}`);
mainWindow!.loadURL(playerUrl);
+ // Re-hide after loadURL for embedded instances
+ if (isEmbedded) mainWindow!.hide();
// Verify CDP port is actually listening
const http = await import("node:http");
diff --git a/apps/player/package.json b/apps/player/package.json
index be4e39c..357db48 100644
--- a/apps/player/package.json
+++ b/apps/player/package.json
@@ -14,7 +14,7 @@
"@xterm/xterm": "^6.0.0",
"browser2video": "workspace:*",
"dockview": "^5.0.0",
- "jabterm": "github:holiber/jabterm#v0.1.3",
+ "jabterm": "github:holiber/jabterm#v0.1.4",
"lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -33,4 +33,4 @@
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
-}
+}
\ No newline at end of file
diff --git a/apps/player/playwright.config.ts b/apps/player/playwright.config.ts
index faa442d..6ab1543 100644
--- a/apps/player/playwright.config.ts
+++ b/apps/player/playwright.config.ts
@@ -7,7 +7,7 @@ const totalTimeout = Number(process.env.SMOKE_TOTAL_TIMEOUT_MS) || 180_000;
export default defineConfig({
testDir: "./tests",
testMatch: "*.e2e.test.ts",
- timeout: isSmoke ? perTestTimeout : 5 * 60 * 1000,
+ timeout: isSmoke ? perTestTimeout : 10 * 60 * 1000,
globalTimeout: isSmoke ? totalTimeout : 15 * 60 * 1000,
outputDir: "../../.cache/tests/test-e2e__electron",
...(isSmoke && { maxFailures: 1 }),
diff --git a/apps/player/server/executor.ts b/apps/player/server/executor.ts
index cdb577a..e6c4b82 100644
--- a/apps/player/server/executor.ts
+++ b/apps/player/server/executor.ts
@@ -40,6 +40,7 @@ export class Executor {
private session: Session | null = null;
private ctx: T | null = null;
private executedUpTo = -1;
+ private _aborted = false;
private descriptor: ScenarioDescriptor;
private sessionOpts: Partial;
private projectRoot: string | null;
@@ -145,7 +146,9 @@ export class Executor {
this.session.replayLog.onEvent = this.onReplayEvent;
}
- if (this.onLiveFrame && this.viewMode === "video") {
+ // Start screencasting for video mode, or when embedded (no ElectronView overlay)
+ const isEmbedded = process.env.B2V_EMBEDDED === "1";
+ if (this.onLiveFrame && (this.viewMode === "video" || isEmbedded)) {
await this.startScreencast();
}
} catch (err) {
@@ -216,7 +219,8 @@ export class Executor {
const paneCount = layout.panes?.length ?? 0;
console.error(`[executor] Layout changed mid-run: panes=${paneCount} grid=${!!layout.gridConfig}`);
this.onPaneLayout?.(layout);
- if (this.onLiveFrame && this.viewMode === "video") {
+ const isEmbedded = process.env.B2V_EMBEDDED === "1";
+ if (this.onLiveFrame && (this.viewMode === "video" || isEmbedded)) {
await this.stopScreencast();
await this.startScreencast();
}
@@ -274,13 +278,14 @@ export class Executor {
onStepComplete?: (result: StepResult) => void,
): Promise {
if (targetIndex < 0 || targetIndex >= this.descriptor.steps.length) {
- throw new Error(`Step index ${targetIndex} out of range (0-${this.descriptor.steps.length - 1})`);
+ throw new Error(`Step index ${targetIndex} out of range(0 - ${this.descriptor.steps.length - 1})`);
}
// Ensure session is initialised in the target mode before fast-forwarding
await this.ensureSession(mode);
for (let i = this.executedUpTo + 1; i < targetIndex; i++) {
+ if (this._aborted) throw new Error("Execution aborted");
onStepStart?.(i, true);
const { screenshot, durationMs } = await this.executeStep(this.descriptor.steps[i], i, "fast");
this.executedUpTo = i;
@@ -288,6 +293,7 @@ export class Executor {
}
if (targetIndex > this.executedUpTo) {
+ if (this._aborted) throw new Error("Execution aborted");
onStepStart?.(targetIndex, false);
const { screenshot, durationMs } = await this.executeStep(
this.descriptor.steps[targetIndex],
@@ -304,17 +310,39 @@ export class Executor {
}
async reset(): Promise {
+ const wasAborted = this._aborted;
+ this._aborted = true;
await this.stopScreencast();
if (this.session) {
try {
- const result = await this.session.finish();
- this.lastVideoPath = result.video ?? null;
+ if (wasAborted) {
+ // Force-abort: close pages immediately (interrupts running steps)
+ await this.session.abort();
+ } else {
+ // Graceful finish: compose video, generate subtitles, etc.
+ const result = await this.session.finish();
+ this.lastVideoPath = result.video ?? null;
+ }
} catch { /* ignore */ }
this.session = null;
this.ctx = null;
this.executedUpTo = -1;
this.lastEmittedLayout = "";
}
+ this._aborted = false;
+ }
+
+ /** Force-abort the current execution (called by cancel button). */
+ async abort(): Promise {
+ this._aborted = true;
+ await this.stopScreencast();
+ if (this.session) {
+ try { await this.session.abort(); } catch { /* ignore */ }
+ this.session = null;
+ this.ctx = null;
+ this.executedUpTo = -1;
+ this.lastEmittedLayout = "";
+ }
}
async dispose(): Promise {
diff --git a/apps/player/server/index.ts b/apps/player/server/index.ts
index 8cac958..bfe6691 100644
--- a/apps/player/server/index.ts
+++ b/apps/player/server/index.ts
@@ -544,35 +544,44 @@ wss.on("connection", (ws) => {
send(ws, { type: "error", message: "No scenario loaded" });
break;
}
- for (let i = 0; i < executor.stepCount; i++) {
- await executor.runTo(
- i,
- "human",
- (index, fastForward) => send(ws, { type: "stepStart", index, fastForward }),
- (result) => {
- send(ws, { type: "stepComplete", ...result });
- persistStepCache(result.index, result.screenshot, result.durationMs, executor!);
- },
- );
- }
- await executor.reset();
- const videoPath = executor.videoPath ?? undefined;
- if (videoPath && currentCacheDir) {
- try {
- if (fs.existsSync(videoPath)) {
- cache.saveVideo(currentCacheDir, videoPath);
+ try {
+ for (let i = 0; i < executor.stepCount; i++) {
+ await executor.runTo(
+ i,
+ "human",
+ (index, fastForward) => send(ws, { type: "stepStart", index, fastForward }),
+ (result) => {
+ send(ws, { type: "stepComplete", ...result });
+ persistStepCache(result.index, result.screenshot, result.durationMs, executor!);
+ },
+ );
+ }
+ await executor.reset();
+ const videoPath = executor.videoPath ?? undefined;
+ if (videoPath && currentCacheDir) {
+ try {
+ if (fs.existsSync(videoPath)) {
+ cache.saveVideo(currentCacheDir, videoPath);
+ }
+ const subtitlesDir = path.dirname(videoPath);
+ const vttPath = path.join(subtitlesDir, "captions.vtt");
+ if (fs.existsSync(vttPath)) cache.saveSubtitles(currentCacheDir, vttPath);
+ } catch (err) {
+ console.error("[player] Failed to save video to cache:", err);
}
- const subtitlesDir = path.dirname(videoPath);
- const vttPath = path.join(subtitlesDir, "captions.vtt");
- if (fs.existsSync(vttPath)) cache.saveSubtitles(currentCacheDir, vttPath);
- } catch (err) {
- console.error("[player] Failed to save video to cache:", err);
+ }
+ send(ws, { type: "finished", videoPath: videoPath ?? (currentCacheDir ? cache.getVideoPath(currentCacheDir) : null) ?? undefined });
+ } catch (err) {
+ if ((err as Error).message?.includes("aborted")) {
+ console.error("[player] Execution aborted by user");
+ send(ws, { type: "cancelled" });
+ } else {
+ console.error("[player] runAll error:", err);
+ send(ws, { type: "error", message: (err as Error).message ?? "Unknown error" });
}
}
- send(ws, { type: "finished", videoPath: videoPath ?? (currentCacheDir ? cache.getVideoPath(currentCacheDir) : null) ?? undefined });
break;
}
-
case "reset": {
if (executor) await executor.reset();
if (electronMain) electronMain.destroyScenarioView();
@@ -600,7 +609,7 @@ wss.on("connection", (ws) => {
case "cancel": {
if (executor) {
console.error("[player] Cancelling current execution...");
- await executor.reset();
+ await executor.abort();
}
send(ws, { type: "cancelled" });
break;
diff --git a/apps/player/src/components/controls.tsx b/apps/player/src/components/controls.tsx
index aebc1c0..5067b9e 100644
--- a/apps/player/src/components/controls.tsx
+++ b/apps/player/src/components/controls.tsx
@@ -49,6 +49,7 @@ export function Controls({
disabled={!connected || isRunning || activeStep <= 0}
className="p-2 rounded-lg hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed text-zinc-400 hover:text-zinc-200 transition-colors"
title="Previous step"
+ data-testid="ctrl-prev"
>
@@ -58,6 +59,7 @@ export function Controls({
onClick={onCancel}
className="p-1.5 px-3 rounded-lg bg-red-600 hover:bg-red-500 text-white text-sm font-medium flex items-center gap-1.5 transition-colors"
title="Stop"
+ data-testid="ctrl-stop"
>
Stop
@@ -68,6 +70,7 @@ export function Controls({
disabled={!connected || allDone}
className="p-1.5 px-3 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-30 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5 transition-colors"
title={connected ? "Play all" : "Disconnected"}
+ data-testid="ctrl-play-all"
>
Play all
@@ -81,6 +84,7 @@ export function Controls({
disabled={!connected || isRunning || nextStep < 0}
className="p-2 rounded-lg hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed text-zinc-400 hover:text-zinc-200 transition-colors"
title="Next step"
+ data-testid="ctrl-next"
>
@@ -142,6 +146,7 @@ export function Controls({
disabled={isRunning}
className="p-1.5 rounded-lg hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed text-zinc-400 hover:text-zinc-200 transition-colors"
title="Reset"
+ data-testid="ctrl-reset"
>
@@ -151,6 +156,7 @@ export function Controls({
disabled={isRunning}
className="p-1.5 px-2.5 rounded-lg hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed text-zinc-400 hover:text-zinc-200 transition-colors flex items-center gap-1.5"
title="Clear cache"
+ data-testid="ctrl-clear-cache"
>
Clear cache
diff --git a/apps/player/src/components/scenario-grid.tsx b/apps/player/src/components/scenario-grid.tsx
index 5c62e67..22b9617 100644
--- a/apps/player/src/components/scenario-grid.tsx
+++ b/apps/player/src/components/scenario-grid.tsx
@@ -42,7 +42,6 @@ type ScenarioPaneParams = {
function TerminalPane({ testId, wsUrl }: { testId: string; wsUrl: string }) {
const containerRef = useRef(null);
- const jabtermRef = useRef(null);
useEffect(() => {
console.log(`[TerminalPane] ${testId} connecting to ${wsUrl}`);
@@ -60,21 +59,6 @@ function TerminalPane({ testId, wsUrl }: { testId: string; wsUrl: string }) {
return () => { ro.disconnect(); clearTimeout(timer); };
}, []);
- // Expose JabTerm capture buffer to DOM so waitForPrompt/waitForText can read it
- useEffect(() => {
- const el = containerRef.current;
- if (!el) return;
- const interval = setInterval(() => {
- const ref = jabtermRef.current;
- if (!ref) return;
- try {
- const text = ref.readAll?.() ?? "";
- el.setAttribute("data-b2v-output", text.slice(-2000));
- } catch { /* ignore */ }
- }, 200);
- return () => clearInterval(interval);
- }, []);
-
if (!wsUrl) {
return (
@@ -91,10 +75,10 @@ function TerminalPane({ testId, wsUrl }: { testId: string; wsUrl: string }) {
style={{ background: "#1e1e1e" }}
>
);
diff --git a/apps/player/src/components/scenario-picker.tsx b/apps/player/src/components/scenario-picker.tsx
index 0eeb630..e20e4a1 100644
--- a/apps/player/src/components/scenario-picker.tsx
+++ b/apps/player/src/components/scenario-picker.tsx
@@ -45,6 +45,7 @@ export function ScenarioPicker({ onLoad, connected, scenarioName, scenarioFiles,
onChange={(e) => handleSelect(e.target.value)}
disabled={!connected}
className="appearance-none bg-zinc-800 border border-zinc-700 rounded px-3 py-1 pr-7 text-xs text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-blue-600 disabled:opacity-30"
+ data-testid="picker-switch"
>
Switch scenario...
{scenarioFiles.map((f) => (
@@ -64,6 +65,7 @@ export function ScenarioPicker({ onLoad, connected, scenarioName, scenarioFiles,
onChange={(e) => handleSelect(e.target.value)}
disabled={!connected}
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 pr-8 text-sm text-zinc-200 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-blue-600 disabled:opacity-30"
+ data-testid="picker-select"
>
Select a scenario file...
{scenarioFiles.map((f) => (
@@ -113,7 +115,7 @@ export function ScenarioPicker({ onLoad, connected, scenarioName, scenarioFiles,
>
{cacheSize && cacheSize > 0
- ? `${formatBytes(cacheSize)} clear cache`
+ ? `Clear cache (${formatBytes(cacheSize)})`
: "Clear cache"}
)}
diff --git a/apps/player/src/components/step-graph.tsx b/apps/player/src/components/step-graph.tsx
index acc7ad6..aec466a 100644
--- a/apps/player/src/components/step-graph.tsx
+++ b/apps/player/src/components/step-graph.tsx
@@ -52,6 +52,7 @@ export function StepGraph({
ref={isActive ? activeRef : undefined}
onClick={() => onStepClick(i)}
className={`cursor-pointer rounded border transition-all ${stateStyles[state]} ${isActive ? "shadow-md shadow-blue-500/20" : "hover:border-zinc-500"}`}
+ data-testid={`step-card-${i}`}
>
{/* Widescreen 16:9 thumbnail with overlaid text */}
diff --git a/apps/player/tests/electron.e2e.test.ts b/apps/player/tests/electron.e2e.test.ts
index 2a925f5..8a3f59f 100644
--- a/apps/player/tests/electron.e2e.test.ts
+++ b/apps/player/tests/electron.e2e.test.ts
@@ -41,7 +41,7 @@ test.afterAll(async () => {
await new Promise
((resolve) => {
proc.on("exit", () => resolve());
setTimeout(() => {
- try { process.kill(pid, "SIGKILL"); } catch {}
+ try { process.kill(pid, "SIGKILL"); } catch { }
resolve();
}, 5_000);
});
@@ -150,6 +150,27 @@ test("electron: basic-ui scenario runs without errors", async () => {
await expect(errorBanner).toBeHidden();
});
+test("electron: stop button cancels running scenario", async () => {
+ test.setTimeout(60_000);
+
+ const playAll = await loadAndPlayScenario(BASIC_UI);
+ await playAll.click();
+
+ // Wait for execution to start — stop button should appear
+ const stopBtn = page.locator('button[title="Stop"]');
+ await expect(stopBtn).toBeVisible({ timeout: 30_000 });
+
+ // Wait a moment for the step to actually be running
+ await page.waitForTimeout(500);
+
+ // Click Stop
+ await stopBtn.click();
+
+ // Verify: Play All button returns (execution cancelled)
+ const playAllBtn = page.locator('button[title="Play all"]');
+ await expect(playAllBtn).toBeVisible({ timeout: 15_000 });
+});
+
test("electron: all-in-one scenario uses scenario-grid preview without extra windows", async () => {
test.setTimeout(300_000);
@@ -171,8 +192,8 @@ test("electron: all-in-one scenario uses scenario-grid preview without extra win
const errorPromise = errorBanner
.waitFor({ state: "visible", timeout: 60_000 })
.then(() => "error" as const);
- gridPromise.catch(() => {});
- errorPromise.catch(() => {});
+ gridPromise.catch(() => { });
+ errorPromise.catch(() => { });
winner = await Promise.race([gridPromise, errorPromise]);
} catch {
// Both waitFors failed — page may have navigated or closed
@@ -314,7 +335,7 @@ function isPortFree(port: number): boolean {
}
test("electron: clean shutdown — no orphaned processes", async () => {
- test.setTimeout(30_000);
+ test.setTimeout(60_000);
const pid = electronApp.process().pid;
diff --git a/apps/player/tests/player-self-test.e2e.test.ts b/apps/player/tests/player-self-test.e2e.test.ts
new file mode 100644
index 0000000..03d44a4
--- /dev/null
+++ b/apps/player/tests/player-self-test.e2e.test.ts
@@ -0,0 +1,213 @@
+/**
+ * Player Self-Test E2E — Runs the comprehensive self-test scenario through the player.
+ *
+ * Architecture:
+ * This Playwright test launches the player (Electron), selects the
+ * "player-self-test" scenario via the picker, clicks "Play All", and
+ * watches all steps complete. The actual test logic lives in
+ * tests/scenarios/player-self-test.scenario.ts which uses InjectedActor
+ * to drive an inner player instance.
+ *
+ * This is "using our player to test our player".
+ */
+
+import { test, expect, _electron, type ElectronApplication, type Page } from "@playwright/test";
+import { execSync } from "node:child_process";
+import path from "node:path";
+
+const PROJECT_ROOT = path.resolve(import.meta.dirname, "../../..");
+const PLAYER_DIR = path.resolve(import.meta.dirname, "..");
+const TEST_PORT = 9561;
+const TEST_CDP_PORT = 9365;
+
+/**
+ * Human mode: visible cursor animations + breathe pauses.
+ * Activated via B2V_HUMAN=1 (which also implies --headed in playwright.config).
+ * --headed alone gives a visible window but keeps fast mode.
+ */
+const isHuman = !!process.env.B2V_HUMAN;
+
+let electronApp: ElectronApplication;
+let page: Page;
+
+function isPortFree(port: number): boolean {
+ try {
+ const pids = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: "utf8" }).trim();
+ return pids.length === 0;
+ } catch {
+ return true;
+ }
+}
+
+test.describe.configure({ mode: "serial" });
+
+test.beforeAll(async () => {
+ const t0 = performance.now();
+ const ms = () => `${((performance.now() - t0) / 1000).toFixed(1)}s`;
+
+ console.log(`[self-test ${ms()}] Launching Electron player...`);
+ electronApp = await _electron.launch({
+ args: [PLAYER_DIR],
+ cwd: PROJECT_ROOT,
+ env: {
+ ...process.env,
+ NODE_OPTIONS: "--experimental-strip-types --no-warnings",
+ PORT: String(TEST_PORT),
+ B2V_CDP_PORT: String(TEST_CDP_PORT),
+ // Pass human mode to the player so the session respects it
+ ...(isHuman ? { B2V_MODE: "human" } : {}),
+ },
+ timeout: 60_000,
+ });
+ console.log(`[self-test ${ms()}] Electron launched`);
+
+ page = await electronApp.firstWindow();
+ await page.waitForLoadState("domcontentloaded");
+ console.log(`[self-test ${ms()}] domcontentloaded`);
+});
+
+test.afterAll(async () => {
+ if (!electronApp) return;
+ try {
+ const proc = electronApp.process();
+ const pid = proc.pid;
+ if (pid && proc.exitCode === null && proc.signalCode === null) {
+ process.kill(pid, "SIGTERM");
+ await new Promise((resolve) => {
+ proc.on("exit", () => resolve());
+ setTimeout(() => {
+ try { process.kill(pid, "SIGKILL"); } catch { }
+ resolve();
+ }, 5_000);
+ });
+ }
+ } catch { /* already exited or disposed */ }
+});
+
+// ===========================================================================
+// Test: Run the self-test scenario through the player
+// ===========================================================================
+
+test("load and run player-self-test scenario", async () => {
+ // This test runs the full comprehensive scenario — give it plenty of time
+ test.setTimeout(600_000); // 10 minutes
+
+ // Wait for the player's studio UI to be ready
+ console.log("[self-test] Waiting for studio ready...");
+ await page.waitForSelector("[data-preview-mode='studio-react']", { timeout: 90_000 });
+ console.log("[self-test] Studio ready!");
+
+ // Select the player-self-test scenario from the picker
+ console.log("[self-test] Loading player-self-test scenario...");
+ await page.selectOption("[data-testid='picker-select']", {
+ label: "tests/scenarios/player-self-test.scenario.ts",
+ });
+
+ // Wait for scenario steps to appear
+ await page.waitForSelector("[data-testid='step-card-0']", { timeout: 30_000 });
+ const stepCards = page.locator("[data-testid^='step-card-']");
+ const totalSteps = await stepCards.count();
+ console.log(`[self-test] Scenario loaded: ${totalSteps} steps`);
+
+ // Click "Play All"
+ console.log("[self-test] Clicking Play All...");
+ await page.click("[data-testid='ctrl-play-all']");
+
+ // Monitor progress — wait for ALL steps to reach "done" state
+ // Poll step-card class names to track state
+ const maxWaitMs = 8 * 60_000; // 8 minutes max for the scenario
+ const pollIntervalMs = 2_000;
+ const deadline = Date.now() + maxWaitMs;
+ let lastLog = "";
+
+ while (Date.now() < deadline) {
+ await page.waitForTimeout(pollIntervalMs);
+
+ // Count states by checking step card CSS classes
+ const states: string[] = [];
+ const count = await stepCards.count();
+ for (let i = 0; i < count; i++) {
+ const card = stepCards.nth(i);
+ const classes = await card.getAttribute("class") ?? "";
+ if (classes.includes("emerald")) states.push("done");
+ else if (classes.includes("blue")) states.push("running");
+ else if (classes.includes("yellow")) states.push("ff");
+ else states.push("pending");
+ }
+
+ const doneCount = states.filter((s) => s === "done").length;
+ const runningIdx = states.findIndex((s) => s === "running" || s === "ff");
+ const summary = `${doneCount}/${count} done` + (runningIdx >= 0 ? `, step ${runningIdx} running` : "");
+
+ if (summary !== lastLog) {
+ console.log(`[self-test] ${summary}`);
+ lastLog = summary;
+ }
+
+ // Check if all done
+ if (doneCount === count) {
+ console.log(`[self-test] All ${count} steps completed!`);
+ break;
+ }
+
+ // Check for no running/ff steps while not all done — scenario may have stopped/errored
+ if (runningIdx < 0 && doneCount < count && doneCount > 0) {
+ // Might be between steps, wait a bit more
+ await page.waitForTimeout(3000);
+ // Re-check
+ const recheck: string[] = [];
+ for (let i = 0; i < count; i++) {
+ const classes = await stepCards.nth(i).getAttribute("class") ?? "";
+ if (classes.includes("emerald")) recheck.push("done");
+ else if (classes.includes("blue") || classes.includes("yellow")) recheck.push("active");
+ else recheck.push("pending");
+ }
+ if (recheck.filter((s) => s === "active").length === 0 && recheck.filter((s) => s === "done").length < count) {
+ const stuckAt = recheck.findIndex((s) => s === "pending");
+ console.error(`[self-test] Scenario appears stuck! ${recheck.filter((s) => s === "done").length}/${count} done, stuck at step ${stuckAt}`);
+ break;
+ }
+ }
+ }
+
+ // Final verification
+ const finalStates: string[] = [];
+ const finalCount = await stepCards.count();
+ for (let i = 0; i < finalCount; i++) {
+ const classes = await stepCards.nth(i).getAttribute("class") ?? "";
+ finalStates.push(classes.includes("emerald") ? "done" : "not-done");
+ }
+
+ const doneTotal = finalStates.filter((s) => s === "done").length;
+ console.log(`[self-test] Final result: ${doneTotal}/${finalCount} steps done`);
+
+ // All steps should be done
+ expect(doneTotal).toBe(finalCount);
+});
+
+// ===========================================================================
+// Clean shutdown
+// ===========================================================================
+
+test("clean shutdown — no zombie processes", async () => {
+ test.setTimeout(30_000);
+
+ const pid = electronApp.process().pid;
+ if (pid) {
+ process.kill(pid, "SIGTERM");
+ }
+
+ await new Promise((resolve) => {
+ const proc = electronApp.process();
+ if (proc.exitCode !== null || proc.signalCode !== null) return resolve();
+ proc.on("exit", () => resolve());
+ });
+
+ for (let i = 0; i < 20; i++) {
+ if (isPortFree(TEST_PORT) && isPortFree(TEST_CDP_PORT)) break;
+ await new Promise((r) => setTimeout(r, 500));
+ }
+
+ expect(isPortFree(TEST_PORT)).toBe(true);
+ expect(isPortFree(TEST_CDP_PORT)).toBe(true);
+});
diff --git a/apps/player/tests/player.scenario.e2e.test.ts b/apps/player/tests/player.scenario.e2e.test.ts
index 0e84e97..fe2a45e 100644
--- a/apps/player/tests/player.scenario.e2e.test.ts
+++ b/apps/player/tests/player.scenario.e2e.test.ts
@@ -19,6 +19,9 @@ const PLAYER_DIR = path.resolve(import.meta.dirname, "..");
const TEST_PORT = 9541;
const TEST_CDP_PORT = 9345;
+/** xterm v6 uses .xterm-accessibility-tree, v5 uses .xterm-rows */
+const XTERM_TEXT_SELECTOR = ".xterm-accessibility-tree, .xterm-rows";
+
let electronApp: ElectronApplication;
let page: Page;
@@ -70,7 +73,7 @@ test.afterAll(async () => {
await new Promise((resolve) => {
proc.on("exit", () => resolve());
setTimeout(() => {
- try { process.kill(pid, "SIGKILL"); } catch {}
+ try { process.kill(pid, "SIGKILL"); } catch { }
resolve();
}, 5_000);
});
@@ -211,7 +214,7 @@ test("echo command works in terminal", async () => {
const frame = terminalIframe.contentFrame();
// Wait for xterm to render
- await frame.locator(".xterm-rows").waitFor({ state: "visible", timeout: 30_000 });
+ await frame.locator(".xterm-accessibility-tree, .xterm-rows").waitFor({ state: "visible", timeout: 30_000 });
// Click on the terminal to focus it
await frame.locator("[data-testid='jabterm-container']").click();
@@ -222,7 +225,7 @@ test("echo command works in terminal", async () => {
await page.keyboard.press("Enter");
// Wait for the echo output to appear
- await expect(frame.locator(".xterm-rows")).toContainText(sentinel, { timeout: 15_000 });
+ await expect(frame.locator(".xterm-accessibility-tree, .xterm-rows")).toContainText(sentinel, { timeout: 15_000 });
});
test("htop command works", async () => {
@@ -243,7 +246,7 @@ test("htop command works", async () => {
await page.waitForTimeout(2000);
// htop shows CPU/memory info — check for some typical content
- const content = await frame.locator(".xterm-rows").textContent({ timeout: 10_000 });
+ const content = await frame.locator(XTERM_TEXT_SELECTOR).textContent({ timeout: 10_000 });
// htop should show some process information — at minimum non-empty output
expect((content ?? "").length).toBeGreaterThan(10);
@@ -268,7 +271,7 @@ test("open another terminal tab and type ls", async () => {
const secondFrame = terminalIframes.nth(1).contentFrame();
// Wait for xterm to render in the new tab
- await secondFrame.locator(".xterm-rows").waitFor({ state: "visible", timeout: 30_000 });
+ await secondFrame.locator(XTERM_TEXT_SELECTOR).waitFor({ state: "visible", timeout: 30_000 });
// Focus and type ls
await secondFrame.locator("[data-testid='jabterm-container']").click();
@@ -277,7 +280,7 @@ test("open another terminal tab and type ls", async () => {
// Wait for ls output
await page.waitForTimeout(2000);
- const content = await secondFrame.locator(".xterm-rows").textContent({ timeout: 10_000 });
+ const content = await secondFrame.locator(XTERM_TEXT_SELECTOR).textContent({ timeout: 10_000 });
expect((content ?? "").length).toBeGreaterThan(0);
});
@@ -292,7 +295,7 @@ test("switch between terminal tabs and verify text persists", async () => {
// Get current active tab's content (should be the "ls" terminal)
const secondFrame = terminalIframes.nth(1).contentFrame();
- const lsContent = await secondFrame.locator(".xterm-rows").textContent({ timeout: 5_000 });
+ const lsContent = await secondFrame.locator(XTERM_TEXT_SELECTOR).textContent({ timeout: 5_000 });
expect(lsContent ?? "").toContain("ls");
// Click the first terminal's tab header to switch back
@@ -320,7 +323,7 @@ test("switch between terminal tabs and verify text persists", async () => {
// After switching, the first terminal should now be visible
// The echo sentinel text should still be present in the first terminal
const firstFrame = terminalIframes.first().contentFrame();
- const echoContent = await firstFrame.locator(".xterm-rows").textContent({ timeout: 10_000 });
+ const echoContent = await firstFrame.locator(XTERM_TEXT_SELECTOR).textContent({ timeout: 10_000 });
// The echo command output should still be there
expect((echoContent ?? "").length).toBeGreaterThan(10);
});
diff --git a/package.json b/package.json
index 5d1cf13..193f5bb 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,9 @@
"run:console-logs": "node tests/scenarios/console-logs.test.ts",
"gen:docs": "node scripts/gen-docs.ts",
"gen:video-page": "node scripts/gen-video-page.ts",
- "player": "pnpm -C apps/player dev"
+ "player": "pnpm -C apps/player dev",
+ "self-test": "pnpm -C apps/player exec playwright test player-self-test",
+ "self-test:human": "B2V_HUMAN=1 pnpm -C apps/player exec playwright test player-self-test --headed"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
diff --git a/packages/browser2video-test/fixtures.ts b/packages/browser2video-test/fixtures.ts
new file mode 100644
index 0000000..495dd18
--- /dev/null
+++ b/packages/browser2video-test/fixtures.ts
@@ -0,0 +1,161 @@
+/**
+ * Playwright fixtures for browser2video integration.
+ *
+ * Provides:
+ * - `session` — b2v Session (lazily created, shared across all tests)
+ * - `actor` — test-scoped convenience accessor for the current actor
+ * - Auto step wrapping — each test(title) → session.beginStep(title) / endStep()
+ *
+ * Usage with openPage:
+ * ```ts
+ * import { test, expect, setActor, getSession } from '@browser2video/test';
+ *
+ * test.beforeAll(async () => {
+ * const session = await getSession();
+ * const { actor } = await session.openPage({ url: 'http://localhost:3000' });
+ * setActor(actor);
+ * });
+ *
+ * test('Create Todo', async ({ actor }) => {
+ * await actor.type('#input', 'Buy groceries');
+ * await actor.click('#add');
+ * });
+ * ```
+ *
+ * Usage with createGrid:
+ * ```ts
+ * import { test, expect, setGrid, getSession } from '@browser2video/test';
+ *
+ * test.beforeAll(async () => {
+ * const session = await getSession();
+ * const grid = await session.createGrid([...], { ... });
+ * setGrid(grid);
+ * });
+ *
+ * test('Open terminal', async ({ actor }) => {
+ * await actor.typeAndEnter('ls -la');
+ * });
+ * ```
+ */
+import { test as base } from "@playwright/test";
+import { createSession, type Session, type GridHandle, type Actor, TerminalActor } from "browser2video";
+
+// ---------------------------------------------------------------------------
+// Shared state per worker
+// ---------------------------------------------------------------------------
+
+let _session: Session | null = null;
+let _sessionPromise: Promise | null = null;
+let _currentGrid: GridHandle | null = null;
+let _currentActor: Actor | TerminalActor | null = null;
+
+// ---------------------------------------------------------------------------
+// Fixture types
+// ---------------------------------------------------------------------------
+
+export interface B2VTestFixtures {
+ /** The b2v Session. */
+ session: Session;
+ /** The current grid handle. Set via `setGrid()` in beforeAll. */
+ grid: GridHandle;
+ /** The current actor. Set via `setActor()` or derived from `setGrid()`. */
+ actor: Actor | TerminalActor;
+ /** Auto-fixture: wraps each test in beginStep/endStep. Do not use directly. */
+ _b2vAutoStep: void;
+}
+
+export interface B2VWorkerFixtures {
+ /** Internal: worker-level Session lifecycle. */
+ _b2vWorker: void;
+}
+
+// ---------------------------------------------------------------------------
+// Extended test
+// ---------------------------------------------------------------------------
+
+export const test = base.extend({
+ // Worker-scoped: ensure session is cleaned up after all tests
+ _b2vWorker: [async ({ }, use) => {
+ await use();
+ // Cleanup when worker is done
+ if (_session) {
+ try { await _session.finish(); } catch { /* cleanup */ }
+ _session = null;
+ _sessionPromise = null;
+ _currentGrid = null;
+ _currentActor = null;
+ }
+ }, { scope: "worker", auto: true }],
+
+ // Test-scoped session accessor
+ session: async ({ }, use) => {
+ await use(await getSession());
+ },
+
+ // Auto-fixture: wraps test body in beginStep / endStep
+ _b2vAutoStep: [async ({ }, use, testInfo) => {
+ const session = await getSession();
+ session.beginStep(testInfo.title);
+ await use();
+ await session.endStep();
+ }, { auto: true }],
+
+ // Grid accessor
+ grid: async ({ }, use) => {
+ if (!_currentGrid) {
+ throw new Error("No grid. Call setGrid() in test.beforeAll after createGrid.");
+ }
+ await use(_currentGrid);
+ },
+
+ // Actor accessor — works with both openPage and createGrid
+ actor: async ({ }, use) => {
+ if (_currentActor) {
+ await use(_currentActor);
+ } else if (_currentGrid) {
+ await use(_currentGrid.actors[0]);
+ } else {
+ throw new Error(
+ "No actor available. Call setActor() or setGrid() in test.beforeAll.",
+ );
+ }
+ },
+});
+
+// ---------------------------------------------------------------------------
+// Public helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Get or create the b2v Session. Safe to call from test.beforeAll.
+ * The session is created once and shared across all tests in the worker.
+ */
+export async function getSession(): Promise {
+ if (_session) return _session;
+ if (!_sessionPromise) {
+ _sessionPromise = createSession().then((s) => {
+ _session = s;
+ return s;
+ });
+ }
+ return _sessionPromise;
+}
+
+/**
+ * Set the active actor for subsequent tests.
+ * Call from test.beforeAll after session.openPage().
+ */
+export function setActor(actor: Actor | TerminalActor): void {
+ _currentActor = actor;
+}
+
+/**
+ * Set the active grid (and its first actor) for subsequent tests.
+ * Call from test.beforeAll after session.createGrid().
+ */
+export function setGrid(grid: GridHandle): void {
+ _currentGrid = grid;
+ if (!_currentActor) {
+ _currentActor = grid.actors[0];
+ }
+}
diff --git a/packages/browser2video-test/index.ts b/packages/browser2video-test/index.ts
new file mode 100644
index 0000000..8e8f214
--- /dev/null
+++ b/packages/browser2video-test/index.ts
@@ -0,0 +1,25 @@
+/**
+ * @browser2video/test — Playwright integration for browser2video.
+ *
+ * Each test(title, ...) automatically becomes a b2v step with that title.
+ * The test context provides `session`, `grid`, and `actor` fixtures.
+ *
+ * ```ts
+ * import { test, expect, setGrid } from '@browser2video/test';
+ *
+ * test.beforeAll(async ({ session }) => {
+ * const grid = await session.createGrid([
+ * { url: 'http://localhost:3000', label: 'App' },
+ * ], { viewport: { width: 1280, height: 720 }, grid: [[0]] });
+ * setGrid(grid);
+ * });
+ *
+ * test('Create Todo', async ({ actor }) => {
+ * await actor.type('[data-testid="input"]', 'Buy groceries');
+ * await actor.click('[data-testid="add-btn"]');
+ * });
+ * ```
+ */
+export { test, setGrid, setActor, getSession } from "./fixtures.ts";
+export type { B2VTestFixtures, B2VWorkerFixtures } from "./fixtures.ts";
+export { expect } from "@playwright/test";
diff --git a/packages/browser2video-test/package.json b/packages/browser2video-test/package.json
new file mode 100644
index 0000000..24a45dd
--- /dev/null
+++ b/packages/browser2video-test/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@browser2video/test",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./index.ts"
+ },
+ "dependencies": {
+ "browser2video": "workspace:*"
+ },
+ "peerDependencies": {
+ "@playwright/test": ">=1.40.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/browser2video/actor.ts b/packages/browser2video/actor.ts
index c5dc129..ba51ad3 100644
--- a/packages/browser2video/actor.ts
+++ b/packages/browser2video/actor.ts
@@ -4,7 +4,7 @@
* and drawing. Shared by the unified runner.
*/
import type { Page, Frame, ElementHandle, Locator } from "playwright";
-import type { Mode, ActorDelays, DelayRange } from "./types.ts";
+import type { Mode, ModeRef, ActorDelays, DelayRange } from "./types.ts";
import type { ReplayEvent } from "./replay-log.ts";
import type { AudioDirectorAPI, SpeakOptions } from "./narrator.ts";
@@ -14,7 +14,7 @@ import type { AudioDirectorAPI, SpeakOptions } from "./narrator.ts";
export const DEFAULT_DELAYS: Record = {
human: {
- breatheMs: [150, 150],
+ breatheMs: [1000, 1000],
afterScrollIntoViewMs: [350, 350],
mouseMoveStepMs: [3, 3],
clickEffectMs: [25, 25],
@@ -186,30 +186,53 @@ export const CURSOR_OVERLAY_SCRIPT = `
}
}
window.__b2v_cursors = {};
-
- var CURSOR_COLORS = {
- 'default': { fill: 'white', stroke: 'black' },
- 'alice': { fill: '#f0abfc', stroke: '#86198f' },
- 'bob': { fill: '#93c5fd', stroke: '#1e40af' },
- 'narrator':{ fill: '#fde68a', stroke: '#92400e' },
- };
+ window.__b2v_cursorIndex = 0;
+ window.__b2v_cursorColors = window.__b2v_cursorColors || {};
+
+ // Auto-rotating high-visibility palette for multi-actor scenarios
+ var AUTO_COLORS = [
+ { fill: '#fb923c', stroke: '#9a3412' }, // coral/orange
+ { fill: '#38bdf8', stroke: '#0c4a6e' }, // sky blue
+ { fill: '#a3e635', stroke: '#365314' }, // lime
+ { fill: '#c084fc', stroke: '#581c87' }, // violet
+ { fill: '#fbbf24', stroke: '#78350f' }, // amber
+ { fill: '#2dd4bf', stroke: '#134e4a' }, // teal
+ { fill: '#fb7185', stroke: '#881337' }, // rose
+ { fill: '#818cf8', stroke: '#3730a3' }, // indigo
+ ];
function getCursorEl(id) {
if (window.__b2v_cursors[id]) return window.__b2v_cursors[id];
- var colors = CURSOR_COLORS[id] || CURSOR_COLORS['default'];
+ if (!document.body) return null; // body not ready yet
+
+ // First actor ('default' or index 0) → classic white cursor
+ // Subsequent actors → pick from rotating palette
+ var colors;
+ // Check for pre-registered custom color first
+ if (window.__b2v_cursorColors[id]) {
+ colors = window.__b2v_cursorColors[id];
+ } else if (id === 'default' || window.__b2v_cursorIndex === 0) {
+ colors = { fill: 'white', stroke: 'black' };
+ } else {
+ colors = AUTO_COLORS[(window.__b2v_cursorIndex - 1) % AUTO_COLORS.length];
+ }
+ window.__b2v_cursorIndex++;
+
var cursor = document.createElement('div');
cursor.id = '__b2v_cursor_' + id;
cursor.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'z-index:' + (999999 - Object.keys(window.__b2v_cursors).length),
- 'width:20px', 'height:20px', 'pointer-events:none',
- 'transform:translate(-2px,-2px)',
+ 'width:32px', 'height:32px', 'pointer-events:none',
+ 'transform:translate(-3px,-3px)',
'transition:transform 40ms ease-in-out',
'will-change:transform',
+ 'filter:drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
+ 'display:none',
].join(';');
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
- svg.setAttribute('width', '20');
- svg.setAttribute('height', '20');
+ svg.setAttribute('width', '32');
+ svg.setAttribute('height', '32');
svg.setAttribute('viewBox', '0 0 20 20');
svg.setAttribute('fill', 'none');
var pathEl = document.createElementNS(svgNS, 'path');
@@ -219,30 +242,65 @@ export const CURSOR_OVERLAY_SCRIPT = `
pathEl.setAttribute('stroke-width', '1.2');
pathEl.setAttribute('stroke-linejoin', 'round');
svg.appendChild(pathEl);
+
cursor.appendChild(svg);
document.body.appendChild(cursor);
window.__b2v_cursors[id] = cursor;
return cursor;
}
- // Legacy single-cursor element for backwards compat
- getCursorEl('default');
-
- var oldRipple = document.getElementById('__b2v_ripple_container');
- if (oldRipple) oldRipple.remove();
- const rippleContainer = document.createElement('div');
- rippleContainer.id = '__b2v_ripple_container';
- rippleContainer.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:999998;pointer-events:none;';
- document.body.appendChild(rippleContainer);
+ // Ripple container for click effects — deferred until body exists
+ var rippleContainer = null;
+ function ensureRippleContainer() {
+ if (rippleContainer && rippleContainer.parentNode) return rippleContainer;
+ if (!document.body) return null;
+ var old = document.getElementById('__b2v_ripple_container');
+ if (old) old.remove();
+ rippleContainer = document.createElement('div');
+ rippleContainer.id = '__b2v_ripple_container';
+ rippleContainer.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:999998;pointer-events:none;';
+ document.body.appendChild(rippleContainer);
+ return rippleContainer;
+ }
- document.documentElement.style.scrollBehavior = 'smooth';
+ // Set smooth scrolling on documentElement (available before body)
+ if (document.documentElement) {
+ document.documentElement.style.scrollBehavior = 'smooth';
+ }
window.__b2v_moveCursor = function(x, y, actorId) {
var el = getCursorEl(actorId || 'default');
- el.style.transform = 'translate(' + (x - 2) + 'px,' + (y - 2) + 'px)';
+ if (!el) return; // body not ready
+ var wasHidden = el.style.display === 'none';
+ if (wasHidden) {
+ // First appearance: teleport without transition to avoid sliding from corner
+ el.style.transition = 'none';
+ el.style.transform = 'translate(' + (x - 2) + 'px,' + (y - 2) + 'px)';
+ el.style.display = '';
+ // Re-enable transition after a frame
+ requestAnimationFrame(function() { el.style.transition = 'transform 40ms ease-in-out'; });
+ } else {
+ el.style.transform = 'translate(' + (x - 2) + 'px,' + (y - 2) + 'px)';
+ }
+ };
+
+ // Pre-register a custom color for an actor ID (call before first moveCursor)
+ window.__b2v_setCursorColor = function(actorId, fill, stroke) {
+ window.__b2v_cursorColors[actorId] = { fill: fill, stroke: stroke };
+ // Update existing cursor if already created
+ var existing = window.__b2v_cursors[actorId];
+ if (existing) {
+ var pathEl = existing.querySelector('path');
+ if (pathEl) {
+ pathEl.setAttribute('fill', fill);
+ pathEl.setAttribute('stroke', stroke);
+ }
+ }
};
window.__b2v_clickEffect = function(x, y) {
+ var rc = ensureRippleContainer();
+ if (!rc) return;
const ring = document.createElement('div');
ring.style.cssText = \`
position: fixed; pointer-events: none;
@@ -253,24 +311,30 @@ export const CURSOR_OVERLAY_SCRIPT = `
transform: translate(-50%, -50%);
animation: __b2v_ripple 0.6s ease-out forwards;
\`;
- rippleContainer.appendChild(ring);
+ rc.appendChild(ring);
setTimeout(() => ring.remove(), 700);
};
if (!document.getElementById('__b2v_style')) {
- const style = document.createElement('style');
- style.id = '__b2v_style';
- style.textContent = \`
- @keyframes __b2v_ripple {
- 0% { width: 0; height: 0; opacity: 1; }
- 100% { width: 80px; height: 80px; opacity: 0; }
- }
- \`;
- document.head.appendChild(style);
+ var ensureStyle = function() {
+ if (!document.head) return;
+ const style = document.createElement('style');
+ style.id = '__b2v_style';
+ style.textContent = \`
+ @keyframes __b2v_ripple {
+ 0% { width: 0; height: 0; opacity: 1; }
+ 100% { width: 80px; height: 80px; opacity: 0; }
+ }
+ \`;
+ document.head.appendChild(style);
+ };
+ if (document.head) ensureStyle();
+ else document.addEventListener('DOMContentLoaded', ensureStyle);
}
})();
`;
+
// ---------------------------------------------------------------------------
// Init scripts injected into every page
// ---------------------------------------------------------------------------
@@ -304,11 +368,17 @@ export const FAST_MODE_INIT_SCRIPT = `
export class Actor {
page: Page;
- mode: Mode;
+ /**
+ * Shared mode reference. Mode is a session-level concept — all actors
+ * created from the same session (or sharing the same ref) switch together.
+ * Read via `this.mode` getter.
+ */
+ readonly _modeRef: ModeRef;
voice?: string;
speed?: number;
private cursorX = 0;
private cursorY = 0;
+ private _cursorInitialized = false;
protected delays: ActorDelays;
/** DOM context for selector lookups — page by default, iframe Frame when inside a grid */
protected _context: Page | Frame;
@@ -327,18 +397,24 @@ export class Actor {
_scenarioIframeSelector: string | null = null;
/** Expected viewport size of the scenario iframe (for coordinate conversion) */
_scenarioViewport: { width: number; height: number } | null = null;
+ /** Custom cursor color (fill + stroke). */
+ private _cursorColor?: { fill: string; stroke: string };
+
+ /** Current execution mode — reads from the shared ModeRef. */
+ get mode(): Mode { return this._modeRef.current; }
constructor(
page: Page,
- mode: Mode,
- opts?: { delays?: Partial; voice?: string; speed?: number },
+ modeOrRef: Mode | ModeRef,
+ opts?: { delays?: Partial; voice?: string; speed?: number; cursorColor?: { fill: string; stroke: string } },
) {
this.page = page;
this._context = page;
- this.mode = mode;
- this.delays = mergeDelays(mode, opts?.delays);
+ this._modeRef = typeof modeOrRef === "string" ? { current: modeOrRef } : modeOrRef;
+ this.delays = mergeDelays(this.mode, opts?.delays);
this.voice = opts?.voice;
this.speed = opts?.speed;
+ this._cursorColor = opts?.cursorColor;
}
/** Actor identifier used for per-actor cursor overlay. */
@@ -426,6 +502,12 @@ export class Actor {
async injectCursor() {
if (this.mode !== "human" || this._embedded) return;
await this.page.evaluate(CURSOR_OVERLAY_SCRIPT);
+ if (this._cursorColor) {
+ const { fill, stroke } = this._cursorColor;
+ await this.page.evaluate(
+ `window.__b2v_setCursorColor?.('${this.cursorId}', '${fill}', '${stroke}')`,
+ );
+ }
}
/**
@@ -435,8 +517,20 @@ export class Actor {
async moveCursorTo(x: number, y: number) {
if (this.mode !== "human") return;
await this._refreshIframeBox();
+ const tx = Math.round(x);
+ const ty = Math.round(y);
+ if (!this._cursorInitialized) {
+ // First cursor movement: teleport to target (skip windMouse from 0,0)
+ this._cursorInitialized = true;
+ this.cursorX = tx;
+ this.cursorY = ty;
+ await this.page.mouse.move(tx, ty);
+ await this.page.evaluate(`window.__b2v_moveCursor?.(${tx}, ${ty}, '${this.cursorId}')`);
+ this._emitCursorMove(tx, ty);
+ return;
+ }
const from = { x: this.cursorX, y: this.cursorY };
- const points = windMouse(from, { x: Math.round(x), y: Math.round(y) });
+ const points = windMouse(from, { x: tx, y: ty });
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
await this.page.mouse.move(p.x, p.y);
@@ -444,8 +538,8 @@ export class Actor {
this._emitCursorMove(p.x, p.y);
await sleep(easedStepMs(pickMs(this.delays.mouseMoveStepMs), i, points.length));
}
- this.cursorX = Math.round(x);
- this.cursorY = Math.round(y);
+ this.cursorX = tx;
+ this.cursorY = ty;
}
/**
@@ -497,17 +591,27 @@ export class Actor {
};
if (this.mode === "human") {
- const from = { x: this.cursorX, y: this.cursorY };
- const points = windMouse(from, target);
-
- for (let i = 0; i < points.length; i++) {
- const p = points[i]!;
- await this.page.mouse.move(p.x, p.y);
+ if (!this._cursorInitialized) {
+ // First cursor movement: teleport to target (skip windMouse from 0,0)
+ this._cursorInitialized = true;
+ await this.page.mouse.move(target.x, target.y);
await this.page.evaluate(
- `window.__b2v_moveCursor?.(${p.x}, ${p.y}, '${this.cursorId}')`,
+ `window.__b2v_moveCursor?.(${target.x}, ${target.y}, '${this.cursorId}')`,
);
- this._emitCursorMove(p.x, p.y);
- await sleep(easedStepMs(pickMs(this.delays.mouseMoveStepMs), i, points.length));
+ this._emitCursorMove(target.x, target.y);
+ } else {
+ const from = { x: this.cursorX, y: this.cursorY };
+ const points = windMouse(from, target);
+
+ for (let i = 0; i < points.length; i++) {
+ const p = points[i]!;
+ await this.page.mouse.move(p.x, p.y);
+ await this.page.evaluate(
+ `window.__b2v_moveCursor?.(${p.x}, ${p.y}, '${this.cursorId}')`,
+ );
+ this._emitCursorMove(p.x, p.y);
+ await sleep(easedStepMs(pickMs(this.delays.mouseMoveStepMs), i, points.length));
+ }
}
}
diff --git a/packages/browser2video/index.ts b/packages/browser2video/index.ts
index cd8a70b..d167ece 100644
--- a/packages/browser2video/index.ts
+++ b/packages/browser2video/index.ts
@@ -38,6 +38,7 @@ export {
export { Actor, TypeAction, generateWebVTT } from "./actor.ts";
export { TerminalActor } from "./terminal-actor.ts";
+export { InjectedActor } from "./injected-actor.ts";
export { ReplayLog, type ReplayEvent } from "./replay-log.ts";
// ---------------------------------------------------------------------------
@@ -70,6 +71,7 @@ export type {
StepRecord,
TerminalHandle,
Mode,
+ ModeRef,
RecordMode,
DelayRange,
ActorDelays,
@@ -102,6 +104,7 @@ export {
ActorDelaysSchema,
LayoutConfigSchema,
ViewportSchema, type Viewport,
+ createModeRef,
} from "./schemas/common.ts";
// Schemas — session
diff --git a/packages/browser2video/injected-actor.ts b/packages/browser2video/injected-actor.ts
new file mode 100644
index 0000000..db55a93
--- /dev/null
+++ b/packages/browser2video/injected-actor.ts
@@ -0,0 +1,327 @@
+/**
+ * @description InjectedActor — injects a visible cursor and typing engine
+ * directly into a page's DOM. Unlike the regular Actor (which uses real CDP
+ * mouse/keyboard events for recording), InjectedActor dispatches synthetic
+ * DOM events and renders cursor movement inside the page itself.
+ *
+ * Use case: testing apps where you need visible in-page cursors without CDP
+ * screencasts — e.g., the player smoke-testing its own UI.
+ *
+ * ```ts
+ * const actor = new InjectedActor(page, "tester");
+ * await actor.init();
+ * await actor.click("[data-testid='add-btn']");
+ * await actor.type("#search", "hello");
+ * ```
+ */
+import type { Page, Frame } from "playwright";
+import type { Mode, ModeRef, ActorDelays, DelayRange } from "./types.ts";
+import { CURSOR_OVERLAY_SCRIPT, windMouse, linearPath, pickMs, mergeDelays } from "./actor.ts";
+
+function sleep(ms: number) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+function easedStepMs(baseMs: number, i: number, n: number): number {
+ if (n <= 1) return baseMs;
+ const t = Math.min(1, Math.max(0, i / (n - 1)));
+ const mult = 0.3 + 1.2 * t * t;
+ return Math.max(0, Math.round(baseMs * mult));
+}
+
+// ---------------------------------------------------------------------------
+// InjectedActor
+// ---------------------------------------------------------------------------
+
+export class InjectedActor {
+ readonly page: Page;
+ readonly actorId: string;
+ /** Shared mode reference — same pattern as Actor. */
+ readonly _modeRef: ModeRef;
+ private cursorX = 0;
+ private cursorY = 0;
+ private _initialized = false;
+ private _cursorInitialized = false;
+ private delays: ActorDelays;
+ /** DOM context for selector lookups — page by default, can be an iframe Frame */
+ private _context: Page | Frame;
+ /** Custom cursor color (fill + stroke) — set before first cursor movement. */
+ private _cursorColor?: { fill: string; stroke: string };
+
+ /** Current execution mode — reads from the shared ModeRef. */
+ get mode(): Mode { return this._modeRef.current; }
+
+ constructor(
+ page: Page,
+ actorId: string = "injected",
+ opts?: {
+ mode?: Mode | ModeRef;
+ delays?: Partial;
+ context?: Page | Frame;
+ cursorColor?: { fill: string; stroke: string };
+ },
+ ) {
+ this.page = page;
+ this.actorId = actorId;
+ const modeOrRef = opts?.mode ?? "human";
+ this._modeRef = typeof modeOrRef === "string" ? { current: modeOrRef } : modeOrRef;
+ this.delays = mergeDelays(this.mode, opts?.delays);
+ this._context = opts?.context ?? page;
+ this._cursorColor = opts?.cursorColor;
+ }
+
+ /** Inject the cursor overlay script into the page. Must be called once before interaction. */
+ async init(): Promise {
+ if (this._initialized) return;
+ await this.page.evaluate(CURSOR_OVERLAY_SCRIPT);
+ // Apply custom cursor color if provided
+ if (this._cursorColor) {
+ await this.page.evaluate(
+ `window.__b2v_setCursorColor?.('${this.actorId}', '${this._cursorColor.fill}', '${this._cursorColor.stroke}')`,
+ );
+ }
+ this._initialized = true;
+ }
+
+ // -----------------------------------------------------------------------
+ // Cursor movement
+ // -----------------------------------------------------------------------
+
+ /** Move cursor smoothly to absolute page coordinates. */
+ async moveCursorTo(x: number, y: number): Promise {
+ await this.init();
+ const tx = Math.round(x);
+ const ty = Math.round(y);
+
+ if (!this._cursorInitialized) {
+ this._cursorInitialized = true;
+ this.cursorX = tx;
+ this.cursorY = ty;
+ await this.page.evaluate(
+ `window.__b2v_moveCursor?.(${tx}, ${ty}, '${this.actorId}')`,
+ );
+ return;
+ }
+
+ if (this.mode === "fast") {
+ this.cursorX = tx;
+ this.cursorY = ty;
+ await this.page.evaluate(
+ `window.__b2v_moveCursor?.(${tx}, ${ty}, '${this.actorId}')`,
+ );
+ return;
+ }
+
+ const from = { x: this.cursorX, y: this.cursorY };
+ const points = windMouse(from, { x: tx, y: ty });
+ for (let i = 0; i < points.length; i++) {
+ const p = points[i]!;
+ await this.page.evaluate(
+ `window.__b2v_moveCursor?.(${p.x}, ${p.y}, '${this.actorId}')`,
+ );
+ await sleep(easedStepMs(pickMs(this.delays.mouseMoveStepMs), i, points.length));
+ }
+ this.cursorX = tx;
+ this.cursorY = ty;
+ }
+
+ // -----------------------------------------------------------------------
+ // Element interaction helpers
+ // -----------------------------------------------------------------------
+
+ /**
+ * Resolve an element's bounding box center. Scrolls it into view first.
+ */
+ private async _getElementCenter(
+ selector: string,
+ ): Promise<{ x: number; y: number }> {
+ const el = await this._context.waitForSelector(selector, {
+ state: "visible",
+ timeout: 10_000,
+ });
+ if (!el) throw new Error(`Element not found: ${selector}`);
+
+ const scrollBehavior: ScrollBehavior = this.mode === "human" ? "smooth" : "auto";
+ await el.evaluate(
+ (e, b) => (e as Element).scrollIntoView({ block: "center", behavior: b }),
+ scrollBehavior,
+ );
+ await sleep(pickMs(this.delays.afterScrollIntoViewMs));
+
+ const box = await el.boundingBox();
+ if (!box) throw new Error(`Element has no bounding box: ${selector}`);
+ return {
+ x: Math.round(box.x + box.width / 2),
+ y: Math.round(box.y + box.height / 2),
+ };
+ }
+
+ // -----------------------------------------------------------------------
+ // Click
+ // -----------------------------------------------------------------------
+
+ /**
+ * Click on an element. Moves cursor smoothly, shows click ripple, and
+ * dispatches a real Playwright click (which fires all DOM events properly).
+ */
+ async click(selector: string): Promise {
+ const center = await this._getElementCenter(selector);
+ await this.moveCursorTo(center.x, center.y);
+
+ // Show visual click effect
+ if (this.mode === "human") {
+ await this.page.evaluate(
+ `window.__b2v_clickEffect?.(${center.x}, ${center.y})`,
+ );
+ await sleep(pickMs(this.delays.clickEffectMs));
+ }
+
+ // Perform actual click via Playwright (this fires native events properly)
+ await this.page.mouse.click(center.x, center.y);
+
+ if (this.mode === "human") {
+ await sleep(pickMs(this.delays.afterClickMs));
+ }
+ }
+
+ /**
+ * Click at specific page coordinates.
+ */
+ async clickAt(x: number, y: number): Promise {
+ await this.moveCursorTo(x, y);
+
+ if (this.mode === "human") {
+ await this.page.evaluate(`window.__b2v_clickEffect?.(${x}, ${y})`);
+ await sleep(pickMs(this.delays.clickEffectMs));
+ }
+
+ await this.page.mouse.click(x, y);
+
+ if (this.mode === "human") {
+ await sleep(pickMs(this.delays.afterClickMs));
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Type
+ // -----------------------------------------------------------------------
+
+ /**
+ * Type text into an element. Clicks the element first to focus it,
+ * then types character by character with human-like delays.
+ */
+ async type(selector: string, text: string): Promise {
+ await this.click(selector);
+ await sleep(pickMs(this.delays.beforeTypeMs));
+
+ if (this.mode === "fast") {
+ await this.page.keyboard.type(text, { delay: 0 });
+ return;
+ }
+
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (ch === "\n") {
+ await this.page.keyboard.press("Enter");
+ } else {
+ await this.page.keyboard.type(ch, { delay: pickMs(this.delays.keyDelayMs) });
+ }
+ if (ch === " " || ch === "@" || ch === ".") {
+ await sleep(pickMs(this.delays.keyBoundaryPauseMs));
+ }
+ }
+
+ await sleep(pickMs(this.delays.afterTypeMs));
+ }
+
+ /**
+ * Type text and press Enter.
+ */
+ async typeAndEnter(selector: string, text: string): Promise {
+ await this.type(selector, text + "\n");
+ }
+
+ // -----------------------------------------------------------------------
+ // Key press
+ // -----------------------------------------------------------------------
+
+ /**
+ * Press a keyboard key.
+ */
+ async pressKey(key: string): Promise {
+ await this.page.keyboard.press(key);
+ if (this.mode === "human") {
+ await sleep(pickMs(this.delays.breatheMs));
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Navigation
+ // -----------------------------------------------------------------------
+
+ /** Navigate the page to a URL. */
+ async goto(url: string): Promise {
+ await this._context.goto(url, { waitUntil: "domcontentloaded" });
+ // Re-inject cursor overlay after navigation
+ this._initialized = false;
+ await this.init();
+ }
+
+ // -----------------------------------------------------------------------
+ // Wait/assert helpers
+ // -----------------------------------------------------------------------
+
+ /** Wait for an element to appear. */
+ async waitFor(selector: string, timeout = 10_000): Promise {
+ await this._context.waitForSelector(selector, { state: "visible", timeout });
+ }
+
+ /** Wait for an element to become hidden. */
+ async waitForHidden(selector: string, timeout = 10_000): Promise {
+ await this._context.waitForSelector(selector, { state: "hidden", timeout });
+ }
+
+ // -----------------------------------------------------------------------
+ // Scroll
+ // -----------------------------------------------------------------------
+
+ /** Scroll within an element or the page. */
+ async scroll(selector: string | null, deltaY: number): Promise {
+ if (selector) {
+ await this.moveCursorTo(
+ ...(Object.values(await this._getElementCenter(selector)) as [number, number]),
+ );
+ }
+
+ const behavior: ScrollBehavior = this.mode === "human" ? "smooth" : "auto";
+
+ if (selector) {
+ await this._context.evaluate(
+ ({ selector, deltaY, behavior }) => {
+ const el = document.querySelector(selector) as HTMLElement | null;
+ if (el) el.scrollBy({ top: deltaY, behavior });
+ else window.scrollBy({ top: deltaY, behavior });
+ },
+ { selector, deltaY, behavior },
+ );
+ } else {
+ await this._context.evaluate(
+ ({ deltaY, behavior }) => {
+ window.scrollBy({ top: deltaY, behavior });
+ },
+ { deltaY, behavior },
+ );
+ }
+
+ await sleep(this.mode === "human" ? 600 : 50);
+ }
+
+ // -----------------------------------------------------------------------
+ // Breathe
+ // -----------------------------------------------------------------------
+
+ /** Add a breathing pause between major steps (human mode only). */
+ async breathe(): Promise {
+ await sleep(pickMs(this.delays.breatheMs));
+ }
+}
diff --git a/packages/browser2video/package.json b/packages/browser2video/package.json
index 2e24ebd..d24e624 100644
--- a/packages/browser2video/package.json
+++ b/packages/browser2video/package.json
@@ -28,7 +28,8 @@
"exports": {
".": "./index.ts",
"./scenario": "./scenario.ts",
- "./terminal": "./terminal-ws-server.ts"
+ "./terminal": "./terminal-ws-server.ts",
+ "./injected-actor": "./injected-actor.ts"
},
"bin": {
"b2v": "./bin/b2v.js",
@@ -61,4 +62,4 @@
"@types/yargs": "^17.0.33",
"typescript": "~5.7.0"
}
-}
+}
\ No newline at end of file
diff --git a/packages/browser2video/schemas/common.ts b/packages/browser2video/schemas/common.ts
index ab3af57..75d6614 100644
--- a/packages/browser2video/schemas/common.ts
+++ b/packages/browser2video/schemas/common.ts
@@ -9,6 +9,16 @@ export const ModeSchema = z
export type Mode = z.infer;
+/**
+ * Shared mutable reference to the current execution mode.
+ * Mode is a session-level concept — all actors read from the same ref.
+ * Change `current` to switch mode mid-scenario for all actors simultaneously.
+ */
+export interface ModeRef { current: Mode }
+
+/** Create a ModeRef from a mode string. */
+export function createModeRef(mode: Mode): ModeRef { return { current: mode }; }
+
export const RecordModeSchema = z
.enum(["screencast", "screen", "none"])
.describe("Video recording backend.");
diff --git a/packages/browser2video/schemas/session.ts b/packages/browser2video/schemas/session.ts
index 3d39af6..e246d6b 100644
--- a/packages/browser2video/schemas/session.ts
+++ b/packages/browser2video/schemas/session.ts
@@ -18,6 +18,10 @@ export const SessionOptionsSchema = z.object({
displaySize: z.string().optional().describe("Linux display size, e.g. '2560x720'."),
narration: NarrationOptionsSchema.optional().describe("TTS narration options."),
cdpPort: z.number().int().optional().describe("Chrome DevTools Protocol port for external tool connections (0 = disabled). Default: B2V_CDP_PORT env or 0."),
+ cursorColor: z.object({
+ fill: z.string(),
+ stroke: z.string(),
+ }).optional().describe("Custom cursor color for Actor instances. Default: white/black."),
});
export type SessionOptions = z.infer;
diff --git a/packages/browser2video/session.ts b/packages/browser2video/session.ts
index 4cb1ece..e0d5789 100644
--- a/packages/browser2video/session.ts
+++ b/packages/browser2video/session.ts
@@ -17,11 +17,12 @@ import type {
StepRecord,
TerminalHandle,
Mode,
+ ModeRef,
LayoutConfig,
ActorDelays,
} from "./types.ts";
-import { Actor, generateWebVTT, HIDE_CURSOR_INIT_SCRIPT, FAST_MODE_INIT_SCRIPT, pickMs, DEFAULT_DELAYS } from "./actor.ts";
+import { Actor, generateWebVTT, HIDE_CURSOR_INIT_SCRIPT, FAST_MODE_INIT_SCRIPT, CURSOR_OVERLAY_SCRIPT, pickMs, DEFAULT_DELAYS } from "./actor.ts";
import { TerminalActor } from "./terminal-actor.ts";
import { ReplayLog } from "./replay-log.ts";
import { composeVideos } from "./video-compositor.ts";
@@ -153,6 +154,100 @@ const TERMINAL_PAGE_HTML = `
// Pane state
// ---------------------------------------------------------------------------
+/**
+ * Records the CDP page screencast to a raw webm file via ffmpeg.
+ * Used for Electron/CDP pages where Playwright's recordVideo is unavailable.
+ */
+class CdpScreencastRecorder {
+ private ffmpegProc: ChildProcess | null = null;
+ private cdpSession: any = null;
+ private stopped = false;
+ private page: Page;
+ private outputPath: string;
+ private ffmpeg: string;
+ private width: number;
+ private height: number;
+
+ constructor(page: Page, outputPath: string, ffmpeg: string, width: number, height: number) {
+ this.page = page;
+ this.outputPath = outputPath;
+ this.ffmpeg = ffmpeg;
+ this.width = width;
+ this.height = height;
+ }
+
+ async start(): Promise {
+ let frameCount = 0;
+
+ // Start ffmpeg to receive JPEG frames on stdin and output raw webm
+ this.ffmpegProc = spawn(this.ffmpeg, [
+ "-y",
+ "-f", "image2pipe",
+ "-vcodec", "mjpeg", // explicit input codec for JPEG frames
+ "-framerate", "25",
+ "-i", "pipe:0",
+ "-c:v", "libvpx",
+ "-b:v", "2M",
+ "-pix_fmt", "yuv420p",
+ "-auto-alt-ref", "0",
+ this.outputPath,
+ ], { stdio: ["pipe", "pipe", "pipe"] });
+ // Drain stderr but capture last line for errors
+ let lastStderr = "";
+ this.ffmpegProc.stderr?.on("data", (d: Buffer) => { lastStderr = d.toString().trim(); });
+ this.ffmpegProc.on("error", (e) => console.error(`[cdp-recorder] ffmpeg error:`, e.message));
+ this.ffmpegProc.on("close", (code) => {
+ if (code !== 0 && frameCount > 0) {
+ console.error(`[cdp-recorder] ffmpeg exited with code ${code}: ${lastStderr}`);
+ }
+ });
+
+ // Start CDP screencast
+ this.cdpSession = await this.page.context().newCDPSession(this.page);
+ this.cdpSession.on("Page.screencastFrame", (params: any) => {
+ if (this.stopped) return;
+ // Ack the frame so CDP sends the next one
+ this.cdpSession.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => { });
+ // Write JPEG data to ffmpeg stdin
+ const buf = Buffer.from(params.data, "base64");
+ try { this.ffmpegProc?.stdin?.write(buf); } catch { }
+ frameCount++;
+ });
+ await this.cdpSession.send("Page.startScreencast", {
+ format: "jpeg",
+ quality: 80,
+ maxWidth: this.width,
+ maxHeight: this.height,
+ everyNthFrame: 1,
+ });
+
+ // Save frame count for logging on stop
+ (this as any)._frameCount = () => frameCount;
+ }
+
+ async stop(): Promise {
+ if (this.stopped) return;
+ this.stopped = true;
+ try {
+ await this.cdpSession?.send("Page.stopScreencast").catch(() => { });
+ await this.cdpSession?.detach().catch(() => { });
+ } catch { }
+ const fc = (this as any)._frameCount?.() ?? 0;
+ console.error(`[cdp-recorder] Stopped: ${fc} frames captured → ${this.outputPath}`);
+ // Close ffmpeg stdin to signal end-of-input and wait for it to finish
+ return new Promise((resolve) => {
+ if (!this.ffmpegProc) { resolve(); return; }
+ this.ffmpegProc.on("close", () => resolve());
+ try { this.ffmpegProc.stdin?.end(); } catch { }
+ // Timeout: kill ffmpeg if it doesn't finish in 10s
+ setTimeout(() => {
+ try { this.ffmpegProc?.kill("SIGKILL"); } catch { }
+ resolve();
+ }, 10_000);
+ });
+ }
+}
+
interface PaneState {
id: string;
type: "browser" | "terminal";
@@ -162,6 +257,7 @@ interface PaneState {
actor?: Actor;
terminal?: TerminalHandle;
rawVideoPath?: string;
+ cdpRecorder?: CdpScreencastRecorder;
process?: ChildProcess;
createdAtMs: number;
}
@@ -199,7 +295,7 @@ export class Session {
private lastGridConfig: { panes: GridPaneConfig[]; grid?: number[][]; viewport: { width: number; height: number }; jabtermWsUrl?: string } | null = null;
// Resolved options
- readonly mode: Mode;
+ readonly _modeRef: ModeRef;
readonly record: boolean;
readonly headed: boolean;
readonly artifactDir: string;
@@ -208,16 +304,34 @@ export class Session {
private readonly delays?: Partial;
private readonly cdpPort: number;
private narrationOpts?: NarrationOptions;
+ /** Custom cursor color for Actor instances. */
+ readonly cursorColor?: { fill: string; stroke: string };
private audioDirector!: AudioDirectorAPI & { getEvents?: () => AudioEvent[] };
/** Replay log for streaming cursor/click/step events to the player */
readonly replayLog = new ReplayLog();
+ /** Current execution mode — reads from the shared ModeRef. */
+ get mode(): Mode { return this._modeRef.current; }
+
+ /**
+ * Switch execution mode mid-scenario.
+ * All actors created from this session will immediately see the new mode.
+ */
+ setMode(mode: Mode) { this._modeRef.current = mode; }
+
+ /**
+ * Shared mode reference. Pass this to standalone Actors so they
+ * share the session's mode and respond to setMode() calls.
+ */
+ get modeRef(): ModeRef { return this._modeRef; }
+
constructor(opts: SessionOptions = {}) {
const underPW = isUnderPlaywright();
- this.mode = opts.mode
+ const resolvedMode: Mode = opts.mode
?? (process.env.B2V_MODE as Mode | undefined)
?? (underPW ? "fast" : "human");
+ this._modeRef = { current: resolvedMode };
this.record = opts.record
?? (process.env.B2V_RECORD !== undefined ? process.env.B2V_RECORD !== "false" : !underPW);
@@ -239,6 +353,11 @@ export class Session {
if (opts.narration) {
this.narrationOpts = opts.narration;
}
+
+ // Cursor color: from opts, env var, or default (white/black)
+ const envCursorColor = process.env.B2V_CURSOR_COLOR;
+ this.cursorColor = opts.cursorColor
+ ?? (envCursorColor ? (() => { const [fill, stroke] = envCursorColor.split(','); return fill && stroke ? { fill, stroke } : undefined; })() : undefined);
}
/** Launch the browser. Called automatically by createSession(). */
@@ -449,6 +568,11 @@ export class Session {
page = found;
context = page.context();
console.error(`[session] Found Electron-managed page via CDP: ${page.url()}`);
+
+ // Start CDP screencast recording for Electron pages
+ if (this.record) {
+ rawVideoPath = path.join(this.artifactDir, `${id}.raw.webm`);
+ }
} else {
const ctxOpts: {
viewport: { width: number; height: number };
@@ -471,16 +595,27 @@ export class Session {
// Set dark background on the blank page immediately to avoid white flash in recordings
await page.evaluate(() => { document.documentElement.style.background = "#1a1a2e"; });
+ // Init scripts — MUST be registered BEFORE navigation so they run on the initial page load.
+ // addInitScript only fires on subsequent navigations if added after goto.
+ if (this.mode === "human") {
+ await page.addInitScript(HIDE_CURSOR_INIT_SCRIPT);
+ // Register cursor overlay as init script so it persists across navigations
+ // (page.evaluate is lost on navigation; framenavigated re-inject races with page load)
+ await page.addInitScript(CURSOR_OVERLAY_SCRIPT);
+ // Pre-register custom cursor color if set (must run after CURSOR_OVERLAY_SCRIPT)
+ if (this.cursorColor) {
+ const { fill, stroke } = this.cursorColor;
+ await page.addInitScript(`window.__b2v_setCursorColor?.('default', '${fill}', '${stroke}')`);
+ }
+ }
+ if (this.mode === "fast") await page.addInitScript(FAST_MODE_INIT_SCRIPT);
+
// Navigate if URL provided
if (opts.url) {
await page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 30000 });
}
}
- // Init scripts
- if (this.mode === "human") await page.addInitScript(HIDE_CURSOR_INIT_SCRIPT);
- if (this.mode === "fast") await page.addInitScript(FAST_MODE_INIT_SCRIPT);
-
// Console/error listeners
page.on("console", (msg) => {
if (msg.type() === "error") console.error(` [${label} Error] ${msg.text()}`);
@@ -489,14 +624,14 @@ export class Session {
console.error(` [${label} Error] ${(err as Error).message}`);
});
- const actor = new Actor(page, this.mode, { delays: this.delays });
+ const actor = new Actor(page, this._modeRef, { delays: this.delays, cursorColor: this.cursorColor });
this._wireReplayEvents(actor);
- // Auto-inject cursor overlay after every navigation (human mode)
+ // Also keep framenavigated fallback for cursor injection (belt & suspenders)
if (this.mode === "human") {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
- actor.injectCursor().catch(() => {});
+ actor.injectCursor().catch(() => { });
}
});
}
@@ -505,6 +640,13 @@ export class Session {
this.panes.set(id, pane);
this.paneOrder.push(id);
+ // Start CDP screencast recorder for Electron pages (Playwright's recordVideo is unavailable)
+ if (this.record && rawVideoPath && this._cdpEndpoint) {
+ const recorder = new CdpScreencastRecorder(page, rawVideoPath, this.ffmpeg, vpW, vpH);
+ await recorder.start();
+ pane.cdpRecorder = recorder;
+ }
+
if (this.record) {
console.error(` Recording started: ${label} (${vpW}x${vpH})`);
}
@@ -592,7 +734,7 @@ export class Session {
const pushOutput = (data: Buffer) => {
const text = String(data);
fs.appendFileSync(logPath, text, "utf-8");
- page.evaluate((t: string) => (window as any).__b2v_appendOutput?.(t), text).catch(() => {});
+ page.evaluate((t: string) => (window as any).__b2v_appendOutput?.(t), text).catch(() => { });
};
proc.stdout?.on("data", pushOutput);
proc.stderr?.on("data", pushOutput);
@@ -610,11 +752,11 @@ export class Session {
? pickMs(delays.keyDelayMs as [number, number])
: pickMs(DEFAULT_DELAYS.human.keyDelayMs);
for (const ch of text) {
- page.evaluate((c: string) => (window as any).__b2v_appendOutput?.(c), ch).catch(() => {});
+ page.evaluate((c: string) => (window as any).__b2v_appendOutput?.(c), ch).catch(() => { });
await sleep(keyDelay);
}
// Show newline visually
- page.evaluate((c: string) => (window as any).__b2v_appendOutput?.(c), "\n").catch(() => {});
+ page.evaluate((c: string) => (window as any).__b2v_appendOutput?.(c), "\n").catch(() => { });
await sleep(50);
}
@@ -627,7 +769,7 @@ export class Session {
await sleep(300); // let process start
} else {
- termHandle = { send: async () => {}, page };
+ termHandle = { send: async () => { }, page };
}
const pane: PaneState = {
@@ -679,7 +821,7 @@ export class Session {
// Lazy-start terminal WS server (singleton)
if (!this.terminalServer) {
- this.terminalServer = await startTerminalWsServer();
+ this.terminalServer = await startTerminalWsServer(0, this.artifactDir);
this.cleanupFns.push(() => this.terminalServer!.close());
}
@@ -763,7 +905,7 @@ export class Session {
// Lazy-start terminal WS server (singleton)
if (!this.terminalServer) {
- this.terminalServer = await startTerminalWsServer();
+ this.terminalServer = await startTerminalWsServer(0, this.artifactDir);
this.cleanupFns.push(() => this.terminalServer!.close());
}
@@ -869,17 +1011,25 @@ export class Session {
}
if (pc.type === "terminal") {
- // Wait for xterm content — jabterm always spawns a shell, so wait for prompt
+ // Command panes (mc, htop, etc.) render TUI — wait for any non-empty content.
+ // Shell panes (no command) show a prompt — wait for prompt characters.
+ const isCommandPane = !!pc.cmd;
await page.waitForFunction(
- (sel: string) => {
+ ([sel, waitForPrompt]: [string, boolean]) => {
const root = document.querySelector(sel);
if (!root) return false;
+ // xterm v6: .xterm-accessibility-tree; xterm v5: .xterm-rows
+ const tree = root.querySelector(".xterm-accessibility-tree");
const rows = root.querySelector(".xterm-rows");
- if (!rows) return false;
- const text = rows.textContent ?? "";
- return text.includes("$") || text.includes("#") || text.includes("%");
+ if (!tree && !rows) return false;
+ const text = (tree ?? rows as any)?.textContent ?? "";
+ if (waitForPrompt) {
+ return text.includes("$") || text.includes("#") || text.includes("%");
+ }
+ // For command panes, any non-empty content means the TUI has rendered
+ return text.trim().length > 0;
},
- testIdSel,
+ [testIdSel, !isCommandPane] as [string, boolean],
{ timeout: 30000 },
);
} else {
@@ -928,22 +1078,26 @@ export class Session {
iframeName = pc.testId;
}
- const actor = new TerminalActor(page, this.mode, selector, {
+ const actor = new TerminalActor(page, this._modeRef, selector, {
delays: this.delays,
frame: iframeFrame,
iframeName,
});
+ // Assign unique cursor identity from pane label — each actor gets its own colored cursor
+ actor.cursorId = (pc.title ?? pc.testId ?? `actor-${i}`).toLowerCase().replace(/\s+/g, '-');
this._wireReplayEvents(actor);
actors.push(actor);
+ }
- if (this.mode === "human" && i === 0) {
- page.on("framenavigated", (f) => {
- if (f === page.mainFrame()) {
- actors[0].injectCursor().catch(() => {});
- }
- });
- await actors[0].injectCursor();
- }
+ // Inject cursor overlay once for all actors — cursors are lazily created
+ // on first moveCursor call, so each actor's cursor appears when it starts interacting
+ if (this.mode === "human" && actors.length > 0) {
+ page.on("framenavigated", (f) => {
+ if (f === page.mainFrame()) {
+ actors[0].injectCursor().catch(() => { });
+ }
+ });
+ await actors[0].injectCursor();
}
// Auto-execute commands for terminal panes that have a command specified.
@@ -1105,6 +1259,49 @@ export class Session {
this.steps.push({ index: idx, caption, startMs, endMs });
};
+ // ---------------------------------------------------------------------------
+ // Open/close step API (for Playwright fixture integration)
+ // ---------------------------------------------------------------------------
+
+ private _currentStepCaption: string | null = null;
+ private _currentStepStartMs: number = 0;
+
+ /**
+ * Begin a step boundary. Call `endStep()` when the step is done.
+ * This is the "open" half of `step()` — designed for Playwright fixtures
+ * where the test body runs between `beginStep` and `endStep`.
+ *
+ * ```ts
+ * session.beginStep("Create Todo");
+ * // ... test body ...
+ * await session.endStep();
+ * ```
+ */
+ beginStep(caption: string, narration?: string): void {
+ this.stepIndex++;
+ this._currentStepCaption = caption;
+ this._currentStepStartMs = Date.now() - this.startTime;
+ console.error(` [Step ${this.stepIndex}] ${caption}`);
+ this.replayLog.emit({ type: "stepStart", index: this.stepIndex, caption, ts: this._currentStepStartMs });
+ }
+
+ /**
+ * End the current step boundary started by `beginStep()`.
+ * Emits `stepEnd`, records step metadata, and adds a breathing pause.
+ */
+ async endStep(): Promise {
+ if (!this._currentStepCaption) return;
+ const endMs = Date.now() - this.startTime;
+ this.replayLog.emit({ type: "stepEnd", index: this.stepIndex, ts: endMs });
+ this.steps.push({ index: this.stepIndex, caption: this._currentStepCaption, startMs: this._currentStepStartMs, endMs });
+
+ // Breathing pause after each step (human mode)
+ const firstActor = [...this.panes.values()].find((p) => p.actor)?.actor;
+ if (firstActor) await firstActor.breathe();
+
+ this._currentStepCaption = null;
+ }
+
/** Access the audio director for narration/sound effects. */
get audio(): AudioDirectorAPI {
return this.audioDirector;
@@ -1141,7 +1338,11 @@ export class Session {
gridConfig: this.lastGridConfig ?? undefined,
pageUrl,
viewport: this.lastGridConfig?.viewport ?? { width: 1280, height: 720 },
- electronView: !!(this._cdpEndpoint && this._onRequestPage),
+ // electronView uses Electron's WebContentsView overlay, which only works
+ // when the player IS the native Electron window. In embedded mode
+ // (B2V_EMBEDDED=1), the player is viewed through a browser iframe, so
+ // the WebContentsView would render on the hidden inner window = black screen.
+ electronView: !!(this._cdpEndpoint && this._onRequestPage) && process.env.B2V_EMBEDDED !== "1",
};
}
@@ -1191,6 +1392,14 @@ export class Session {
await sleep(80);
}
+ // Stop CDP screencast recorders (Electron mode) — must complete before
+ // compositing so the raw webm files are flushed and closed.
+ for (const pane of this.panes.values()) {
+ if (pane.cdpRecorder) {
+ try { await pane.cdpRecorder.stop(); } catch { }
+ }
+ }
+
// Close pages to flush screencast recordings (skip for CDP-connected
// sessions — the shared Electron page must stay alive between scenarios).
if (this.record && this._ownsBrowser) {
@@ -1263,6 +1472,7 @@ export class Session {
try {
execFileSync(this.ffmpeg, [
"-y", "-i", rawPaths[0],
+ "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
videoPath,
@@ -1337,6 +1547,48 @@ export class Session {
audioEvents: audioEvents.length > 0 ? audioEvents : undefined,
};
}
+
+ /**
+ * Force-abort the session: close all pages and browser immediately.
+ * Unlike finish(), this skips video composition and renders nothing.
+ * Used when the user presses Stop during execution.
+ */
+ async abort(): Promise {
+ if (this.finished) return;
+ this.finished = true;
+
+ // Force-close all pages — this interrupts any running Playwright operations
+ for (const pane of this.panes.values()) {
+ try { await pane.page.close(); } catch { /* already closed */ }
+ }
+
+ // Close browser contexts
+ if (this._ownsBrowser) {
+ for (const pane of this.panes.values()) {
+ try { await pane.context.close(); } catch { /* ignore */ }
+ }
+ }
+
+ // Disconnect/close browser
+ if (this.browser) {
+ try { await this.browser.close(); } catch { /* ignore */ }
+ }
+
+ // Kill terminal processes
+ for (const pane of this.panes.values()) {
+ if (pane.process) {
+ try { pane.process.kill("SIGTERM"); } catch { /* ignore */ }
+ }
+ }
+
+ // Run registered cleanup functions
+ for (const fn of this.cleanupFns) {
+ try { await fn(); } catch { /* ignore */ }
+ }
+ this.cleanupFns = [];
+
+ console.error(" Session aborted by user.");
+ }
}
// ---------------------------------------------------------------------------
diff --git a/packages/browser2video/terminal-actor.ts b/packages/browser2video/terminal-actor.ts
index f4860df..320ed16 100644
--- a/packages/browser2video/terminal-actor.ts
+++ b/packages/browser2video/terminal-actor.ts
@@ -5,7 +5,7 @@
* Created by session.createTerminal().
*/
import type { Page, Frame } from "playwright";
-import type { Mode, ActorDelays } from "./types.ts";
+import type { Mode, ModeRef, ActorDelays } from "./types.ts";
import { Actor, TypeAction, pickMs } from "./actor.ts";
function sleep(ms: number) {
@@ -33,7 +33,7 @@ export class TerminalActor extends Actor {
constructor(
page: Page,
- mode: Mode,
+ modeOrRef: Mode | ModeRef,
selector: string,
opts?: {
delays?: Partial;
@@ -41,7 +41,7 @@ export class TerminalActor extends Actor {
iframeName?: string;
},
) {
- super(page, mode, opts);
+ super(page, modeOrRef, opts);
this.selector = selector;
this._dom = opts?.frame ?? page;
this._iframeName = opts?.iframeName;
@@ -200,11 +200,11 @@ export class TerminalActor extends Actor {
([sel, inc]: [string, string[]]) => {
const root = document.querySelector(sel);
if (!root) return false;
- // Prefer .xterm-rows textContent, fall back to data-b2v-output (JabTerm capture buffer)
+ // xterm v6: .xterm-accessibility-tree; xterm v5: .xterm-rows
+ const tree = root.querySelector(".xterm-accessibility-tree");
const rows = root.querySelector(".xterm-rows");
- let text = String((rows as any)?.textContent ?? "").trim();
- if (!text) text = (root as any)?.getAttribute?.("data-b2v-output") ?? "";
- if (!text) text = String((root as any)?.textContent ?? "");
+ const text = String((tree ?? rows as any)?.textContent ?? "").trim();
+ if (!text) return false;
return inc.every((s: string) => text.includes(s));
},
[this.selector, includes] as [string, string[]],
@@ -222,10 +222,10 @@ export class TerminalActor extends Actor {
(sel: string) => {
const root = document.querySelector(sel);
if (!root) return false;
- // Prefer .xterm-rows textContent, fall back to data-b2v-output (JabTerm capture buffer)
+ // xterm v6: .xterm-accessibility-tree; xterm v5: .xterm-rows
+ const tree = root.querySelector(".xterm-accessibility-tree");
const rows = root.querySelector(".xterm-rows");
- let rawText = String((rows as any)?.textContent ?? "").trim();
- if (!rawText) rawText = (root as any)?.getAttribute?.("data-b2v-output") ?? "";
+ const rawText = String((tree ?? rows as any)?.textContent ?? "").trim();
if (!rawText) return false;
const lines = rawText.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
@@ -248,9 +248,11 @@ export class TerminalActor extends Actor {
return this._dom.evaluate((sel: string) => {
const root = document.querySelector(sel);
if (!root) return true;
+ // xterm v6: .xterm-accessibility-tree; xterm v5: .xterm-rows
+ const tree = root.querySelector(".xterm-accessibility-tree");
const rows = root.querySelector(".xterm-rows");
- if (!rows) return true;
- const lines = (rows.textContent ?? "").split("\n");
+ if (!tree && !rows) return true;
+ const lines = ((tree ?? rows as any)?.textContent ?? "").split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line) continue;
diff --git a/packages/browser2video/terminal-ws-server.ts b/packages/browser2video/terminal-ws-server.ts
index 533db20..7109d73 100644
--- a/packages/browser2video/terminal-ws-server.ts
+++ b/packages/browser2video/terminal-ws-server.ts
@@ -21,11 +21,11 @@ export type GridPaneConfig =
* Start a terminal WebSocket server powered by jabterm.
* Each WebSocket connection spawns a real PTY process.
*/
-export async function startTerminalWsServer(port = 0): Promise {
+export async function startTerminalWsServer(port = 0, cwd?: string): Promise {
const server: JabtermServer = await createTerminalServer({
port,
host: "127.0.0.1",
- cwd: process.cwd(),
+ cwd: cwd ?? process.cwd(),
});
const baseWsUrl = `ws://127.0.0.1:${server.port}`;
diff --git a/packages/browser2video/types.ts b/packages/browser2video/types.ts
index 3c3e848..912d166 100644
--- a/packages/browser2video/types.ts
+++ b/packages/browser2video/types.ts
@@ -8,12 +8,15 @@ import type { Page } from "playwright";
// Re-export all shared types from schemas
export type {
Mode,
+ ModeRef,
RecordMode,
DelayRange,
ActorDelays,
LayoutConfig,
} from "./schemas/common.ts";
+export { createModeRef } from "./schemas/common.ts";
+
export type {
SessionOptions,
PageOptions,
diff --git a/packages/browser2video/video-compositor.ts b/packages/browser2video/video-compositor.ts
index 54478f7..f91a01f 100644
--- a/packages/browser2video/video-compositor.ts
+++ b/packages/browser2video/video-compositor.ts
@@ -153,10 +153,11 @@ export function composeVideos(opts: ComposeOptions): void {
/** Re-encode a single WebM to MP4 at constant 60fps for smooth playback. */
function reencodeToMp4(inputPath: string, outputPath: string, ffmpeg: string): void {
+ // pad filter: ensure even dimensions for libx264 (CDP screencast can produce odd sizes)
const args = [
"-y",
"-i", inputPath,
- "-vf", "fps=60,format=yuv420p",
+ "-vf", "fps=60,pad=ceil(iw/2)*2:ceil(ih/2)*2,format=yuv420p",
"-r", "60",
"-fps_mode", "cfr",
"-c:v", "libx264",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c272f0b..b6383a4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -115,8 +115,8 @@ importers:
specifier: ^5.0.0
version: 5.0.0(react@19.2.4)
jabterm:
- specifier: github:holiber/jabterm#v0.1.3
- version: https://codeload.github.com/holiber/jabterm/tar.gz/e4ed68ebe07be58f986d258fbbf68a465c3382b4(react@19.2.4)
+ specifier: github:holiber/jabterm#v0.1.4
+ version: https://codeload.github.com/holiber/jabterm/tar.gz/9e008dafa4a0d7741be927aad6aec28dda547589(react@19.2.4)
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@19.2.4)
@@ -201,6 +201,15 @@ importers:
specifier: ~5.7.0
version: 5.7.3
+ packages/browser2video-test:
+ dependencies:
+ '@playwright/test':
+ specifier: '>=1.40.0'
+ version: 1.58.2
+ browser2video:
+ specifier: workspace:*
+ version: link:../browser2video
+
packages/test-runner:
dependencies:
yargs:
@@ -218,6 +227,9 @@ importers:
tests/scenarios:
dependencies:
+ '@browser2video/test':
+ specifier: workspace:*
+ version: link:../../packages/browser2video-test
'@playwright/test':
specifier: ^1.50.0
version: 1.58.2
@@ -5267,6 +5279,16 @@ packages:
peerDependencies:
ws: '*'
+ jabterm@https://codeload.github.com/holiber/jabterm/tar.gz/9e008dafa4a0d7741be927aad6aec28dda547589:
+ resolution: {tarball: https://codeload.github.com/holiber/jabterm/tar.gz/9e008dafa4a0d7741be927aad6aec28dda547589}
+ version: 0.1.4
+ hasBin: true
+ peerDependencies:
+ react: '>=18'
+ peerDependenciesMeta:
+ react:
+ optional: true
+
jabterm@https://codeload.github.com/holiber/jabterm/tar.gz/e4ed68ebe07be58f986d258fbbf68a465c3382b4:
resolution: {tarball: https://codeload.github.com/holiber/jabterm/tar.gz/e4ed68ebe07be58f986d258fbbf68a465c3382b4}
version: 0.1.3
@@ -13844,6 +13866,18 @@ snapshots:
dependencies:
ws: 8.19.0
+ jabterm@https://codeload.github.com/holiber/jabterm/tar.gz/9e008dafa4a0d7741be927aad6aec28dda547589(react@19.2.4):
+ dependencies:
+ '@xterm/addon-fit': 0.11.0
+ '@xterm/xterm': 6.0.0
+ node-pty: 1.1.0
+ ws: 8.19.0
+ optionalDependencies:
+ react: 19.2.4
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
jabterm@https://codeload.github.com/holiber/jabterm/tar.gz/e4ed68ebe07be58f986d258fbbf68a465c3382b4(react@19.2.4):
dependencies:
'@xterm/addon-fit': 0.11.0
diff --git a/tests/scenarios/chat.scenario.ts b/tests/scenarios/chat.scenario.ts
index 282fd5c..733157c 100644
--- a/tests/scenarios/chat.scenario.ts
+++ b/tests/scenarios/chat.scenario.ts
@@ -40,7 +40,8 @@ const CHAT = {
} as const;
const NARRATOR = {
- intro: "Welcome to Browser 2 Video. In this demo, we record a scenario with multiple actors, each with their own unique voice. On the left is Alice's chat window. On the right, Bob has a browser and a terminal.",
+ intro: "Welcome to Browser 2 Video. In this demo, we record a scenario with multiple actors, each with their own unique voice.",
+ meetActors: "On the left is Alice's chat window. On the right, Bob has a browser and a terminal.",
outro: "And that's it. Different actors, different voices, dynamic layouts. All in one recording.",
} as const;
@@ -75,8 +76,8 @@ export default defineScenario("Chat Demo", (s) => {
const [alice, bobBrowser, bobTerminal] = grid.actors;
- // Narrator pointer on the grid page — only used during intro, never concurrently with actors
- const pointer = new Actor(grid.page, "human");
+ // Narrator pointer on the grid page — shares session's mode ref
+ const pointer = new Actor(grid.page, session.modeRef);
pointer.cursorId = 'narrator';
alice.setVoice("shimmer");
@@ -114,38 +115,43 @@ export default defineScenario("Chat Demo", (s) => {
return { alice, bobBrowser, bobTerminal, pointer, grid, chatBaseUrl, calendarUrl, docHash, narrate };
});
- // ── Narrator intro: circle around each pane ───────────────────────
+ // ── Narrator intro: speak first, then circle panes ───────────────
s.step("Introduction",
({ narrate }) => narrate(NARRATOR.intro),
+ async ({ grid }) => {
+ await grid.page.waitForTimeout(3000);
+ },
+ );
+
+ // ── Circle each pane as narrator describes them ──────────────────
+ s.step("Meet the actors",
+ ({ narrate }) => narrate(NARRATOR.meetActors),
async ({ pointer, grid }) => {
- await grid.page.waitForTimeout(2000);
- // Circle around Alice's pane
+ await grid.page.waitForTimeout(500);
+ // Circle around Alice's pane — "On the left is Alice's chat window"
await pointer.circleAround('[data-testid="browser-pane-0"]');
await grid.page.waitForTimeout(1000);
- // Circle around Bob's browser pane
+ // Circle around Bob's browser pane — "On the right, Bob has a browser"
await pointer.circleAround('[data-testid="browser-pane-1"]');
await grid.page.waitForTimeout(500);
- // Circle around Bob's terminal pane
+ // Circle around Bob's terminal pane — "and a terminal"
await pointer.circleAround('[data-testid="xterm-term-shell-2"]');
await grid.page.waitForTimeout(1000);
},
);
- // ── Bob types brainfuck in terminal (sequential, no cursor conflict) ──
- s.step("Bob codes in terminal", async ({ bobTerminal, grid }) => {
+ // ── Alice types message while Bob's terminal shows activity ────────
+ s.step("Alice sends, Bob codes", async ({ alice, bobTerminal, grid }) => {
await grid.page.waitForTimeout(500);
- await bobTerminal.typeAndEnter('echo "++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>." | head -c 40');
- await grid.page.waitForTimeout(1000);
- });
-
- // ── Alice types + speaks her message ──────────────────────────────
- s.step("Alice sends a message", async ({ alice, grid }) => {
- await alice.type('[data-testid="chat-input"]', CHAT.aliceMsg + " 🎬").speak(CHAT.aliceMsg);
+ // Bob starts a script that produces output over time (uses keyboard first)
+ await bobTerminal.typeAndEnter('for i in 1 2 3 4 5; do sleep 0.4 && echo "compiling module $i..."; done');
+ // Now Alice types + speaks (Bob's script output scrolls concurrently)
+ await alice.type('[data-testid="chat-input"]', CHAT.aliceMsg).speak(CHAT.aliceMsg);
await alice.click('[data-testid="chat-send"]');
await grid.page.waitForTimeout(500);
});
- // ── Bob opens chat, sees Alice's message (silent) ─────────────────
+ // ── Bob opens chat, sees Alice's message + notification beep ─────
s.step("Bob sees the message", async ({ bobBrowser, grid, chatBaseUrl, docHash }) => {
await grid.page.waitForTimeout(1000);
const bobChatUrl = `${chatBaseUrl}#${docHash}`;
@@ -157,12 +163,27 @@ export default defineScenario("Chat Demo", (s) => {
undefined,
{ timeout: 15000 },
);
+ // Play notification beep when Bob sees Alice's message
+ await f.evaluate(() => {
+ try {
+ const ctx = new AudioContext();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.frequency.value = 880;
+ osc.type = "sine";
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
+ osc.connect(gain).connect(ctx.destination);
+ osc.start();
+ osc.stop(ctx.currentTime + 0.15);
+ } catch { /* ignore if audio not available */ }
+ });
await grid.page.waitForTimeout(1000);
});
// ── Bob types + speaks his reply ──────────────────────────────────
s.step("Bob responds", async ({ bobBrowser, grid }) => {
- await bobBrowser.type('[data-testid="chat-input"]', CHAT.bobReply1 + " 📅").speak(CHAT.bobReply1);
+ await bobBrowser.type('[data-testid="chat-input"]', CHAT.bobReply1).speak(CHAT.bobReply1);
await bobBrowser.click('[data-testid="chat-send"]');
await grid.page.waitForTimeout(500);
});
@@ -194,7 +215,7 @@ export default defineScenario("Chat Demo", (s) => {
// ── Bob types + speaks his confirmation ───────────────────────────
s.step("Bob confirms", async ({ bobBrowser, grid }) => {
- await bobBrowser.type('[data-testid="chat-input"]', CHAT.bobReply2 + " 🍿").speak(CHAT.bobReply2);
+ await bobBrowser.type('[data-testid="chat-input"]', CHAT.bobReply2).speak(CHAT.bobReply2);
await bobBrowser.click('[data-testid="chat-send"]');
await grid.page.waitForTimeout(500);
});
@@ -207,7 +228,7 @@ export default defineScenario("Chat Demo", (s) => {
undefined,
{ timeout: 15000 },
);
- await alice.type('[data-testid="chat-input"]', CHAT.aliceReply + " 🎉").speak(CHAT.aliceReply);
+ await alice.type('[data-testid="chat-input"]', CHAT.aliceReply).speak(CHAT.aliceReply);
await alice.click('[data-testid="chat-send"]');
await grid.page.waitForTimeout(1000);
});
diff --git a/tests/scenarios/collab.test.ts b/tests/scenarios/collab.test.ts
index 75eeeeb..f824256 100644
--- a/tests/scenarios/collab.test.ts
+++ b/tests/scenarios/collab.test.ts
@@ -7,5 +7,5 @@ if (isDirectRun) {
runScenario(descriptor).then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
} else {
const { test } = await import("@playwright/test");
- test("collab", async () => { test.setTimeout(180_000); await runScenario(descriptor); });
+ test.skip("collab (requires Electron — run via apps/player E2E)", async () => { test.setTimeout(180_000); await runScenario(descriptor); });
}
diff --git a/tests/scenarios/external-website.scenario.ts b/tests/scenarios/external-website.scenario.ts
new file mode 100644
index 0000000..4796fbd
--- /dev/null
+++ b/tests/scenarios/external-website.scenario.ts
@@ -0,0 +1,89 @@
+/**
+ * Browse alexn.pro portfolio, find the Three Charts project,
+ * navigate to its demo, and interact with the chart.
+ */
+import { defineScenario, type Actor, type Page } from "browser2video";
+
+interface Ctx {
+ actor: Actor;
+ page: Page;
+}
+
+export default defineScenario("External Website", (s) => {
+ s.setup(async (session) => {
+ const { page, actor } = await session.openPage({
+ url: "https://alexn.pro",
+ viewport: { width: 1280, height: 720 },
+ });
+ return { actor, page };
+ });
+
+ s.step("Wait for portfolio page", async ({ actor }) => {
+ await actor.waitFor("main", 20000);
+ // Move cursor to center so it's visible from the start
+ await actor.moveCursorTo(640, 360);
+ });
+
+ s.step("Scroll to projects section", async ({ actor }) => {
+ await actor.moveCursorTo(640, 400);
+ await actor.scroll(null, 800);
+ await actor.moveCursorTo(640, 350);
+ await actor.scroll(null, 600);
+ });
+
+ s.step("Find Three Charts project", async ({ actor, page }) => {
+ const threeCharts = page.locator("text=Three charts").first();
+ await threeCharts.waitFor({ state: "visible", timeout: 15000 });
+ await actor.clickLocator(threeCharts);
+ await page.waitForTimeout(2000);
+ });
+
+ s.step("Navigate to Three Charts demo", async ({ actor, page }) => {
+ await actor.goto("https://holiber.github.io/three-charts/demo/");
+ await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => { });
+ await page.waitForTimeout(3000);
+ // Move cursor to chart area
+ await actor.moveCursorTo(640, 400);
+ });
+
+ s.step("Switch to bars view", async ({ actor, page }) => {
+ const barsBtn = page.locator('button[name="switch-bars"]');
+ await barsBtn.waitFor({ state: "visible", timeout: 10000 });
+ await actor.clickLocator(barsBtn);
+ await page.waitForTimeout(1000);
+ });
+
+ s.step("Change timeframe: 5 minutes", async ({ actor, page }) => {
+ const btn5m = page.locator('button.timeframe:has-text("5m")');
+ await btn5m.waitFor({ state: "visible", timeout: 10000 });
+ await actor.clickLocator(btn5m);
+ await page.waitForTimeout(1000);
+ });
+
+ s.step("Change timeframe: 30 minutes", async ({ actor, page }) => {
+ const btn30m = page.locator('button.timeframe:has-text("30m")');
+ await actor.clickLocator(btn30m);
+ await page.waitForTimeout(1000);
+ });
+
+ s.step("Switch to line view", async ({ actor, page }) => {
+ const lineBtn = page.locator('button[name="switch-line"]');
+ await actor.clickLocator(lineBtn);
+ await page.waitForTimeout(1000);
+ });
+
+ s.step("Change timeframe: 1 hour", async ({ actor, page }) => {
+ const btn1h = page.locator('button.timeframe:has-text("1h")');
+ await actor.clickLocator(btn1h);
+ await page.waitForTimeout(1000);
+ });
+
+ s.step("Toggle trend overlays", async ({ actor, page }) => {
+ const redTrend = page.locator('input[name="redtrend"]');
+ await actor.clickLocator(redTrend);
+ await page.waitForTimeout(500);
+ const blueTrend = page.locator('input[name="bluetrend"]');
+ await actor.clickLocator(blueTrend);
+ await page.waitForTimeout(500);
+ });
+});
diff --git a/tests/scenarios/external-website.test.ts b/tests/scenarios/external-website.test.ts
new file mode 100644
index 0000000..1ac4e2a
--- /dev/null
+++ b/tests/scenarios/external-website.test.ts
@@ -0,0 +1,11 @@
+import { fileURLToPath } from "url";
+import { runScenario } from "browser2video";
+import descriptor from "./external-website.scenario.ts";
+
+const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
+if (isDirectRun) {
+ runScenario(descriptor).then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
+} else {
+ const { test } = await import("@playwright/test");
+ test("external-website", async () => { test.setTimeout(120_000); await runScenario(descriptor); });
+}
diff --git a/tests/scenarios/github-mobile.scenario.ts b/tests/scenarios/github-mobile.scenario.ts
deleted file mode 100644
index 39aa1f4..0000000
--- a/tests/scenarios/github-mobile.scenario.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Browse the browser2video GitHub repo in mobile viewport (iPhone 14).
- * Demonstrates compact video recording on a narrow screen.
- */
-import { defineScenario, type Actor, type Page } from "browser2video";
-
-interface Ctx {
- actor: Actor;
- page: Page;
-}
-
-export default defineScenario("GitHub Mobile", (s) => {
- s.setup(async (session) => {
- const { page, actor } = await session.openPage({
- url: "https://github.com/nicedoc/browser2video",
- viewport: { width: 390, height: 844 },
- });
- return { actor, page };
- });
-
- s.step("Wait for repository page", async ({ actor }) => {
- await actor.waitFor("main", 20000);
- });
-
- s.step("Scroll to the file list", async ({ actor }) => {
- await actor.scroll(null, 400);
- });
-
- s.step("Browse file tree", async ({ actor }) => {
- await actor.scroll(null, 300);
- await actor.scroll(null, -200);
- });
-
- s.step("Open docs folder", async ({ actor, page }) => {
- const docsLink = page.locator('a[href$="/tree/main/docs"]:visible').first();
- await docsLink.waitFor({ state: "visible", timeout: 15000 });
- await actor.clickLocator(docsLink);
- await page.waitForURL(/\/tree\/.*\/docs/, { timeout: 15000 });
- await actor.waitFor("main", 10000);
- });
-
- s.step("Browse docs contents", async ({ actor, page }) => {
- await actor.scroll(null, 200);
- const firstItem = page.locator("a.Link--primary:visible").first();
- const itemName = await firstItem.textContent();
- console.log(` Clicking on: ${itemName?.trim()}`);
- await actor.clickLocator(firstItem);
- await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
- });
-
- s.step("Navigate back to repo", async ({ actor, page }) => {
- const repoLink = page.locator('a[href="/nicedoc/browser2video"]').first();
- await actor.clickLocator(repoLink);
- await actor.waitFor("main", 10000);
- });
-
- s.step("Scroll through README", async ({ actor }) => {
- await actor.scroll(null, 600);
- await actor.scroll(null, 600);
- await actor.scroll(null, -300);
- });
-});
diff --git a/tests/scenarios/github-mobile.test.ts b/tests/scenarios/github-mobile.test.ts
deleted file mode 100644
index e96607a..0000000
--- a/tests/scenarios/github-mobile.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { fileURLToPath } from "url";
-import { runScenario } from "browser2video";
-import descriptor from "./github-mobile.scenario.ts";
-
-const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
-if (isDirectRun) {
- runScenario(descriptor).then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
-} else {
- const { test } = await import("@playwright/test");
- test("github-mobile", async () => { test.setTimeout(90_000); await runScenario(descriptor); });
-}
diff --git a/tests/scenarios/mcp-generated/all-in-one.test.ts b/tests/scenarios/mcp-generated/all-in-one.test.ts
index 7e25c5d..8cf9ae5 100644
--- a/tests/scenarios/mcp-generated/all-in-one.test.ts
+++ b/tests/scenarios/mcp-generated/all-in-one.test.ts
@@ -7,5 +7,5 @@ if (isDirectRun) {
runScenario(descriptor).then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
} else {
const { test } = await import("@playwright/test");
- test("all-in-one", async () => { test.setTimeout(300_000); await runScenario(descriptor); });
+ test.skip("all-in-one (requires Electron — run via apps/player E2E)", async () => { test.setTimeout(300_000); await runScenario(descriptor); });
}
diff --git a/tests/scenarios/notes-demo.b2v.test.ts b/tests/scenarios/notes-demo.b2v.test.ts
new file mode 100644
index 0000000..56d5379
--- /dev/null
+++ b/tests/scenarios/notes-demo.b2v.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Sample test demonstrating @browser2video/test integration.
+ *
+ * Each test() call automatically becomes a b2v step using the test title.
+ * The `actor` fixture is set via `setActor()` in beforeAll.
+ *
+ * Run:
+ * npx playwright test notes-demo.b2v.test.ts
+ */
+import { test, expect, setActor, getSession } from "@browser2video/test";
+import { startServer } from "browser2video";
+
+test.describe("Notes Demo", () => {
+ test.beforeAll(async () => {
+ const session = await getSession();
+
+ // Start the project's demo Vite server
+ const server = await startServer({ type: "vite", root: "apps/demo" });
+ if (!server) throw new Error("Failed to start demo server");
+ session.addCleanup(() => server.stop());
+
+ const { actor } = await session.openPage({
+ url: `${server.baseURL}/notes?role=boss`,
+ });
+ setActor(actor);
+ });
+
+ test("Add first task", async ({ actor }) => {
+ await actor.type('[data-testid="note-input"]', "Setup database");
+ await actor.click('[data-testid="note-add-btn"]');
+ });
+
+ test("Add second task", async ({ actor }) => {
+ await actor.type('[data-testid="note-input"]', "Write API routes");
+ await actor.click('[data-testid="note-add-btn"]');
+ });
+
+ test("Complete first task", async ({ actor }) => {
+ await actor.click('[data-testid="note-check-0"]');
+ });
+});
diff --git a/tests/scenarios/package.json b/tests/scenarios/package.json
index 50e442a..606aaf7 100644
--- a/tests/scenarios/package.json
+++ b/tests/scenarios/package.json
@@ -8,9 +8,10 @@
},
"dependencies": {
"browser2video": "workspace:*",
+ "@browser2video/test": "workspace:*",
"@playwright/test": "^1.50.0"
},
"devDependencies": {
"typescript": "~5.7.0"
}
-}
+}
\ No newline at end of file
diff --git a/tests/scenarios/player-self-test.scenario.ts b/tests/scenarios/player-self-test.scenario.ts
new file mode 100644
index 0000000..264db52
--- /dev/null
+++ b/tests/scenarios/player-self-test.scenario.ts
@@ -0,0 +1,595 @@
+/**
+ * Comprehensive Player Self-Test Scenario
+ *
+ * Architecture:
+ * Root Player (A) → loads this scenario → spawns Player B (inner)
+ * → session.openPage() connects to Player B's web UI
+ * → InjectedActor drives Player B's studio + todo app
+ * → Root Player captures screenshots from the session page
+ *
+ * Phases:
+ * 1. Studio + Terminal — split horizontal, add browser + terminal
+ * 2. Terminal launches demo — vite dev server in terminal
+ * 3. Todo app — add, reorder, scroll, delete todos via nested actor
+ * 4. Close terminal — verify app stops working
+ * 5. Scenario playback — load basic-ui, play, stop, step through
+ * 6. Console error check — no unexpected errors
+ */
+import path from "node:path";
+import http from "node:http";
+import { spawn, type ChildProcess } from "node:child_process";
+import { defineScenario, type Actor, type Page } from "browser2video";
+import { InjectedActor } from "browser2video/injected-actor";
+
+const PLAYER_DIR = path.resolve(import.meta.dirname, "../../apps/player");
+const INNER_PORT = 9591;
+const INNER_CDP_PORT = 9395;
+const DEMO_VITE_PORT = 5199;
+
+interface Ctx {
+ page: Page;
+ injected: InjectedActor;
+ innerProcess: ChildProcess;
+ consoleErrors: string[];
+}
+
+/** Wait until the inner player's HTTP server is responding. */
+async function waitForPort(port: number, timeoutMs = 30_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const ok = await new Promise((resolve) => {
+ const req = http.get(`http://localhost:${port}`, (res) => {
+ res.resume();
+ resolve(res.statusCode !== undefined);
+ });
+ req.on("error", () => resolve(false));
+ req.setTimeout(1000, () => { req.destroy(); resolve(false); });
+ });
+ if (ok) return;
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ throw new Error(`Port ${port} did not become available within ${timeoutMs}ms`);
+}
+
+export default defineScenario("Player Self-Test", (s) => {
+ s.options({ layout: "row" });
+
+ s.setup(async (session) => {
+ const t0 = Date.now();
+ const elapsed = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
+
+ // Resolve the electron binary.
+ // Inside Electron's runtime `require("electron")` returns the module
+ // object rather than the binary path. We resolve the package then read
+ // the actual executable from its internal helper.
+ const { createRequire } = await import("node:module");
+ const playerRequire = createRequire(path.join(PLAYER_DIR, "package.json"));
+ let electronPath: string;
+ const raw = playerRequire("electron");
+ if (typeof raw === "string") {
+ electronPath = raw;
+ } else {
+ // Running inside Electron — find the binary through the package dir
+ const electronPkgDir = path.dirname(playerRequire.resolve("electron/package.json"));
+ // The electron package has a `path.txt` that contains the binary path
+ const { default: fs } = await import("node:fs");
+ const pathTxtFile = path.join(electronPkgDir, "path.txt");
+ if (fs.existsSync(pathTxtFile)) {
+ const rel = fs.readFileSync(pathTxtFile, "utf-8").trim();
+ electronPath = path.join(electronPkgDir, "dist", rel);
+ } else {
+ // Fallback: current Electron binary (process.execPath)
+ electronPath = process.execPath;
+ }
+ }
+ console.error(`[self-test setup ${elapsed()}] Electron path resolved`);
+
+ // Kill any stale processes on the inner player's ports from previous runs
+ const { execSync } = await import("node:child_process");
+ for (const port of [INNER_PORT, INNER_CDP_PORT]) {
+ try {
+ const pids = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: "utf8" }).trim();
+ if (pids) {
+ for (const pid of pids.split("\n").filter(Boolean)) {
+ if (pid === String(process.pid)) continue;
+ try { execSync(`kill -9 ${pid} 2>/dev/null`); } catch { }
+ console.error(`[self-test setup] Killed stale process ${pid} on port ${port}`);
+ }
+ await new Promise((r) => setTimeout(r, 300));
+ }
+ } catch { }
+ }
+ console.error(`[self-test setup ${elapsed()}] Port cleanup done`);
+
+ console.error(`[self-test setup ${elapsed()}] Spawning inner player on port ${INNER_PORT}...`);
+ const innerProcess = spawn(
+ electronPath,
+ [PLAYER_DIR],
+ {
+ cwd: path.resolve(PLAYER_DIR, "../.."),
+ env: {
+ ...process.env,
+ NODE_OPTIONS: "--experimental-strip-types --no-warnings",
+ PORT: String(INNER_PORT),
+ B2V_CDP_PORT: String(INNER_CDP_PORT),
+ B2V_EMBEDDED: "1",
+ B2V_CURSOR_COLOR: "#fb923c,#9a3412", // orange cursor for scenario Actor
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ innerProcess.stdout?.on("data", (d) => process.stderr.write(`[inner] ${d}`));
+ innerProcess.stderr?.on("data", (d) => process.stderr.write(`[inner] ${d}`));
+
+ session.addCleanup(async () => {
+ // Safety-net: kill inner player if it wasn't cleaned up by the test step
+ if (!innerProcess.killed && innerProcess.exitCode === null) {
+ console.error("[self-test] Cleaning up inner player (safety-net)...");
+ try { innerProcess.kill("SIGTERM"); } catch { }
+ await new Promise((r) => setTimeout(r, 1000));
+ try { innerProcess.kill("SIGKILL"); } catch { }
+ }
+ });
+
+ // Wait for inner player to be FULLY ready (HTTP + WS + Vite)
+ console.error(`[self-test setup ${elapsed()}] Waiting for inner player HTTP...`);
+ await waitForPort(INNER_PORT, 60_000);
+ console.error(`[self-test setup ${elapsed()}] Inner player HTTP is up, opening page...`);
+
+ // Open inner player's web UI in session browser (no fixed delay needed —
+ // the studio-react selector below is the real readiness check)
+ const { page } = await session.openPage({
+ url: `http://localhost:${INNER_PORT}`,
+ viewport: { width: 1280, height: 720 },
+ });
+ console.error(`[self-test setup ${elapsed()}] Page created`);
+
+ await page.waitForLoadState("domcontentloaded");
+ console.error(`[self-test setup ${elapsed()}] domcontentloaded`);
+
+ // Wait for the studio-react mode which only appears after WS connects
+ // and the terminal server URL is received from the backend
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ await page.waitForSelector("[data-preview-mode='studio-react']", { timeout: 15_000 });
+ break;
+ } catch {
+ console.error(`[self-test setup ${elapsed()}] Attempt ${attempt + 1}: studio not ready, reloading...`);
+ await page.reload();
+ await page.waitForLoadState("domcontentloaded");
+ }
+ }
+ console.error(`[self-test setup ${elapsed()}] Inner player UI ready!`);
+
+ // Collect console errors for Phase 6
+ const consoleErrors: string[] = [];
+ page.on("console", (msg) => {
+ if (msg.type() === "error") {
+ const text = msg.text();
+ // Ignore known benign errors
+ if (text.includes("Content Security Policy") || text.includes("favicon.ico")) return;
+ consoleErrors.push(text);
+ }
+ });
+
+ // Create InjectedActor — shares session's mode ref
+ // Pink cursor to distinguish from scenario's orange cursor
+ const injected = new InjectedActor(page, "tester", {
+ mode: session.modeRef,
+ cursorColor: { fill: "#ff69b4", stroke: "#c2185b" }, // hot pink
+ });
+ await injected.init();
+
+ // Sync Playwright's internal viewport tracking with the actual view size.
+ // The Electron WebContentsView starts at 0×0 and is later resized via IPC,
+ // but Playwright's CDP-side viewport tracking keeps the initial 0×0 value,
+ // causing ALL elements to be reported as "outside of the viewport".
+ await page.setViewportSize({ width: 1280, height: 720 });
+
+ return { page, injected, innerProcess, consoleErrors };
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Phase 1 — Studio + Terminal
+ // ═══════════════════════════════════════════════════════════════════
+
+ s.step("Player UI is ready", async ({ injected }) => {
+ await injected.moveCursorTo(640, 360);
+ await injected.breathe();
+ });
+
+ s.step("Inner player window is hidden", async ({ }) => {
+ // Verify the inner player's Electron BrowserWindow is NOT visible
+ // on screen. It should be hidden (show:false), minimized, and off-screen.
+ // Use osascript to check all Electron windows and their positions.
+ const { execSync } = await import("node:child_process");
+ try {
+ // Get all Electron windows and their properties via osascript
+ const script = `
+ tell application "System Events"
+ set windowInfo to ""
+ repeat with p in (every process whose name contains "Electron")
+ repeat with w in (every window of p)
+ set pos to position of w
+ set sz to size of w
+ set windowInfo to windowInfo & (item 1 of pos) & "," & (item 2 of pos) & "," & (item 1 of sz) & "," & (item 2 of sz) & "\\n"
+ end repeat
+ end repeat
+ return windowInfo
+ end tell
+ `;
+ const result = execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, {
+ encoding: "utf8",
+ timeout: 5000,
+ }).trim();
+
+ const windows = result.split("\n").filter(Boolean);
+ console.error(`[self-test] Electron windows found: ${windows.length}`);
+ for (const w of windows) {
+ const [x, y, width, height] = w.split(",").map(Number);
+ console.error(` Window at (${x},${y}) size ${width}x${height}`);
+ // Flag windows that appear at (0,0) or near the parent player's position
+ // with a significant size — these would overlap
+ if (x >= 0 && x < 100 && y >= 0 && y < 100 && width > 10 && height > 10) {
+ // Check if this is the inner player (small 1x1 windows are OK)
+ const isLargeAtOrigin = width > 100 && height > 100;
+ if (isLargeAtOrigin) {
+ // Count how many large windows are at the origin area
+ const largeWindows = windows.filter(ww => {
+ const [wx, wy, ww2, wh] = ww.split(",").map(Number);
+ return wx >= 0 && wx < 100 && wy >= 0 && wy < 100 && ww2 > 100 && wh > 100;
+ });
+ if (largeWindows.length > 1) {
+ console.error(`[self-test] WARNING: ${largeWindows.length} large Electron windows near origin — possible overlap!`);
+ }
+ }
+ }
+ }
+ } catch (err: any) {
+ // osascript may fail without accessibility permissions — just log
+ console.error(`[self-test] Could not check window positions: ${err.message}`);
+ }
+ });
+
+ s.step("Split screen horizontally", async ({ injected, page }) => {
+ // Change layout to top-bottom
+ await page.selectOption("[data-testid='studio-layout-picker']", "top-bottom");
+ // After layout change, 2 placeholders should appear
+ const placeholders = page.locator("[data-testid='studio-placeholder-add']");
+ await placeholders.first().waitFor({ timeout: 10_000 });
+ await injected.breathe();
+ });
+
+ s.step("Add terminal in bottom pane", async ({ injected, page }) => {
+ // Click the second placeholder (bottom)
+ const placeholders = page.locator("[data-testid='studio-placeholder-add']");
+ const count = await placeholders.count();
+ await placeholders.nth(count - 1).click();
+
+ // Terminal option in popup
+ await injected.waitFor("[data-testid='studio-add-pane-popup']");
+ await injected.click("[data-testid='studio-add-terminal']");
+
+ // Wait for terminal iframe
+ await injected.waitFor("[data-testid='studio-terminal-iframe']");
+ await injected.breathe();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Phase 2 — Terminal launches demo app
+ // ═══════════════════════════════════════════════════════════════════
+
+ s.step("Launch demo app in terminal", async ({ page }) => {
+ const termIframe = page.locator("[data-testid='studio-terminal-iframe']");
+ const frame = termIframe.contentFrame();
+
+ // Wait for xterm to render
+ await frame.locator(".xterm-accessibility-tree, .xterm-rows").waitFor({ state: "visible", timeout: 30_000 });
+ await frame.locator("[data-testid='jabterm-container']").click();
+
+ // Type the vite dev command
+ await page.keyboard.type(`cd apps/demo && npx vite --port ${DEMO_VITE_PORT}`, { delay: 15 });
+ await page.keyboard.press("Enter");
+
+ // Wait for Vite to show "ready"
+ await frame.locator(".xterm-accessibility-tree, .xterm-rows").filter({ hasText: "ready" }).waitFor({ timeout: 30_000 });
+ console.error("[self-test] Demo Vite server is ready!");
+ await page.waitForTimeout(500);
+ });
+
+ s.step("Add browser pane with todo app", async ({ injected, page }) => {
+ // Click the remaining placeholder (top)
+ await injected.click("[data-testid='studio-placeholder-add']");
+ await injected.waitFor("[data-testid='studio-add-pane-popup']");
+ await injected.click("[data-testid='studio-add-browser']");
+
+ // URL dialog — clear and type the notes app URL
+ await injected.waitFor("[data-testid='studio-browser-url-dialog']");
+ const urlInput = page.locator("[data-testid='studio-browser-url-input']");
+ await urlInput.fill(`http://localhost:${DEMO_VITE_PORT}/notes`);
+
+ await injected.click("[data-testid='studio-browser-url-confirm']");
+ await injected.waitForHidden("[data-testid='studio-browser-url-dialog']");
+ await injected.waitFor("[data-testid='studio-browser-iframe']");
+
+ // Wait for the notes app to load inside the iframe
+ const browserIframe = page.locator("[data-testid='studio-browser-iframe']");
+ const noteFrame = browserIframe.contentFrame();
+ await noteFrame.locator("[data-testid='notes-page']").waitFor({ timeout: 30_000 });
+ console.error("[self-test] Todo app loaded in browser iframe!");
+ await injected.breathe();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Phase 3 — Todo CRUD via nested InjectedActor in iframe
+ // ═══════════════════════════════════════════════════════════════════
+
+ s.step("Add 8 todos", async ({ page }) => {
+ const browserIframe = page.locator("[data-testid='studio-browser-iframe']");
+ const noteFrame = browserIframe.contentFrame();
+
+ const todos = [
+ "Set up database schema",
+ "Implement API endpoints",
+ "Build React components",
+ "Add authentication flow",
+ "Write unit tests",
+ "Set up CI pipeline",
+ "Deploy to staging",
+ "Performance testing",
+ ];
+
+ for (const todo of todos) {
+ await noteFrame.locator("[data-testid='note-input']").fill(todo);
+ await noteFrame.locator("[data-testid='note-add-btn']").click();
+ await page.waitForTimeout(200);
+ }
+
+ // Verify all 8 appear
+ const items = noteFrame.locator("[data-testid^='note-item-']");
+ const count = await items.count();
+ if (count < 8) throw new Error(`Expected 8 todos, got ${count}`);
+ console.error(`[self-test] Added ${count} todos`);
+ });
+
+ s.step("Reorder a todo", async ({ page }) => {
+ const browserIframe = page.locator("[data-testid='studio-browser-iframe']");
+ const noteFrame = browserIframe.contentFrame();
+
+ // Drag the last item (index 7) to the top (index 0)
+ const dragHandle = noteFrame.locator("[data-testid='note-drag-7']");
+ const dropTarget = noteFrame.locator("[data-testid='note-item-0']");
+
+ const dragBox = await dragHandle.boundingBox();
+ const dropBox = await dropTarget.boundingBox();
+ if (dragBox && dropBox) {
+ await page.mouse.move(dragBox.x + dragBox.width / 2, dragBox.y + dragBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(dropBox.x + dropBox.width / 2, dropBox.y + dropBox.height / 2, { steps: 10 });
+ await page.mouse.up();
+ await page.waitForTimeout(500);
+ }
+ console.error("[self-test] Reordered a todo");
+ });
+
+ s.step("Scroll the todo list", async ({ page }) => {
+ const browserIframe = page.locator("[data-testid='studio-browser-iframe']");
+ const noteFrame = browserIframe.contentFrame();
+
+ const notesList = noteFrame.locator("[data-testid='notes-page']");
+ // Use locator.evaluate which is valid on FrameLocator's locators
+ await notesList.evaluate((el: Element) => el.scrollBy(0, 200));
+ await page.waitForTimeout(300);
+ await notesList.evaluate((el: Element) => el.scrollBy(0, -200));
+ await page.waitForTimeout(300);
+ console.error("[self-test] Scrolled todo list");
+ });
+
+ s.step("Delete a todo", async ({ page }) => {
+ const browserIframe = page.locator("[data-testid='studio-browser-iframe']");
+ const noteFrame = browserIframe.contentFrame();
+
+ const countBefore = await noteFrame.locator("[data-testid^='note-item-']").count();
+ await noteFrame.locator("[data-testid='note-delete-0']").click();
+ await page.waitForTimeout(500);
+ const countAfter = await noteFrame.locator("[data-testid^='note-item-']").count();
+ if (countAfter >= countBefore) throw new Error(`Delete failed: ${countBefore} → ${countAfter}`);
+ console.error(`[self-test] Deleted todo: ${countBefore} → ${countAfter}`);
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Phase 4 — Close terminal, verify app stops working
+ // ═══════════════════════════════════════════════════════════════════
+
+ s.step("Close terminal pane", async ({ injected, page }) => {
+ // Click on the terminal's dockview tab header to make it the active panel.
+ // Clicking inside an iframe doesn't change dockview's activePanel tracking,
+ // so we must click the .dv-tab element directly.
+ const tabs = page.locator(".dv-tab");
+ const tabCount = await tabs.count();
+ for (let i = 0; i < tabCount; i++) {
+ const tab = tabs.nth(i);
+ const text = await tab.textContent();
+ if (text?.includes("Shell")) {
+ await tab.click();
+ await page.waitForTimeout(300);
+ break;
+ }
+ }
+
+ // Close the terminal via the close button
+ await injected.click("[data-testid='studio-close-active']");
+ await page.waitForTimeout(1000);
+
+ // Verify terminal iframe is gone
+ const termIframes = page.locator("[data-testid='studio-terminal-iframe']");
+ const count = await termIframes.count();
+ if (count > 0) throw new Error("Terminal is still visible after close");
+ console.error("[self-test] Terminal closed");
+ });
+
+ s.step("Verify todo app stops (server killed)", async ({ page }) => {
+ const browserIframe = page.locator("[data-testid='studio-browser-iframe']");
+ const noteFrame = browserIframe.contentFrame();
+
+ // The demo vite server was killed with the terminal
+ // Navigate the iframe to trigger a reload by setting src
+ const src = await browserIframe.getAttribute("src") ?? `http://localhost:${DEMO_VITE_PORT}/notes`;
+ await page.evaluate((s) => {
+ const iframe = document.querySelector("[data-testid='studio-browser-iframe']") as HTMLIFrameElement;
+ if (iframe) iframe.src = s;
+ }, src);
+ await page.waitForTimeout(3000);
+
+ // The page should not show the notes app anymore
+ const notesStillWork = await noteFrame.locator("[data-testid='notes-page']").isVisible().catch(() => false);
+ console.error(`[self-test] After terminal close: notesStillWork=${notesStillWork}`);
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Phase 5 — Scenario playback
+ // ═══════════════════════════════════════════════════════════════════
+
+ s.step("Load basic-ui scenario", async ({ injected, page }) => {
+ // Use the scenario picker to load basic-ui
+ // Find the option whose value contains 'basic-ui'
+ await page.selectOption("[data-testid='picker-select']", { label: "tests/scenarios/basic-ui.scenario.ts" });
+ await page.waitForTimeout(2000);
+
+ // Wait for scenario steps to appear in the sidebar
+ await page.waitForSelector("[data-testid='step-card-0']", { timeout: 30_000 });
+ const stepCards = page.locator("[data-testid^='step-card-']");
+ const stepCount = await stepCards.count();
+ console.error(`[self-test] basic-ui scenario loaded: ${stepCount} steps`);
+ await injected.breathe();
+ });
+
+ s.step("Play all, then stop after first slide", async ({ injected, page }) => {
+ // Click Play all
+ await injected.click("[data-testid='ctrl-play-all']");
+
+ // Wait for the first step to start running
+ await page.waitForTimeout(3000);
+
+ // Click Stop
+ await injected.click("[data-testid='ctrl-stop']");
+ await page.waitForTimeout(1000);
+ console.error("[self-test] Played and stopped after first slide");
+ await injected.breathe();
+ });
+
+ s.step("Step through all slides one by one", async ({ injected, page }) => {
+ // Reset first
+ await injected.click("[data-testid='ctrl-reset']");
+ await page.waitForTimeout(1000);
+
+ // Get the number of steps
+ const stepCards = page.locator("[data-testid^='step-card-']");
+ const stepCount = await stepCards.count();
+
+ // Click each step card individually
+ for (let i = 0; i < Math.min(stepCount, 5); i++) {
+ console.error(`[self-test] Clicking step ${i}...`);
+ await injected.click(`[data-testid='step-card-${i}']`);
+
+ // Wait for the step to complete (screenshot appears)
+ await page.waitForTimeout(5000);
+
+ // Assert: the preview area should NOT be in electron-scenario-view mode
+ // (which shows a black background when the player is embedded).
+ // It should fall back to screenshot mode instead.
+ const previewMode = await page.getAttribute("[data-preview-mode]", "data-preview-mode");
+ if (previewMode === "electron-scenario-view") {
+ throw new Error(
+ `Step ${i}: Preview is in "electron-scenario-view" mode (black background). ` +
+ `Embedded players should use screenshot/screencast mode instead.`
+ );
+ }
+ }
+ console.error(`[self-test] Stepped through ${Math.min(stepCount, 5)} slides`);
+ });
+
+ s.step("Verify scenario screenshots are not blank", async ({ page }) => {
+ // After stepping through BasicUI slides, the step-card thumbnails
+ // should contain actual screenshots (base64 PNG), not be empty.
+ // This proves the scenario executed, the cursor interacted with
+ // elements, and the page rendered visible content.
+ const stepCards = page.locator("[data-testid^='step-card-']");
+ const cardCount = await stepCards.count();
+
+ let screenshotsFound = 0;
+ for (let i = 0; i < Math.min(cardCount, 5); i++) {
+ const img = stepCards.nth(i).locator("img");
+ const imgCount = await img.count();
+ if (imgCount > 0) {
+ const src = await img.first().getAttribute("src");
+ if (src && src.startsWith("data:image/") && src.length > 100) {
+ screenshotsFound++;
+ }
+ }
+ }
+
+ console.error(`[self-test] Step screenshots found: ${screenshotsFound}/${Math.min(cardCount, 5)}`);
+ if (screenshotsFound === 0) {
+ throw new Error(
+ "No step screenshots found in step cards. " +
+ "The BasicUI scenario should produce visible screenshots after each step."
+ );
+ }
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Phase 6 — Cleanup & verification
+ // ═══════════════════════════════════════════════════════════════════
+
+ s.step("Inner player shuts down cleanly", async ({ innerProcess }) => {
+ // Send SIGTERM and wait for graceful exit
+ const exitPromise = new Promise((resolve) => {
+ innerProcess.on("exit", (code) => resolve(code));
+ });
+
+ console.error("[self-test] Sending SIGTERM to inner player...");
+ innerProcess.kill("SIGTERM");
+
+ const exitCode = await Promise.race([
+ exitPromise,
+ new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 10_000)),
+ ]);
+
+ if (exitCode === "timeout") {
+ console.error("[self-test] Inner player didn't exit in 10s, sending SIGKILL...");
+ innerProcess.kill("SIGKILL");
+ const killResult = await Promise.race([
+ exitPromise,
+ new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 5_000)),
+ ]);
+ if (killResult === "timeout") {
+ throw new Error("Inner player process didn't exit after SIGKILL");
+ }
+ console.error(`[self-test] Inner player killed (exit code: ${killResult})`);
+ } else {
+ console.error(`[self-test] Inner player exited cleanly (code: ${exitCode})`);
+ }
+
+ // Verify the inner player's port is freed
+ await new Promise((r) => setTimeout(r, 500));
+ try {
+ const probe = await fetch(`http://localhost:${INNER_PORT}`);
+ throw new Error(`Inner player port ${INNER_PORT} is still responding (status: ${probe.status})`);
+ } catch (err: any) {
+ if (err.message.includes("still responding")) throw err;
+ // fetch failed = port is freed = good
+ console.error(`[self-test] Port ${INNER_PORT} is freed`);
+ }
+ });
+
+ s.step("No unexpected console errors", async ({ consoleErrors }) => {
+ if (consoleErrors.length > 0) {
+ console.error(`[self-test] Console errors found:`);
+ for (const err of consoleErrors) {
+ console.error(` - ${err}`);
+ }
+ }
+ console.error(`[self-test] Console errors: ${consoleErrors.length}`);
+ });
+});