From cdd64d5fe18f9112d39116eb8970dfc1bddda4b6 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:31:46 +1000 Subject: [PATCH] Generate safe thumbnails in background and recover failed close saves --- electron/electron-env.d.ts | 3 + electron/ipc/project/manager.test.ts | 83 ++++++++++++++++++- electron/ipc/project/manager.ts | 82 +++++++++++++----- electron/ipc/register/project.ts | 7 +- electron/main.ts | 58 ++++++++----- electron/preload.ts | 14 +++- electron/saveBeforeClose.test.ts | 71 ++++++++++++++++ electron/saveBeforeClose.ts | 65 +++++++++++++++ .../project/useProjectLibraryController.ts | 33 +++++++- tests/ui/bridge.ts | 1 + tests/ui/project-dashboard.spec.ts | 11 ++- 11 files changed, 376 insertions(+), 52 deletions(-) create mode 100644 electron/saveBeforeClose.test.ts create mode 100644 electron/saveBeforeClose.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 5409fcbe..70e8f5f1 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -859,6 +859,9 @@ interface Window { trashProjectFiles: ( paths: string[], ) => Promise<{ success: boolean; deleted: string[]; errors: string[] }>; + onProjectThumbnailReady: ( + callback: (ready: { path: string; thumbnailPath: string; updatedAt: number }) => void, + ) => () => void; listProjectFiles: () => Promise<{ success: boolean; projectsDir?: string | null; diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 120f357b..925e0725 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -190,7 +190,11 @@ describe("local media path policy", () => { ); const before = await fs.readFile(projectPath, "utf8"); const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot); - expect(entry?.thumbnailPath).toBe(manager.getProjectThumbnailPath(projectPath)); + expect(entry?.thumbnailPath).toBeNull(); + await manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath)); + expect((await manager.buildProjectLibraryEntry(projectPath, tempRoot))?.thumbnailPath).toBe( + manager.getProjectThumbnailPath(projectPath), + ); expect(generate).toHaveBeenCalledWith(source); await manager.buildProjectLibraryEntry(projectPath, tempRoot); expect(generate).toHaveBeenCalledTimes(1); @@ -283,4 +287,81 @@ describe("local media path policy", () => { expect(result.success).toBe(true); await expect(resolveApprovedLocalMediaPath(audioPath)).resolves.toBe(resolvedAudioPath); }); + it("returns a library entry while a thumbnail decode is still pending", async () => { + let release!: (data: Buffer) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const generate = vi.fn(() => pending); + vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate })); + const manager = await import("./manager"); + const source = path.join(tempRoot, "slow.mp4"); + const projectPath = path.join(tempRoot, "slow.recordly"); + await fs.writeFile(source, "video"); + await fs.writeFile( + projectPath, + JSON.stringify({ version: 2, videoPath: source, editor: {} }), + ); + const ready = vi.fn(); + const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot, ready); + expect(entry?.path).toBe(projectPath); + expect(entry?.thumbnailPath).toBeNull(); + const task = manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath)); + release(Buffer.from("fixture thumbnail")); + await task; + expect(generate).toHaveBeenCalledTimes(1); + expect(ready).toHaveBeenCalledWith(expect.objectContaining({ path: projectPath })); + }); + + it("replaces a thumbnail symlink without overwriting its target", async () => { + vi.doMock("./firstFrameThumbnail", () => ({ + createProjectFirstFrameThumbnail: vi.fn().mockResolvedValue(Buffer.from("new poster")), + })); + const manager = await import("./manager"); + const source = path.join(tempRoot, "source.mp4"); + const projectPath = path.join(tempRoot, "linked.recordly"); + const target = path.join(tempRoot, "private.txt"); + await fs.writeFile(source, "video"); + await fs.writeFile( + projectPath, + JSON.stringify({ version: 2, videoPath: source, editor: {} }), + ); + await fs.writeFile(target, "must remain intact"); + const thumbnail = manager.getProjectThumbnailPath(projectPath); + try { + await fs.symlink(target, thumbnail); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") return; + throw error; + } + await manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath)); + expect(await fs.readFile(target, "utf8")).toBe("must remain intact"); + expect((await fs.lstat(thumbnail)).isSymbolicLink()).toBe(false); + expect(await fs.readFile(thumbnail, "utf8")).toBe("new poster"); + }); + + it("discards a thumbnail when the project changes during decoding", async () => { + let release!: (data: Buffer) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const generate = vi.fn(() => pending); + vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate })); + const manager = await import("./manager"); + const source = path.join(tempRoot, "source.mp4"); + const projectPath = path.join(tempRoot, "edited.recordly"); + await fs.writeFile(source, "video"); + await fs.writeFile( + projectPath, + JSON.stringify({ version: 2, videoPath: source, editor: {} }), + ); + const task = manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath)); + await vi.waitFor(() => expect(generate).toHaveBeenCalled()); + await fs.appendFile(projectPath, " "); + release(Buffer.from("obsolete poster")); + expect(await task).toBeNull(); + await expect(fs.stat(manager.getProjectThumbnailPath(projectPath))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); }); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index be9493d7..4b066aba 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -1,8 +1,9 @@ +import { randomUUID } from "node:crypto"; import { createProjectFirstFrameThumbnail } from "./firstFrameThumbnail"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; import type { ProjectPreviewData } from "../../../src/types/projectPreview"; import { hasFreshProjectThumbnail } from "./thumbnailFreshness"; -import { existsSync, constants as fsConstants, realpathSync } from "node:fs"; +import { type Stats, existsSync, constants as fsConstants, realpathSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; @@ -323,9 +324,58 @@ export async function rememberRecentProject(projectPath: string) { await saveRecentProjectPaths([projectPath, ...existingPaths]); } +export type ProjectThumbnailReady = { path: string; thumbnailPath: string; updatedAt: number }; +const thumbnailJobs = new Map>(); + +function sameProjectRevision(left: Stats, right: Stats) { + return ( + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs && + left.size === right.size && + left.ino === right.ino && + left.dev === right.dev + ); +} + +export function refreshProjectThumbnail(projectPath: string, revision: Stats) { + const pending = thumbnailJobs.get(projectPath); + if (pending) return pending; + const task = (async (): Promise => { + const thumbnailPath = getProjectThumbnailPath(projectPath); + let temporary: string | undefined; + try { + if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null; + const project = parseJsonWithByteOrderMark(await fs.readFile(projectPath, "utf-8")); + if (!isLoadableProjectData(project)) return null; + const media = await resolveProjectMediaSources(project); + if (!media.success) return null; + const thumbnail = await createProjectFirstFrameThumbnail(media.videoPath); + if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null; + if (!(await hasFreshProjectThumbnail(thumbnailPath, revision.mtimeMs))) { + temporary = `${thumbnailPath}.${randomUUID()}.tmp`; + await fs.writeFile(temporary, thumbnail, { flag: "wx" }); + // Stamp the source revision, rather than generation time, so a later edit invalidates it. + await fs.utimes(temporary, new Date(), Math.ceil(revision.mtimeMs) / 1000); + if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null; + // Rename replaces a sidecar symlink itself; it never writes through its target. + await fs.rename(temporary, thumbnailPath); + } + return { path: projectPath, thumbnailPath, updatedAt: revision.mtimeMs }; + } catch { + // Offline, corrupt or deleted media must not hold up the library. + return null; + } finally { + if (temporary) await fs.rm(temporary, { force: true }).catch(() => undefined); + } + })().finally(() => thumbnailJobs.delete(projectPath)); + thumbnailJobs.set(projectPath, task); + return task; +} + export async function buildProjectLibraryEntry( projectPath: string, projectsDir: string, + onThumbnailReady?: (ready: ProjectThumbnailReady) => void, ): Promise { try { const normalizedPath = normalizePath(projectPath); @@ -339,25 +389,13 @@ export async function buildProjectLibraryEntry( } const thumbnailPath = getProjectThumbnailPath(normalizedPath); - let thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs); + const thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs); if (!thumbnailExists) { - try { - const project = parseJsonWithByteOrderMark( - await fs.readFile(normalizedPath, "utf-8"), - ); - if (isLoadableProjectData(project)) { - const media = await resolveProjectMediaSources(project); - if (media.success) { - const thumbnail = await createProjectFirstFrameThumbnail(media.videoPath); - if (!(await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs))) { - await fs.writeFile(thumbnailPath, thumbnail); - } - thumbnailExists = true; - } - } - } catch { - // Missing/offline source media must not hide a project from the library. - } + void refreshProjectThumbnail(normalizedPath, stats) + .then((ready) => { + if (ready) onThumbnailReady?.(ready); + }) + .catch(() => undefined); } return { @@ -384,7 +422,9 @@ export async function buildProjectLibraryEntry( } } -export async function listProjectLibraryEntries() { +export async function listProjectLibraryEntries( + onThumbnailReady?: (ready: ProjectThumbnailReady) => void, +) { const projectsDir = await getProjectsDir(); const projectPaths: string[] = []; @@ -409,7 +449,7 @@ export async function listProjectLibraryEntries() { const entries = ( await Promise.all( candidatePaths.map((candidatePath) => - buildProjectLibraryEntry(candidatePath, projectsDir), + buildProjectLibraryEntry(candidatePath, projectsDir, onThumbnailReady), ), ) ) diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 27767ba6..88dfb71d 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -748,9 +748,12 @@ export function registerProjectHandlers() { } }); - ipcMain.handle("list-project-files", async () => { + ipcMain.handle("list-project-files", async (event) => { try { - const library = await listProjectLibraryEntries(); + const library = await listProjectLibraryEntries((ready) => { + if (!event.sender.isDestroyed()) + event.sender.send("project-library-thumbnail-ready", ready); + }); return { success: true, projectsDir: library.projectsDir, diff --git a/electron/main.ts b/electron/main.ts index b012ec6a..70e45d66 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,4 @@ +import { createSaveBeforeCloseController } from "./saveBeforeClose"; import { clearRecordingTrashUndo } from "./ipc/recording/library"; import fs from "node:fs/promises"; import path from "node:path"; @@ -6,6 +7,7 @@ import { app, BrowserWindow, desktopCapturer, + dialog, webContents as electronWebContents, ipcMain, Menu, @@ -813,7 +815,39 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; }); - let savingBeforeClose = false; + const closeAfterSave = () => { + if (process.platform === "win32" && !isAppQuitting) closeEditorWindowToHud(editorWindow); + else closeEditorWindowBypassingUnsavedPrompt(editorWindow); + }; + let closeSaveRequestId = 0; + const closeSave = createSaveBeforeCloseController({ + requestSave: () => + editorWindow.webContents.send("request-save-before-close", ++closeSaveRequestId), + subscribe: (done) => { + const saved = (event: Electron.IpcMainEvent, success: unknown, requestId: unknown) => { + if (event.sender === editorWindow.webContents && requestId === closeSaveRequestId) + done(success === true); + }; + ipcMain.on("save-before-close-done", saved); + return () => ipcMain.removeListener("save-before-close-done", saved); + }, + offerDiscard: async () => { + const result = await dialog.showMessageBox(editorWindow, { + type: "warning", + buttons: ["Discard & Close", "Cancel"], + defaultId: 1, + cancelId: 1, + title: "Project could not be saved", + message: "Saving failed or did not respond. Discard unsaved changes and close?", + }); + return result.response === 0; + }, + close: closeAfterSave, + cancel: () => { + isAppQuitting = false; + }, + }); + editorWindow.once("closed", closeSave.dispose); editorWindow.on("close", (event) => { if (isForceClosing || !editorHasUnsavedChanges) { if (process.platform === "win32" && !isForceClosing && !isAppQuitting) { @@ -822,28 +856,8 @@ function createEditorWindowWrapper() { } return; } - event.preventDefault(); - - if (editorWindow.webContents.isDestroyed() || savingBeforeClose) return; - savingBeforeClose = true; - const saved = (sender: Electron.IpcMainEvent, success: boolean) => { - if (sender.sender !== editorWindow.webContents) return; - ipcMain.removeListener("save-before-close-done", saved); - editorWindow.removeListener("closed", cancelPendingSave); - savingBeforeClose = false; - if (!success) { - isAppQuitting = false; - return; - } - if (process.platform === "win32" && !isAppQuitting) - closeEditorWindowToHud(editorWindow); - else closeEditorWindowBypassingUnsavedPrompt(editorWindow); - }; - const cancelPendingSave = () => ipcMain.removeListener("save-before-close-done", saved); - ipcMain.on("save-before-close-done", saved); - editorWindow.once("closed", cancelPendingSave); - editorWindow.webContents.send("request-save-before-close"); + closeSave.start(); }); return editorWindow; diff --git a/electron/preload.ts b/electron/preload.ts index ddfadbfa..468ef2e9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -862,6 +862,16 @@ contextBridge.exposeInMainWorld("electronAPI", { getProjectsDirectory: () => { return ipcRenderer.invoke("get-projects-directory"); }, + onProjectThumbnailReady: ( + callback: (ready: { path: string; thumbnailPath: string; updatedAt: number }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + ready: { path: string; thumbnailPath: string; updatedAt: number }, + ) => callback(ready); + ipcRenderer.on("project-library-thumbnail-ready", listener); + return () => ipcRenderer.removeListener("project-library-thumbnail-ready", listener); + }, listProjectFiles: () => { return ipcRenderer.invoke("list-project-files"); }, @@ -1059,14 +1069,14 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.send("set-has-unsaved-changes", hasChanges); }, onRequestSaveBeforeClose: (callback: () => Promise) => { - const listener = async () => { + const listener = async (_event: Electron.IpcRendererEvent, requestId: unknown) => { let saved = false; try { saved = await callback(); } catch { saved = false; } - ipcRenderer.send("save-before-close-done", saved); + ipcRenderer.send("save-before-close-done", saved, requestId); }; ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); diff --git a/electron/saveBeforeClose.test.ts b/electron/saveBeforeClose.test.ts new file mode 100644 index 00000000..43799b8b --- /dev/null +++ b/electron/saveBeforeClose.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createSaveBeforeCloseController } from "./saveBeforeClose"; +afterEach(() => vi.useRealTimers()); +function fixture(discard = false) { + let reply!: (saved: boolean) => void; + const unsubscribe = vi.fn(); + const options = { + requestSave: vi.fn(), + subscribe: vi.fn((done: (saved: boolean) => void) => { + reply = done; + return unsubscribe; + }), + offerDiscard: vi.fn().mockResolvedValue(discard), + close: vi.fn(), + cancel: vi.fn(), + timeoutMs: 100, + }; + return { + options, + unsubscribe, + reply: (saved: boolean) => reply(saved), + controller: createSaveBeforeCloseController(options), + }; +} +it("closes once after saving and removes the listener", () => { + const f = fixture(); + f.controller.start(); + f.controller.start(); + f.reply(true); + f.reply(true); + expect(f.options.requestSave).toHaveBeenCalledTimes(1); + expect(f.options.close).toHaveBeenCalledTimes(1); + expect(f.unsubscribe).toHaveBeenCalledTimes(1); + expect(f.options.offerDiscard).not.toHaveBeenCalled(); +}); +it.each([true, false])("offers discard/cancel after failed save (discard=%s)", async (discard) => { + const f = fixture(discard); + f.controller.start(); + f.reply(false); + await vi.waitFor(() => + expect(discard ? f.options.close : f.options.cancel).toHaveBeenCalledTimes(1), + ); + expect(f.unsubscribe).toHaveBeenCalledTimes(1); + if (!discard) { + f.controller.start(); + expect(f.options.requestSave).toHaveBeenCalledTimes(2); + } +}); +it("recovers a hung renderer on timeout and ignores late replies", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.controller.start(); + await vi.advanceTimersByTimeAsync(100); + f.reply(true); + expect(f.options.offerDiscard).toHaveBeenCalledTimes(1); + expect(f.options.cancel).toHaveBeenCalledTimes(1); + expect(f.options.close).not.toHaveBeenCalled(); + f.controller.start(); + expect(f.options.requestSave).toHaveBeenCalledTimes(2); +}); +it("removes a pending timeout/listener when the window is destroyed", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.controller.start(); + f.controller.dispose(); + await vi.advanceTimersByTimeAsync(100); + f.reply(true); + expect(f.unsubscribe).toHaveBeenCalledTimes(1); + expect(f.options.close).not.toHaveBeenCalled(); + expect(f.options.offerDiscard).not.toHaveBeenCalled(); +}); diff --git a/electron/saveBeforeClose.ts b/electron/saveBeforeClose.ts new file mode 100644 index 00000000..05f6ea7e --- /dev/null +++ b/electron/saveBeforeClose.ts @@ -0,0 +1,65 @@ +type Options = { + requestSave: () => void; + subscribe: (done: (saved: boolean) => void) => () => void; + offerDiscard: () => Promise; + close: () => void; + cancel: () => void; + timeoutMs?: number; +}; + +/** Saves before closing, with a cancellable recovery path for failed or missing replies. */ +export function createSaveBeforeCloseController(options: Options) { + let pending = false; + let recovering = false; + let disposed = false; + let timer: ReturnType | undefined; + let unsubscribe: (() => void) | undefined; + const cleanup = () => { + if (timer) clearTimeout(timer); + timer = undefined; + unsubscribe?.(); + unsubscribe = undefined; + }; + const done = (saved: boolean) => { + if (!pending || recovering || disposed) return; + cleanup(); + if (saved) { + pending = false; + options.close(); + return; + } + recovering = true; + void Promise.resolve() + .then(options.offerDiscard) + .then((discard) => { + if (disposed) return; + if (discard) options.close(); + else options.cancel(); + }) + .catch(() => { + if (!disposed) options.cancel(); + }) + .finally(() => { + pending = false; + recovering = false; + }); + }; + return { + start() { + if (pending || disposed) return; + pending = true; + try { + unsubscribe = options.subscribe(done); + timer = setTimeout(() => done(false), options.timeoutMs ?? 30_000); + options.requestSave(); + } catch { + done(false); + } + }, + dispose() { + disposed = true; + pending = false; + cleanup(); + }, + }; +} diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts index f7037c78..87812921 100644 --- a/src/components/video-editor/project/useProjectLibraryController.ts +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -1,5 +1,5 @@ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */ -import { type RefObject, useCallback } from "react"; +import { type RefObject, useCallback, useEffect, useRef } from "react"; import { PROJECT_THUMBNAIL_WIDTH, PROJECT_THUMBNAIL_HEIGHT } from "@/lib/projectThumbnail"; import { FrameRenderer } from "@/lib/exporter/frameRenderer"; import { toFileUrl } from "../projectPersistence"; @@ -77,6 +77,9 @@ export function useProjectLibraryController({ speedRegions, zoomRegions, } = timeline; + const thumbnailUpdates = useRef( + new Map(), + ); const refreshProjectLibrary = useCallback(async () => { setProjectLibraryLoading(true); try { @@ -85,7 +88,18 @@ export function useProjectLibraryController({ throw new Error(result.error || "Failed to load project library"); } - setProjectLibraryEntries(result.entries); + setProjectLibraryEntries( + result.entries.map((entry) => { + const ready = thumbnailUpdates.current.get(entry.path); + return ready?.updatedAt === entry.updatedAt + ? { ...entry, thumbnailPath: ready.thumbnailPath } + : entry; + }), + ); + const paths = new Set(result.entries.map((entry) => entry.path)); + for (const key of thumbnailUpdates.current.keys()) { + if (!paths.has(key)) thumbnailUpdates.current.delete(key); + } } catch (projectLibraryError) { console.warn("Unable to refresh project library:", projectLibraryError); } finally { @@ -93,6 +107,21 @@ export function useProjectLibraryController({ } }, []); + useEffect( + () => + window.electronAPI.onProjectThumbnailReady?.((ready) => { + thumbnailUpdates.current.set(ready.path, ready); + setProjectLibraryEntries((entries) => + entries.map((entry) => + entry.path === ready.path && entry.updatedAt === ready.updatedAt + ? { ...entry, thumbnailPath: ready.thumbnailPath } + : entry, + ), + ); + }), + [setProjectLibraryEntries], + ); + const captureProjectThumbnail = useCallback(async () => { const previewHandle = videoPlaybackRef.current; const previewVideo = previewHandle?.video ?? null; diff --git a/tests/ui/bridge.ts b/tests/ui/bridge.ts index 477a6e17..f061ca55 100644 --- a/tests/ui/bridge.ts +++ b/tests/ui/bridge.ts @@ -93,6 +93,7 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m setCurrentRecordingSession: success, setHasUnsavedChanges: success, onAuthCallbackUrl: subscribe, + onProjectThumbnailReady: subscribe, getPendingAuthCallbackUrl: async () => null, ackAuthCallbackUrl: success, onMenuSaveProject: subscribe, diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index 3b44dc26..18b489ba 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -4,7 +4,9 @@ import { hasFreshProjectThumbnail } from "../../electron/ipc/project/thumbnailFr import { expect, test } from "@playwright/test"; import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge"; -test("Home opens with a wireframe while saving and returns to the main library", async ({ page }) => { +test("Home opens with a wireframe while saving and returns to the main library", async ({ + page, +}) => { await installDesktopBridge(page); await page.goto("/?windowType=editor"); await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1"); @@ -30,9 +32,14 @@ test("Home opens with a wireframe while saving and returns to the main library", await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible(); await page.keyboard.press("Escape"); await expect(home).not.toBeVisible(); + await page.evaluate(() => { + delete document.documentElement.dataset.savePending; + }); await page.getByRole("button", { name: "Home", exact: true }).click(); await expect(home.getByRole("textbox", { name: "Search projects" })).toBeVisible(); - await expect(home.getByLabel("Loading projects")).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-save-pending", "true"); + await page.evaluate(() => window.dispatchEvent(new Event("test-release-save"))); + await expect(home.getByLabel("Loading projects")).toHaveCount(0); }); test("home dashboard searches, sorts, opens projects and returns to the editor", async ({