f3c5b8a65d
Addresses the review feedback on #658 (CodeRabbit + Codex) and the structural notes from the quality pass. Correctness: - Compute the recorder's streaming state at finalize time, not at construction. A stream that fails to open is now reported as not-streamed, so its buffered chunks are saved as a complete in-memory fallback instead of being dropped (was total data loss on open failure). - Await every in-flight chunk write before onstop resolves, so the main process never closes the write stream while a final chunk is still in flight (was truncating the tail of a recording under load). - Open the disk write stream by awaiting its 'open' event, so a bad path or permission error rejects up front instead of being acknowledged as success and then silently dropping bytes. - Close the stream and remove the partial file when a streamed recording is discarded or fails, so cancelled/failed runs don't leak descriptors or orphan partial recordings. - Surface a mid-stream write failure as a rejected recording rather than saving a silently truncated file. Structure: - Extract the streaming concern into electron/ipc/recordingStream.ts (RecordingStreamRegistry) and src/hooks/recorderHandle.ts, out of the 2.8k-line handlers.ts and the screen-recorder hook. - Key write streams by output file name, removing the implicit recordingId/+1 contract that spanned the IPC boundary. - Collapse the duplicated screen/webcam finalize blocks into one helper and the repeated duration-validity guard into one check; patch the screen and webcam durations in parallel. Adds unit tests for the registry (real temp-dir fs) and the recorder handle state machine (open-failure fallback, in-order writes awaited before stop, mid-stream failure). Extends the vitest include glob to collect electron-side tests. Verified: tsc --noEmit clean; biome clean; vitest 180/180. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
3.2 KiB
TypeScript
85 lines
3.2 KiB
TypeScript
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { RecordingStreamRegistry } from "./recordingStream";
|
|
|
|
describe("RecordingStreamRegistry", () => {
|
|
let dir: string;
|
|
const pathFor = (name: string) => path.join(dir, name);
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("streams chunks to disk in order and reports streamed on finalize", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
|
await registry.append("rec.webm", Buffer.from("hello "));
|
|
await registry.append("rec.webm", Buffer.from("world"));
|
|
|
|
const streamed = await registry.finalize("rec.webm");
|
|
|
|
expect(streamed).toBe(true);
|
|
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
|
|
// A second finalize has nothing to close.
|
|
expect(await registry.finalize("rec.webm")).toBe(false);
|
|
});
|
|
|
|
it("reports not-streamed when no stream was opened", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
expect(await registry.finalize("missing.webm")).toBe(false);
|
|
expect(registry.has("missing.webm")).toBe(false);
|
|
});
|
|
|
|
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
// Parent directory does not exist, so createWriteStream emits 'error' on open.
|
|
await expect(
|
|
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
|
|
).rejects.toThrow();
|
|
// A failed open must not register a stream the renderer would treat as live.
|
|
expect(registry.has("rec.webm")).toBe(false);
|
|
});
|
|
|
|
it("rejects append when no stream is open", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
|
|
/No active recording stream/,
|
|
);
|
|
});
|
|
|
|
it("discard closes the stream and removes the partial file", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
|
await registry.append("rec.webm", Buffer.from("partial"));
|
|
|
|
await registry.discard("rec.webm", pathFor("rec.webm"));
|
|
|
|
expect(registry.has("rec.webm")).toBe(false);
|
|
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
|
|
// Nothing left to finalize after a discard.
|
|
expect(await registry.finalize("rec.webm")).toBe(false);
|
|
});
|
|
|
|
it("discard tolerates a missing file", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("opening the same file twice replaces the prior stream", async () => {
|
|
const registry = new RecordingStreamRegistry();
|
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
|
await registry.append("rec.webm", Buffer.from("first"));
|
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
|
await registry.append("rec.webm", Buffer.from("second"));
|
|
await registry.finalize("rec.webm");
|
|
|
|
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
|
|
});
|
|
});
|