Files
openscreen/electron/ipc/recordingStream.test.ts
neurot1cal f3c5b8a65d fix: harden streaming lifecycle and lift it out of the IPC god-module
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>
2026-05-26 16:09:39 -07:00

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");
});
});