Files
webadderall 4061a0fdf5 Fix Recordly 2.0 part 2 follow-up review findings
Preserve existing PR history and integrate validated second-cycle fixes.
2026-11-08 08:52:13 +11:00

232 lines
9.0 KiB
TypeScript

import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
setNativeScreenRecordingActive,
nativeScreenRecordingActive,
windowsCaptureTargetPath,
setWindowsPendingVideoPath,
setWindowsCaptureOutputBuffer,
setWindowsCaptureProcess,
setWindowsCaptureStopRequested,
setWindowsCaptureTargetPath,
setWindowsMicAudioPath,
setWindowsNativeCaptureActive,
setWindowsSystemAudioPath,
windowsPendingVideoPath,
} from "../state";
const { send, validate } = vi.hoisted(() => ({ send: vi.fn(), validate: vi.fn() }));
vi.mock("electron", () => ({
app: { getPath: () => os.tmpdir() },
BrowserWindow: { getAllWindows: () => [{ isDestroyed: () => false, webContents: { send } }] },
}));
vi.mock("./diagnostics", () => ({ validateRecordedVideo: validate }));
import { attachWindowsCaptureLifecycle, recoverNativeWindowsCaptureOutput } from "./windows";
import { WindowsRecordingFinalizer } from "./windowsFinalizer";
import { RecordingOwnerRecovery } from "./ownerRecovery";
describe("Windows interrupted recording recovery", () => {
let directory: string;
let capturedPath: string;
let finalPath: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-win-recovery-"));
capturedPath = path.join(directory, "temporary.mp4");
finalPath = path.join(directory, "recording.mp4");
await fs.writeFile(capturedPath, "finalized video fixture");
validate.mockReset().mockResolvedValue({ fileSizeBytes: 4000, durationSeconds: 2 });
send.mockClear();
setWindowsPendingVideoPath(null);
setWindowsNativeCaptureActive(false);
setWindowsCaptureProcess(null);
setWindowsCaptureStopRequested(false);
setWindowsCaptureTargetPath(finalPath);
setWindowsSystemAudioPath(null);
setWindowsMicAudioPath(null);
setWindowsCaptureOutputBuffer(
`CAPTURE_INTERRUPTED: WGC capture session failed during recording\nRecording interrupted. Output path: ${capturedPath}\n`,
);
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it("publishes finalized partial video and both WAV tracks with timing metadata", async () => {
for (const kind of ["system", "mic"]) {
await fs.writeFile(path.join(directory, `temporary.${kind}.wav`), `${kind} audio`);
await fs.writeFile(
path.join(directory, `temporary.${kind}.wav.json`),
'{"startDelayMs":12}',
);
}
setWindowsSystemAudioPath(path.join(directory, "recording.system.wav"));
setWindowsMicAudioPath(path.join(directory, "recording.mic.wav"));
await expect(recoverNativeWindowsCaptureOutput()).resolves.toEqual({
success: true,
path: finalPath,
error: "Recording interrupted: WGC capture session failed during recording. A partial recording was saved.",
});
expect(validate).toHaveBeenCalledWith(capturedPath);
expect(windowsPendingVideoPath).toBe(finalPath);
expect(await fs.readFile(finalPath, "utf8")).toBe("finalized video fixture");
for (const kind of ["system", "mic"]) {
expect(await fs.readFile(path.join(directory, `recording.${kind}.wav`), "utf8")).toBe(
`${kind} audio`,
);
expect(
JSON.parse(
await fs.readFile(path.join(directory, `recording.${kind}.wav.json`), "utf8"),
),
).toEqual({ startDelayMs: 12 });
}
});
it("keeps invalid output on disk without reporting successful recovery", async () => {
validate.mockRejectedValueOnce(new Error("no decodable video stream"));
await expect(recoverNativeWindowsCaptureOutput()).resolves.toBeNull();
expect(await fs.readFile(capturedPath, "utf8")).toBe("finalized video fixture");
await expect(fs.access(finalPath)).rejects.toThrow();
});
it("does not read active or unfinalized encoder output", async () => {
setWindowsNativeCaptureActive(true);
await expect(recoverNativeWindowsCaptureOutput()).resolves.toBeNull();
setWindowsNativeCaptureActive(false);
setWindowsCaptureOutputBuffer(
"CAPTURE_INTERRUPTED: writer failed\nERROR: Failed to finalize Media Foundation encoder",
);
await expect(recoverNativeWindowsCaptureOutput()).resolves.toBeNull();
expect(validate).not.toHaveBeenCalled();
});
it("does not move audio or clear state if another session replaced a deferred recovery", async () => {
let release!: () => void;
validate.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const oldAudio = path.join(directory, "temporary.system.wav");
await fs.writeFile(oldAudio, "old audio");
setWindowsSystemAudioPath(path.join(directory, "recording.system.wav"));
const recovering = recoverNativeWindowsCaptureOutput();
const newVideo = path.join(directory, "new.mp4");
const newAudio = path.join(directory, "new.system.wav");
await fs.writeFile(newAudio, "new audio");
setWindowsCaptureTargetPath(newVideo);
setWindowsSystemAudioPath(newAudio);
setWindowsNativeCaptureActive(true);
setNativeScreenRecordingActive(true);
release();
await expect(recovering).resolves.toBeNull();
expect(await fs.readFile(newAudio, "utf8")).toBe("new audio");
expect(await fs.readFile(oldAudio, "utf8")).toBe("old audio");
expect(windowsCaptureTargetPath).toBe(newVideo);
expect(nativeScreenRecordingActive).toBe(true);
});
it("reserves on unexpected close, joins recovery through mux, and then admits the next capture", async () => {
let releaseValidation!: () => void;
validate.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseValidation = resolve;
}),
);
let releaseMux!: () => void;
const muxGate = new Promise<void>((resolve) => {
releaseMux = resolve;
});
const performMux = vi.fn(async () => {
const video = windowsPendingVideoPath;
setWindowsPendingVideoPath(null);
await muxGate;
return { success: true, path: video ?? undefined };
});
const finalizer = new WindowsRecordingFinalizer(
() => Boolean(windowsPendingVideoPath),
performMux,
);
const owner = Object.assign(new EventEmitter(), { id: 1 }) as unknown as Pick<
Electron.WebContents,
"id" | "on" | "removeListener"
>;
const ownership = new RecordingOwnerRecovery({
stop: async () => ({ success: false }),
needsFinish: () => finalizer.needsFinish,
finish: () => finalizer.run(),
discard: async () => {},
reset: () => {},
report: async () => {},
});
await ownership.start(owner, async () => ({ success: true }));
const child = Object.assign(new EventEmitter(), { stdin: new PassThrough() });
let recovery!: ReturnType<typeof finalizer.recover>;
setWindowsNativeCaptureActive(true);
attachWindowsCaptureLifecycle(
child as Parameters<typeof attachWindowsCaptureLifecycle>[0],
() => {
recovery = finalizer.recover(recoverNativeWindowsCaptureOutput);
},
);
child.once("close", () => {
expect(finalizer.needsFinish).toBe(true);
ownership.captureExited();
});
child.emit("close", 1);
const newStart = vi.fn(async () => ({ success: true }));
expect((await ownership.start(owner, newStart)).success).toBe(false);
expect(finalizer.recover(recoverNativeWindowsCaptureOutput)).toBe(recovery);
releaseValidation();
for (let i = 0; i < 100 && performMux.mock.calls.length === 0; i++)
await new Promise((resolve) => setTimeout(resolve, 1));
expect(performMux).toHaveBeenCalledOnce();
expect(windowsPendingVideoPath).toBeNull();
expect((await ownership.start(owner, newStart)).success).toBe(false);
releaseMux();
await expect(recovery).resolves.toMatchObject({
success: true,
path: finalPath,
error: expect.stringContaining("partial recording was saved"),
});
expect(finalizer.needsFinish).toBe(false);
expect((await ownership.start(owner, newStart)).success).toBe(true);
expect(newStart).toHaveBeenCalledOnce();
});
it("does not clear or recover a replacement session when an older child closes", () => {
const previous = Object.assign(new EventEmitter(), { stdin: new PassThrough() });
const replacement = new EventEmitter();
const interrupted = vi.fn();
setNativeScreenRecordingActive(true);
setWindowsNativeCaptureActive(true);
setWindowsCaptureProcess(
replacement as unknown as Parameters<typeof setWindowsCaptureProcess>[0],
);
attachWindowsCaptureLifecycle(
previous as unknown as Parameters<typeof attachWindowsCaptureLifecycle>[0],
interrupted,
);
previous.emit("close", 1);
expect(interrupted).not.toHaveBeenCalled();
expect(nativeScreenRecordingActive).toBe(true);
expect(windowsCaptureTargetPath).toBe(finalPath);
});
it("surfaces the actual interruption to the renderer when capture exits", () => {
const child = Object.assign(new EventEmitter(), { stdin: new PassThrough() });
setWindowsNativeCaptureActive(true);
attachWindowsCaptureLifecycle(child as Parameters<typeof attachWindowsCaptureLifecycle>[0]);
child.emit("close", 1);
expect(send).toHaveBeenCalledWith("recording-interrupted", {
reason: "capture-stopped",
message: "WGC capture session failed during recording",
});
});
});