import { expect, test, type BrowserContext, type Page } from "@playwright/test"; import fs from "node:fs"; import crypto from "node:crypto"; import path from "node:path"; import { importGLBSemantics } from "../../protocol/glb-import"; import { observeNative } from "../fixtures/native-ui-workflow"; const archiveRuns = new WeakMap(); test.beforeEach(async ({ page, browser }) => { const record={started:Date.now(),chrome:browser.version(),requestFailures:[] as string[],consoleErrors:[] as string[]}; archiveRuns.set(page,record); page.on('requestfailed',request=>record.requestFailures.push(`${request.url()}: ${request.failure()?.errorText}`)); page.on('console',message=>{if(message.type()==='error')record.consoleErrors.push(message.text());}); page.on('pageerror',error=>record.consoleErrors.push(error.message)); await observeNative(page); }); const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); test.afterEach(async ({page},info) => { const root=path.resolve(import.meta.dirname,'../../..'), directory=process.env.WBV2_EVIDENCE_DIR??path.join(root,'tests/golden/WBV2-P1424-001/release-validation-20261021'), archive=path.resolve(process.env.M6_BINARY_ARCHIVE??path.join(root,'release/blender-web-offline.tar.gz')); const sha=(file:string)=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'), record=archiveRuns.get(page)!; const receipt={...record,title:info.title,viewport:page.viewportSize(),crossOriginIsolated:await page.evaluate(()=>crossOriginIsolated),durationMs:Date.now()-record.started,exitCode:info.status==='passed'&&record.requestFailures.length===0&&record.consoleErrors.length===0?0:1,archive,archiveSha256:sha(archive),fixtureSha256:sha(basicBlend),testSourceSha256:sha(import.meta.filename),engineManifest:await (await page.request.get('/engine-manifest.json')).json(),wasmModuleManifest:await (await page.request.get('/wasm-module-manifest.json')).json()}; if (!receipt.crossOriginIsolated) receipt.exitCode=1; fs.mkdirSync(directory,{recursive:true}); const file=path.join(directory,`${record.started}-${info.title.split(' ')[0]}-archive-receipt.json`); fs.writeFileSync(file,JSON.stringify(receipt,null,2)); await info.attach('archive-production-receipt',{path:file,contentType:'application/json'}); expect(receipt.crossOriginIsolated).toBe(true); expect(record.requestFailures).toEqual([]); expect(record.consoleErrors).toEqual([]); }); 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, modeler = false): Promise { await page.goto(modeler ? "/?ui=web" : "/"); await expect(page.locator(".brand")).toBeVisible(); await expect(page.locator(".brand")).toHaveAttribute("title", "Blender 5.2.0 LTS Web"); 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 addCube(page: Page) { await page.getByRole('button', {name:'添加',exact:true}).click(); await page.getByRole('menuitem', {name:'网格',exact:true}).click(); await page.getByRole('menuitem', {name:'立方体',exact:true}).click(); } async function stableIds(page: Page) { return page.evaluate(() => new Promise<{objectId:string;dataId:string;name:string}[]>((resolve,reject) => { const worker=(window as unknown as {__nativeWorkflowEngine:Worker}).__nativeWorkflowEngine,requestId='archive-'+crypto.randomUUID(); const timer=setTimeout(()=>{worker.removeEventListener('message',receive);reject(new Error('Archive Main snapshot timeout'));},30000); const receive=({data}:MessageEvent<{requestId:string;ok:boolean;result?:{snapshot?:{nodes:{id:string;dataId?:string|null;name:string}[]}};error?:unknown}>)=>{if(data.requestId!==requestId||typeof data.ok!=='boolean')return;clearTimeout(timer);worker.removeEventListener('message',receive);if(!data.ok||!data.result?.snapshot){reject(new Error(String(data.error??'Missing Main snapshot')));return;}resolve(data.result.snapshot.nodes.map(node=>({objectId:node.id,dataId:node.dataId??'',name:node.name})).sort((a,b)=>a.objectId.localeCompare(b.objectId)));};worker.addEventListener('message',receive);worker.postMessage({requestId,command:{type:'snapshot'}}); })); } 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 }, testInfo) => { 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([]); await testInfo.attach("offline-cold-boot-evidence", { contentType: "application/json", body: JSON.stringify({ browser: "chromium", ...network }, null, 2), }); }); test("M6-10 reopens and continues an OPFS project offline after Worker reconstruction", async ({ context, page }, testInfo) => { const network = await isolateExternalNetwork(context, page); await waitForArchiveBoot(page, true); 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 addCube(page); 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: baseline r\d+, 0 operation\(s\), 0 quarantined/); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); expect(await stableIds(page)).toEqual(editedIds); await addCube(page); 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([]); await testInfo.attach("offline-project-recovery-evidence", { contentType: "application/json", body: JSON.stringify({ browser: "chromium", ...network, initialCount, initialIds, editedIds, finalCount: await objectCount(page), firstCommit, secondCommit, glb: { meshCount: imported.meshCount, primitiveCount: imported.primitiveCount } }, null, 2), }); });