Files
workinf_Blender_Wasm/web/tests/e2e/archive-offline.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

167 lines
11 KiB
TypeScript

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<Page,{started:number;chrome:string;requestFailures:string[];consoleErrors:string[]}>();
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<void> {
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<number> {
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),
});
});