import { expect, test, type BrowserContext, type Page } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; import { importGLBSemantics } from "../../protocol/glb-import"; const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); function isLoopbackRequest(value: string): boolean { const url = new URL(value); if (!["http:", "https:"].includes(url.protocol)) return true; return url.hostname === "localhost" || url.hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname); } async function isolateExternalNetwork(context: BrowserContext, page: Page) { const externalAttempts: string[] = []; const requested: string[] = []; page.on("request", (request) => requested.push(request.url())); await context.route("**/*", async (route) => { if (isLoopbackRequest(route.request().url())) await route.continue(); else { externalAttempts.push(route.request().url()); await route.abort("internetdisconnected"); } }); return { externalAttempts, requested }; } async function waitForArchiveBoot(page: Page): Promise { await page.goto("/"); await expect(page.getByText("Web Blender Modeler V1", { exact: true }).first()).toBeVisible(); await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1"); await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file"); await expect(page.locator(".status-bar")).toContainText("Storage: IndexedDB + OPFS"); await page.evaluate(() => document.fonts.ready); expect(await page.evaluate(() => document.fonts.status)).toBe("loaded"); } async function objectCount(page: Page): Promise { const text = await page.getByTestId("scene-stats").innerText(); const match = text.match(/Objects (\d+)/); if (!match) throw new Error(`scene stats omit object count: ${text}`); return Number(match[1]); } async function stableIds(page: Page) { return page.locator(".outliner-content .tree-row.child").evaluateAll((rows) => rows.map((row) => ({ objectId: (row as HTMLElement).dataset.nodeId ?? "", dataId: (row as HTMLElement).dataset.dataId ?? "", name: row.querySelector("span:nth-of-type(3)")?.textContent ?? "", })).sort((left, right) => left.objectId.localeCompare(right.objectId))); } async function opfsCommit(page: Page, projectId: string) { return page.evaluate(async (id) => { const root = await navigator.storage.getDirectory(); const projects = await root.getDirectoryHandle("projects"); const project = await projects.getDirectoryHandle(id); const manifestFile = await (await project.getFileHandle("scene.blend.meta.json")).getFile(); const manifest = JSON.parse(await manifestFile.text()) as { revision: number; bytes: number; sha256: string }; const blend = await (await project.getFileHandle("scene.blend")).getFile(); const bytes = await blend.arrayBuffer(); const digest = await crypto.subtle.digest("SHA-256", bytes); const sha256 = Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); return { ...manifest, actualBytes: bytes.byteLength, actualSha256: sha256 }; }, projectId); } test("M6-09 cold boots the extracted binary archive with no external network", async ({ context, page }) => { const network = await isolateExternalNetwork(context, page); await waitForArchiveBoot(page); const paths = network.requested.filter((value) => value.startsWith(page.url().replace(/\/$/, ""))).map((value) => new URL(value).pathname); expect(paths).toContain("/engine-manifest.json"); expect(paths.some((value) => value.endsWith(".wasm"))).toBe(true); expect(paths.some((value) => value.endsWith(".css"))).toBe(true); expect(paths.some((value) => value.includes(".worker-") && value.endsWith(".js"))).toBe(true); expect(network.externalAttempts).toEqual([]); }); test("M6-10 reopens and continues an OPFS project offline after Worker reconstruction", async ({ context, page }) => { const network = await isolateExternalNetwork(context, page); await waitForArchiveBoot(page); const input = fs.readFileSync(basicBlend); const projectId = `m6-offline-${Date.now()}`; await page.getByTestId("blend-file-input").setInputFiles({ name: `${projectId}.blend`, mimeType: "application/octet-stream", buffer: input, }); await expect(page.getByTestId("engine-status")).toContainText("Engine: SceneIR r"); const initialCount = await objectCount(page); const initialIds = await stableIds(page); await page.getByRole("button", { name: "添加立方体" }).click(); await expect.poll(() => objectCount(page)).toBe(initialCount + 1); const editedIds = await stableIds(page); const firstCreated = editedIds.find((entry) => !initialIds.some((initial) => initial.objectId === entry.objectId)); expect(firstCreated?.objectId).toBeTruthy(); expect(firstCreated?.dataId).toBeTruthy(); await page.getByRole("button", { name: "撤销" }).click(); await expect.poll(() => objectCount(page)).toBe(initialCount); await page.getByRole("button", { name: "重做" }).click(); await expect.poll(() => objectCount(page)).toBe(initialCount + 1); const firstSave = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); await firstSave; const firstCommit = await opfsCommit(page, projectId); expect(firstCommit.revision).toBeGreaterThan(0); expect(firstCommit.bytes).toBe(firstCommit.actualBytes); expect(firstCommit.sha256).toBe(firstCommit.actualSha256); expect(firstCommit.sha256).toMatch(/^[a-f0-9]{64}$/); await page.reload(); await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file"); await page.getByRole("button", { name: "恢复项目" }).click(); await expect(page.getByTestId("engine-status")).toContainText("Recovery: 0 operation(s), 0 quarantined"); expect(await stableIds(page)).toEqual(editedIds); await page.getByRole("button", { name: "添加立方体" }).click(); await expect.poll(() => objectCount(page)).toBe(initialCount + 2); await page.getByRole("button", { name: "撤销" }).click(); await expect.poll(() => objectCount(page)).toBe(initialCount + 1); await page.getByRole("button", { name: "重做" }).click(); await expect.poll(() => objectCount(page)).toBe(initialCount + 2); const secondSave = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); await secondSave; const secondCommit = await opfsCommit(page, projectId); expect(secondCommit.revision).toBeGreaterThan(firstCommit.revision); expect(secondCommit.sha256).toBe(secondCommit.actualSha256); expect(secondCommit.sha256).not.toBe(firstCommit.sha256); const glbDownload = page.waitForEvent("download"); await page.getByRole("button", { name: "导出 GLB" }).click(); const glb = await glbDownload; const glbPath = await glb.path(); expect(glbPath).toBeTruthy(); const glbBytes = fs.readFileSync(glbPath!); const glbBuffer = glbBytes.buffer.slice(glbBytes.byteOffset, glbBytes.byteOffset + glbBytes.byteLength); const imported = importGLBSemantics(glbBuffer); expect(imported.meshCount).toBeGreaterThan(0); expect(imported.primitiveCount).toBeGreaterThan(0); expect(imported.meshes.some((mesh) => mesh.blenderId === firstCreated?.dataId)).toBe(true); expect(network.externalAttempts).toEqual([]); });