import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } = vi.hoisted(() => { const events: string[] = []; const surfaceState = { byTabId: {} as Record<string, unknown>, }; return { events, onFrame: vi.fn(() => vi.fn()), registrySet: vi.fn((_atom: unknown, value: string | null) => { events.push(value === null ? "clear" : `publish:${value}`); }), save: vi.fn(async () => ({ id: "recording-test", tabId: "recording-tab", path: "/tmp/recording-test.webm", mimeType: "video/webm" as const, sizeBytes: 0, createdAt: "2026-06-26T00:00:00.000Z", })), startScreencast: vi.fn(async () => { events.push("start-screencast"); }), stopScreencast: vi.fn(async () => undefined), surfaceState, }; }); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { recording: { onFrame, save, startScreencast, stopScreencast }, },})); vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { set: registrySet },})); vi.mock("./browserSurfaceStore", () => ({ useBrowserSurfaceStore: { getState: () => surfaceState, },})); import { BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, BrowserRecordingConflictError, BrowserRecordingOperationError, BrowserRecordingRequiresVisibleTabError, startBrowserRecording, stopBrowserRecording,} from "./browserRecording"; class FakeMediaRecorder { static isTypeSupported(): boolean { return true; } state: RecordingState = "inactive"; private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>(); addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); this.listeners.set(type, listeners); } start(): void { this.state = "recording"; } stop(): void { this.state = "inactive"; for (const listener of this.listeners.get("stop") ?? []) { if (typeof listener === "function") listener(new Event("stop")); else listener.handleEvent(new Event("stop")); } }} describe("browser recording", () => { beforeEach(() => { events.length = 0; surfaceState.byTabId = { "recording-tab": { visible: true, rect: { x: 0, y: 0, width: 800, height: 600 }, content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, }, }; vi.clearAllMocks(); vi.stubGlobal("window", globalThis); vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); vi.stubGlobal("document", { createElement: () => ({ width: 0, height: 0, captureStream: () => ({}), getContext: () => ({ drawImage: vi.fn() }), }), }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it("starts recording for a visible tab", async () => { await startBrowserRecording("recording-tab"); expect(events).toEqual(["start-screencast", "publish:recording-tab"]); await stopBrowserRecording("recording-tab"); }); it("rejects recording for a hidden tab before starting screencast", async () => { surfaceState.byTabId = { "recording-tab": { visible: false, rect: { x: 0, y: 0, width: 800, height: 600 }, content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, }, }; await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( BrowserRecordingRequiresVisibleTabError, ); expect(startScreencast).not.toHaveBeenCalled(); expect(registrySet).not.toHaveBeenCalled(); }); it("does not report success for a second start while the first is still starting", async () => { let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); await new Promise<void>((resolve) => { finishStartingScreencast = resolve; }); }); const firstStart = startBrowserRecording("recording-tab"); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( BrowserRecordingConflictError, ); finishStartingScreencast?.(); await firstStart; await stopBrowserRecording("recording-tab"); }); it("does not report success for a start while the recording is stopping", async () => { let finishStoppingScreencast: (() => void) | undefined; stopScreencast.mockImplementationOnce(async () => { await new Promise<void>((resolve) => { finishStoppingScreencast = resolve; }); return undefined; }); await startBrowserRecording("recording-tab"); const stopPromise = stopBrowserRecording("recording-tab"); await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( BrowserRecordingConflictError, ); finishStoppingScreencast?.(); await stopPromise; }); it("shares an in-progress stop with duplicate callers", async () => { let finishStoppingScreencast: (() => void) | undefined; stopScreencast.mockImplementationOnce(async () => { await new Promise<void>((resolve) => { finishStoppingScreencast = resolve; }); return undefined; }); await startBrowserRecording("recording-tab"); const firstStop = stopBrowserRecording("recording-tab"); await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); const duplicateStop = stopBrowserRecording("recording-tab"); finishStoppingScreencast?.(); const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]); expect(duplicateArtifact).toEqual(firstArtifact); expect(stopScreencast).toHaveBeenCalledOnce(); expect(save).toHaveBeenCalledOnce(); }); it("stops a screencast that finishes starting after cancellation", async () => { let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); await new Promise<void>((resolve) => { finishStartingScreencast = resolve; }); }); const startPromise = startBrowserRecording("recording-tab"); const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( BrowserRecordingOperationError, ); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); finishStartingScreencast?.(); await rejectedStart; await stopPromise; expect(stopScreencast).toHaveBeenCalledTimes(2); expect(events.at(-1)).toBe("clear"); }); it("does not release the recording slot until a cancelled start settles", async () => { let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); await new Promise<void>((resolve) => { finishStartingScreencast = resolve; }); }); const firstStart = startBrowserRecording("recording-tab"); const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( BrowserRecordingOperationError, ); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab")); await new Promise((resolve) => setTimeout(resolve, 0)); const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; finishStartingScreencast?.(); await rejectedFirstStart; await stopPromise; await restartAfterStop; await stopBrowserRecording("recording-tab"); expect(startCallsBeforeFirstSettled).toBe(1); }); it("fails a stop that waits too long for startup without freeing the recording slot", async () => { vi.useFakeTimers(); let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); await new Promise<void>((resolve) => { finishStartingScreencast = resolve; }); }); const startPromise = startBrowserRecording("recording-tab"); const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( BrowserRecordingOperationError, ); expect(startScreencast).toHaveBeenCalledOnce(); const stopPromise = stopBrowserRecording("recording-tab"); await Promise.resolve(); await Promise.resolve(); expect(stopScreencast).toHaveBeenCalledOnce(); const rejection = expect(stopPromise).rejects.toMatchObject({ operation: "wait-startup", tabId: "recording-tab", }); await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); await rejection; expect(save).not.toHaveBeenCalled(); await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( BrowserRecordingConflictError, ); finishStartingScreencast?.(); await rejectedStart; const cleanupResult = await stopBrowserRecording("recording-tab"); expect(cleanupResult).toBeNull(); expect(save).not.toHaveBeenCalled(); expect(events.at(-1)).toBe("clear"); });});
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } =
vi.hoisted(() => {
const events: string[] = [];
const surfaceState = {
byTabId: {} as Record<string, unknown>,
};
return {
events,
onFrame: vi.fn(() => vi.fn()),
registrySet: vi.fn((_atom: unknown, value: string | null) => {
events.push(value === null ? "clear" : `publish:${value}`);
}),
save: vi.fn(async () => ({
id: "recording-test",
tabId: "recording-tab",
path: "/tmp/recording-test.webm",
mimeType: "video/webm" as const,
sizeBytes: 0,
createdAt: "2026-06-26T00:00:00.000Z",
})),
startScreencast: vi.fn(async () => {
events.push("start-screencast");
}),
stopScreencast: vi.fn(async () => undefined),
surfaceState,
};
});
vi.mock("~/components/preview/previewBridge", () => ({
previewBridge: {
recording: { onFrame, save, startScreencast, stopScreencast },
},
}));
vi.mock("~/rpc/atomRegistry", () => ({
appAtomRegistry: { set: registrySet },
}));
vi.mock("./browserSurfaceStore", () => ({
useBrowserSurfaceStore: {
getState: () => surfaceState,
},
}));
import {
BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
BrowserRecordingConflictError,
BrowserRecordingOperationError,
BrowserRecordingRequiresVisibleTabError,
startBrowserRecording,
stopBrowserRecording,
} from "./browserRecording";
class FakeMediaRecorder {
static isTypeSupported(): boolean {
return true;
}
state: RecordingState = "inactive";
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
start(): void {
this.state = "recording";
}
stop(): void {
this.state = "inactive";
for (const listener of this.listeners.get("stop") ?? []) {
if (typeof listener === "function") listener(new Event("stop"));
else listener.handleEvent(new Event("stop"));
}
}
}
describe("browser recording", () => {
beforeEach(() => {
events.length = 0;
surfaceState.byTabId = {
"recording-tab": {
visible: true,
rect: { x: 0, y: 0, width: 800, height: 600 },
content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 },
},
};
vi.clearAllMocks();
vi.stubGlobal("window", globalThis);
vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
vi.stubGlobal("document", {
createElement: () => ({
width: 0,
height: 0,
captureStream: () => ({}),
getContext: () => ({ drawImage: vi.fn() }),
}),
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts recording for a visible tab", async () => {
await startBrowserRecording("recording-tab");
expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
await stopBrowserRecording("recording-tab");
});
it("rejects recording for a hidden tab before starting screencast", async () => {
surfaceState.byTabId = {
"recording-tab": {
visible: false,
rect: { x: 0, y: 0, width: 800, height: 600 },
content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 },
},
};
await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
BrowserRecordingRequiresVisibleTabError,
);
expect(startScreencast).not.toHaveBeenCalled();
expect(registrySet).not.toHaveBeenCalled();
});
it("does not report success for a second start while the first is still starting", async () => {
let finishStartingScreencast: (() => void) | undefined;
startScreencast.mockImplementationOnce(async () => {
events.push("start-screencast");
await new Promise<void>((resolve) => {
finishStartingScreencast = resolve;
});
});
const firstStart = startBrowserRecording("recording-tab");
await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
BrowserRecordingConflictError,
);
finishStartingScreencast?.();
await firstStart;
await stopBrowserRecording("recording-tab");
});
it("does not report success for a start while the recording is stopping", async () => {
let finishStoppingScreencast: (() => void) | undefined;
stopScreencast.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => {
finishStoppingScreencast = resolve;
});
return undefined;
});
await startBrowserRecording("recording-tab");
const stopPromise = stopBrowserRecording("recording-tab");
await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
BrowserRecordingConflictError,
);
finishStoppingScreencast?.();
await stopPromise;
});
it("shares an in-progress stop with duplicate callers", async () => {
let finishStoppingScreencast: (() => void) | undefined;
stopScreencast.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => {
finishStoppingScreencast = resolve;
});
return undefined;
});
await startBrowserRecording("recording-tab");
const firstStop = stopBrowserRecording("recording-tab");
await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
const duplicateStop = stopBrowserRecording("recording-tab");
finishStoppingScreencast?.();
const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]);
expect(duplicateArtifact).toEqual(firstArtifact);
expect(stopScreencast).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledOnce();
});
it("stops a screencast that finishes starting after cancellation", async () => {
let finishStartingScreencast: (() => void) | undefined;
startScreencast.mockImplementationOnce(async () => {
events.push("start-screencast");
await new Promise<void>((resolve) => {
finishStartingScreencast = resolve;
});
});
const startPromise = startBrowserRecording("recording-tab");
const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
BrowserRecordingOperationError,
);
await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
const stopPromise = stopBrowserRecording("recording-tab");
await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
finishStartingScreencast?.();
await rejectedStart;
await stopPromise;
expect(stopScreencast).toHaveBeenCalledTimes(2);
expect(events.at(-1)).toBe("clear");
});
it("does not release the recording slot until a cancelled start settles", async () => {
let finishStartingScreencast: (() => void) | undefined;
startScreencast.mockImplementationOnce(async () => {
events.push("start-screencast");
await new Promise<void>((resolve) => {
finishStartingScreencast = resolve;
});
});
const firstStart = startBrowserRecording("recording-tab");
const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf(
BrowserRecordingOperationError,
);
await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
const stopPromise = stopBrowserRecording("recording-tab");
const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab"));
await new Promise((resolve) => setTimeout(resolve, 0));
const startCallsBeforeFirstSettled = startScreencast.mock.calls.length;
finishStartingScreencast?.();
await rejectedFirstStart;
await stopPromise;
await restartAfterStop;
await stopBrowserRecording("recording-tab");
expect(startCallsBeforeFirstSettled).toBe(1);
});
it("fails a stop that waits too long for startup without freeing the recording slot", async () => {
vi.useFakeTimers();
let finishStartingScreencast: (() => void) | undefined;
startScreencast.mockImplementationOnce(async () => {
events.push("start-screencast");
await new Promise<void>((resolve) => {
finishStartingScreencast = resolve;
});
});
const startPromise = startBrowserRecording("recording-tab");
const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
BrowserRecordingOperationError,
);
expect(startScreencast).toHaveBeenCalledOnce();
const stopPromise = stopBrowserRecording("recording-tab");
await Promise.resolve();
await Promise.resolve();
expect(stopScreencast).toHaveBeenCalledOnce();
const rejection = expect(stopPromise).rejects.toMatchObject({
operation: "wait-startup",
tabId: "recording-tab",
});
await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
await rejection;
expect(save).not.toHaveBeenCalled();
await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
BrowserRecordingConflictError,
);
finishStartingScreencast?.();
await rejectedStart;
const cleanupResult = await stopBrowserRecording("recording-tab");
expect(cleanupResult).toBeNull();
expect(save).not.toHaveBeenCalled();
expect(events.at(-1)).toBe("clear");
});
});