Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2302060
fix: auto-find free CDP port when default 9334 is busy
holiber Feb 22, 2026
e543975
feat: per-actor colored cursors with auto-palette
holiber Feb 22, 2026
9398e50
fix: stop button, cursor colors, cache button, CDP port fallback
holiber Feb 22, 2026
3a9ba28
chore: update jabterm v0.1.3 → v0.1.4, clean up workarounds
holiber Feb 22, 2026
4ffcc14
fix: xterm v6 accessibility tree + cursor first-use teleport
holiber Feb 22, 2026
18db1f2
feat: improve chat scenario + xterm v6 test selectors
holiber Feb 22, 2026
0a03769
fix: resolve keyboard contention in concurrent actor typing
holiber Feb 22, 2026
908efc7
fix: grid creation wait for command panes (mc, htop, etc.)
holiber Feb 22, 2026
0b98429
chore: revert command-pane grid wait split (user preference)
holiber Feb 22, 2026
5dcc591
feat: add @browser2video/test package — Playwright integration
holiber Feb 23, 2026
735e51f
fix: sample test uses project's notes demo, not external URL
holiber Feb 23, 2026
e19017c
fix: getSession() lazy init — safe for test.beforeAll
holiber Feb 23, 2026
f0ca8f9
feat: replace github-mobile test with external-website test
holiber Feb 23, 2026
7060704
fix: skip Electron-only tests in headless mode
holiber Feb 23, 2026
ea90e9d
fix: add cursor movement to external-website scenario
holiber Feb 23, 2026
2cdaebc
fix: TUI scenario hang — command panes wait for content, not prompt
holiber Feb 23, 2026
26d6e05
fix: bump clean shutdown test timeout to 60s
holiber Feb 23, 2026
f6c648c
feat: terminal server defaults cwd to scenario artifact dir
holiber Feb 23, 2026
9d6def7
fix: register cursor overlay as init script for reliable cross-naviga…
holiber Feb 23, 2026
0efd897
fix: make cursor overlay script safe for init-script (body-deferred)
holiber Feb 23, 2026
6e1efda
fix: stop button now works — abort() force-closes session
holiber Feb 23, 2026
723214a
feat: add InjectedActor for in-page cursor injection and player self-…
holiber Feb 23, 2026
48e9bd0
feat: player-tests-player scenario via defineScenario
holiber Feb 23, 2026
e51323c
feat: comprehensive player self-test scenario (15 steps, 6 phases)
holiber Feb 23, 2026
fbfca91
fix: make player-self-test work inside the Electron player app
holiber Feb 23, 2026
161bf8e
fix: hide inner player window when running embedded (B2V_EMBEDDED)
holiber Feb 23, 2026
7c64fe5
fix: hide inner player window + add cleanup assertions (16 steps)
holiber Feb 23, 2026
b636272
feat: CDP screencast video recording for Electron/human mode
holiber Feb 23, 2026
08eadf8
feat: add run-self-test.ts — standalone self-test runner
holiber Feb 23, 2026
a062bdf
fix: aggressively hide inner player window + add overlap assertion
holiber Feb 23, 2026
f11d252
fix: embedded player rendering, custom cursor colors, startup time
holiber Feb 24, 2026
a83d1a5
perf: optimize self-test startup time
holiber Feb 24, 2026
e397bd3
feat: custom cursor colors for player and scenarios
holiber Feb 24, 2026
a0637c9
fix: larger cursor (32px), drop shadow, vivid hot pink for tester
holiber Feb 24, 2026
3e1fbbc
fix: move addInitScript before page.goto for cursor color to work
holiber Feb 24, 2026
5b8db0c
chore: clean up cursor diagnostic code from executor.ts
holiber Feb 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/b2vPlayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions agents/self-test.md
Original file line number Diff line number Diff line change
@@ -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 |
74 changes: 68 additions & 6 deletions apps/player/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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,<html><body style='background:%230d1117;color:%23888;display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui'><div>Starting…</div></body></html>");
// 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...`);
Expand All @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions apps/player/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -33,4 +33,4 @@
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
}
2 changes: 1 addition & 1 deletion apps/player/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
38 changes: 33 additions & 5 deletions apps/player/server/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class Executor<T = any> {
private session: Session | null = null;
private ctx: T | null = null;
private executedUpTo = -1;
private _aborted = false;
private descriptor: ScenarioDescriptor<T>;
private sessionOpts: Partial<SessionOptions>;
private projectRoot: string | null;
Expand Down Expand Up @@ -145,7 +146,9 @@ export class Executor<T = any> {
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) {
Expand Down Expand Up @@ -216,7 +219,8 @@ export class Executor<T = any> {
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();
}
Expand Down Expand Up @@ -274,20 +278,22 @@ export class Executor<T = any> {
onStepComplete?: (result: StepResult) => void,
): Promise<StepResult> {
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;
onStepComplete?.({ index: i, screenshot, mode: "fast", durationMs });
}

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],
Expand All @@ -304,17 +310,39 @@ export class Executor<T = any> {
}

async reset(): Promise<void> {
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<void> {
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<void> {
Expand Down
59 changes: 34 additions & 25 deletions apps/player/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading