Fix Recordly 2.0 part 8 follow-up review findings

Preserve existing PR history and integrate validated second-cycle fixes.
This commit is contained in:
webadderall committed 2026-11-07 18:27:37 +11:00
commit 132f16df6b
35 files changed
+693 -30

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ The service provides R2 video storage, D1 metadata, range streaming, a responsiv
## Comments
Anyone with a valid link can watch a public recording. Leaving timestamped feedback requires a signed-in account; Recordly Cloud uses the viewer’s verified Recordly account and profile name. The server derives comment authorship from the session, and viewers can edit or delete their own comments. Account and IP rate limits and the 2,000-character comment limit apply. Self-hosted viewers sign in to the account system configured by that service.
Anyone with a valid link can watch a public recording. Leaving timestamped feedback requires a signed-in account; Recordly Cloud uses the viewer’s verified Recordly account and profile name. The server derives comment authorship from the session, and viewers can edit or delete their own comments. Account and IP rate limits and the 2,000-character comment limit apply. The self-hosted worker requires a separate, signed-in account local to that instance; a supplied display name alone does not authorize a comment.
## Third-party licensing
+79
View File
@@ -324,3 +324,82 @@ it.each([
])("rejects invalid callback before registry/relay lookup: %s", (url) => {
expect(parseFlowCallback(url, "recordly")).toBeNull();
});
it.each([
"decrypt",
"JSON",
"shape",
])("preserves unrelated %s failures while registering a new exact route", async (failure) => {
const a = relay();
const launcher = relay();
const records = path.join(directory, "flows");
await fs.mkdir(records, { recursive: true });
const damagedId = "flow_broken";
const damaged = path.join(records, `recordly.${damagedId}.${"a".repeat(32)}.enc`);
const bytes =
failure === "decrypt"
? Buffer.from("unreadable ciphertext")
: failure === "JSON"
? encrypt("{invalid JSON")
: encrypt(JSON.stringify({ version: 1, protocol: "recordly" }));
await fs.writeFile(damaged, bytes);
const current = owner("new-owner");
await a.instance.register("flow_alice", "exact-verifier", current);
expect(await launcher.instance.route(callback("flow_alice"))).toBe("forwarded");
expect(a.deliver).toHaveBeenCalledExactlyOnceWith(current, callback("flow_alice"));
expect(await launcher.instance.route(callback(damagedId))).toBe("unavailable");
await expect(
launcher.instance.register(damagedId, "must-not-replace", owner("other")),
).rejects.toThrow("unlock");
expect(await fs.readFile(damaged)).toEqual(bytes);
expect(a.hasVerifier).toHaveBeenCalledExactlyOnceWith("exact-verifier");
expect(launcher.deliver).not.toHaveBeenCalled();
});
it("counts unreadable generations toward the 128-route limit without deleting them", async () => {
const a = relay();
const launcher = relay();
const records = path.join(directory, "flows");
await fs.mkdir(records, { recursive: true });
const bytes = Buffer.from("unreadable ciphertext");
const files = Array.from({ length: 127 }, (_, n) =>
path.join(records, `recordly.corrupt_${String(n).padStart(3, "0")}.${"a".repeat(32)}.enc`),
);
await Promise.all(files.map((file) => fs.writeFile(file, bytes)));
await a.instance.register("flow_alice", "exact-verifier", owner("a"));
expect(await fs.readdir(records)).toHaveLength(128);
expect(await launcher.instance.route(callback("flow_alice"))).toBe("forwarded");
await expect(
launcher.instance.register("flow_bob00", "other-verifier", owner("b")),
).rejects.toThrow("Too many sign-in return routes");
expect(await fs.readdir(records)).toHaveLength(128);
for (const file of files) expect(await fs.readFile(file)).toEqual(bytes);
});
it("still removes only readable proven-dead generations while preserving ancient live and unreadable records", async () => {
const a = relay();
const b = relay();
await a.instance.register("flow_alice", "alice-verifier", owner("a"));
const live = await record("flow_alice");
const liveBytes = await fs.readFile(live.file);
await fs.utimes(live.file, new Date(0), new Date(0));
const deadPid = 2147483647;
const deadNonce = "b".repeat(32);
const deadRecord = { ...live.value, flowId: "flow_dead0", pid: deadPid, nonce: deadNonce };
const deadFile = path.join(directory, "flows", `recordly.flow_dead0.${deadNonce}.enc`);
await fs.writeFile(deadFile, encrypt(JSON.stringify(deadRecord)));
const damaged = path.join(directory, "flows", `recordly.flow_broken.${"c".repeat(32)}.enc`);
const damagedBytes = Buffer.from("unreadable ciphertext");
await fs.writeFile(damaged, damagedBytes);
const realKill = process.kill.bind(process);
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
if (pid === deadPid)
throw Object.assign(new Error("Exited fixture process"), { code: "ESRCH" });
return realKill(pid, signal);
});
await b.instance.register("flow_bob00", "bob-verifier", owner("b"));
await expect(fs.access(deadFile)).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(live.file)).toEqual(liveBytes);
expect(await fs.readFile(damaged)).toEqual(damagedBytes);
expect(await b.instance.route(callback("flow_alice"))).toBe("forwarded");
});
+9 -1
View File
@@ -392,7 +392,15 @@ export function createAuthFlowRelay<Owner>(options: {
let live = 0;
for (const name of await fs.readdir(recordsDirectory)) {
if (!name.startsWith(`${options.protocol}.`) || !name.endsWith(".enc")) continue;
const prior = await readRecord(name);
let prior: Registration | null;
try {
prior = await readRecord(name);
} catch {
// Unreadable unrelated generations still occupy capacity. Preserve
// them without blocking a different, verifiable sign-in flow.
live++;
continue;
}
if (!prior) continue;
if (dead(prior.pid))
await fs
+2 -1
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -56,7 +57,7 @@ describe("desktop wallpaper source", () => {
});
describe("modern macOS wallpaper providers", () => {
const assetID = "6511D2B5-E185-4886-9505-B4004E920D27";
const assetID = randomUUID().toUpperCase();
const preference = JSON.stringify({
provider: "com.apple.wallpaper.choice.aerials",
assetID,
+14 -1
View File
@@ -244,7 +244,20 @@ describe("local media path policy", () => {
// Keep the fractional revision in the past so an immediate edit is always newer.
await fs.utimes(projectPath, new Date(), Math.floor(Date.now() / 1000) - 2 + 0.123456);
preview = await manager.readProjectPreview(projectPath);
await manager.cacheProjectThumbnail(projectPath, preview.revision!, dataUrl);
// A cache volume may retain less timestamp precision than the project volume.
const writeTimes = fs.utimes.bind(fs);
const stamp = vi.spyOn(fs, "utimes").mockImplementationOnce((file, accessed, modified) =>
writeTimes(
file,
accessed,
typeof modified === "number" ? Math.floor(modified * 1000) / 1000 : modified,
),
);
try {
await manager.cacheProjectThumbnail(projectPath, preview.revision!, dataUrl);
} finally {
stamp.mockRestore();
}
expect((await manager.buildProjectLibraryEntry(projectPath, tempRoot))?.thumbnailPath).toBe(
target,
);
+1 -1
View File
@@ -403,7 +403,7 @@ export async function cacheProjectThumbnail(projectPath: string, revision: strin
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
await fs.writeFile(temporary, png, { flag: "wx" });
// A subsequent project edit must invalidate the cached frame, even if it races this write.
await fs.utimes(temporary, new Date(), stats.mtimeMs / 1000);
await fs.utimes(temporary, new Date(), Math.ceil(stats.mtimeMs) / 1000);
if (!sameProjectRevision(stats, await fs.stat(normalizedPath))) return null;
await fs.rename(temporary, target);
return target;
+1
View File
@@ -271,6 +271,7 @@ export function registerCloudShareHandlers() {
ownerId: event.sender.id,
token,
};
activeUpload.token = token;
activeUpload.finishing = true;
const controller = activeUpload.controller;
activeUploads.set(uploadId, activeUpload);
@@ -192,7 +192,12 @@ describe("cloud share IPC lifecycle", () => {
});
}
function server(holdMetadata = false, partBytes?: number) {
const requests: { path: string; method?: string; body?: string }[] = [];
const requests: {
path: string;
method?: string;
body?: string;
authorization: string | null;
}[] = [];
let releaseMetadata: (() => void) | undefined;
const metadataGate = holdMetadata
? new Promise<void>((resolve) => {
@@ -208,6 +213,7 @@ describe("cloud share IPC lifecycle", () => {
path: url.pathname,
method: init.method,
body: typeof init.body === "string" ? init.body : undefined,
authorization: new Headers(init.headers).get("authorization"),
});
if (url.pathname === "/api/upload")
return json({
@@ -326,6 +332,105 @@ describe("cloud share IPC lifecycle", () => {
expect(owner.listenerCount("destroyed")).toBe(0);
});
it("uses the validated final-upload token for an existing progressive upload without a separate refresh IPC", async () => {
const remote = server();
const id = uploadId();
expect(begin(id)).toEqual({ success: true });
await vi.waitFor(() =>
expect(
remote.requests.some((request) => request.path.includes("upload-multipart")),
).toBe(true),
);
expect(
remote.requests.find((request) => request.path === "/api/upload")?.authorization,
).toBe("Bearer token");
expect(
await call("cloud-share-upload", owner, {
endpoint,
token: "fresh-final-token",
uploadId: id,
filePath: finalPath,
}),
).toMatchObject({ success: true });
for (const path of [
"upload-part/",
"upload-complete/",
"/api/metadata/",
"upload-thumbnail/",
]) {
const requests = remote.requests.filter((request) => request.path.includes(path));
expect(requests.length).toBeGreaterThan(0);
expect(
requests.every((request) => request.authorization === "Bearer fresh-final-token"),
).toBe(true);
}
expect(remote.requests.filter((request) => request.path === "/api/upload")).toHaveLength(1);
expect(owner.listenerCount("destroyed")).toBe(0);
});
it("uses the fresh final-upload token to clean up a progressive reservation after invalid final input", async () => {
const remote = server();
const id = uploadId();
expect(begin(id)).toEqual({ success: true });
await vi.waitFor(() =>
expect(
remote.requests.some((request) => request.path.includes("upload-multipart")),
).toBe(true),
);
expect(
await call("cloud-share-upload", owner, {
endpoint,
token: "fresh-cleanup-token",
uploadId: id,
filePath: "",
}),
).toMatchObject({ success: false, error: "Export a video before sharing it." });
await vi.waitFor(() =>
expect(remote.requests.some((request) => request.path === "/api/delete/abc123")).toBe(
true,
),
);
expect(
remote.requests.find((request) => request.path === "/api/delete/abc123")?.authorization,
).toBe("Bearer fresh-cleanup-token");
expect(owner.listenerCount("destroyed")).toBe(0);
});
it.each([
"another renderer",
"invalid token",
])("does not replace progressive credentials from %s during final upload", async (attempt) => {
const remote = server();
const id = uploadId();
expect(begin(id)).toEqual({ success: true });
await vi.waitFor(() =>
expect(
remote.requests.some((request) => request.path.includes("upload-multipart")),
).toBe(true),
);
expect(
await call("cloud-share-upload", attempt === "another renderer" ? stranger : owner, {
endpoint,
token: attempt === "invalid token" ? "invalid\ntoken" : "foreign-token",
uploadId: id,
filePath: finalPath,
}),
).toMatchObject({ success: false });
expect(owner.listenerCount("destroyed")).toBe(1);
expect(call("cloud-share-cancel", owner, id)).toEqual({ success: true });
await vi.waitFor(() =>
expect(remote.requests.some((request) => request.path === "/api/delete/abc123")).toBe(
true,
),
);
expect(
remote.requests.find((request) => request.path === "/api/delete/abc123")?.authorization,
).toBe("Bearer token");
expect(remote.requests.some((request) => request.path.includes("/api/metadata/"))).toBe(
false,
);
});
it("uses parallel multipart for finished clips below the old 50 MiB cutoff", async () => {
const partBytes = 5 * 1024 * 1024;
await fs.truncate(finalPath, partBytes * 2 + 16);
@@ -332,3 +332,52 @@ it("persists the dev dismissal before native destruction and restores it if clos
expect(render()?.props.open).toBe(false);
error.mockRestore();
});
it("opens production permission recovery only once for each new request nonce", () => {
vi.stubEnv("DEV", false);
window.location.search = "?windowType=editor";
state.settings.set(seenKey, true);
vi.mocked(window.electronAPI.showOnboardingWindow).mockResolvedValue(true);
render({ standalone: false, requestNonce: 1 });
render({ standalone: false, requestNonce: 1, user: { ...user } });
expect(state.settings.get(pendingKey)).toBe(true);
expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledExactlyOnceWith({
preview: false,
});
render({ standalone: false, requestNonce: 2 });
render({ standalone: false, requestNonce: 2, user: { ...user } });
expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledTimes(2);
expect(vi.mocked(window.electronAPI.showOnboardingWindow).mock.calls[1]).toEqual([
{ preview: false },
]);
expect(state.settings.get(seenKey)).toBe(true);
expect(state.writes.some(([key]) => key === seenKey)).toBe(false);
});
it("opens real permission recovery from a native event even after the dev tour was dismissed", async () => {
window.location.search = "?windowType=editor";
state.settings.set(seenKey, true);
state.settings.set("recordly.dev.onboardingDismissed", true);
render({ standalone: false });
expect(window.electronAPI.showOnboardingWindow).not.toHaveBeenCalled();
const requestPermissions = vi
.mocked(window.electronAPI.onRecordingPermissionsRequested)
.mock.calls.at(-1)?.[0];
expect(requestPermissions).toBeTypeOf("function");
requestPermissions!();
expect(state.settings.get(pendingKey)).toBe(true);
expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledExactlyOnceWith({
preview: false,
});
await vi.waitFor(() => {
const recovery = render({ standalone: false });
expect(recovery?.type).toBe(RecordingPermissionsDialog);
expect(recovery?.props.open).toBe(true);
});
expect(window.electronAPI.showOnboardingWindow).toHaveBeenCalledOnce();
expect(state.settings.get(seenKey)).toBe(true);
expect(state.settings.get("recordly.dev.onboardingDismissed")).toBe(true);
expect(
state.writes.some(([key]) => key === seenKey || key === "recordly.dev.onboardingDismissed"),
).toBe(false);
});
@@ -56,10 +56,10 @@ export function PlusFeatureDetails({
Briefcase,
FileText,
Monitor,
Link,
InfinityIcon,
CloudArrowUp,
...(limits?.plusWeeklyUploadLimit ? [CloudArrowUp] : []),
InfinityIcon,
Link,
ArrowClockwise,
Sparkle,
];
+1 -1
View File
@@ -475,7 +475,7 @@ function LaunchWindowContent() {
className={`${styles.recordingControls} ${styles.recordingFinalizing} gap-2 text-sm text-muted-foreground`}
>
<Spinner size="sm" />
Finishing recording…
{captureT("finishingRecording", "Finishing recording…")}
</div>
) : recording || restarting ? (
recordingControls
@@ -125,7 +125,9 @@ export function DashboardGrid({
</p>
{errorRecordingSource && errorRecordingSource !== errorSource && (
<p className="mt-2 select-text break-words text-muted-foreground">
Recording: {errorSourceName(errorRecordingSource)}
{uiText("ui.recordingSource", "Recording: {{name}}", {
name: errorSourceName(errorRecordingSource),
})}
</p>
)}
<div className="mt-3">
@@ -93,7 +93,11 @@ export function DashboardSettings({
);
setHideHud(protection.enabled);
} catch (error) {
if (active) setFeedback({ area: "hud", error: String(error) });
if (active)
setFeedback({
area: "hud",
error: error instanceof Error ? error.message : String(error),
});
}
})();
return () => {
@@ -282,6 +286,19 @@ export function DashboardSettings({
{status("hud")}
</div>
)}
{!captureSupported && status("hud")}
{hideHud === undefined && feedback?.area === "hud" && feedback.error && (
<Button
size="sm"
variant="secondary"
onClick={() => {
setFeedback(undefined);
setReload((value) => value + 1);
}}
>
{t("settings.application.retry", "Try again")}
</Button>
)}
</SettingsCategory>
<SettingsCategory category="general">
<header className="dashboard-settings-group dashboard-settings-subgroup">
@@ -249,3 +249,53 @@ it("does not invalidate an active relink for a discarded render of another proje
expect.any(Function),
);
});
it.each([
false,
true,
])("keeps toast retry on committed handlers after discarded render, missing recording=%s", async (missing) => {
const open = vi
.fn()
.mockResolvedValueOnce({
success: false,
error: missing
? "Project video file not found: missing.mp4"
: "Project temporarily busy",
})
.mockResolvedValue({ success: true, project: result.project, path: target });
const relink = vi.fn().mockResolvedValue(result);
bridge({ openProjectFileAtPath: open, relinkProjectRecording: relink });
const project = initialProject();
const applyCommitted = vi.fn().mockResolvedValue(true);
const actions = render(project, applyCommitted);
await actions.handleOpenProjectFromLibrary(target);
const retry = vi.mocked(toast.error).mock.calls.at(-1)?.[1]?.action?.onClick;
expect(retry).toBeTypeOf("function");
const saveDiscarded = vi.fn().mockResolvedValue(false);
render(
{ ...project, currentProjectPath: "/projects/discarded.recordly" },
vi.fn(),
{ hasUnsavedChanges: true, saveProject: saveDiscarded },
false,
);
retry?.();
await vi.waitFor(() =>
expect(applyCommitted).toHaveBeenCalledWith(
result.project,
target,
...(missing ? [expect.any(Function)] : []),
),
);
expect(saveDiscarded).not.toHaveBeenCalled();
expect(missing ? relink : open).toHaveBeenCalledTimes(missing ? 1 : 2);
// The same persistent toast action must adopt a later committed handler.
const saveNext = vi.fn().mockResolvedValue(true);
const applyNext = vi.fn().mockResolvedValue(true);
render({ ...project, currentProjectPath: "/projects/committed.recordly" }, applyNext, {
hasUnsavedChanges: true,
saveProject: saveNext,
});
retry?.();
await vi.waitFor(() => expect(applyNext).toHaveBeenCalled());
expect(saveNext).toHaveBeenCalledOnce();
});
@@ -176,7 +176,9 @@ export function useProjectOpenActions({
refreshProjectLibrary,
],
);
latestRelink.current = handleRelinkProjectRecording;
useLayoutEffect(() => {
latestRelink.current = handleRelinkProjectRecording;
}, [handleRelinkProjectRecording]);
const handleOpenProjectFromLibrary = useCallback(
async (projectPath: string) => {
@@ -216,7 +218,9 @@ export function useProjectOpenActions({
reportOpenError,
],
);
latestOpen.current = handleOpenProjectFromLibrary;
useLayoutEffect(() => {
latestOpen.current = handleOpenProjectFromLibrary;
}, [handleOpenProjectFromLibrary]);
const handleImportMediaOrProject = useCallback(async () => {
if (opening.current) return;
@@ -163,13 +163,13 @@ export function useBackgroundSettings(
const result = await window.electronAPI.importWallpaperVideo(file);
if (!result.success || !result.path) throw new Error("Unreadable video");
const filePath = result.path;
setCustomImages((prev) => [filePath, ...prev]);
setCustomImages((prev) => (prev.includes(filePath) ? prev : [filePath, ...prev]));
onWallpaperChange(filePath);
toast.success(te("videoBackgroundAdded", "Video background added"));
return;
}
const dataUrl = await prepareWallpaperImage(file);
setCustomImages((prev) => [...prev, dataUrl]);
setCustomImages((prev) => (prev.includes(dataUrl) ? prev : [...prev, dataUrl]));
onWallpaperChange(dataUrl);
toast.success(tSettings("background.uploadSuccess"));
} catch {
@@ -196,7 +196,7 @@ export function useBackgroundSettings(
});
return;
}
setCustomImages((prev) => [filePath, ...prev]);
setCustomImages((prev) => (prev.includes(filePath) ? prev : [filePath, ...prev]));
onWallpaperChange(filePath);
toast.success(te("videoBackgroundAdded", "Video background added"));
} catch {
@@ -379,12 +379,15 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
ids: string[];
clips: ClipRegion[];
} | null>(null);
const selectedClipIds =
clipSelection?.primaryId === selectedClipId && clipSelection?.clips === clipRegions
? clipSelection.ids
: selectedClipId
? [selectedClipId]
: [];
const selectedClipIds = useMemo(
() =>
clipSelection?.primaryId === selectedClipId && clipSelection?.clips === clipRegions
? clipSelection.ids
: selectedClipId
? [selectedClipId]
: [],
[clipSelection, selectedClipId, clipRegions],
);
useEffect(() => {
if (
clipSelection &&
+1
View File
@@ -12,6 +12,7 @@
"actions": "Aktionen",
"action": "Aktion",
"recordingControls": "Aufnahmesteuerung",
"finishingRecording": "Aufnahme wird abgeschlossen…",
"discardingRecording": "Aufnahme wird verworfen",
"recording": "Aufnahme läuft",
"discardRecording": "Aufnahme verwerfen",
+1
View File
@@ -12,6 +12,7 @@
"actions": "Actions",
"action": "Action",
"recordingControls": "Recording controls",
"finishingRecording": "Finishing recording…",
"discardingRecording": "Discarding recording",
"recording": "Recording",
"discardRecording": "Discard recording",
+1
View File
@@ -12,6 +12,7 @@
"actions": "Acciones",
"action": "Acción",
"recordingControls": "Controles de grabación",
"finishingRecording": "Finalizando la grabación…",
"discardingRecording": "Descartando grabación",
"recording": "Grabando",
"discardRecording": "Descartar grabación",
+1
View File
@@ -12,6 +12,7 @@
"actions": "Actions",
"action": "Action",
"recordingControls": "Commandes d’enregistrement",
"finishingRecording": "Finalisation de l’enregistrement…",
"discardingRecording": "Suppression de l’enregistrement",
"recording": "Enregistrement en cours",
"discardRecording": "Supprimer l’enregistrement",
+1
View File
@@ -12,6 +12,7 @@
"actions": "Azioni",
"action": "Azione",
"recordingControls": "Controlli di registrazione",
"finishingRecording": "Finalizzazione della registrazione…",
"discardingRecording": "Eliminazione della registrazione",
"recording": "Registrazione in corso",
"discardRecording": "Elimina registrazione",
+1
View File
@@ -12,6 +12,7 @@
"actions": "操作",
"action": "操作",
"recordingControls": "録画コントロール",
"finishingRecording": "録画を仕上げています…",
"discardingRecording": "録画を破棄中",
"recording": "録画中",
"discardRecording": "録画を破棄",
+1
View File
@@ -12,6 +12,7 @@
"actions": "작업",
"action": "작업",
"recordingControls": "녹화 제어",
"finishingRecording": "녹화를 마무리하는 중…",
"discardingRecording": "녹화 삭제 중",
"recording": "녹화 중",
"discardRecording": "녹화 삭제",
+1
View File
@@ -12,6 +12,7 @@
"actions": "Acties",
"action": "Actie",
"recordingControls": "Opnamebediening",
"finishingRecording": "Opname afronden…",
"discardingRecording": "Opname wordt verwijderd",
"recording": "Opname bezig",
"discardRecording": "Opname verwijderen",
+1
View File
@@ -12,6 +12,7 @@
"actions": "Ações",
"action": "Ação",
"recordingControls": "Controles de gravação",
"finishingRecording": "Finalizando a gravação…",
"discardingRecording": "Descartando gravação",
"recording": "Gravando",
"discardRecording": "Descartar gravação",
+1
View File
@@ -12,6 +12,7 @@
"actions": "操作",
"action": "操作",
"recordingControls": "录制控制",
"finishingRecording": "正在完成录制…",
"discardingRecording": "正在放弃录制",
"recording": "正在录制",
"discardRecording": "放弃录制",
+1
View File
@@ -12,6 +12,7 @@
"actions": "操作",
"action": "操作",
"recordingControls": "錄製控制",
"finishingRecording": "正在完成錄製…",
"discardingRecording": "正在捨棄錄製",
"recording": "錄製中",
"discardRecording": "捨棄錄製",
+10 -4
View File
@@ -56,9 +56,11 @@ export async function prepareExportAttempt(
session.native.nativeStaticLayoutBackgroundSkipReason = null;
session.sourceVideoInfo = null;
session.metrics.totalExportStartTimeMs = getNowMs();
const backendPreference = control.nativeFailure
? "webcodecs"
: (session.config.backendPreference ?? "auto");
// Forced software constrains routing too, including cloud and native fallbacks.
const backendPreference =
control.nativeFailure || session.config.forceSoftwareEncoding
? "webcodecs"
: (session.config.backendPreference ?? "auto");
const runtimePlatform = hooks.getRuntimePlatform();
let useNativeEncoder = false;
let triedNativeStaticLayoutWithProbe = false;
@@ -79,8 +81,12 @@ export async function prepareExportAttempt(
reason: "windows-auto-static-layout-disabled",
});
}
const cloudCompression = session.config.deliveryProfile === "cloud" && !control.nativeFailure;
const cloudCompression =
session.config.deliveryProfile === "cloud" &&
!control.nativeFailure &&
!session.config.forceSoftwareEncoding;
const shouldTryNativeStaticLayout =
!session.config.forceSoftwareEncoding &&
!cloudCompression &&
!control.nativeFailure &&
(backendPreference === "breeze" ||
@@ -110,6 +110,134 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(initialize).toHaveBeenCalledTimes(scenario === "success" ? 0 : 1);
expect(exporterInternals(exporter).session.config.bitrate).toBe(6_000_000);
});
it.each(
[
{
name: "cloud",
platform: "MacIntel",
backendPreference: "webcodecs",
deliveryProfile: "cloud",
staticRoute: false,
},
{
name: "platform auto",
platform: "MacIntel",
backendPreference: "auto",
staticRoute: false,
},
{
name: "auto software escalation",
platform: "Linux x86_64",
backendPreference: "auto",
staticRoute: false,
},
{
name: "Breeze static",
platform: "Linux x86_64",
backendPreference: "breeze",
staticRoute: true,
},
{
name: "experimental static",
platform: "Linux x86_64",
backendPreference: "webcodecs",
experimentalNvidiaCudaExport: true,
staticRoute: true,
},
].flatMap((scenario) =>
[undefined, false, true].map((forceSoftwareEncoding) => ({
...scenario,
forceSoftwareEncoding,
})),
),
)("respects forceSoftwareEncoding=$forceSoftwareEncoding across $name export routing", async ({
platform,
backendPreference,
deliveryProfile,
experimentalNvidiaCudaExport,
staticRoute,
forceSoftwareEncoding,
}) => {
vi.stubGlobal("navigator", { platform });
const exporter = new ModernVideoExporter({
videoUrl: "file:///recording.mp4",
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 8_000_000,
wallpaper: "#000000",
backendPreference,
preferredRenderBackend: "webgpu",
deliveryProfile,
experimentalNvidiaCudaExport,
forceSoftwareEncoding,
} as never);
const operations = exporterInternals(exporter).operations;
const nativeStart = vi
.spyOn(operations, "tryStartNativeVideoExport")
.mockResolvedValue(true);
const staticLayout = vi
.spyOn(operations, "tryExportNativeStaticLayout")
.mockResolvedValue({ success: true, tempFilePath: "/tmp/static.mp4" });
vi.spyOn(operations, "loadNativeStaticLayoutVideoInfo").mockResolvedValue(mocks.videoInfo);
vi.spyOn(operations, "finishNativeVideoExport").mockResolvedValue({
success: true,
tempFilePath: "/tmp/native.mp4",
});
const initialize = vi
.spyOn(operations, "initializeEncoder")
.mockResolvedValue({ codec: "avc1.640033", hardwareAcceleration: "prefer-software" });
const result = await exporter.export();
expect(result.success).toBe(true);
if (forceSoftwareEncoding) {
expect(nativeStart).not.toHaveBeenCalled();
expect(staticLayout).not.toHaveBeenCalled();
expect(initialize).toHaveBeenCalledOnce();
const { FrameRenderer } = await import("./modernFrameRenderer");
expect(FrameRenderer).toHaveBeenCalledWith(
expect.objectContaining({
width: 1920,
height: 1080,
preferredRenderBackend: "webgpu",
}),
);
} else {
expect(staticLayout).toHaveBeenCalledTimes(staticRoute ? 1 : 0);
expect(nativeStart).toHaveBeenCalledTimes(staticRoute ? 0 : 1);
}
expect(exporterInternals(exporter).session.config.bitrate).toBe(8_000_000);
});
it("fails forced software cleanly when software WebCodecs is unavailable without retrying native hardware", async () => {
vi.stubGlobal("navigator", { platform: "Linux x86_64" });
const exporter = new ModernVideoExporter({
videoUrl: "file:///recording.mp4",
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 8_000_000,
wallpaper: "#000000",
backendPreference: "auto",
forceSoftwareEncoding: true,
} as never);
const operations = exporterInternals(exporter).operations;
const nativeStart = vi
.spyOn(operations, "tryStartNativeVideoExport")
.mockResolvedValue(true);
const staticLayout = vi.spyOn(operations, "tryExportNativeStaticLayout");
vi.spyOn(operations, "initializeEncoder").mockRejectedValue(
new Error("Software encoder unavailable"),
);
vi.spyOn(operations, "finishNativeVideoExport").mockResolvedValue({
success: true,
tempFilePath: "/tmp/native.mp4",
});
const result = await exporter.export();
expect(result.success).toBe(false);
expect(result.error).toContain("Software encoder unavailable");
expect(nativeStart).not.toHaveBeenCalled();
expect(staticLayout).not.toHaveBeenCalled();
});
it("removes failed native writes without creating an unhandled rejection", async () => {
const exporter = new ModernVideoExporter({} as never);
const write = Promise.reject(new Error("Native write failed"));
+118
View File
@@ -98,3 +98,121 @@ describe("wallpapers", () => {
);
});
});
const wallpaperHooks = vi.hoisted(() => ({
states: [] as unknown[],
index: 0,
effects: [] as (() => void)[],
prepare: vi.fn(async (file: File) => `data:image/png;base64,${file.name}`),
save: vi.fn(),
}));
vi.mock("react", async (original) => ({
...(await original<typeof import("react")>()),
useState: (initial: unknown) => {
const index = wallpaperHooks.index++;
if (!(index in wallpaperHooks.states))
wallpaperHooks.states[index] = typeof initial === "function" ? initial() : initial;
return [
wallpaperHooks.states[index],
(update: unknown) => {
wallpaperHooks.states[index] =
typeof update === "function" ? update(wallpaperHooks.states[index]) : update;
},
];
},
useEffect: (effect: () => void) => wallpaperHooks.effects.push(effect),
useMemo: (factory: () => unknown) => factory(),
useRef: (value: unknown) => ({ current: value }),
}));
vi.mock("@/components/video-editor/editorPreferences", () => ({
DEFAULT_EDITOR_PREFERENCES: { wallpaper: "#000000", backgroundBlur: 0 },
loadEditorPreferences: () => ({ customWallpapers: [] }),
saveEditorPreferences: wallpaperHooks.save,
}));
vi.mock("@/components/video-editor/useWallpaperPreviews", () => ({
useWallpaperPreviews: () => ({ wallpapers: [], previews: {} }),
}));
vi.mock("@/components/video-editor/settings/useSettingsTranslations", () => ({
useSettingsTranslations: () => ({
te: (_key: string, fallback: string) => fallback,
tSettings: (key: string) => key,
}),
}));
vi.mock("@/lib/assetPath", () => ({ peekWallpaperThumbnailUrl: () => undefined }));
vi.mock("@/lib/wallpaperUpload", async (original) => ({
...(await original<typeof import("./wallpaperUpload")>()),
prepareWallpaperImage: wallpaperHooks.prepare,
}));
vi.mock("@/components/ui/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { useBackgroundSettings } from "@/components/video-editor/settings/useBackgroundSettings";
describe("uploaded wallpaper list", () => {
beforeEach(() => {
wallpaperHooks.states = [];
wallpaperHooks.index = 0;
wallpaperHooks.effects = [];
vi.clearAllMocks();
});
afterEach(() => vi.unstubAllGlobals());
function render(onWallpaperChange: (path: string) => void) {
wallpaperHooks.index = 0;
wallpaperHooks.effects = [];
// biome-ignore lint/correctness/useHookAtTopLevel: React hooks are intentionally mocked to exercise the upload callbacks and state commits.
const settings = useBackgroundSettings(
{ selected: "#000000", onWallpaperChange },
{ customWallpapers: ["older"] } as never,
true,
);
for (const effect of wallpaperHooks.effects) effect();
return settings;
}
it.each([
"image",
"video-file",
"video-picker",
])("keeps repeated %s imports unique without reordering existing entries", async (kind) => {
const onWallpaperChange = vi.fn();
const suffix = kind === "image" ? "png" : "mp4";
const paths = ["first", "second", "first"].map((name) =>
kind === "image"
? `data:image/png;base64,${name}.${suffix}`
: `/imports/${name}.${suffix}`,
);
const picker = vi
.fn()
.mockResolvedValueOnce({ success: true, path: paths[0] })
.mockResolvedValueOnce({ success: true, path: paths[1] })
.mockResolvedValueOnce({ success: true, path: paths[2] });
vi.stubGlobal("window", {
electronAPI: {
openVideoFilePicker: picker,
importWallpaperVideo: vi.fn(async (file: File) => ({
success: true,
path: `/imports/${file.name}`,
})),
},
});
let settings = render(onWallpaperChange);
for (const name of ["first", "second", "first"]) {
if (kind === "video-picker") await settings.handleVideoUpload();
else
await settings.handleImageUpload({
target: {
files: [
new File([name], `${name}.${suffix}`, {
type: kind === "image" ? "image/png" : "video/mp4",
}),
],
value: "selected",
},
} as unknown as React.ChangeEvent<HTMLInputElement>);
settings = render(onWallpaperChange);
}
const expected =
kind === "image" ? ["older", paths[0], paths[1]] : [paths[1], paths[0], "older"];
expect(settings.customImages).toEqual(expected);
expect(wallpaperHooks.save).toHaveBeenLastCalledWith({ customWallpapers: expected });
expect(onWallpaperChange.mock.calls.map(([path]) => path)).toEqual(paths);
});
});
+1 -1
View File
@@ -511,7 +511,7 @@ test("a queued selection can still be canceled with Escape", async ({ page }) =>
await page.mouse.click(1380, 200);
await expect(page.locator("html")).toHaveAttribute("data-queued", "true");
await page.keyboard.press("Escape");
expect(await pick(page)).toBeNull();
await expect.poll(() => pick(page)).toBeNull();
});
test("a window expanding from another display keeps its frontmost hit-test rank", async ({
+56
View File
@@ -82,6 +82,62 @@ for (const fails of [false, true]) {
});
}
for (const failure of ["result", "error", "non-error"] as const) {
test(`HUD visibility read failure can retry in place (${failure})`, async ({ page }) => {
await installSettingsBridge(page);
await installDesktopBridgeOverrides(page, (failure) => {
let failing = true;
let reads = 0;
window.addEventListener("test-allow-hud-read", () => {
failing = false;
});
window.electronAPI.getHudOverlayCaptureProtection = async () => {
document.documentElement.dataset.hudProtectionReads = String(++reads);
if (failing) {
if (failure === "error") throw new Error("HUD bridge unavailable");
if (failure === "non-error") return Promise.reject("HUD bridge disconnected");
return { success: false, enabled: true };
}
return { success: true, enabled: false };
};
window.electronAPI.setHudOverlayCaptureProtection = async (enabled) => {
document.documentElement.dataset.hideHudRequested = String(enabled);
return { success: true, enabled };
};
}, failure);
const home = await openDashboard(page);
await home.getByRole("button", { name: "Recordly account" }).click();
await page.getByRole("dialog", { name: "Account menu", exact: true })
.getByRole("button", { name: "Settings", exact: true }).click();
await home.getByRole("tab", { name: "Recording", exact: true }).click();
const panel = home.getByRole("tabpanel", { name: /^Recording settings/ });
const retry = panel.getByRole("button", { name: "Try again", exact: true });
await expect(panel.getByRole("alert")).toBeVisible();
await expect(retry).toBeVisible();
const failureText = failure === "result"
? "Could not update capture protection"
: failure === "error" ? "HUD bridge unavailable" : "HUD bridge disconnected";
await panel.getByRole("button", { name: "Technical details", exact: true }).click();
await expect(panel.getByText(failureText, { exact: true })).toBeVisible();
await expect(panel.getByText(`Error: ${failureText}`, { exact: true })).toHaveCount(0);
const reads = Number(await page.locator("html").getAttribute("data-hud-protection-reads"));
const url = page.url();
await page.evaluate(() => window.dispatchEvent(new Event("test-allow-hud-read")));
await retry.click();
const toggle = panel.getByRole("switch", {
name: "Hide recording controls from captured video", exact: true,
});
await expect(toggle).toBeEnabled();
await expect(toggle).not.toBeChecked();
await expect(panel.getByRole("alert")).toHaveCount(0);
await expect(retry).toHaveCount(0);
await expect.poll(async () => Number(await page.locator("html")
.getAttribute("data-hud-protection-reads"))).toBeGreaterThan(reads);
await expect(page.locator("html")).not.toHaveAttribute("data-hide-hud-requested");
expect(page.url()).toBe(url);
});
}
test("Settings uses compact themes and keyboard tabs, and persists appearance", async ({
page,
}) => {
+3 -3
View File
@@ -126,7 +126,7 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio
await expect(panel.locator("[data-recording-path]")).toHaveCount(2);
await expect(panel.getByRole("img", { name: "Preview of second.mp4" })).toBeVisible();
await page.screenshot({
path: "test-results/videos-panel.png",
path: test.info().outputPath("videos-panel.png"),
animations: "disabled",
});
await page.getByRole("radio", { name: "Scene", exact: true }).click();
@@ -265,7 +265,7 @@ test("dashboard project list scrolls vertically with long names", async ({ page
await entry.last().scrollIntoViewIfNeeded();
await expect(entry.last()).toBeInViewport();
await page.screenshot({
path: "test-results/dashboard-projects-vertical.png",
path: test.info().outputPath("dashboard-projects-vertical.png"),
});
});
@@ -355,7 +355,7 @@ test("Clips shows original filenames and truncates long names beside real still
.toBe(true);
expect(await panel.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
await page.screenshot({
path: "test-results/videos-filenames.png",
path: test.info().outputPath("videos-filenames.png"),
animations: "disabled",
});
});
+12
View File
@@ -50,11 +50,23 @@ test("webcam import, anchor placement, precise adjustment and removal stay disco
"true",
);
await expect(page.getByRole("switch", { name: "Custom position", exact: true })).toHaveCount(0);
const topRight = page.getByRole("row", { name: "Top right", exact: true });
await topRight.click();
await expect(topRight).toHaveAttribute("aria-selected", "true");
const beforeAdjustment = (await camera.boundingBox())!;
const preview = (await page.locator("[data-preview-overlay]").boundingBox())!;
const horizontalTravel = preview.width - beforeAdjustment.width;
expect(horizontalTravel).toBeGreaterThan(0);
const horizontal = page.getByRole("slider", { name: "Horizontal", exact: true });
await horizontal.focus();
await horizontal.press("Home");
await horizontal.press("ArrowRight");
await expect(horizontal).toHaveValue("1");
// Manual position must move the rendered camera across the available preview,
// rather than merely updating the slider or hiding a redundant label.
await expect
.poll(async () => beforeAdjustment.x - (await camera.boundingBox())!.x)
.toBeGreaterThan(horizontalTravel / 2);
await expect(sidebar.getByText("Custom position", { exact: true })).toHaveCount(0);
await page.getByRole("row", { name: "Center", exact: true }).click();
await expect(horizontal).toHaveValue("50");