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>
167 lines
11 KiB
TypeScript
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),
|
|
});
|
|
});
|