返回 DeepSeek-Reasonix
recorder.test.ts
根目录 / desktop / electron / src / main / browser / recorder.test.ts
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
127 lines TYPESCRIPT