import { expect, test } from "@playwright/test"; import path from "node:path"; const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); test("M7-09 persists recent projects and restores UI state after a damaged-index Worker restart", async ({ page }) => { await page.goto("/?worker-fault=storage"); await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); const app = page.locator(".blender-app"); await page.getByTestId("blend-file-input").setInputFiles(basicBlend); await expect(page.getByText("Cube", { exact: true })).toBeVisible(); const download = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); await download; await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); await page.getByRole("button", { name: "Modeling" }).click(); await page.getByRole("slider", { name: "当前帧" }).fill("12"); await expect(app).toHaveAttribute("data-current-frame", "12"); await page.locator('.tree-row.child').filter({ hasText: "Camera" }).click(); await page.locator('.tree-row.child').filter({ hasText: "Cube" }).click({ modifiers: ["Shift"] }); const selectedBefore = await app.getAttribute("data-selected-object-ids"); const revisionBefore = await app.getAttribute("data-current-main-revision"); expect(selectedBefore?.split(",")).toHaveLength(2); await page.evaluate(async () => { const database = await new Promise((resolve, reject) => { const request = indexedDB.open("blender-web-metadata", 7); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const transaction = database.transaction("setting", "readwrite"); transaction.objectStore("setting").put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects: [{ broken: true }] } }); await new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); database.close(); }); await page.getByTestId("inject-worker-crash").click(); await expect(app).toHaveAttribute("data-worker-fault-source", "storage"); await page.getByTestId("restart-and-recover").click(); await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 }); await expect(app).toHaveAttribute("data-workspace", "Modeling"); await expect(app).toHaveAttribute("data-current-frame", "12"); await expect(app).toHaveAttribute("data-selected-object-ids", selectedBefore ?? ""); await expect(app).toHaveAttribute("data-current-main-revision", revisionBefore ?? ""); await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); await expect(app).toHaveAttribute("data-recent-project-quarantined", "1"); const persistedIndex = await page.evaluate(async () => { const database = await new Promise((resolve, reject) => { const request = indexedDB.open("blender-web-metadata", 7); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const transaction = database.transaction("setting", "readonly"); const row = await new Promise<{ value?: { projects?: Array<{ projectId?: string }> } } | undefined>((resolve, reject) => { const request = transaction.objectStore("setting").get("recent-projects:v1"); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); database.close(); return row?.value?.projects?.map((project) => project.projectId) ?? []; }); expect(persistedIndex).toEqual(["basic_scene"]); await page.reload(); await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); await page.getByTestId("recent-projects").selectOption("basic_scene"); await expect(page.getByText("Cube", { exact: true })).toBeVisible(); await expect(app).toHaveAttribute("data-project-id", "basic_scene"); await expect(page.getByTestId("engine-status")).toContainText("Recovery:"); }); test("M7-09 StorageClient keeps deterministic recent projects across Worker instances", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const base = { schemaVersion: 1 as const, revision: 1, bytes: 8, sha256: "a".repeat(64), updatedAt: "2026-08-15T12:00:00.000Z", backend: "unknown" as const, }; const first = new StorageClient(); await Promise.all([ first.touchRecentProject({ ...base, projectId: "older", displayName: "Older", lastOpenedAt: "2026-08-15T11:00:00.000Z" }), first.touchRecentProject({ ...base, projectId: "newer", displayName: "Newer", lastOpenedAt: "2026-08-15T13:00:00.000Z" }), first.touchRecentProject({ ...base, projectId: "older", displayName: "Older latest", revision: 2, lastOpenedAt: "2026-08-15T14:00:00.000Z" }), ]); first.terminate(); const restarted = new StorageClient(); const listed = await restarted.listRecentProjects(); restarted.terminate(); return listed; }); expect(result.quarantined).toBe(0); expect(result.projects.map(({ projectId, revision }) => [projectId, revision])).toEqual([["older", 2], ["newer", 1]]); }); test("M7-10 isolates a missing recent project and removes only its reference", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const storage = new StorageClient(); await storage.touchRecentProject({ schemaVersion: 1, projectId: "missing_recent", displayName: "Missing recent.blend", revision: 1, bytes: 8, sha256: "a".repeat(64), updatedAt: "2026-08-15T12:00:00.000Z", lastOpenedAt: "2026-08-15T12:00:00.000Z", backend: "opfs", }); storage.terminate(); }); const app = page.locator(".blender-app"); await page.getByTestId("blend-file-input").setInputFiles(basicBlend); await expect(page.getByText("Cube", { exact: true })).toBeVisible(); const download = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); await download; await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1"); await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "missing_recent"); await expect(page.getByTestId("recent-project-repair-banner")).toContainText("项目内容缺失"); await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="MISSING"]')).toBeVisible(); await page.getByTestId("remove-recent-project-missing_recent").click(); await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0"); await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); await expect(page.getByTestId("recent-project-repair-banner")).toHaveCount(0); const listed = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const storage = new StorageClient(); const result = await storage.listRecentProjects(); storage.terminate(); return result; }); expect(listed.projects.map(({ projectId }) => projectId)).toEqual(["basic_scene"]); expect(listed.issues).toEqual([]); }); test("M7-10 isolates a hash-mismatched entry while retaining other recent projects", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); const app = page.locator(".blender-app"); await page.getByTestId("blend-file-input").setInputFiles(basicBlend); await expect(page.getByText("Cube", { exact: true })).toBeVisible(); const download = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); await download; await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); await page.evaluate(async () => { const database = await new Promise((resolve, reject) => { const request = indexedDB.open("blender-web-metadata", 7); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const transaction = database.transaction("setting", "readwrite"); const store = transaction.objectStore("setting"); const row = await new Promise<{ value?: { projects?: Array> } } | undefined>((resolve, reject) => { const request = store.get("recent-projects:v1"); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const projects = (row?.value?.projects ?? []).map((project) => project.projectId === "basic_scene" ? { ...project, sha256: "f".repeat(64) } : project); store.put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects } }); await new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); database.close(); }); await page.reload(); await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1"); await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "basic_scene"); await expect(app).toHaveAttribute("data-recent-project-ids", ""); await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="HASH_MISMATCH"]')).toBeVisible(); await page.getByTestId("remove-recent-project-basic_scene").click(); await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0"); await expect(app).toHaveAttribute("data-recent-project-ids", ""); await expect(page.getByText("Cube", { exact: true })).toHaveCount(0); });