Fix Recordly 2.0 part 7 follow-up review findings

Preserve existing PR history and integrate validated second-cycle fixes.
This commit is contained in:
webadderall committed 2026-11-07 19:05:16 +11:00
commit 5562f1c724
22 files changed
+391 -56

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. The self-hosted worker requires a separate, signed-in account local to that instance; a supplied display name alone does not authorize a comment.
Anyone with a valid link can watch a public recording. Leaving timestamped comments 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
+129 -1
View File
@@ -1,6 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import { createServer, request } from "node:http";
import { createServer, type IncomingMessage, request, type ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
@@ -403,3 +403,131 @@ it("still removes only readable proven-dead generations while preserving ancient
expect(await fs.readFile(damaged)).toEqual(damagedBytes);
expect(await b.instance.route(callback("flow_alice"))).toBe("forwarded");
});
async function foreignRoute(
handler: (incoming: IncomingMessage, response: ServerResponse) => void,
) {
const a = relay();
const launcher = relay();
await a.instance.register("flow_alice", "exact-verifier", owner("a"));
const original = await record("flow_alice");
const listener = createServer(handler);
await new Promise<void>((resolve) => listener.listen(0, "127.0.0.1", resolve));
const address = listener.address();
if (!address || typeof address === "string") throw new Error("Missing fixture port");
await fs.writeFile(
original.file,
encrypt(JSON.stringify({ ...original.value, port: address.port })),
);
return {
a,
launcher,
value: original.value,
close: async () => {
listener.closeAllConnections();
await new Promise<void>((resolve) => listener.close(() => resolve()));
},
};
}
it("bounds forwarding by five seconds even while an unrelated listener keeps sending response bytes", async () => {
let received!: (response: ServerResponse) => void;
const receiving = new Promise<ServerResponse>((resolve) => {
received = resolve;
});
let observed = "";
const fixture = await foreignRoute((incoming, response) => {
incoming.on("data", (chunk) => {
observed += chunk.toString();
});
incoming.on("end", () => {
response.writeHead(200, { "Content-Type": "application/json" });
response.write("{");
received(response);
});
});
try {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const completed = vi.fn();
const forwarding = fixture.launcher.instance
.route(callback("flow_alice", "SLOW_PRIVATE_AUTH_CODE"))
.then((result) => {
completed(result);
return result;
});
const response = await receiving;
const disconnected = new Promise<void>((resolve) => response.once("close", resolve));
for (let second = 0; second < 4; second++) {
response.write(" ");
await new Promise<void>((resolve) => setImmediate(resolve));
await vi.advanceTimersByTimeAsync(1000);
expect(completed).not.toHaveBeenCalled();
}
response.write(" ");
await new Promise<void>((resolve) => setImmediate(resolve));
await vi.advanceTimersByTimeAsync(999);
expect(completed).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(completed).toHaveBeenCalledExactlyOnceWith("unavailable");
expect(await forwarding).toBe("unavailable");
await disconnected;
expect(response.destroyed).toBe(true);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(completed).toHaveBeenCalledOnce();
expect(observed).not.toContain("SLOW_PRIVATE_AUTH_CODE");
expect(observed).not.toContain(fixture.value.capability);
expect(fixture.a.deliver).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
await fixture.close();
}
});
it.each([
"aborted response",
"invalid response",
"oversized response",
"request error",
])("clears the forwarding deadline after %s", async (failure) => {
const fixture = await foreignRoute((incoming, response) => {
if (failure === "request error") {
incoming.socket.destroy();
return;
}
incoming.resume();
incoming.on("end", () => {
response.writeHead(200, { "Content-Type": "application/json" });
if (failure === "aborted response") {
response.write("{");
setImmediate(() => response.destroy());
} else if (failure === "oversized response") response.write("x".repeat(257));
else response.end("invalid JSON");
});
});
try {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
expect(await fixture.launcher.instance.route(callback("flow_alice"))).toBe("unavailable");
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(fixture.a.deliver).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
await fixture.close();
}
});
it("clears the forwarding deadline after a valid authenticated acknowledgement", async () => {
const a = relay();
const launcher = relay();
await a.instance.register("flow_alice", "exact-verifier", owner("a"));
try {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
expect(await launcher.instance.route(callback("flow_alice"))).toBe("forwarded");
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(a.deliver).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
+31 -8
View File
@@ -459,6 +459,14 @@ export function createAuthFlowRelay<Owner>(options: {
);
if (!record || record.instance === instance) return "unavailable";
return await new Promise<FlowRelayResult>((resolve) => {
let deadline: ReturnType<typeof setTimeout> | undefined;
let settled = false;
const settle = (result: FlowRelayResult) => {
if (settled) return;
settled = true;
if (deadline !== undefined) clearTimeout(deadline);
resolve(result);
};
// A dead process's PID and port can be reused. Only the actual owner
// can decrypt this envelope and prove it accepted the exact request.
const envelope = encryptFlowCallback(record, callback.url);
@@ -478,14 +486,17 @@ export function createAuthFlowRelay<Owner>(options: {
let body = "";
response.on("data", (chunk) => {
body += chunk;
if (body.length > 256) response.destroy();
if (body.length > 256) {
settle("unavailable");
response.destroy();
}
});
response.on("error", () => resolve("unavailable"));
response.on("aborted", () => resolve("unavailable"));
response.on("error", () => settle("unavailable"));
response.on("aborted", () => settle("unavailable"));
response.on("end", () => {
try {
const reply = JSON.parse(body);
resolve(
settle(
response.statusCode === 200 &&
reply.accepted === true &&
sameProof(
@@ -496,14 +507,26 @@ export function createAuthFlowRelay<Owner>(options: {
: "unavailable",
);
} catch {
resolve("unavailable");
settle("unavailable");
}
});
},
);
request.setTimeout(5000, () => request.destroy());
request.on("error", () => resolve("unavailable"));
request.end(payload);
deadline = setTimeout(() => {
settle("unavailable");
request.destroy();
}, 5000);
request.setTimeout(5000, () => {
settle("unavailable");
request.destroy();
});
request.on("error", () => settle("unavailable"));
try {
request.end(payload);
} catch {
settle("unavailable");
request.destroy();
}
});
} catch {
return "unavailable";
@@ -368,6 +368,11 @@ export function DashboardSettings({
</div>
</SettingsRow>
{status("recordings")}
{folderErrors.recordings && (
<p role="alert" className="settings-inline-feedback text-danger">
{folderErrors.recordings}
</p>
)}
</div>
<div className="settings-item">
<SettingsRow
@@ -399,6 +404,11 @@ export function DashboardSettings({
</Button>
</SettingsRow>
{status("projects")}
{folderErrors.projects && (
<p role="alert" className="settings-inline-feedback text-danger">
{folderErrors.projects}
</p>
)}
</div>
</div>
{(folderErrors.recordings || folderErrors.projects) && (
@@ -42,6 +42,7 @@ describe("dashboard localization", () => {
expect(html).toContain(messages.yourProjects);
expect(html).toContain(messages.searchProjects);
const category = new Intl.PluralRules(locale).select(count);
expect(Object.hasOwn(messages, `projectCount_${category}`)).toBe(true);
const expected = (
messages[`projectCount_${category}`] ?? messages.projectCount_other
).replace("{{number}}", new Intl.NumberFormat(locale).format(count));
@@ -32,15 +32,16 @@ vi.mock("react", async (original) => ({
useLayoutEffect: scheduleEffect,
}));
vi.mock("@/components/ui/toast", () => ({
toast: { warning: vi.fn(), error: vi.fn(), dismiss: vi.fn(), success: vi.fn() },
toast: { warning: vi.fn(), error: vi.fn(), dismiss: vi.fn(), success: vi.fn(), info: vi.fn() },
}));
vi.mock("@/i18n/runtime", () => ({ translate: (_key: string, fallback: string) => fallback }));
vi.mock("@/i18n/runtime", () => ({ translate: vi.fn((_key: string, fallback: string) => fallback) }));
vi.mock("../projectPersistence", async () => ({
fromFileUrl: (await import("../projectMediaPaths")).fromFileUrl,
resolveVideoUrl: vi.fn(),
}));
import { toast } from "@/components/ui/toast";
import { translate } from "@/i18n/runtime";
import { useProjectOpenActions } from "./useProjectOpenActions";
const target = "/projects/reconnect.recordly";
@@ -299,3 +300,47 @@ it.each([
await vi.waitFor(() => expect(applyNext).toHaveBeenCalled());
expect(saveNext).toHaveBeenCalledOnce();
});
it.each([
{
name: "plan retention",
errors: [],
sourcesKept: ["/media/planned.mp4", "/media/also-planned.mp4"],
deleted: [target],
},
{
name: "deletion failure",
errors: ["Could not trash project.recordly: EACCES"],
sourcesKept: ["/media/unshared.mp4"],
deleted: [],
},
{ name: "no retained files", errors: [], sourcesKept: [], deleted: [target] },
])("reports retained sources neutrally for $name", async ({ errors, sourcesKept, deleted }) => {
const trashProjectFiles = vi
.fn()
.mockResolvedValue({ success: errors.length === 0, deleted, errors, sourcesKept });
bridge({ trashProjectFiles });
const refreshProjectLibrary = vi.fn().mockResolvedValue(undefined);
const actions = render(initialProject(), undefined, { refreshProjectLibrary });
const options = { includeSources: true, protectedSources: errors.length ? [] : sourcesKept };
await expect(actions.handleDeleteProjects([target], options)).resolves.toEqual(deleted);
expect(trashProjectFiles).toHaveBeenCalledWith([target], options);
expect(refreshProjectLibrary).toHaveBeenCalledOnce();
if (errors.length) {
expect(toast.error).toHaveBeenCalledWith("Could not delete some projects", {
description: errors.join("\n"),
});
} else {
expect(toast.error).not.toHaveBeenCalled();
}
if (sourcesKept.length) {
expect(translate).toHaveBeenCalledWith(
"ui.cleanup.sharedSourcesKept",
"Source files kept: {{count}}.",
{ count: sourcesKept.length },
);
expect(toast.info).toHaveBeenCalledWith("Source files kept: {{count}}.");
} else {
expect(toast.info).not.toHaveBeenCalled();
}
});
@@ -384,7 +384,7 @@ export function useProjectOpenActions({
),
{ description: result.errors.join("\n") },
);
if (result.sourcesKept?.length) toast.info(translate("ui.cleanup.sharedSourcesKept", "Kept {{count}} source files used by other projects or the current editor.", { count: result.sourcesKept.length }));
if (result.sourcesKept?.length) toast.info(translate("ui.cleanup.sharedSourcesKept", "Source files kept: {{count}}.", { count: result.sourcesKept.length }));
return result.deleted;
},
[project, refreshProjectLibrary],
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Quelldateien und Cloud-Links werden geprüft…",
"reviewSources": "Zu entfernende Quellaufnahmen: {{count}}. Beibehaltene gemeinsam genutzte Quelldateien: {{kept}}.",
"cloudDeleteFailed": "Das Cloud-Video für {{name}} konnte nicht gelöscht werden. Das Projekt und seine Quellaufnahmen wurden beibehalten.",
"sharedSourcesKept": "{{count}} Quelldateien wurden beibehalten, da sie von anderen Projekten oder im aktuellen Editor verwendet werden.",
"sharedSourcesKept": "Beibehaltene Quelldateien: {{count}}.",
"signInToDeleteCloud": "Melde dich vor dem Löschen von {{name}} bei dem Konto an, dem das geteilte Video gehört.",
"cloudOriginMismatch": "Der gespeicherte Cloud-Link für {{name}} gehört zu einem anderen Server. Das Projekt und seine Quelldateien wurden beibehalten.",
"deletionFailed": "Einige ausgewählte Elemente konnten nicht gelöscht werden.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Checking source files and cloud links…",
"reviewSources": "Source recordings to remove: {{count}}. Shared source files kept: {{kept}}.",
"cloudDeleteFailed": "Could not delete the cloud video for {{name}}. Its project and source recordings were kept.",
"sharedSourcesKept": "Kept {{count}} source files used by other projects or the current editor.",
"sharedSourcesKept": "Source files kept: {{count}}.",
"signInToDeleteCloud": "Sign in to the account that owns the shared video before deleting {{name}}.",
"cloudOriginMismatch": "The saved cloud link for {{name}} belongs to a different server. Its project and sources were kept.",
"deletionFailed": "Could not delete some selected items.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Comprobando archivos de origen y enlaces de la nube…",
"reviewSources": "Grabaciones de origen que se eliminarán: {{count}}. Archivos de origen compartidos conservados: {{kept}}.",
"cloudDeleteFailed": "No se pudo eliminar el vídeo en la nube de {{name}}. Se conservaron su proyecto y sus grabaciones de origen.",
"sharedSourcesKept": "Se conservaron {{count}} archivos de origen que usan otros proyectos o el editor actual.",
"sharedSourcesKept": "Archivos de origen conservados: {{count}}.",
"signInToDeleteCloud": "Inicia sesión en la cuenta propietaria del vídeo compartido antes de eliminar {{name}}.",
"cloudOriginMismatch": "El enlace de la nube guardado para {{name}} pertenece a otro servidor. Se conservaron su proyecto y sus archivos de origen.",
"deletionFailed": "No se pudieron eliminar algunos elementos seleccionados.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Vérification des fichiers sources et des liens cloud…",
"reviewSources": "Enregistrements sources à supprimer : {{count}}. Fichiers sources partagés conservés : {{kept}}.",
"cloudDeleteFailed": "Impossible de supprimer la vidéo dans le cloud de {{name}}. Son projet et ses enregistrements sources ont été conservés.",
"sharedSourcesKept": "{{count}} fichiers sources utilisés par d’autres projets ou par l’éditeur actuel ont été conservés.",
"sharedSourcesKept": "Fichiers sources conservés : {{count}}.",
"signInToDeleteCloud": "Connectez-vous au compte propriétaire de la vidéo partagée avant de supprimer {{name}}.",
"cloudOriginMismatch": "Le lien cloud enregistré pour {{name}} appartient à un autre serveur. Son projet et ses fichiers sources ont été conservés.",
"deletionFailed": "Impossible de supprimer certains éléments sélectionnés.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Verifica dei file sorgente e dei collegamenti cloud…",
"reviewSources": "Registrazioni sorgente da rimuovere: {{count}}. File sorgente condivisi conservati: {{kept}}.",
"cloudDeleteFailed": "Impossibile eliminare il video nel cloud di {{name}}. Il progetto e le relative registrazioni sorgente sono stati conservati.",
"sharedSourcesKept": "Sono stati conservati {{count}} file sorgente usati da altri progetti o dall’editor attuale.",
"sharedSourcesKept": "File sorgente conservati: {{count}}.",
"signInToDeleteCloud": "Accedi all’account proprietario del video condiviso prima di eliminare {{name}}.",
"cloudOriginMismatch": "Il collegamento cloud salvato per {{name}} appartiene a un altro server. Il progetto e i relativi file sorgente sono stati conservati.",
"deletionFailed": "Impossibile eliminare alcuni elementi selezionati.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "元ファイルとクラウドリンクを確認中…",
"reviewSources": "削除する元録画:{{count}}件。保持する共有元ファイル:{{kept}}件。",
"cloudDeleteFailed": "{{name}}のクラウド動画を削除できませんでした。プロジェクトと元録画は保持されました。",
"sharedSourcesKept": "他のプロジェクトや現在のエディターで使用している元ファイル{{count}}件を保持しました。",
"sharedSourcesKept": "保持した元ファイル:{{count}}件。",
"signInToDeleteCloud": "{{name}}を削除する前に、共有動画を所有するアカウントにログインしてください。",
"cloudOriginMismatch": "{{name}}に保存されたクラウドリンクは別のサーバーのものです。プロジェクトと元ファイルは保持されました。",
"deletionFailed": "選択した項目の一部を削除できませんでした。",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "원본 파일과 클라우드 링크 확인 중…",
"reviewSources": "삭제할 원본 녹화: {{count}}개. 유지할 공유 원본 파일: {{kept}}개.",
"cloudDeleteFailed": "{{name}}의 클라우드 동영상을 삭제할 수 없습니다. 프로젝트와 원본 녹화는 유지되었습니다.",
"sharedSourcesKept": "다른 프로젝트나 현재 편집기에서 사용하는 원본 파일 {{count}}개를 유지했습니다.",
"sharedSourcesKept": "유지된 원본 파일: {{count}}개.",
"signInToDeleteCloud": "{{name}}을(를) 삭제하기 전에 공유 동영상을 소유한 계정에 로그인하세요.",
"cloudOriginMismatch": "{{name}}에 저장된 클라우드 링크는 다른 서버에 속합니다. 프로젝트와 원본 파일은 유지되었습니다.",
"deletionFailed": "선택한 항목 중 일부를 삭제할 수 없습니다.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Bronbestanden en cloudlinks controleren…",
"reviewSources": "Te verwijderen bronopnamen: {{count}}. Behouden gedeelde bronbestanden: {{kept}}.",
"cloudDeleteFailed": "De cloudvideo van {{name}} kon niet worden verwijderd. Het project en de bronopnamen zijn behouden.",
"sharedSourcesKept": "{{count}} bronbestanden behouden die door andere projecten of de huidige editor worden gebruikt.",
"sharedSourcesKept": "Behouden bronbestanden: {{count}}.",
"signInToDeleteCloud": "Meld je aan bij het account dat eigenaar is van de gedeelde video voordat je {{name}} verwijdert.",
"cloudOriginMismatch": "De opgeslagen cloudlink voor {{name}} hoort bij een andere server. Het project en de bronbestanden zijn behouden.",
"deletionFailed": "Sommige geselecteerde items konden niet worden verwijderd.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "Verificando arquivos de origem e links da nuvem…",
"reviewSources": "Gravações de origem a remover: {{count}}. Arquivos de origem compartilhados mantidos: {{kept}}.",
"cloudDeleteFailed": "Não foi possível excluir o vídeo na nuvem de {{name}}. O projeto e suas gravações de origem foram mantidos.",
"sharedSourcesKept": "Foram mantidos {{count}} arquivos de origem usados por outros projetos ou pelo editor atual.",
"sharedSourcesKept": "Arquivos de origem mantidos: {{count}}.",
"signInToDeleteCloud": "Entre na conta proprietária do vídeo compartilhado antes de excluir {{name}}.",
"cloudOriginMismatch": "O link da nuvem salvo para {{name}} pertence a outro servidor. O projeto e seus arquivos de origem foram mantidos.",
"deletionFailed": "Não foi possível excluir alguns itens selecionados.",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "正在检查源文件和云端链接…",
"reviewSources": "要移除的原始录制:{{count}}。保留的共享源文件:{{kept}}。",
"cloudDeleteFailed": "无法删除 {{name}} 的云端视频。其项目和原始录制已保留。",
"sharedSourcesKept": "已保留其他项目或当前编辑器正在使用的 {{count}} 个源文件。",
"sharedSourcesKept": "已保留的源文件:{{count}} 个。",
"signInToDeleteCloud": "删除 {{name}} 前,请登录拥有该共享视频的账户。",
"cloudOriginMismatch": "{{name}} 中保存的云端链接属于另一台服务器。其项目和源文件已保留。",
"deletionFailed": "无法删除部分所选项目。",
+1 -1
View File
@@ -279,7 +279,7 @@
"checkingDeletion": "正在檢查來源檔案和雲端連結…",
"reviewSources": "要移除的原始錄製:{{count}}。保留的共用來源檔案:{{kept}}。",
"cloudDeleteFailed": "無法刪除 {{name}} 的雲端影片。其專案和原始錄製已保留。",
"sharedSourcesKept": "已保留其他專案或目前編輯器正在使用的 {{count}} 個來源檔案。",
"sharedSourcesKept": "已保留的來源檔案:{{count}} 個。",
"signInToDeleteCloud": "刪除 {{name}} 前,請登入擁有該共用影片的帳戶。",
"cloudOriginMismatch": "{{name}} 中儲存的雲端連結屬於另一台伺服器。其專案和來源檔案已保留。",
"deletionFailed": "無法刪除部分所選項目。",
+1
View File
@@ -11,6 +11,7 @@ describe("translation lookup", () => {
const catalog = catalogs[`./locales/${locale}/ui.json`];
for (const count of [0, 1, 2, 1234]) {
const category = new Intl.PluralRules(locale).select(count);
expect(Object.hasOwn(catalog, `projectCount_${category}`)).toBe(true);
const number = new Intl.NumberFormat(locale).format(count);
const expected = (
catalog[`projectCount_${category}`] ?? catalog.projectCount_other
+77
View File
@@ -82,6 +82,83 @@ for (const fails of [false, true]) {
});
}
for (const failure of ["recordings", "projects", "both"] as const) {
test(`folder read errors explain retry and recover in place (${failure})`, async ({ page }) => {
await installSettingsBridge(page);
await installDesktopBridgeOverrides(page, (failure) => {
let failing = true;
let recordingsReads = 0;
let projectsReads = 0;
window.addEventListener("test-allow-folder-reads", () => { failing = false; });
window.electronAPI.getRecordingsDirectory = async () => {
document.documentElement.dataset.recordingsReads = String(++recordingsReads);
if (failing && failure !== "projects") throw new Error("Recordings folder unavailable");
return { success: true, path: "/restored/recordings" };
};
window.electronAPI.getProjectsDirectory = async () => {
document.documentElement.dataset.projectsReads = String(++projectsReads);
if (failing && failure !== "recordings") return { success: false };
return { success: true, path: "/restored/projects" };
};
window.electronAPI.openRecordingsFolder = async () => {
document.documentElement.dataset.openedFolder = "recordings";
return { success: true };
};
window.electronAPI.openProjectsDirectory = async () => {
document.documentElement.dataset.openedFolder = "projects";
return { success: true };
};
}, failure);
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Recordly account", exact: true }).click();
await page.getByRole("dialog", { name: "Account menu", exact: true })
.getByRole("button", { name: "Settings", exact: true }).click();
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("tab", { name: "General", exact: true }).click();
const panel = settings.getByRole("tabpanel", { name: /^General settings/ });
const folders = [
{ area: "recordings", title: "Recordings folder", error: "Recordings folder unavailable" },
{ area: "projects", title: "Projects folder", error: "Folder location is unavailable." },
] as const;
for (const folder of folders) {
const row = panel.locator(".settings-item").filter({ has: page.getByText(folder.title, { exact: true }) });
const open = row.getByRole("button", { name: "Open folder", exact: true });
if (failure === folder.area || failure === "both") {
await expect(row.getByRole("alert")).toBeVisible();
await expect(row.getByRole("alert")).toHaveText(folder.error);
await expect(open).toBeDisabled();
} else await expect(open).toBeEnabled();
}
const retry = panel.getByRole("button", { name: "Try again", exact: true });
await expect(retry).toBeVisible();
const counts = await page.locator("html").evaluate((node) => ({
recordings: Number(node.dataset.recordingsReads), projects: Number(node.dataset.projectsReads),
}));
const url = page.url();
await page.evaluate(() => window.dispatchEvent(new Event("test-allow-folder-reads")));
await retry.click();
await expect(retry).toHaveCount(0);
for (const folder of folders) {
const row = panel.locator(".settings-item").filter({ has: page.getByText(folder.title, { exact: true }) });
const open = row.getByRole("button", { name: "Open folder", exact: true });
await expect(open).toBeEnabled();
await expect(row.getByText(folder.error, { exact: true })).toHaveCount(0);
await row.locator(".settings-info").hover();
await expect(page.getByRole("tooltip")).toContainText(`/restored/${folder.area}`);
await page.mouse.move(20, 20);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await open.click();
await expect(page.locator("html")).toHaveAttribute("data-opened-folder", folder.area);
}
const updated = await page.locator("html").evaluate((node) => ({
recordings: Number(node.dataset.recordingsReads), projects: Number(node.dataset.projectsReads),
}));
expect(updated.recordings).toBeGreaterThan(counts.recordings);
expect(updated.projects).toBeGreaterThan(counts.projects);
expect(page.url()).toBe(url);
});
}
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);
+22 -7
View File
@@ -740,13 +740,21 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
page,
}) => {
await installDesktopBridge(page);
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.saveShortcuts = async (value) => {
document.documentElement.dataset.savedShortcuts = JSON.stringify(value);
return { success: true };
};
});
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Home", exact: true }).click();
const home = page.getByRole("dialog", { name: "Projects dashboard" });
const sidebar = home.getByRole("complementary", { name: "Library navigation" });
await expect(sidebar.getByRole("button", { name: "Import", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("img", { name: "Placeholder banner" })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Settings", exact: true })).toHaveCount(0);
const settingsButton = sidebar.getByRole("button", { name: "Settings", exact: true });
await expect(settingsButton).toHaveCount(1);
await expect(settingsButton).toBeVisible();
const importButton = home.getByRole("button", { name: "Import", exact: true });
const all = home.getByRole("button", { name: "All", exact: true });
expect((await importButton.boundingBox())!.y - (await all.boundingBox())!.y).toBeLessThan(0);
@@ -754,8 +762,8 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
"Import",
);
await openDashboardSettings(page, home);
await home.getByRole("button", { name: "Edit shortcuts", exact: true }).click();
const shortcuts = page.getByRole("dialog", { name: "Keyboard Shortcuts", exact: true });
await home.getByRole("tab", { name: "Shortcuts", exact: true }).click();
const shortcuts = home.getByRole("tabpanel", { name: /^Shortcuts settings/ });
await expect(shortcuts).toBeVisible();
const change = shortcuts.getByRole("button", { name: /^Change Add Zoom shortcut, currently / });
await change.click();
@@ -768,15 +776,22 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
await shortcuts.getByRole("button", { name: "Save", exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: "test-results/dashboard-shortcuts.png", animations: "disabled" });
await shortcuts.getByRole("button", { name: "Save", exact: true }).click();
await expect
.poll(() => page.locator("html").getAttribute("data-saved-shortcuts"))
.toContain('"addZoom":{"key":"j"}');
await expect(shortcuts).toBeVisible();
await home.getByRole("tab", { name: "General", exact: true }).click();
await expect(shortcuts).not.toBeVisible();
await home.getByRole("button", { name: "Edit shortcuts", exact: true }).click();
await home.getByRole("tab", { name: "Shortcuts", exact: true }).click();
await expect(change).toHaveText("J");
await expect(change).toHaveAccessibleName("Change Add Zoom shortcut, currently J");
await page.setViewportSize({ width: 800, height: 600 });
await shortcuts.getByRole("button", { name: "Save", exact: true }).scrollIntoViewIfNeeded();
await expect(shortcuts.getByRole("button", { name: "Save", exact: true })).toBeInViewport();
await expect(
shortcuts.getByRole("heading", { name: "Keyboard Shortcuts", exact: true }),
).toBeInViewport();
const shortcutsHeading = shortcuts.getByRole("heading", { level: 2 });
await expect(shortcutsHeading).toContainText("Shortcuts");
await shortcutsHeading.scrollIntoViewIfNeeded();
await expect(shortcutsHeading).toBeInViewport();
await page.screenshot({
path: "test-results/dashboard-shortcuts-compact.png",
animations: "disabled",
+60 -25
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
for (const storageBytes of [
220_200_000, 94_999_999_999, 95_000_000_000, 100_000_000_000, 101_000_000_000,
@@ -52,11 +52,11 @@ for (const storageBytes of [
}
for (const signedIn of [false, true]) {
test(`${signedIn ? "Free" : "Signed-out"} library keeps footer actions on one line and opens Settings`, async ({
test(`${signedIn ? "Free" : "Signed-out"} library footer rows fit and socials open from the footer`, async ({
page,
}) => {
await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: true });
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.cloudBilling = async () => ({
success: true,
billing: {
@@ -77,6 +77,10 @@ for (const signedIn of [false, true]) {
storageLimitBytes: 100_000_000_000,
},
});
window.electronAPI.openExternalUrl = async (url: string) => {
document.documentElement.dataset.openedExternalUrl = url;
return { success: true };
};
});
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Home", exact: true }).click();
@@ -87,34 +91,65 @@ for (const signedIn of [false, true]) {
else await expect(usage).toHaveCount(0);
for (const width of [1440, 800]) {
await page.setViewportSize({ width, height: 1000 });
const bounds = [];
const settingsButton = navigation.getByRole("button", { name: "Settings", exact: true });
const settingsButton = navigation.getByRole("button", {
name: "Settings",
exact: true,
});
await expect(settingsButton).toHaveText("Settings");
const settingsBounds = (await settingsButton.boundingBox())!;
for (const name of ["Upgrade", "Join Discord", "GitHub"]) {
const button = navigation.getByRole("button", { name, exact: true });
await expect(button).toBeVisible();
bounds.push((await button.boundingBox())!);
}
expect(settingsBounds.y + settingsBounds.height).toBeLessThanOrEqual(bounds[0].y);
for (let index = 1; index < bounds.length; index++) {
expect(
Math.abs(
bounds[index].y +
bounds[index].height / 2 -
bounds[0].y -
bounds[0].height / 2,
),
).toBeLessThan(1);
expect(bounds[index].x).toBeGreaterThanOrEqual(
bounds[index - 1].x + bounds[index - 1].width,
);
}
const footer = navigation.locator(".community-links");
const upgrade = footer.getByRole("button", { name: "Upgrade", exact: true });
await expect(upgrade).toBeVisible();
await expect(footer.getByRole("button")).toHaveCount(3);
const upgradeBounds = (await upgrade.boundingBox())!;
expect(settingsBounds.y + settingsBounds.height).toBeLessThanOrEqual(upgradeBounds.y);
const navBounds = (await navigation.boundingBox())!;
expect(bounds.at(-1)!.x + bounds.at(-1)!.width).toBeLessThanOrEqual(
expect(upgradeBounds.x).toBeGreaterThanOrEqual(navBounds.x);
expect(upgradeBounds.x + upgradeBounds.width).toBeLessThanOrEqual(
navBounds.x + navBounds.width,
);
const socialBounds = [];
for (const name of ["Socials", "Contact"]) {
const button = footer.getByRole("button", { name, exact: true });
await expect(button).toBeVisible();
socialBounds.push((await button.boundingBox())!);
}
expect(upgradeBounds.y + upgradeBounds.height).toBeLessThanOrEqual(socialBounds[0].y);
expect(
Math.abs(
socialBounds[0].y +
socialBounds[0].height / 2 -
socialBounds[1].y -
socialBounds[1].height / 2,
),
).toBeLessThan(1);
expect(socialBounds[0].x).toBeGreaterThanOrEqual(navBounds.x);
expect(socialBounds[1].x).toBeGreaterThanOrEqual(
socialBounds[0].x + socialBounds[0].width,
);
expect(socialBounds[1].x + socialBounds[1].width).toBeLessThanOrEqual(
navBounds.x + navBounds.width,
);
}
await navigation
.locator(".community-links")
.getByRole("button", {
name: "Socials",
exact: true,
})
.click();
const socials = page.getByRole("dialog", { name: "Socials", exact: true });
for (const { name, url } of [
{ name: "Join Discord", url: "https://discord.gg/sdv2FBVNgE" },
{ name: "GitHub", url: "https://github.com/webadderallorg/Recordly" },
]) {
const link = socials.getByRole("button", { name, exact: true });
await expect(link).toBeVisible();
await link.click();
await expect(page.locator("html")).toHaveAttribute("data-opened-external-url", url);
}
await page.keyboard.press("Escape");
await expect(socials).toHaveCount(0);
await navigation.getByRole("button", { name: "Settings", exact: true }).click();
await expect(
dashboard.getByRole("region", { name: "Dashboard settings", exact: true }),