Fix Recordly 2.0 part 6 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 2c527482be
39 files changed
+688 -46

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 and leave timestamped feedback using a display name. No account or sign-in is required. The existing per-IP rate limit and 2,000-character comment limit still apply.
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
@@ -240,7 +240,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);
const before = await fs.readFile(target!);
await fs.appendFile(projectPath, " ");
+1 -1
View File
@@ -361,7 +361,7 @@ export async function cacheProjectThumbnail(projectPath: string, revision: strin
try {
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
@@ -262,6 +262,7 @@ export function registerCloudShareHandlers() {
ownerId: event.sender.id,
token,
};
activeUpload.token = token;
activeUpload.finishing = true;
const controller = activeUpload.controller;
activeUploads.set(uploadId, activeUpload);
@@ -138,7 +138,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) => {
@@ -154,6 +159,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({
@@ -272,6 +278,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);
@@ -58,10 +58,10 @@ export function PlusFeatureDetails({
Briefcase,
FileText,
Monitor,
Link,
InfinityIcon,
CloudArrowUp,
...(limits?.plusWeeklyUploadLimit ? [CloudArrowUp] : []),
InfinityIcon,
Link,
ArrowClockwise,
Heart,
Sparkle,
+1 -1
View File
@@ -460,7 +460,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
@@ -115,7 +115,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 () => {
@@ -277,6 +281,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();
});
@@ -175,7 +175,9 @@ export function useProjectOpenActions({
refreshProjectLibrary,
],
);
latestRelink.current = handleRelinkProjectRecording;
useLayoutEffect(() => {
latestRelink.current = handleRelinkProjectRecording;
}, [handleRelinkProjectRecording]);
const handleOpenProjectFromLibrary = useCallback(
async (projectPath: string) => {
@@ -214,7 +216,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" ||
@@ -446,4 +446,35 @@ describe("ModernFrameRenderer webcam export fallback", () => {
});
}
});
it("keeps a resizing blur canvas texture dynamic across export frames", () => {
const renderer = createRenderer();
renderer.config.webcam = { ...DEFAULT_WEBCAM_OVERLAY, enabled: true };
renderer.currentVideoTime = 0;
renderer.webcamMedia.webcamVideoElement = {
currentTime: 0, readyState: 2, seeking: false, videoWidth: 640,
videoHeight: 360, duration: Number.NaN,
};
renderer.scene.webcamRootContainer = {
visible: false, addChild: vi.fn(), position: { set: vi.fn() },
};
renderer.scene.webcamContainer = { addChildAt: vi.fn() };
renderer.scene.webcamMaskGraphics = {};
renderer.motion.animationState = { appliedScale: 1 };
const canvas = createMockCanvas();
canvas.width = 100;
canvas.height = 100;
vi.spyOn(renderer.webcam.webcamMotionBlur, "render").mockReturnValue({
canvas, x: 0, y: 0,
});
renderer.webcam.updateWebcamOverlay();
const sprite = renderer.webcam.webcamBlurSprite;
expect(sprite).toBeTruthy();
expect(sprite?.texture.dynamic).toBe(true);
canvas.width = 160;
canvas.height = 120;
renderer.webcam.updateWebcamOverlay();
expect(renderer.webcam.webcamBlurSprite).toBe(sprite);
expect(sprite?.texture.source.update).toHaveBeenCalledOnce();
});
});
+3 -3
View File
@@ -292,9 +292,9 @@ export class FrameWebcam {
});
if (blur) {
if (!this.webcamBlurSprite) {
this.webcamBlurSprite = new Sprite(
this.context.textures.createTextureFromSource(blur.canvas),
);
const texture = this.context.textures.createTextureFromSource(blur.canvas);
texture.dynamic = true;
this.webcamBlurSprite = new Sprite(texture);
this.context.scene.webcamRootContainer.addChild(this.webcamBlurSprite);
} else {
this.webcamBlurSprite.texture.source.update();
@@ -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
@@ -81,6 +81,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,
}) => {
+13 -5
View File
@@ -1,15 +1,24 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test("advanced controls preserve values and remember each section's view", async ({ page }) => {
await installDesktopBridge(page);
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getCurrentRecordingSession = async () => ({
success: true,
session: {
videoPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
webcamPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
timeOffsetMs: 0,
},
});
});
await page.goto("/?windowType=editor");
const advanced = page.getByRole("switch", { name: "Advanced settings" });
await expect(advanced).toBeVisible({ timeout: 20000 });
await expect(page.getByRole("switch", { name: "Link padding sides" })).toHaveCount(0);
const blur = page.getByRole("slider", { name: "Blur", exact: true });
await expect(blur).not.toBeVisible();
await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(blur).toBeVisible();
await blur.focus();
await page.keyboard.press("ArrowRight");
const value = await blur.inputValue();
@@ -29,12 +38,11 @@ test("advanced controls preserve values and remember each section's view", async
animations: "disabled",
});
await page.getByRole("radio", { name: "Scene", exact: true }).click();
await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(advanced).toBeChecked();
await expect(blur).toHaveValue(value);
await page.locator("aside").getByText("Advanced", { exact: true }).click();
await expect(blur).toHaveValue(value);
await page.getByRole("row", { name: "Color", exact: true }).click();
await page.getByRole("row", { name: "Colors", exact: true }).click();
await page.getByRole("button", { name: "Add custom color", exact: true }).click();
await page.getByRole("textbox", { name: "Hex color", exact: true }).fill("#27AE60");
await page.keyboard.press("Tab");
+8 -5
View File
@@ -11,7 +11,6 @@ test("editor loads video, switches tools and edits export options", async ({ pag
await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible({
timeout: 20000,
});
await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
await expect
.poll(() =>
@@ -28,7 +27,7 @@ test("editor loads video, switches tools and edits export options", async ({ pag
animations: "disabled",
});
const inspectorWidth = (await page.locator("aside").boundingBox())?.width;
await page.getByRole("row", { name: "Color", exact: true }).click();
await page.getByRole("row", { name: "Colors", exact: true }).click();
await expect(page.getByRole("button", { name: "Add custom color", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Add custom color", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Custom color", exact: true })).toBeVisible();
@@ -86,9 +85,13 @@ test("editor loads video, switches tools and edits export options", async ({ pag
await expect(crop).toBeVisible();
await crop.getByRole("button", { name: "Done", exact: true }).click();
await expect(crop).toHaveCount(0);
await page.getByRole("button", { name: "Original aspect ratio", exact: true }).click();
await page.getByRole("menuitem", { name: "1:1", exact: true }).click();
await expect(page.getByRole("button", { name: "1:1", exact: true })).toBeVisible();
await page.getByRole("button", { name: /^Aspect ratio:/ }).click();
await page
.getByRole("dialog", { name: "Aspect ratio", exact: true })
.getByRole("radio", { name: "1:1", exact: true })
.press("Space");
await expect(page.getByRole("button", { name: "Aspect ratio: 1:1", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Open presets", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
+1 -1
View File
@@ -167,7 +167,7 @@ for (const plan of ["free", "pro", "loading", "unavailable"] as const) {
});
await page.goto(`/?windowType=editor&gifPlan=${plan}`);
await expect(page.getByRole("row", { name: "Color", exact: true })).toBeVisible();
await expect(page.getByRole("row", { name: "Colors", exact: true })).toBeVisible();
await expect
.poll(() =>
page
-1
View File
@@ -47,7 +47,6 @@ for (const storageBytes of [
await expect(settings).toBeVisible();
await expect(settings.getByRole("region", { name: "Cloud usage", exact: true })).toHaveCount(0);
await settings.getByRole("button", { name: "Manage account", exact: true }).click();
await settings.getByRole("tab", { name: "Plan & billing", exact: true }).click();
await expect(settings.getByLabel("Cloud usage", { exact: true })).toContainText("Cloud storage");
});
}
+3 -4
View File
@@ -126,12 +126,11 @@ 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();
await expect(panel).toHaveCount(0);
await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
expect(errors).toEqual([]);
});
@@ -266,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"),
});
});
@@ -356,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",
});
});