| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import { build } from "esbuild"; |
| 4 | import { createRequire } from "node:module"; |
| 5 | import { EventEmitter } from "node:events"; |
| 6 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 7 | import { tmpdir } from "node:os"; |
| 8 | import { join, resolve } from "node:path"; |
| 9 | import { BrowserSurfaceManager } from "./surfaceManager.js"; |
| 10 | import { FakeViewFactory, silentLog } from "./fakeGuestViews.js"; |
| 11 | import { GrantRegistry } from "./grants.js"; |
| 12 | import type { BrowserRecorder } from "./recorder.js"; |
| 13 | |
| 14 | // Only native allocation is faked. Exercise the production recorder state |
| 15 | // machine with a deterministic barrier before surface preparation completes. |
| 16 | const bundle = await build({ entryPoints: [resolve("src/main/browser/recorder.ts")], bundle: true, platform: "node", format: "cjs", external: ["electron"], write: false, logLevel: "silent" }); |
| 17 | const require = createRequire(import.meta.url); |
| 18 | const electron = { |
| 19 | BrowserWindow: class { constructor() { assert.fail("cancelled preparation allocated a renderer"); } }, |
| 20 | MessageChannelMain: class { port1 = { postMessage() {}, close() {} }; port2 = {}; }, |
| 21 | session: { fromPartition: () => ({ setDisplayMediaRequestHandler() {} }) }, |
| 22 | }; |
| 23 | const module = { exports: {} as { BrowserRecorder: typeof BrowserRecorder } }; |
| 24 | new Function("require", "module", "exports", "__dirname", bundle.outputFiles[0].text)((name: string) => name === "electron" ? electron : require(name), module, module.exports, resolve("dist")); |
| 25 | |
| 26 | async function fixture() { |
| 27 | const directory = await mkdtemp(join(tmpdir(), "recorder-state-")); |
| 28 | const views = new FakeViewFactory(); |
| 29 | const surfaces = new BrowserSurfaceManager({ views, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog }); |
| 30 | const tab = await surfaces.open("https://example.test", { taskId: "task", sessionId: "session", temporary: true }); |
| 31 | surfaces.takeover(tab.id, "user starts recording"); |
| 32 | let entered!: () => void; |
| 33 | const preparing = new Promise<void>(resolve => { entered = resolve; }); |
| 34 | tab.view.prepareCapture = signal => { entered(); return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })); }; |
| 35 | const recorder = new module.exports.BrowserRecorder(surfaces, new GrantRegistry({ generation: () => "g" }), directory); |
| 36 | const start = recorder.userRequest(tab, "start", directory).then(() => undefined, () => undefined); |
| 37 | await preparing; |
| 38 | return { surfaces, tab, recorder, directory, async close() { await recorder.close(); await start; surfaces.destroyAll(); await rm(directory, { recursive: true, force: true }); } }; |
| 39 | } |
| 40 | |
| 41 | test("user recording survives ordinary input but still stops on page or service loss", async () => { |
| 42 | for (const reason of ["navigation", "crash", "renderer-loss"]) { |
| 43 | const f = await fixture(); |
| 44 | try { |
| 45 | f.surfaces.takeover(f.tab.id, "user keydown"); |
| 46 | assert.equal((await f.recorder.userRequest(f.tab, "status", f.directory))?.state, "preparing", "user input must not cancel a user-owned recording"); |
| 47 | const view = f.tab.view as import("./fakeGuestViews.js").FakeGuestView; |
| 48 | if (reason === "navigation") view.fire().onNavigate("https://example.test/next", false); |
| 49 | else if (reason === "crash") view.fire().onRenderProcessGone("crashed"); |
| 50 | else f.surfaces.pauseForRendererLoss("service disconnected"); |
| 51 | await f.recorder.close(); |
| 52 | assert.equal((await f.recorder.userRequest(f.tab, "status", f.directory))?.state, "interrupted"); |
| 53 | } finally { await f.close(); } |
| 54 | } |
| 55 | }); |
| 56 | |
| 57 | test("stop during preparation cancels instead of waiting for the recording duration", async () => { |
| 58 | const f = await fixture(); |
| 59 | try { |
| 60 | const stop = f.recorder.userRequest(f.tab, "stop", f.directory); |
| 61 | await stop; |
| 62 | assert.equal((await f.recorder.userRequest(f.tab, "status", f.directory))?.state, "cancelled"); |
| 63 | } finally { await f.close(); } |
| 64 | }); |
| 65 | |
| 66 | test("terminal recording status publishes only after cleanup releases the global slot", async () => { |
| 67 | for (const outcome of ["completed", "cancelled"] as const) { |
| 68 | const directory = await mkdtemp(join(tmpdir(), "recorder-publication-")); |
| 69 | await writeFile(join(directory, "browser-recorder.js"), "void 0;"); |
| 70 | let releaseCleanup!: () => void, reachedCleanup!: () => void; |
| 71 | const cleanupBlocked = new Promise<void>(resolve => { releaseCleanup = resolve; }); |
| 72 | const cleaning = new Promise<void>(resolve => { reachedCleanup = resolve; }); |
| 73 | let port!: EventEmitter & { postMessage(data: { type: string }): void; close(): void; start(): void }; |
| 74 | const fakeElectron = { |
| 75 | session: electron.session, |
| 76 | MessageChannelMain: class { |
| 77 | port1 = port = Object.assign(new EventEmitter(), { close() {}, start() {}, postMessage(data: { type: string }) { |
| 78 | if (data.type !== "stop") return; |
| 79 | const bytes = Buffer.alloc(40); bytes.writeUInt32BE(0x1a45dfa3); |
| 80 | port.emit("message", { data: { type: "chunk", data: bytes } }); |
| 81 | port.emit("message", { data: { type: "stopped", width: 800, height: 600, durationMs: 1000 } }); |
| 82 | } }); |
| 83 | port2 = {}; |
| 84 | }, |
| 85 | BrowserWindow: class { |
| 86 | webContents = { once() {}, postMessage() { port.emit("message", { data: { type: "started" } }); } }; |
| 87 | async loadFile() {} |
| 88 | isDestroyed() { return false; } |
| 89 | destroy() {} |
| 90 | }, |
| 91 | }; |
| 92 | const loaded = { exports: {} as { BrowserRecorder: typeof BrowserRecorder } }; |
| 93 | const fs = require("node:fs/promises"); |
| 94 | new Function("require", "module", "exports", "__dirname", bundle.outputFiles[0].text)((name: string) => { |
| 95 | if (name === "electron") return fakeElectron; |
| 96 | if (name === "node:fs/promises") return { ...fs, async rm(path: string, options: unknown) { |
| 97 | if (path.endsWith(".html")) { reachedCleanup(); await cleanupBlocked; } |
| 98 | return fs.rm(path, options); |
| 99 | } }; |
| 100 | return require(name); |
| 101 | }, loaded, loaded.exports, resolve("dist")); |
| 102 | const views = new FakeViewFactory(); |
| 103 | const surfaces = new BrowserSurfaceManager({ views, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog }); |
| 104 | const tab = await surfaces.open("https://example.test", { taskId: "task", sessionId: "session", temporary: true }); |
| 105 | views.views[0].page.run = () => ({ width: 800, height: 600 }); |
| 106 | let leases = 0; |
| 107 | tab.view.prepareCapture = async () => { leases++; return () => { leases--; }; }; |
| 108 | const grants = new GrantRegistry({ generation: () => "g" }); |
| 109 | grants.install({ grantId: "grant", taskId: "task", sessionId: "session" }); |
| 110 | const recorder = new loaded.exports.BrowserRecorder(surfaces, grants, directory); |
| 111 | try { |
| 112 | const initial = await recorder.request(tab, "grant", "start", "", directory); |
| 113 | const finish = recorder.request(tab, "grant", outcome === "completed" ? "stop" : "cancel", initial.id, directory); |
| 114 | await cleaning; |
| 115 | const pending = await recorder.request(tab, "grant", "status", initial.id, directory); |
| 116 | assert.equal(pending.state, "finalizing"); |
| 117 | assert.equal(pending.path, undefined, "a file is not published while cleanup is pending"); |
| 118 | await assert.rejects(recorder.request(tab, "grant", "start", "", directory), /another recording is active/); |
| 119 | releaseCleanup(); |
| 120 | assert.equal((await finish).state, outcome); |
| 121 | assert.equal(leases, 0); |
| 122 | assert.equal((await recorder.request(tab, "grant", "status", initial.id, directory)).state, outcome); |
| 123 | assert.equal((await recorder.request(tab, "grant", "start", "", directory)).state, "recording", "terminal status guarantees immediate admission"); |
| 124 | } finally { releaseCleanup(); await recorder.close(); surfaces.destroyAll(); await rm(directory, { recursive: true, force: true }); } |
| 125 | } |
| 126 | }); |
| 127 |