diff --git a/docs/cloud-sharing.md b/docs/cloud-sharing.md index ea27c55a..26246ade 100644 --- a/docs/cloud-sharing.md +++ b/docs/cloud-sharing.md @@ -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 diff --git a/electron/authFlowRelay.test.ts b/electron/authFlowRelay.test.ts index cdafeb91..c4e98daa 100644 --- a/electron/authFlowRelay.test.ts +++ b/electron/authFlowRelay.test.ts @@ -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"); +}); diff --git a/electron/authFlowRelay.ts b/electron/authFlowRelay.ts index d1f4f07c..048a48d0 100644 --- a/electron/authFlowRelay.ts +++ b/electron/authFlowRelay.ts @@ -392,7 +392,15 @@ export function createAuthFlowRelay(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 diff --git a/electron/desktopWallpaper.test.ts b/electron/desktopWallpaper.test.ts index 0c1e54da..927de72d 100644 --- a/electron/desktopWallpaper.test.ts +++ b/electron/desktopWallpaper.test.ts @@ -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, diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index c81c6233..2c6e0fad 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -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, " "); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index f3e045d7..9d0b5b28 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -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; diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts index 127902f6..645f804d 100644 --- a/electron/ipc/register/cloudShare.ts +++ b/electron/ipc/register/cloudShare.ts @@ -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); diff --git a/electron/ipc/register/cloudShareHandlers.test.ts b/electron/ipc/register/cloudShareHandlers.test.ts index 365bd379..b0e1a5ac 100644 --- a/electron/ipc/register/cloudShareHandlers.test.ts +++ b/electron/ipc/register/cloudShareHandlers.test.ts @@ -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((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); diff --git a/src/components/billing/PlusFeatureDetails.tsx b/src/components/billing/PlusFeatureDetails.tsx index b6afe1ee..7bbba9e8 100644 --- a/src/components/billing/PlusFeatureDetails.tsx +++ b/src/components/billing/PlusFeatureDetails.tsx @@ -58,10 +58,10 @@ export function PlusFeatureDetails({ Briefcase, FileText, Monitor, - Link, + InfinityIcon, CloudArrowUp, ...(limits?.plusWeeklyUploadLimit ? [CloudArrowUp] : []), - InfinityIcon, + Link, ArrowClockwise, Heart, Sparkle, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f174e3c5..a46a3378 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -460,7 +460,7 @@ function LaunchWindowContent() { className={`${styles.recordingControls} ${styles.recordingFinalizing} gap-2 text-sm text-muted-foreground`} > - Finishing recording… + {captureT("finishingRecording", "Finishing recording…")} ) : recording || restarting ? ( recordingControls diff --git a/src/components/video-editor/dashboard/DashboardGrid.tsx b/src/components/video-editor/dashboard/DashboardGrid.tsx index 79508280..d7d8d4bb 100644 --- a/src/components/video-editor/dashboard/DashboardGrid.tsx +++ b/src/components/video-editor/dashboard/DashboardGrid.tsx @@ -115,7 +115,9 @@ export function DashboardGrid({

{errorRecordingSource && errorRecordingSource !== errorSource && (

- Recording: {errorSourceName(errorRecordingSource)} + {uiText("ui.recordingSource", "Recording: {{name}}", { + name: errorSourceName(errorRecordingSource), + })}

)}
diff --git a/src/components/video-editor/dashboard/DashboardSettings.tsx b/src/components/video-editor/dashboard/DashboardSettings.tsx index ac416ccf..09aadb87 100644 --- a/src/components/video-editor/dashboard/DashboardSettings.tsx +++ b/src/components/video-editor/dashboard/DashboardSettings.tsx @@ -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")}
)} + {!captureSupported && status("hud")} + {hideHud === undefined && feedback?.area === "hud" && feedback.error && ( + + )}
diff --git a/src/components/video-editor/project/relinkContext.test.ts b/src/components/video-editor/project/relinkContext.test.ts index 654a5c82..20bb09c2 100644 --- a/src/components/video-editor/project/relinkContext.test.ts +++ b/src/components/video-editor/project/relinkContext.test.ts @@ -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(); +}); diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index a11a7c52..c18a3f91 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -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; diff --git a/src/components/video-editor/settings/useBackgroundSettings.ts b/src/components/video-editor/settings/useBackgroundSettings.ts index b4e74091..1a8c9011 100644 --- a/src/components/video-editor/settings/useBackgroundSettings.ts +++ b/src/components/video-editor/settings/useBackgroundSettings.ts @@ -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 { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 98c0f550..80dba986 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -379,12 +379,15 @@ const TimelineEditor = forwardRef( 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 && diff --git a/src/i18n/locales/de/capture.json b/src/i18n/locales/de/capture.json index 6fda8db2..5c618233 100644 --- a/src/i18n/locales/de/capture.json +++ b/src/i18n/locales/de/capture.json @@ -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", diff --git a/src/i18n/locales/en/capture.json b/src/i18n/locales/en/capture.json index e8e5f941..a2726be7 100644 --- a/src/i18n/locales/en/capture.json +++ b/src/i18n/locales/en/capture.json @@ -12,6 +12,7 @@ "actions": "Actions", "action": "Action", "recordingControls": "Recording controls", + "finishingRecording": "Finishing recording…", "discardingRecording": "Discarding recording", "recording": "Recording", "discardRecording": "Discard recording", diff --git a/src/i18n/locales/es/capture.json b/src/i18n/locales/es/capture.json index 1c429510..5678110c 100644 --- a/src/i18n/locales/es/capture.json +++ b/src/i18n/locales/es/capture.json @@ -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", diff --git a/src/i18n/locales/fr/capture.json b/src/i18n/locales/fr/capture.json index 49462a43..f5c7a093 100644 --- a/src/i18n/locales/fr/capture.json +++ b/src/i18n/locales/fr/capture.json @@ -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", diff --git a/src/i18n/locales/it/capture.json b/src/i18n/locales/it/capture.json index 942439cd..e5b7bbc3 100644 --- a/src/i18n/locales/it/capture.json +++ b/src/i18n/locales/it/capture.json @@ -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", diff --git a/src/i18n/locales/ja/capture.json b/src/i18n/locales/ja/capture.json index 7eba427a..9024c755 100644 --- a/src/i18n/locales/ja/capture.json +++ b/src/i18n/locales/ja/capture.json @@ -12,6 +12,7 @@ "actions": "操作", "action": "操作", "recordingControls": "録画コントロール", + "finishingRecording": "録画を仕上げています…", "discardingRecording": "録画を破棄中", "recording": "録画中", "discardRecording": "録画を破棄", diff --git a/src/i18n/locales/ko/capture.json b/src/i18n/locales/ko/capture.json index bf80ad49..d9ee2a89 100644 --- a/src/i18n/locales/ko/capture.json +++ b/src/i18n/locales/ko/capture.json @@ -12,6 +12,7 @@ "actions": "작업", "action": "작업", "recordingControls": "녹화 제어", + "finishingRecording": "녹화를 마무리하는 중…", "discardingRecording": "녹화 삭제 중", "recording": "녹화 중", "discardRecording": "녹화 삭제", diff --git a/src/i18n/locales/nl/capture.json b/src/i18n/locales/nl/capture.json index f18830c4..e91f0b9b 100644 --- a/src/i18n/locales/nl/capture.json +++ b/src/i18n/locales/nl/capture.json @@ -12,6 +12,7 @@ "actions": "Acties", "action": "Actie", "recordingControls": "Opnamebediening", + "finishingRecording": "Opname afronden…", "discardingRecording": "Opname wordt verwijderd", "recording": "Opname bezig", "discardRecording": "Opname verwijderen", diff --git a/src/i18n/locales/pt-BR/capture.json b/src/i18n/locales/pt-BR/capture.json index 5fd0d8d0..a705b085 100644 --- a/src/i18n/locales/pt-BR/capture.json +++ b/src/i18n/locales/pt-BR/capture.json @@ -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", diff --git a/src/i18n/locales/zh-CN/capture.json b/src/i18n/locales/zh-CN/capture.json index a497690c..79681ef5 100644 --- a/src/i18n/locales/zh-CN/capture.json +++ b/src/i18n/locales/zh-CN/capture.json @@ -12,6 +12,7 @@ "actions": "操作", "action": "操作", "recordingControls": "录制控制", + "finishingRecording": "正在完成录制…", "discardingRecording": "正在放弃录制", "recording": "正在录制", "discardRecording": "放弃录制", diff --git a/src/i18n/locales/zh-TW/capture.json b/src/i18n/locales/zh-TW/capture.json index 39d26425..930f370d 100644 --- a/src/i18n/locales/zh-TW/capture.json +++ b/src/i18n/locales/zh-TW/capture.json @@ -12,6 +12,7 @@ "actions": "操作", "action": "操作", "recordingControls": "錄製控制", + "finishingRecording": "正在完成錄製…", "discardingRecording": "正在捨棄錄製", "recording": "錄製中", "discardRecording": "捨棄錄製", diff --git a/src/lib/exporter/modernExportPrepare.ts b/src/lib/exporter/modernExportPrepare.ts index 00551876..fdd83956 100644 --- a/src/lib/exporter/modernExportPrepare.ts +++ b/src/lib/exporter/modernExportPrepare.ts @@ -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" || diff --git a/src/lib/exporter/modernFrameWebcam.test.ts b/src/lib/exporter/modernFrameWebcam.test.ts index 5be7a9ac..0b27b678 100644 --- a/src/lib/exporter/modernFrameWebcam.test.ts +++ b/src/lib/exporter/modernFrameWebcam.test.ts @@ -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(); + }); + }); diff --git a/src/lib/exporter/modernFrameWebcam.ts b/src/lib/exporter/modernFrameWebcam.ts index cd44e892..b56201c8 100644 --- a/src/lib/exporter/modernFrameWebcam.ts +++ b/src/lib/exporter/modernFrameWebcam.ts @@ -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(); diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index 5cdd2134..91913f60 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -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")); diff --git a/src/lib/wallpapers.test.ts b/src/lib/wallpapers.test.ts index 215ac26f..726b17a0 100644 --- a/src/lib/wallpapers.test.ts +++ b/src/lib/wallpapers.test.ts @@ -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()), + 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()), + 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); + 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); + }); +}); diff --git a/tests/ui/capture-picker.spec.ts b/tests/ui/capture-picker.spec.ts index f52ff74e..3a3322db 100644 --- a/tests/ui/capture-picker.spec.ts +++ b/tests/ui/capture-picker.spec.ts @@ -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 ({ diff --git a/tests/ui/dashboard-settings.spec.ts b/tests/ui/dashboard-settings.spec.ts index f1352940..c652273a 100644 --- a/tests/ui/dashboard-settings.spec.ts +++ b/tests/ui/dashboard-settings.spec.ts @@ -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, }) => { diff --git a/tests/ui/editor-layout.spec.ts b/tests/ui/editor-layout.spec.ts index 563aa808..faccc20f 100644 --- a/tests/ui/editor-layout.spec.ts +++ b/tests/ui/editor-layout.spec.ts @@ -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"); diff --git a/tests/ui/editor.spec.ts b/tests/ui/editor.spec.ts index 9029cd8d..1e2ed83f 100644 --- a/tests/ui/editor.spec.ts +++ b/tests/ui/editor.spec.ts @@ -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(); diff --git a/tests/ui/gif-optimization.spec.ts b/tests/ui/gif-optimization.spec.ts index 6defad56..04da48ce 100644 --- a/tests/ui/gif-optimization.spec.ts +++ b/tests/ui/gif-optimization.spec.ts @@ -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 diff --git a/tests/ui/storage-visibility.spec.ts b/tests/ui/storage-visibility.spec.ts index 6cce8832..d54c8a08 100644 --- a/tests/ui/storage-visibility.spec.ts +++ b/tests/ui/storage-visibility.spec.ts @@ -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"); }); } diff --git a/tests/ui/videos-library.spec.ts b/tests/ui/videos-library.spec.ts index 7e03611a..5ce1800a 100644 --- a/tests/ui/videos-library.spec.ts +++ b/tests/ui/videos-library.spec.ts @@ -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", }); });