mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const state = vi.hoisted(() => ({ root: "", approved: new Set<string>() }));
|
|
vi.mock("electron", () => ({
|
|
app: { getPath: () => state.root },
|
|
shell: {
|
|
trashItem: vi.fn(async (file: string) => {
|
|
await fs.mkdir(path.join(state.root, ".test-trash"), { recursive: true });
|
|
await fs.rename(file, path.join(state.root, ".test-trash", path.basename(file)));
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock("../../appPaths", () => ({
|
|
USER_DATA_PATH: "/tmp/recordly-test",
|
|
RECORDINGS_DIR: "/tmp/recordly-test",
|
|
}));
|
|
vi.mock("../utils", () => ({
|
|
getRecordingsDir: async () => state.root,
|
|
getTelemetryPathForVideo: (file: string) => `${file}.cursor.json`,
|
|
parseJsonWithByteOrderMark: JSON.parse,
|
|
normalizeVideoSourcePath: (file: string) => file,
|
|
getScreen: vi.fn(),
|
|
}));
|
|
vi.mock("../../mediaServer", () => ({
|
|
getMediaServerBaseUrl: () => "http://127.0.0.1:9999",
|
|
buildMediaUrl: (server: string, file: string) =>
|
|
`${server}/video?path=${encodeURIComponent(file)}`,
|
|
}));
|
|
vi.mock("../project/manager", () => ({
|
|
rememberApprovedLocalReadPath: async (file: string) => {
|
|
state.approved.add(file);
|
|
},
|
|
resolveApprovedLocalMediaPath: async (file: string) => (state.approved.has(file) ? file : null),
|
|
isPathInsideDirectory: (file: string, root: string) => file.startsWith(`${root}${path.sep}`),
|
|
}));
|
|
vi.mock("../ffmpeg/binary", async () => {
|
|
const require = createRequire(import.meta.url);
|
|
return {
|
|
getFfmpegBinaryPath: () => require("ffmpeg-static"),
|
|
getFfprobeBinaryPath: () => require("ffprobe-static").path,
|
|
};
|
|
});
|
|
import { listRecordings, setRecordingsRemoved, clearRecordingTrashUndo } from "./library";
|
|
import { importRecording, discardRecordingImport } from "./importRecording";
|
|
import { getRecordingThumbnail } from "./thumbnail";
|
|
import { getCompanionAudioFallbackInfo } from "./diagnostics";
|
|
const require = createRequire(import.meta.url);
|
|
const ffmpeg = require("ffmpeg-static") as string;
|
|
const run = promisify(execFile);
|
|
beforeEach(async () => {
|
|
state.root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "recordly-library-")));
|
|
state.approved.clear();
|
|
});
|
|
afterEach(async () => {
|
|
await clearRecordingTrashUndo();
|
|
await fs.rm(state.root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("lists recordings, moves recordings and their companions to Trash with reversible removals, and excludes companion media and symlinks", async () => {
|
|
const first = path.join(state.root, "recording-old.mp4");
|
|
const second = path.join(state.root, "recording-new.mov");
|
|
for (const name of [
|
|
first,
|
|
second,
|
|
path.join(state.root, "recording-new.webcam.mp4"),
|
|
path.join(state.root, "recording-new.mic.wav"),
|
|
])
|
|
await fs.writeFile(name, "fixture");
|
|
await fs.symlink(first, path.join(state.root, "linked.mp4"));
|
|
expect((await listRecordings()).map((entry) => entry.path).sort()).toEqual(
|
|
[first, second].sort(),
|
|
);
|
|
await setRecordingsRemoved([first, second], true);
|
|
expect(await listRecordings()).toEqual([]);
|
|
await expect(fs.access(first)).rejects.toThrow();
|
|
await expect(fs.access(path.join(state.root, "recording-new.mic.wav"))).rejects.toThrow();
|
|
expect(
|
|
(await fs.readdir(state.root)).filter((name) => name.startsWith(".recordly-trash-")),
|
|
).toHaveLength(1);
|
|
await setRecordingsRemoved([first, second], false);
|
|
expect(await listRecordings()).toHaveLength(2);
|
|
expect(await fs.readFile(first, "utf8")).toBe("fixture");
|
|
expect(await fs.readFile(path.join(state.root, "recording-new.mic.wav"), "utf8")).toBe(
|
|
"fixture",
|
|
);
|
|
await expect(setRecordingsRemoved(["/tmp/outside.mp4"], true)).rejects.toThrow("outside");
|
|
});
|
|
|
|
it("imports different-sized recordings with playable video, separate audio, stable offsets, and untouched originals", async () => {
|
|
const base = path.join(state.root, "recording-base.mp4");
|
|
const added = path.join(state.root, "recording-added.mp4");
|
|
await run(ffmpeg, [
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=red:s=160x90:r=30:d=1",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"sine=frequency=440:duration=1",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-c:a",
|
|
"aac",
|
|
"-shortest",
|
|
base,
|
|
]);
|
|
await run(ffmpeg, [
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=blue:s=90x160:r=30:d=1",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
added,
|
|
]);
|
|
const webcam = added.replace(".mp4", "-webcam.mp4");
|
|
await run(ffmpeg, [
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=lime:s=80x60:r=30:d=0.8",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
webcam,
|
|
]);
|
|
await fs.writeFile(
|
|
added.replace(".mp4", ".recordly-session.json"),
|
|
JSON.stringify({ version: 2, webcamFileName: path.basename(webcam), timeOffsetMs: 200 }),
|
|
);
|
|
await fs.writeFile(
|
|
`${added}.cursor.json`,
|
|
JSON.stringify({
|
|
samples: [
|
|
{ timeMs: 200, cx: 0.2, cy: 0.4, interactionType: "click", cursorType: "pointer" },
|
|
{
|
|
timeMs: 260,
|
|
cx: 0.2,
|
|
cy: 0.4,
|
|
interactionType: "mouseup",
|
|
cursorType: "pointer",
|
|
},
|
|
{ timeMs: 600, cx: 0.4, cy: 0.5, interactionType: "right-click" },
|
|
],
|
|
}),
|
|
);
|
|
const original = await fs.readFile(base);
|
|
await listRecordings();
|
|
const thumbnail = await getRecordingThumbnail(base);
|
|
expect(thumbnail).toMatch(/^data:image\/jpeg;base64,/);
|
|
const jpeg = Buffer.from(thumbnail.split(",")[1], "base64");
|
|
expect([...jpeg.subarray(0, 2)]).toEqual([0xff, 0xd8]);
|
|
expect(await getRecordingThumbnail(base)).toBe(thumbnail);
|
|
await expect(getRecordingThumbnail("/tmp/unapproved.mp4")).rejects.toThrow("outside");
|
|
const result = await importRecording(base, added);
|
|
expect(result.sourceStartMs).toBeCloseTo(1000, -1);
|
|
expect(result.durationMs).toBeCloseTo(1000, -1);
|
|
expect(result.webcam?.visibleRanges).toEqual([{ startMs: 1200, endMs: 2000 }]);
|
|
const cursor = JSON.parse(await fs.readFile(`${result.path}.cursor.json`, "utf8")).samples;
|
|
expect(
|
|
cursor.map((point: { timeMs: number; interactionType: string }) => [
|
|
point.timeMs,
|
|
point.interactionType,
|
|
]),
|
|
).toEqual([
|
|
[1200, "click"],
|
|
[1260, "mouseup"],
|
|
[1600, "right-click"],
|
|
]);
|
|
expect(cursor[0].cursorType).toBe("pointer");
|
|
expect(cursor[0].cx).toBeCloseTo(0.405078125);
|
|
const webcamPixels = await run(
|
|
ffmpeg,
|
|
[
|
|
"-v",
|
|
"error",
|
|
"-ss",
|
|
"1.5",
|
|
"-i",
|
|
result.webcam!.sourcePath!,
|
|
"-vf",
|
|
"crop=2:2:40:30",
|
|
"-frames:v",
|
|
"1",
|
|
"-f",
|
|
"rawvideo",
|
|
"-pix_fmt",
|
|
"rgb24",
|
|
"-",
|
|
],
|
|
{ encoding: "buffer" },
|
|
);
|
|
expect(webcamPixels.stdout[1]).toBeGreaterThan(200);
|
|
const companions = await getCompanionAudioFallbackInfo(result.path);
|
|
expect(companions.paths).toHaveLength(2);
|
|
expect(companions.paths.every((file) => file.endsWith(".wav"))).toBe(true);
|
|
await run(ffmpeg, ["-v", "error", "-i", result.path, "-f", "null", "-"]);
|
|
const pixels = await run(
|
|
ffmpeg,
|
|
[
|
|
"-v",
|
|
"error",
|
|
"-ss",
|
|
"1.5",
|
|
"-i",
|
|
result.path,
|
|
"-vf",
|
|
"crop=2:2:80:44",
|
|
"-frames:v",
|
|
"1",
|
|
"-f",
|
|
"rawvideo",
|
|
"-pix_fmt",
|
|
"rgb24",
|
|
"-",
|
|
],
|
|
{ encoding: "buffer" },
|
|
);
|
|
expect(pixels.stdout[2]).toBeGreaterThan(200); // blue centre survives aspect-ratio fitting
|
|
const second = await importRecording(result.path, added);
|
|
expect(second.sourceStartMs).toBeCloseTo(2000, -1);
|
|
expect(second.webcam?.visibleRanges).toEqual([
|
|
{ startMs: 1200, endMs: 2000 },
|
|
{ startMs: 2200, endMs: 3000 },
|
|
]);
|
|
await discardRecordingImport(result.path);
|
|
await expect(fs.access(result.path)).rejects.toThrow();
|
|
await expect(fs.access(result.path.replace(/\.mp4$/, ".mic.wav"))).rejects.toThrow();
|
|
await expect(fs.access(result.path.replace(/\.mp4$/, "-webcam.mp4"))).rejects.toThrow();
|
|
await expect(
|
|
fs.access(result.path.replace(/\.mp4$/, ".recordly-session.json")),
|
|
).rejects.toThrow();
|
|
await expect(fs.access(`${result.path}.webcam-ranges.json`)).rejects.toThrow();
|
|
await expect(fs.access(`${result.path}.cursor.json`)).rejects.toThrow();
|
|
await expect(fs.access(second.path)).resolves.toBeUndefined();
|
|
expect(await fs.readFile(base)).toEqual(original);
|
|
expect((await listRecordings()).map((entry) => entry.path).sort()).toEqual(
|
|
[base, added].sort(),
|
|
);
|
|
await expect(importRecording("/tmp/unapproved.mp4", added)).rejects.toThrow(
|
|
"no longer available",
|
|
);
|
|
}, 60000);
|
|
|
|
it("retains staged originals for undo when the OS refuses Trash", async () => {
|
|
const { shell } = await import("electron");
|
|
const file = path.join(state.root, "recording-failure.mp4");
|
|
await fs.writeFile(file, "original");
|
|
await setRecordingsRemoved([file], true);
|
|
vi.mocked(shell.trashItem).mockRejectedValueOnce(new Error("Trash unavailable"));
|
|
await expect(clearRecordingTrashUndo()).rejects.toThrow("Trash unavailable");
|
|
await setRecordingsRemoved([file], false);
|
|
expect(await fs.readFile(file, "utf8")).toBe("original");
|
|
});
|
|
|
|
it("undo never overwrites a new file at the original location", async () => {
|
|
const file = path.join(state.root, "recording-conflict.mp4");
|
|
await fs.writeFile(file, "original");
|
|
await setRecordingsRemoved([file], true);
|
|
await fs.writeFile(file, "new recording");
|
|
await expect(setRecordingsRemoved([file], false)).rejects.toThrow("already exists");
|
|
expect(await fs.readFile(file, "utf8")).toBe("new recording");
|
|
});
|
|
|
|
it("cancels an active import and removes partial outputs without changing originals", async () => {
|
|
const base = path.join(state.root, "recording-base.mp4");
|
|
const added = path.join(state.root, "recording-added.mp4");
|
|
await run(ffmpeg, [
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=red:s=640x480:r=30:d=10",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
base,
|
|
]);
|
|
await fs.copyFile(base, added);
|
|
state.approved.add(base);
|
|
const original = await fs.readFile(base);
|
|
const controller = new AbortController();
|
|
const importing = importRecording(base, added, undefined, controller.signal);
|
|
const rejected = expect(importing).rejects.toMatchObject({ name: "AbortError" });
|
|
await vi.waitFor(async () => {
|
|
const files = await fs.readdir(path.join(state.root, ".recordly-media"));
|
|
expect(files.some((name) => name.startsWith("import-"))).toBe(true);
|
|
});
|
|
controller.abort();
|
|
await rejected;
|
|
expect(await fs.readdir(path.join(state.root, ".recordly-media"))).toEqual([]);
|
|
expect(await fs.readFile(base)).toEqual(original);
|
|
expect(await fs.readFile(added)).toEqual(original);
|
|
});
|
|
|
|
it("restores on volumes without hard links and preserves conflicts", async () => {
|
|
const file = path.join(state.root, "recording-exfat.mp4");
|
|
await fs.writeFile(file, "recording");
|
|
await setRecordingsRemoved([file], true);
|
|
const link = vi
|
|
.spyOn(fs, "link")
|
|
.mockRejectedValue(Object.assign(new Error("unsupported"), { code: "ENOTSUP" }));
|
|
try {
|
|
await setRecordingsRemoved([file], false);
|
|
expect(await fs.readFile(file, "utf8")).toBe("recording");
|
|
} finally {
|
|
link.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("Raw includes camera and audio sources without including metadata or symlinks", async () => {
|
|
const files = ["screen.mp4", "screen.webcam.mp4", "screen.mic.wav", "screen.system.m4a"];
|
|
for (const name of [...files, "screen.cursor.json"]) await fs.writeFile(path.join(state.root, name), "fixture");
|
|
await fs.symlink(path.join(state.root, "screen.mp4"), path.join(state.root, "linked.mp4"));
|
|
expect((await listRecordings(true)).map(entry => entry.name).sort()).toEqual(files.sort());
|
|
expect((await listRecordings()).map(entry => entry.name)).toEqual(["screen.mp4"]);
|
|
});
|