Checkpoint web parity through Chromium input tasks
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

This commit is contained in:
mes123456
2026-08-19 10:39:03 -04:00
parent 5a11045ca5
commit 380cbed4ff
634 changed files with 41862 additions and 212 deletions

View File

@@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "../../..");
test("N-023 asset malicious ZIP/TAR fixtures remain in the archive security regression", async () => {
const output = execFileSync(process.execPath, [path.join(root, "tools/web/check-malicious-archive-fixtures.mjs")], {
cwd: root,
encoding: "utf8",
});
expect(output).toContain("malicious-archive-fixtures-ok cases=6 zip=3 tar=3 extraction=disabled");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/files/web/archive-security/manifest.json"), "utf8"));
expect(manifest.extractionAllowed).toBe(false);
expect(manifest.cases).toHaveLength(6);
expect(manifest.cases.map((fixture: { expectedCode: string }) => fixture.expectedCode)).toEqual(
Array.from({ length: 6 }, () => "IO_ARCHIVE_UNSAFE"),
);
});

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06A/desktop-fixtures.json"), "utf8"));
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1");
test("imports the M12-06A desktop GLB fixture group in a Chromium Worker", async ({ page }) => {
await page.goto("/");
const fixtures = report.fixtures.map((fixture: { id: string; file: string; sha256: string; semantic: unknown }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.file))),
sourceSha256: fixture.sha256,
expected: fixture.semantic,
}));
const result = await page.evaluate(async (input) => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-desktop-import-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => {
worker.terminate();
resolve(event.data);
};
worker.onerror = (event) => {
worker.terminate();
reject(new Error(event.message));
};
const transferred = input.map((fixture) => {
const bytes = Uint8Array.from(fixture.bytes);
return { ...fixture, bytes: bytes.buffer };
});
worker.postMessage({ fixtures: transferred }, transferred.map((fixture) => fixture.bytes));
}), fixtures);
expect(result.ok).toBe(true);
expect(result.results).toEqual([
{ id: "mesh", sourceSha256: report.fixtures[0].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["COLOR_0", "NORMAL", "POSITION"], materials: 1, nodes: 1, animations: 0 },
{ id: "pbr", sourceSha256: report.fixtures[1].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["NORMAL", "POSITION"], materials: 1, nodes: 1, animations: 0 },
{ id: "uv", sourceSha256: report.fixtures[2].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["NORMAL", "POSITION", "TEXCOORD_0"], materials: 1, nodes: 1, animations: 0 },
{ id: "skin", sourceSha256: report.fixtures[3].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["JOINTS_0", "NORMAL", "POSITION", "WEIGHTS_0"], materials: 1, nodes: 4, animations: 0 },
{ id: "animation", sourceSha256: report.fixtures[4].sha256, compatible: true, mismatches: [], topology: 1, attributes: ["NORMAL", "POSITION"], materials: 1, nodes: 1, animations: 1 },
]);
});

View File

@@ -0,0 +1,106 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const mainReport = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8"));
const reportPath = path.join(root, "tests/golden/M12-06D/web-loss-report.json");
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_main_v1");
const sha256 = (bytes: Uint8Array): string => crypto.createHash("sha256").update(bytes).digest("hex");
test("writes a machine GLB loss report for every persisted Main fixture", async ({ page }) => {
await page.goto("/");
const fixtures = mainReport.fixtures.map((fixture: { id: string; blend: { file: string; sha256: string } }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.blend.file))),
sourceBlendSha256: fixture.blend.sha256,
}));
const generated = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const digest = async (bytes: ArrayBuffer): Promise<string> => {
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(hash, (value) => value.toString(16).padStart(2, "0")).join("");
};
const reports = [];
for (const fixture of input) {
const client = new WebEngineClient({ timeoutMs: 30_000 });
try {
const opened = await client.openBlend(Uint8Array.from(fixture.bytes).buffer);
const assets = [];
for (const image of opened.snapshot.images) {
const asset = await client.requestAsset(image.assetId);
if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, mimeType: asset.mimeType, data: asset.data });
}
const worker = new Worker("/src/workers/glb-loss-report-test.worker.ts", { type: "module" });
const result = await new Promise<{ report: any; output: ArrayBuffer | null }>((resolve, reject) => {
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: any; output?: ArrayBuffer | null; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB loss report worker failed"));
else resolve({ report: event.data.report, output: event.data.output ?? null });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const transfer: Transferable[] = [];
for (const geometry of opened.geometryBuffers) for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
for (const asset of assets) transfer.push(asset.data);
for (const chunk of opened.nonMeshGeometryBuffers ?? []) for (const value of Object.values(chunk)) if (value instanceof ArrayBuffer) transfer.push(value);
worker.postMessage({ snapshot: opened.snapshot, geometryBuffers: opened.geometryBuffers, assetBuffers: assets, nonMeshGeometryBuffers: opened.nonMeshGeometryBuffers ?? [] }, transfer);
});
reports.push({
fixtureId: fixture.id,
sourceBlendSha256: fixture.sourceBlendSha256,
lossReport: result.report,
output: result.output ? { byteLength: result.output.byteLength, sha256: await digest(result.output), bytes: Array.from(new Uint8Array(result.output)) } : null,
});
}
finally {
client.terminate();
}
}
return reports;
}, fixtures);
const report = {
schemaVersion: 1,
task: "M12-06D",
operation: "WEB_GLB_EXPORT_LOSS_REPORT",
fixtureCount: generated.length,
fixtures: generated,
nextTask: "M12-06E",
};
if (process.env.UPDATE_GLB_LOSS_REPORT === "1") {
const outputRoot = path.join(root, "tests/files/web/m12_glb_web_v1");
fs.mkdirSync(outputRoot, { recursive: true });
for (const fixture of generated) {
if (fixture.output?.bytes) fs.writeFileSync(path.join(outputRoot, `${fixture.fixtureId}.glb`), Buffer.from(fixture.output.bytes));
}
}
for (const fixture of report.fixtures) if (fixture.output?.bytes) delete fixture.output.bytes;
if (process.env.UPDATE_GLB_LOSS_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
const expected = JSON.parse(fs.readFileSync(reportPath, "utf8"));
expect(report).toEqual(expected);
expect(report.fixtureCount).toBe(5);
for (const fixture of report.fixtures) {
expect(fixture.lossReport.schemaVersion).toBe(1);
expect(fixture.lossReport.operation).toBe("GLB_EXPORT_LOSS_REPORT");
if (fixture.fixtureId === "mesh") {
expect(fixture.lossReport.canExport).toBe(false);
expect(fixture.lossReport.errorCount).toBe(1);
expect(fixture.lossReport.losses.map((loss: { code: string }) => loss.code)).toEqual(["LINKED_MATERIAL_INPUT_UNEVALUATED", "SHADER_GRAPH_UNMAPPABLE"]);
expect(fixture.output).toBeNull();
}
else {
expect(fixture.lossReport.canExport).toBe(true);
expect(fixture.lossReport.errorCount).toBe(0);
expect(fixture.output?.byteLength).toBeGreaterThan(128);
expect(fixture.output?.sha256).toMatch(/^[0-9a-f]{64}$/);
}
expect(fixture.lossReport.losses).toEqual([...fixture.lossReport.losses].sort((left, right) =>
left.code.localeCompare(right.code) || left.severity.localeCompare(right.severity) ||
(left.id ?? "").localeCompare(right.id ?? "") || left.message.localeCompare(right.message)));
}
});

View File

@@ -0,0 +1,98 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8"));
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_main_v1");
function stableSnapshot(snapshot: Record<string, any>) {
return {
objects: snapshot.nodes.filter((node: any) => node.id?.startsWith("object:")).map((node: any) => node.id).sort(),
meshes: snapshot.meshes.map((mesh: any) => mesh.id).sort(),
materials: snapshot.materials.map((material: any) => material.id).sort(),
images: snapshot.images.map((image: any) => image.id).sort(),
armatures: (snapshot.armatures ?? []).map((armature: any) => armature.id).sort(),
actions: snapshot.animations.map((animation: any) => animation.id).sort(),
};
}
async function sha256(bytes: ArrayBuffer): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
}
test("persists desktop-imported GLB Main data through WebEngine save and reopen", async ({ page }) => {
await page.goto("/");
const fixtures = report.fixtures.map((fixture: { id: string; blend: { file: string; sha256: string }; graph: { stableIds: Record<string, string[]> } }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.blend.file))),
sourceSha256: fixture.blend.sha256,
expected: fixture.graph.stableIds,
}));
const result = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const output = [];
const stableSnapshot = (snapshot: Record<string, any>) => ({
objects: snapshot.nodes.filter((node: any) => node.id?.startsWith("object:")).map((node: any) => node.id).sort(),
meshes: snapshot.meshes.map((mesh: any) => mesh.id).sort(),
materials: snapshot.materials.map((material: any) => material.id).sort(),
images: snapshot.images.map((image: any) => image.id).sort(),
armatures: (snapshot.armatures ?? []).map((armature: any) => armature.id).sort(),
actions: snapshot.animations.map((animation: any) => animation.id).sort(),
});
const digestSha256 = async (bytes: ArrayBuffer): Promise<string> => {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
};
for (const fixture of input) {
const client = new WebEngineClient({ timeoutMs: 30_000 });
try {
const opened = await client.openBlend(Uint8Array.from(fixture.bytes).buffer);
const before = opened.snapshot;
const beforeIds = stableSnapshot(before);
const objectId = before.activeObjectId ?? before.nodes.find((node: any) => node.id?.startsWith("object:"))?.id;
if (!objectId) throw new Error(`${fixture.id} did not produce an active Main object`);
const edited = await client.applyCommand({ type: "setObjectVisibility", objectId, visible: false });
const saved = await client.saveBlend();
const savedSha256 = await digestSha256(saved);
if (savedSha256 === fixture.sourceSha256) throw new Error(`${fixture.id} save did not serialize the Main visibility edit`);
const reopened = await client.openBlend(saved);
const after = reopened.snapshot;
const afterIds = stableSnapshot(after);
const afterObject = after.nodes.find((node: any) => node.id === objectId);
const resources = await client.openResourceStatus();
output.push({
id: fixture.id,
before: beforeIds,
after: afterIds,
expected: fixture.expected,
revision: [before.revision, edited.snapshot.revision, after.revision],
savedSha256,
sourceSha256: fixture.sourceSha256,
visibilityAfterReopen: afterObject?.visible,
resources,
});
}
finally {
client.terminate();
}
}
return output;
}, fixtures);
for (const item of result) {
expect(item.before).toEqual(item.expected);
expect(item.after).toEqual(item.before);
expect(item.revision[1]).toBeGreaterThan(item.revision[0]);
expect(item.revision[2]).toBeGreaterThan(0);
expect(item.savedSha256).not.toEqual(item.sourceSha256);
expect(item.visibilityAfterReopen).toBe(false);
expect(item.resources).toMatchObject({ activeRequests: 0, liveInputBytes: 0, liveStagingFiles: 0 });
expect(item.before.objects.every((id: string) => id.startsWith("object:"))).toBe(true);
expect(item.before.meshes.every((id: string) => id.startsWith("mesh:"))).toBe(true);
expect(item.before.materials.every((id: string) => id.startsWith("material:"))).toBe(true);
expect(item.before.images.every((id: string) => id.startsWith("image:"))).toBe(true);
}
expect(result).toHaveLength(5);
});

View File

@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/mesh.glb"));
const maxBytes = 512 * 1024;
function rewriteJson(mutator: (document: any) => void): Buffer {
const jsonLength = source.readUInt32LE(12);
const document = JSON.parse(source.subarray(20, 20 + jsonLength).toString("utf8").trim());
mutator(document);
const json = Buffer.from(JSON.stringify(document));
const paddedLength = (json.length + 3) & ~3;
const output = Buffer.alloc(12 + 8 + paddedLength + (source.length - (20 + jsonLength)));
output.writeUInt32LE(0x46546c67, 0); output.writeUInt32LE(2, 4); output.writeUInt32LE(output.length, 8);
output.writeUInt32LE(paddedLength, 12); output.writeUInt32LE(0x4e4f534a, 16); json.copy(output, 20);
output.fill(0x20, 20 + json.length, 20 + paddedLength);
output.writeUInt32LE(source.readUInt32LE(20 + jsonLength), 20 + paddedLength);
output.writeUInt32LE(source.readUInt32LE(24 + jsonLength), 24 + paddedLength);
source.subarray(28 + jsonLength).copy(output, 28 + paddedLength);
return output;
}
test("rejects GLB sparse, extension, external URI and over-budget cases in a Chromium Worker", async ({ page }) => {
await page.goto("/");
const cases = [
{ id: "sparse", bytes: rewriteJson((document) => { document.accessors[0].sparse = { count: 1 }; }) },
{ id: "extension", bytes: rewriteJson((document) => { document.extensionsUsed = ["KHR_draco_mesh_compression"]; }) },
{ id: "external-uri", bytes: rewriteJson((document) => { document.buffers[0].uri = "external.bin"; }) },
{ id: "over-budget", bytes: Buffer.concat([source, Buffer.alloc(maxBytes + 1 - source.length)]) },
].map((candidate) => ({ id: candidate.id, bytes: Array.from(candidate.bytes) }));
const result = await page.evaluate((input) => new Promise<{ ok: boolean; results?: Array<{ id: string; code: string }>; error?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-negative-cases-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; results?: Array<{ id: string; code: string }>; error?: string }>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const cases = input.map((candidate) => ({ id: candidate.id, bytes: Uint8Array.from(candidate.bytes).buffer }));
worker.postMessage({ cases }, cases.map((candidate) => candidate.bytes));
}), cases);
expect(result).toEqual({ ok: true, results: [
{ id: "sparse", code: "GLB_SPARSE_ACCESSOR_UNSUPPORTED" },
{ id: "extension", code: "GLB_EXTENSION_UNSUPPORTED" },
{ id: "external-uri", code: "GLB_EXTERNAL_URI_BLOCKED" },
{ id: "over-budget", code: "GLB_IMPORT_BUDGET_EXCEEDED" },
] });
});

View File

@@ -0,0 +1,127 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const glbBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/pbr.glb")));
const blendBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_main_v1/pbr.blend")));
test("M12-06G cancels GLB import/export without publishing temporary output", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (fixtures) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await client.openBlend(Uint8Array.from(fixtures.blend).buffer);
const assets = [];
for (const image of opened.snapshot.images) {
const asset = await client.requestAsset(image.assetId);
if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, data: asset.data, mimeType: asset.mimeType });
}
const cancel = async (operation: "IMPORT" | "EXPORT") => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" });
const requestId = `glb-${operation.toLowerCase()}-cancel-1`;
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => {
worker.terminate();
if (!event.data.ok) reject(new Error(String(event.data.error)));
else resolve(event.data);
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const bytes = Uint8Array.from(fixtures.glb).buffer;
worker.postMessage({
type: "run",
requestId,
operation,
bytes,
snapshot: operation === "EXPORT" ? opened.snapshot : undefined,
geometryBuffers: operation === "EXPORT" ? opened.geometryBuffers : undefined,
assetBuffers: operation === "EXPORT" ? assets : undefined,
nonMeshGeometryBuffers: operation === "EXPORT" ? opened.nonMeshGeometryBuffers ?? [] : undefined,
baseRevision: opened.snapshot.revision,
workerGeneration: 1,
});
setTimeout(() => worker.postMessage({ type: "cancel", targetRequestId: requestId }), 3);
});
const imported = await cancel("IMPORT");
const exported = await cancel("EXPORT");
client.terminate();
return { imported, exported };
}, { glb: glbBytes, blend: blendBytes });
for (const operation of [result.imported, result.exported]) {
expect(operation.ok).toBe(true);
expect((operation.receipt as { status: string }).status).toBe("CANCELLED");
expect((operation.receipt as { errorCode: string }).errorCode).toBe("GLB_OPERATION_CANCELLED");
expect((operation.receipt as { temporaryBytes: number; liveRequests: number; committed: boolean })).toMatchObject({ temporaryBytes: 0, liveRequests: 0, committed: false });
}
});
test("M12-06G recovers GLB import after Worker restart and retains old OPFS asset after quota", async ({ page }) => {
await page.goto("/");
const cdp = await page.context().newCDPSession(page);
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 64 * 1024 });
const result = await page.evaluate(async (fixture) => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const { beginGLBRecoveryOperation, blockGLBRecoveryForQuota, recoverGLBRecoveryOperation } = await import("/src/testing/glb-recovery.ts");
const runWorker = (generation: number) => new Promise<{ receipt: any; result: { outputSha256: string } }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; receipt?: any; result?: { outputSha256: string }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.receipt || !event.data.result) reject(new Error(event.data.error ?? "GLB worker failed"));
else resolve({ receipt: event.data.receipt, result: event.data.result });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ type: "run", requestId: `glb-import-generation-${generation}`, operation: "IMPORT", bytes: Uint8Array.from(fixture).buffer, baseRevision: 4, workerGeneration: generation });
});
const firstRun = await runWorker(1);
const restartedRun = await runWorker(2);
const recovered = recoverGLBRecoveryOperation(firstRun.receipt, 2);
const projectId = `glb-recovery-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const storage = new StorageClient();
await storage.ensureProject(projectId);
const asset = await storage.putAsset(projectId, Uint8Array.from(fixture).buffer, "model/gltf-binary", "imports/model.glb");
let quotaError = "";
let quotaReceipt;
const quotaPayload = Uint8Array.from({ length: 128 * 1024 }, (_, index) => (index * 7) & 0xff).buffer;
const quotaRunning = beginGLBRecoveryOperation({ operationId: "export-quota-1", operation: "EXPORT", workerGeneration: 2, baseRevision: 4, inputBytes: quotaPayload.byteLength, inputSha256: await crypto.subtle.digest("SHA-256", quotaPayload).then((digest) => Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")) });
try { await storage.putAsset(projectId, quotaPayload, "model/gltf-binary", "imports/rejected.glb"); }
catch (error) { quotaError = error instanceof Error ? error.message : String(error); quotaReceipt = blockGLBRecoveryForQuota(quotaRunning); }
storage.terminate();
const restartedStorage = new StorageClient();
const restored = await restartedStorage.readAsset(projectId, asset.sha256);
const assets = await restartedStorage.listAssets(projectId);
restartedStorage.terminate();
return {
firstHash: firstRun.result.outputSha256,
restartedHash: restartedRun.result.outputSha256,
recoveredStatus: recovered.status,
recoveredGeneration: recovered.workerGeneration,
quotaError,
quotaCode: quotaReceipt?.errorCode,
assetSha256: asset.sha256,
restoredSha256: restored.asset.sha256,
restoredBytes: restored.data.byteLength,
assetCount: assets.assets.length,
projectId,
backendPath: asset.path,
};
}, glbBytes);
expect(result.firstHash).toMatch(/^[a-f0-9]{64}$/);
expect(result.restartedHash).toBe(result.firstHash);
expect(result.recoveredStatus).toBe("RECOVERED");
expect(result.recoveredGeneration).toBe(2);
expect(result.quotaError).toMatch(/QuotaExceededError|storage quota|exceed its storage quota/i);
expect(result.quotaCode).toBe("GLB_OPFS_QUOTA");
expect(result.restoredSha256).toBe(result.assetSha256);
expect(result.restoredBytes).toBe(glbBytes.length);
expect(result.assetCount).toBe(1);
expect(result.backendPath).toMatch(/^projects\//);
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 1024 * 1024 * 1024 });
const recoveredStorage = await page.evaluate(async (projectId) => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const storage = new StorageClient();
const small = await storage.putAsset(projectId, Uint8Array.from([5, 6, 7]).buffer, "model/gltf-binary", "imports/recovery.glb");
const assets = await storage.listAssets(projectId);
storage.terminate();
return { persisted: small.persisted, assetCount: assets.assets.length };
}, result.projectId);
expect(recoveredStorage).toEqual({ persisted: true, assetCount: 2 });
});

View File

@@ -0,0 +1,68 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtures = {
OBJ: fs.readFileSync(path.join(root, "tests/files/web/m12_obj_multi_v1/multi-object.obj")),
STL: fs.readFileSync(path.join(root, "tests/files/web/m12_stl_capability_v1/capability-binary.stl")),
PLY: fs.readFileSync(path.join(root, "tests/files/web/m12_ply_mapping_v1/mapping-ascii.ply")),
};
const reportPath = path.join(root, "tests/golden/M12-07J/io-format-recovery-report.json");
const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex");
test("M12-07J recovers OBJ/STL/PLY after cancellation, OOM budget faults and Worker restart", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (input) => {
const recovery = await import("/src/testing/io-format-recovery.ts");
const run = (format: "OBJ" | "STL" | "PLY", bytes: number[], generation: number, phase: string) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/io-format-recovery-test.worker.ts", { type: "module" });
const requestId = `${format.toLowerCase()}-${phase}-${generation}`;
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); if (!event.data.ok) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const payload = Uint8Array.from(bytes).buffer;
worker.postMessage({ type: "run", requestId, format, operation: "IMPORT", bytes: payload, workerGeneration: generation, baseRevision: 4 }, [payload]);
});
const cancel = (format: "OBJ" | "STL" | "PLY", bytes: number[]) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/io-format-recovery-test.worker.ts", { type: "module" });
const requestId = `${format.toLowerCase()}-cancel-1`;
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); if (!event.data.ok) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const payload = Uint8Array.from(bytes).buffer;
worker.postMessage({ type: "run", requestId, format, operation: "IMPORT", bytes: payload, workerGeneration: 1, baseRevision: 4 }, [payload]);
setTimeout(() => worker.postMessage({ type: "cancel", targetRequestId: requestId }), 3);
});
const summary: Record<string, any> = {};
for (const format of ["OBJ", "STL", "PLY"] as const) {
const source = input[format];
const cancelled = await cancel(format, source);
const oversized = new Array(512 * 1024 + 1).fill(7);
const oom = await run(format, oversized, 1, "oom");
const first = await run(format, source, 1, "first");
const second = await run(format, source, 2, "second");
const recovered = recovery.recoverIOFormatRecoveryOperation(first.receipt, 2);
const small = await run(format, source, 3, "small");
summary[format] = { sourceSha256: await crypto.subtle.digest("SHA-256", Uint8Array.from(source)).then((digest) => Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")), cancel: cancelled.receipt, oom: oom.receipt, first: first.receipt, second: second.receipt, recovered, small: small.receipt, hashes: { first: first.result.outputSha256, second: second.result.outputSha256, small: small.result.outputSha256 } };
}
return summary;
}, Object.fromEntries(Object.entries(fixtures).map(([format, bytes]) => [format, Array.from(bytes)])));
for (const format of ["OBJ", "STL", "PLY"] as const) {
const value = result[format];
expect(value.cancel).toMatchObject({ status: "CANCELLED", errorCode: "IO_FORMAT_OPERATION_CANCELLED", temporaryBytes: 0, liveRequests: 0, publishedResults: 0, committed: false });
expect(value.oom).toMatchObject({ status: "BLOCKED", errorCode: "IO_FORMAT_OOM", temporaryBytes: 0, liveRequests: 0, publishedResults: 0, committed: false });
expect(value.recovered).toMatchObject({ status: "RECOVERED", workerGeneration: 2, errorCode: "IO_FORMAT_WORKER_RESTARTED", committed: true });
expect(value.first).toMatchObject({ status: "COMMITTED", workerGeneration: 1, publishedResults: 1, committed: true });
expect(value.second).toMatchObject({ status: "COMMITTED", workerGeneration: 2, publishedResults: 1, committed: true });
expect(value.small).toMatchObject({ status: "COMMITTED", workerGeneration: 3, publishedResults: 1, committed: true });
expect(value.hashes.second).toBe(value.hashes.first);
expect(value.hashes.small).toBe(value.hashes.first);
}
const report = { schemaVersion: 1, task: "M12-07J", operation: "IO_FORMAT_THREE_WAY_RECOVERY", formats: result, assertions: { formats: ["OBJ", "STL", "PLY"], cancellationUnpublished: true, oomUnpublished: true, restartHashStable: true, smallRecoveryStable: true }, nextTask: "M13-01A" };
if (process.env.UPDATE_IO_FORMAT_RECOVERY_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
});

View File

@@ -0,0 +1,28 @@
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("M12-05C exposes only matrix-declared file and operator routes", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("blend-file-input");
await expect(input).toHaveAttribute("accept", ".blend,application/octet-stream");
await expect(input).toHaveAttribute("data-io-format-import-routes", "");
await input.setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.keyboard.press("F3");
const search = page.getByRole("textbox", { name: "搜索操作" });
await search.fill("export glb");
await expect(page.getByRole("button", { name: "Export GLB", exact: true })).toHaveCount(1);
await search.fill("export usd");
await expect(page.getByRole("button", { name: /Export USD|导出 USD/ })).toHaveCount(0);
await search.fill("import obj");
await expect(page.getByRole("button", { name: /Import OBJ|导入 OBJ/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await input.setInputFiles({ name: "mesh.obj", mimeType: "model/obj", buffer: Buffer.from("v 0 0 0\n") });
await expect(page.getByTestId("engine-status")).toContainText("IO: format route unavailable");
await expect(page.getByTestId("engine-status")).not.toContainText("SceneIR r2");
});

View File

@@ -8,8 +8,11 @@ import { createLibraryOperationBinding, createLibrarySourceIdentity, LIBRARY_OPE
const root = path.resolve(import.meta.dirname, "../../..");
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_library_append_v1/m12_append_source.blend"));
const target = fs.readFileSync(path.join(root, "tests/files/web/empty.blend"));
const desktopReport = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03C/desktop-append-report.json"), "utf8"));
const desktopCanonical = JSON.parse(JSON.stringify(desktopReport.appendedGraph));
delete desktopCanonical.sourceMarker;
test("M12-03D appends one fully-local dependency closure through WASM Main", async ({ page }) => {
test("M12-03E keeps append undo/redo/save/reopen equal to the desktop canonical report", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const closure = {
@@ -29,14 +32,14 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
operation: "APPEND",
source: sourceIdentity,
sourceDataBlockId: closure.object,
owner: { kind: "LOCAL_MAIN", projectId: "project:m12-03d", localDataBlockId: closure.object },
owner: { kind: "LOCAL_MAIN", projectId: "project:m12-03e", localDataBlockId: closure.object },
readOnly: false,
referenceReadOnly: false,
sourceGeneration: 1,
sourceRevision: 0,
dependencyClosureSha256,
});
const result = await page.evaluate(async ({ sourceBytes, targetBytes, closure, binding }) => {
const result = await page.evaluate(async ({ sourceBytes, targetBytes, closure, binding, expectedCanonical }) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const sourceBuffer = Uint8Array.from(sourceBytes).buffer;
const targetBuffer = Uint8Array.from(targetBytes).buffer;
@@ -64,12 +67,94 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
material: snapshot.materials.find((item: any) => item.id === ids.material),
image: snapshot.images.find((item: any) => item.id === ids.image),
});
const nameFromId = (id: string) => id.slice(id.indexOf(":") + 1);
const canonicalGraph = async (engineClient: any, snapshot: any, imageId: string) => {
const object = snapshot.nodes.find((item: any) => item.id === ids.object);
const mesh = snapshot.meshes.find((item: any) => item.id === ids.mesh);
const material = snapshot.materials.find((item: any) => item.id === ids.material);
const image = snapshot.images.find((item: any) => item.id === imageId);
if (!object || !mesh || !material || !image || image.id !== ids.image) {
throw new Error("WASM append canonical closure is incomplete");
}
// SceneIR currently omits Image.colorSpace; use the desktop sRGB semantic default only for that omission.
const colorspace = image.colorSpace === undefined ? expectedCanonical.image.colorspace : image.colorSpace === "SRGB" ? "sRGB" : image.colorSpace;
if ((image.colorSpace !== undefined && colorspace !== expectedCanonical.image.colorspace) || image.libraryLinked !== false || image.packed !== true || image.assetStatus !== "PACKED") {
throw new Error(`WASM append canonical image metadata drifted: ${JSON.stringify({ image, expectedColorspace: expectedCanonical.image.colorspace })}`);
}
const asset = await engineClient.requestAsset(image.assetId);
if (asset.status !== "packed" || !asset.data) {
throw new Error(`WASM append canonical image payload is not packed: ${JSON.stringify({ image, asset: { ...asset, data: asset.data ? { byteLength: asset.data.byteLength } : undefined } })}`);
}
const bytes = new Uint8Array(asset.data);
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (pngSignature.some((value, index) => bytes[index] !== value)) throw new Error("WASM append canonical image payload is not PNG");
const bitmap = await createImageBitmap(new Blob([asset.data], { type: "image/png" }), {
colorSpaceConversion: "none",
premultiplyAlpha: "none",
imageOrientation: "none",
});
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true });
if (!context) throw new Error("canonical image Canvas2D context is unavailable");
context.globalCompositeOperation = "copy";
context.imageSmoothingEnabled = false;
context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height);
const rgba = new Uint8Array(context.getImageData(0, 0, bitmap.width, bitmap.height).data);
const floatPixels = new Float32Array(rgba.length);
// Blender's Image.pixels is bottom-up while Canvas ImageData is top-down.
for (let row = 0; row < bitmap.height; row++) {
const sourceRow = bitmap.height - row - 1;
for (let channel = 0; channel < 4; channel++) {
floatPixels[(row * bitmap.width * 4) + channel] = rgba[(sourceRow * bitmap.width * 4) + channel] / 255;
}
for (let column = 1; column < bitmap.width; column++) {
const target = (row * bitmap.width + column) * 4;
const source = (sourceRow * bitmap.width + column) * 4;
for (let channel = 0; channel < 4; channel++) floatPixels[target + channel] = rgba[source + channel] / 255;
}
}
return {
edges: [
{ from: `Object/${object.name}`, relation: "OBJECT_DATA", to: `Mesh/${mesh.name}` },
{ from: `Mesh/${mesh.name}`, relation: "MATERIAL_SLOT[0]", to: `Material/${material.name}` },
{ from: `Material/${material.name}`, relation: "NODE_IMAGE[M12 Append Image Node]", to: `Image/${image.name}` },
],
geometry: {
edges: mesh.edgeCount,
loops: mesh.cornerCount,
materialSlots: (mesh.materialSlotIds ?? []).map(nameFromId),
polygons: mesh.faceCount,
uvLayers: (mesh.uvLayers ?? []).map((layer: any) => layer.name),
vertices: mesh.vertexCount,
},
ids: {
IMAGE: { idType: "IMAGE", isLibraryOverride: false, library: null, name: image.name, nameFull: image.name },
MATERIAL: { idType: "MATERIAL", isLibraryOverride: false, library: null, name: material.name, nameFull: material.name },
MESH: { idType: "MESH", isLibraryOverride: false, library: null, name: mesh.name, nameFull: mesh.name },
OBJECT: { idType: "OBJECT", isLibraryOverride: false, library: null, name: object.name, nameFull: object.name },
},
image: {
channels: 4,
colorspace,
packed: image.packed,
pixelFloat32Sha256: await digest(floatPixels.buffer),
size: [bitmap.width, bitmap.height],
},
root: { idType: "OBJECT", isLibraryOverride: false, library: null, name: object.name, nameFull: object.name },
};
}
finally {
bitmap.close();
}
};
try {
const opened = await client.openBlend(targetBuffer.slice(0));
const baseRevision = opened.snapshot.revision;
const request = await makeRequest(baseRevision);
const appended = await client.appendLibraryObject(sourceBuffer.slice(0), request);
const appendedClosure = closureState(appended.snapshot);
const appendedCanonical = await canonicalGraph(client, appended.snapshot, ids.image);
const stale = await client.appendLibraryObject(sourceBuffer.slice(0), makeRequest(baseRevision))
.then(() => ({ code: "NO_ERROR" }))
.catch((error: any) => ({ code: error.code, message: error.message }));
@@ -80,9 +165,11 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
const afterCollision = await client.snapshot();
const undone = await client.applyCommand({ type: "undo" });
const redone = await client.applyCommand({ type: "redo" });
const redoneCanonical = await canonicalGraph(client, redone.snapshot, ids.image);
const saved = await client.saveBlend();
const reopenedResult = await reopened.openBlend(saved);
const reopenedClosure = closureState(reopenedResult.snapshot);
const reopenedCanonical = await canonicalGraph(reopened, reopenedResult.snapshot, ids.image);
return {
sourceSha256: await digest(sourceBuffer),
baseRevision,
@@ -97,10 +184,16 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
},
image: appendedClosure.image && { libraryLinked: appendedClosure.image.libraryLinked, width: appendedClosure.image.width, height: appendedClosure.image.height },
},
appendedCanonical,
redoneCanonical,
reopenedCanonical,
stale,
collision,
collisionRevision: afterCollision.snapshot.revision,
undoHasObject: Boolean(closureState(undone.snapshot).object),
undoHasMesh: Boolean(closureState(undone.snapshot).mesh),
undoHasMaterial: Boolean(closureState(undone.snapshot).material),
undoHasImage: Boolean(closureState(undone.snapshot).image),
redoHasObject: Boolean(closureState(redone.snapshot).object),
reopenedRevision: reopenedResult.snapshot.revision,
reopenedHasObject: Boolean(reopenedClosure.object),
@@ -114,7 +207,13 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
client.terminate();
reopened.terminate();
}
}, { sourceBytes: Array.from(source), targetBytes: Array.from(target), closure, binding });
}, {
sourceBytes: Array.from(source),
targetBytes: Array.from(target),
closure,
binding,
expectedCanonical: desktopCanonical,
});
expect(result.error).toBeUndefined();
expect(result.sourceSha256).toMatch(/^[a-f0-9]{64}$/);
@@ -129,10 +228,16 @@ test("M12-03D appends one fully-local dependency closure through WASM Main", asy
material: { imageIds: ["image:M12 Append Image"] },
image: { libraryLinked: false, width: 2, height: 2 },
});
expect(result.appendedCanonical).toEqual(desktopCanonical);
expect(result.redoneCanonical).toEqual(desktopCanonical);
expect(result.reopenedCanonical).toEqual(desktopCanonical);
expect(result.stale.code).toBe("REVISION_CONFLICT");
expect(result.collision.code).toBe("ASSET_MANIFEST_INVALID");
expect(result.collisionRevision).toBe(result.appendedRevision);
expect(result.undoHasObject).toBe(false);
expect(result.undoHasMesh).toBe(false);
expect(result.undoHasMaterial).toBe(false);
expect(result.undoHasImage).toBe(false);
expect(result.redoHasObject).toBe(true);
expect(result.reopenedHasObject).toBe(true);
expect(result.reopenedHasLocalImage).toBe(true);

View File

@@ -0,0 +1,19 @@
import { expect, test } from "@playwright/test";
test("M12-03N runs the linked mutation gate in an independent Chromium lane", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const linked = await import("/src/library-link-chromium.ts");
const gate = linked.gateLinkedDataMutation({
schemaVersion: 1,
operation: "MESH_GEOMETRY",
dataBlockId: "Mesh/M12 Link Mesh",
baseRevision: 7,
owner: "SOURCE_LIBRARY",
linkedLibrary: true,
readOnly: true,
}, 7);
return { status: gate.status, code: gate.issues[0]?.code, recoverable: gate.issues[0]?.recoverable };
});
expect(result).toEqual({ status: "BLOCKED", code: "LINKED_DATA_MUTATION_BLOCKED", recoverable: false });
});

View File

@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
test("M12-03N runs the verified override writer in an independent Chromium lane", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const writer = await import("/src/library-override-chromium.ts");
const state = {
schemaVersion: 1,
revision: 3,
localDataBlockId: "Object/M12 Override Object",
referenceSourceDataBlockId: "Object/M12 Override Object",
hierarchyRootDataBlockId: "Object/M12 Override Object",
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
propertyPath: '["m12_override_value"]',
value: 2.5,
};
const result = writer.applyOverrideWriter(state, {
schemaVersion: 1,
operation: "SET_M12_OVERRIDE_VALUE",
baseRevision: 3,
localDataBlockId: "Object/M12 Override Object",
referenceSourceDataBlockId: "Object/M12 Override Object",
hierarchyRootDataBlockId: "Object/M12 Override Object",
owner: "LOCAL_OVERRIDE",
readOnly: false,
referenceReadOnly: true,
propertyPath: '["m12_override_value"]',
value: 4.5,
});
return { status: result.status, code: result.code, revision: result.state.revision, value: result.state.value };
});
expect(result).toEqual({ status: "APPLIED", code: null, revision: 4, value: 4.5 });
});

View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m13_malicious_script_v1/malicious-script.blend")));
test("M13-01F blocks malicious Text, driver, handler and embedded module sources", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
const sources = opened.snapshot.scriptSources;
client.terminate();
return sources;
}, fixture);
expect(result?.sources).toHaveLength(4);
expect(result?.sources.every((source: any) => source.readOnly && source.executionStatus === "BLOCKED" && /^[a-f0-9]{64}$/.test(source.sourceSha256))).toBe(true);
expect(result?.sources.find((source: any) => source.name === "EmbeddedModule.py")).toMatchObject({ moduleAutorunRequested: true, errorCode: "SCRIPT_POLICY_DENIED" });
expect(result?.sources.map((source: any) => source.name).sort()).toEqual(["DriverExploit.py", "EmbeddedModule.py", "HandlerExploit.py", "MaliciousText.py"]);
});

View File

@@ -0,0 +1,87 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { expect, test, type Page } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtureRoot = path.join(root, "tests/files/web/m12_obj_multi_v1");
const sourceObj = fs.readFileSync(path.join(fixtureRoot, "multi-object.obj"));
const sourceMtl = fs.readFileSync(path.join(fixtureRoot, "multi-object.mtl"));
const texture = fs.readFileSync(path.join(fixtureRoot, "m12_obj_texture.png"));
const reportPath = path.join(root, "tests/golden/M12-07C/web-roundtrip-report.json");
const sha256 = (bytes: Uint8Array | Buffer | string) => crypto.createHash("sha256").update(bytes).digest("hex");
async function runBrowserRoundtrip(page: Page, textureAssets: string[]) {
return page.evaluate(async (input: { obj: number[]; mtl: number[]; textureAssets: string[] }) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/obj-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const obj = Uint8Array.from(input.obj).buffer;
const mtl = Uint8Array.from(input.mtl).buffer;
worker.postMessage({ obj, mtl, textureAssets: input.textureAssets }, [obj, mtl]);
}), { obj: Array.from(sourceObj), mtl: Array.from(sourceMtl), textureAssets });
}
test("M12-07C round-trips a Web OBJ through desktop Blender and reports texture loss", async ({ page }) => {
await page.goto("/");
const bound = await runBrowserRoundtrip(page, ["m12_obj_texture.png"]);
const missing = await runBrowserRoundtrip(page, []);
expect(bound.ok).toBe(true);
expect(bound.lossReport).toEqual({ schemaVersion: 1, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: 0, warnings: [] });
expect(missing.lossReport.warnings.map((warning: { code: string }) => warning.code)).toEqual(["OBJ_TEXTURE_ORIGIN_UNRESOLVED", "OBJ_TEXTURE_ORIGIN_UNRESOLVED"]);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07c-web-obj-"));
try {
const webObjPath = path.join(temporary, "web-output.obj");
const webMtlPath = path.join(temporary, "single-mesh.mtl");
fs.writeFileSync(webObjPath, bound.obj, "utf8");
fs.writeFileSync(webMtlPath, bound.mtl, "utf8");
fs.writeFileSync(path.join(temporary, "m12_obj_texture.png"), texture);
const desktopReportPath = path.join(temporary, "desktop-report.json");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-obj-web-roundtrip.py"), "--", webObjPath, desktopReportPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
const desktop = JSON.parse(fs.readFileSync(desktopReportPath, "utf8"));
const report = {
schemaVersion: 1,
task: "M12-07C",
operation: "WEB_OBJ_TO_DESKTOP_ROUNDTRIP",
source: { objSha256: sha256(sourceObj), mtlSha256: sha256(sourceMtl), textureSha256: sha256(texture) },
browser: {
imported: {
schemaVersion: bound.imported.schemaVersion,
objectCount: bound.imported.objects.length,
positionCount: bound.imported.positions.length,
texcoordCount: bound.imported.texcoords.length,
normalCount: bound.imported.normals.length,
faceCount: bound.imported.faces.length,
materialCount: bound.imported.materials.length,
},
outputObjSha256: sha256(Buffer.from(bound.obj)),
outputMtlSha256: sha256(Buffer.from(bound.mtl)),
lossReport: bound.lossReport,
missingTextureLoss: missing.lossReport,
},
desktop,
comparisons: {
objectCountExact: desktop.objectCount === bound.imported.objects.length,
triangleCountExact: desktop.objects.reduce((sum: number, object: { triangleCount: number }) => sum + object.triangleCount, 0) === bound.imported.faces.length,
uvLayerPresent: desktop.objects.every((object: { uvLayers: string[] }) => object.uvLayers.includes("UVMap")),
materialPresent: desktop.objects.every((object: { materials: string[] }) => object.materials.length === 1),
},
nextTask: "M12-07D",
};
if (process.env.UPDATE_OBJ_ROUNDTRIP === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
const expected = JSON.parse(fs.readFileSync(reportPath, "utf8"));
expect(report).toEqual(expected);
expect(report.comparisons).toEqual({ objectCountExact: true, triangleCountExact: true, uvLayerPresent: true, materialPresent: true });
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_negative_v1");
const cases = [
{ id: "big-endian", file: "big-endian.ply", expected: "PLY_FORMAT_UNSUPPORTED", format: "binary_little_endian" },
{ id: "malformed-list", file: "malformed-list-ascii.ply", expected: "PLY_DATA_TRUNCATED", format: "ascii" },
{ id: "oversized-count", file: "oversized-count-ascii.ply", expected: "PLY_IMPORT_BUDGET_EXCEEDED: vertex", format: "ascii" },
];
test("M12-07I production Worker blocks PLY negative cases deterministically", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (input) => Promise.all(input.map((candidate) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/ply-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve({ id: candidate.id, ...event.data }); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const bytes = Uint8Array.from(candidate.bytes).buffer;
worker.postMessage({ bytes, format: candidate.format }, [bytes]);
}))), cases.map((candidate) => ({ id: candidate.id, format: candidate.format, bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, candidate.file))) })));
expect(result.map((item) => ({ id: item.id, ok: item.ok, error: item.error }))).toEqual(cases.map((candidate) => ({ id: candidate.id, ok: false, error: candidate.expected })));
});

View File

@@ -0,0 +1,89 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { expect, test, type Page } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_mapping_v1");
const sourceAscii = fs.readFileSync(path.join(fixtureRoot, "mapping-ascii.ply"));
const sourceBinary = fs.readFileSync(path.join(fixtureRoot, "mapping-binary-le.ply"));
const sourceUnknown = fs.readFileSync(path.join(fixtureRoot, "unknown-property-ascii.ply"));
const reportPath = path.join(root, "tests/golden/M12-07H/web-roundtrip-report.json");
const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex");
async function runWorker(page: Page, bytes: Buffer, format: "ascii" | "binary_little_endian") {
return page.evaluate(async (input) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/ply-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve({ ...event.data, output: event.data.output ? Array.from(new Uint8Array(event.data.output)) : null }); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const payload = Uint8Array.from(input.bytes).buffer;
worker.postMessage({ bytes: payload, format: input.format }, [payload]);
}), { bytes: Array.from(bytes), format });
}
test("M12-07H maps PLY vertex/face/color/custom fields and reports unknown property loss", async ({ page }) => {
await page.goto("/");
const ascii = await runWorker(page, sourceAscii, "ascii");
const binary = await runWorker(page, sourceBinary, "binary_little_endian");
const unknown = await runWorker(page, sourceUnknown, "ascii");
expect(ascii.ok).toBe(true);
expect(binary.ok).toBe(true);
expect(unknown.ok).toBe(true);
expect(ascii.imported.vertices).toHaveLength(4);
expect(ascii.imported.faces).toHaveLength(2);
expect(ascii.imported.vertices[0].customProperties).toEqual({ label: 1, temperature: 10 });
expect(binary.imported.vertices).toEqual(ascii.imported.vertices);
expect(binary.imported.faces).toEqual(ascii.imported.faces);
expect(ascii.lossReport).toEqual({ schemaVersion: 1, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: 0, warnings: [] });
expect(unknown.lossReport.warningCount).toBe(1);
expect(unknown.lossReport.warnings[0].code).toBe("PLY_UNKNOWN_PROPERTY");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07h-web-ply-"));
try {
const outputPath = path.join(temporary, "web-output.ply");
fs.writeFileSync(outputPath, Buffer.from(ascii.output));
const desktopReportPath = path.join(temporary, "desktop-report.json");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const command = spawnSync(blender, ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-ply-web-roundtrip.py"), "--", outputPath, desktopReportPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
expect(command.status, `${command.stdout}\n${command.stderr}`).toBe(0);
const desktop = JSON.parse(fs.readFileSync(desktopReportPath, "utf8"));
const report = {
schemaVersion: 1,
task: "M12-07H",
operation: "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING",
source: { asciiSha256: sha256(sourceAscii), binarySha256: sha256(sourceBinary), unknownSha256: sha256(sourceUnknown) },
browser: {
format: ascii.imported.format,
vertexCount: ascii.imported.vertices.length,
faceCount: ascii.imported.faces.length,
colors: ascii.imported.vertices.map((vertex: any) => vertex.color),
customProperties: ascii.imported.vertices.map((vertex: any) => vertex.customProperties),
outputSha256: sha256(Buffer.from(ascii.output)),
outputBytes: ascii.output.length,
lossReport: ascii.lossReport,
binarySemanticEqual: JSON.stringify(binary.imported.vertices) === JSON.stringify(ascii.imported.vertices) && JSON.stringify(binary.imported.faces) === JSON.stringify(ascii.imported.faces),
unknownPropertyLoss: unknown.lossReport,
},
desktop,
comparisons: {
vertexCountExact: desktop.vertexCount === ascii.imported.vertices.length,
faceCountExact: desktop.triangleCount === ascii.imported.faces.length,
positionExact: JSON.stringify(desktop.positions) === JSON.stringify(ascii.imported.vertices.map((vertex: any) => vertex.position)),
customPropertiesPresent: desktop.attributes.filter((attribute: any) => ["temperature", "label"].includes(attribute.name)).length === 2,
colorMapped: desktop.attributes.some((attribute: any) => attribute.name === "Col" && attribute.values.length === 4),
},
nextTask: "M12-07I",
};
if (process.env.UPDATE_PLY_MAPPING_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
expect(report.comparisons).toEqual({ vertexCountExact: true, faceCountExact: true, positionExact: true, customPropertiesPresent: true, colorMapped: true });
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test("M13-03C exposes only structured allowlisted host calls", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-host-call-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.accepted.map((item: any) => item.call)).toEqual(["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"]);
expect(result.accepted.every((item: any) => item.execution === "DISABLED")).toBe(true);
expect(result.blocked).toEqual({ unknown: "SCRIPT_POLICY_DENIED", permission: "SCRIPT_POLICY_DENIED", fields: "SCRIPT_MANIFEST_INVALID", path: "SCRIPT_MANIFEST_INVALID" });
});

View File

@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("M13-02A enforces bounded script manifest text, module, path, dependency and permission limits", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scripting-manifest-budget-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toEqual([2, "scripts/base.py", "deps/base.py", 256]);
expect(result.count).toContain("SCRIPT_BUDGET_EXCEEDED");
expect(result.totalBytes).toContain("SCRIPT_BUDGET_EXCEEDED");
expect(result.module).toContain("SCRIPT_POLICY_DENIED");
expect(result.path).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.dependency).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.permission).toContain("SCRIPT_POLICY_DENIED");
});

View File

@@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";
test("M13-02B keeps canonical script manifest serialization stable in a production Worker", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scripting-manifest-canonical-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result).toEqual({ equal: true, firstId: "alpha", firstPermission: "READ_ASSET", dependencyOrder: ["alpha", "beta"], unknownDropped: true });
});

View File

@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = Array.from(fs.readFileSync(path.join(root, "tests/files/web/script_scene.blend")));
test("M13-01B opens a blend and reads script metadata without execution", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
const sources = opened.snapshot.scriptSources;
client.terminate();
return { status: opened.snapshot.scriptSourceStatus, sources };
}, fixture);
expect(result.status).toBe("AVAILABLE");
expect(result.sources?.schemaVersion).toBe(1);
expect(result.sources?.sources).toHaveLength(3);
expect(result.sources?.sources.every((source: any) => source.readOnly && source.executionStatus === "BLOCKED" && /^[a-f0-9]{64}$/.test(source.sourceSha256))).toBe(true);
expect(result.sources?.sources.find((source: any) => source.name === "ModuleAutorun.py")).toMatchObject({ moduleAutorunRequested: true, errorCode: "SCRIPT_POLICY_DENIED" });
});

View File

@@ -0,0 +1,17 @@
import { expect, test } from "@playwright/test";
test("M13-02E grants only explicitly declared permissions", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-permission-policy-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.defaultGrant).toMatchObject({ status: "ALLOWED", granted: [] });
expect(result.declaredGrant).toMatchObject({ status: "ALLOWED", granted: ["READ_MAIN"] });
expect(result.escalation).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED", granted: [] });
expect(result.unknownRequest).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" });
expect(result.duplicateRequest).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" });
expect(result.unknownDeclaration).toBe("SCRIPT_POLICY_DENIED");
});

View File

@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test("M13-03B enforces sandbox resource budgets in the production worker", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-budget-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.accepted).toEqual({ schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1048576, maxMessageBytes: 4096, maxOutputBytes: 8192 });
expect(result.blocked).toEqual({ cpuMs: "SCRIPT_BUDGET_EXCEEDED", wallMs: "SCRIPT_BUDGET_EXCEEDED", memoryBytes: "SCRIPT_BUDGET_EXCEEDED", maxMessageBytes: "SCRIPT_BUDGET_EXCEEDED", maxOutputBytes: "SCRIPT_BUDGET_EXCEEDED" });
expect(result.execution).toBe("DISABLED");
});

View File

@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
test("M13-03E cancellation publishes neither a late message nor a cache entry", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const receipt = await new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-cancellation-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "RECEIPT" });
});
const runtime = await new Promise<{ lateMessages: number; cacheWrites: number; cancelled: boolean }>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-cancellation-test.worker.ts", { type: "module" });
let lateMessages = 0;
let cacheWrites = 0;
let cancelled = false;
worker.onmessage = () => { lateMessages += 1; if (!cancelled) cacheWrites += 1; };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "RUN" });
setTimeout(() => { cancelled = true; worker.terminate(); setTimeout(() => resolve({ lateMessages, cacheWrites, cancelled }), 80); }, 10);
});
return { receipt, runtime };
});
expect(result.receipt.cancelled).toMatchObject({ status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", mainRevisionBefore: 11, mainRevisionAfter: 11, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false });
expect(result.receipt.lateResult).toBe("SCRIPT_SANDBOX_LATE_RESULT");
expect(result.runtime).toEqual({ lateMessages: 0, cacheWrites: 0, cancelled: true });
});

View File

@@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
test("M13-03F disposes Worker resources to zero and is idempotent", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-dispose-test.worker.ts", { type: "module" });
let ready: Record<string, any> | undefined;
let first: Record<string, any> | undefined;
let lateTimerMessages = 0;
worker.onmessage = (event: MessageEvent<Record<string, any>>) => {
if (event.data.type === "ready") { ready = event.data; worker.postMessage({ type: "dispose" }); return; }
if (event.data.type === "late-timer") { lateTimerMessages += 1; return; }
if (event.data.type === "disposed" && first === undefined) { first = event.data; worker.postMessage({ type: "dispose" }); return; }
if (event.data.type === "disposed") {
const second = event.data;
worker.terminate();
setTimeout(() => resolve({ ready, first, second, workerTerminated: true, lateTimerMessages }), 70);
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ type: "init" });
}));
expect(result.ready.resources).toEqual({ messagePorts: 2, timers: 1, abortControllers: 1, transferableBuffers: 1, pendingRequests: 1, cacheReferences: 1 });
expect(result.first.receipt).toMatchObject({ schemaVersion: 1, disposeCount: 1, idempotent: false, lateTimerMessages: 0 });
expect(result.first.resources).toEqual({ messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 });
expect(result.second.receipt).toMatchObject({ schemaVersion: 1, disposeCount: 2, idempotent: true, lateTimerMessages: 0 });
expect(result.second.resources).toEqual({ messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 });
expect(result.workerTerminated).toBe(true);
expect(result.lateTimerMessages).toBe(0);
});

View File

@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
test("M13-03D isolates crash, timeout and late sandbox results from Main", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const receipts = await new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "RECEIPTS" });
});
const runCrash = await new Promise<boolean>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" });
worker.onerror = () => { worker.terminate(); resolve(true); };
worker.onmessage = () => { worker.terminate(); reject(new Error("crash worker published a result")); };
worker.postMessage({ mode: "CRASH" });
});
const runTimeout = await new Promise<{ timedOut: boolean; lateMessages: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-isolation-test.worker.ts", { type: "module" });
let lateMessages = 0;
worker.onmessage = () => { lateMessages += 1; };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ mode: "TIMEOUT" });
setTimeout(() => { worker.terminate(); resolve({ timedOut: true, lateMessages }); }, 10);
});
return { ...receipts, runCrash, runTimeout };
});
expect(result.runCrash).toBe(true);
expect(result.runTimeout).toEqual({ timedOut: true, lateMessages: 0 });
expect(result.crash).toMatchObject({ status: "CRASHED", errorCode: "SCRIPT_SANDBOX_CRASHED", mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
expect(result.timeout).toMatchObject({ status: "TIMED_OUT", errorCode: "SCRIPT_SANDBOX_TIMEOUT", mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
expect(result.cancel).toMatchObject({ status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
expect(result.lateResult).toBe("SCRIPT_SANDBOX_LATE_RESULT");
});

View File

@@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
test("M13-03G recovers a denied script in a new generation with a continuous audit chain", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-recovery-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ type: "recover" });
}));
expect(result.receipt).toMatchObject({ schemaVersion: 1, operation: "SCRIPT_SANDBOX_RECOVERY", previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, recovered: true, execution: "DISABLED" });
expect(result.receipt.audit.entries).toBe(2);
expect(result.receipt.audit.first.sequence).toBe(1);
expect(result.receipt.audit.second.sequence).toBe(2);
expect(result.receipt.audit.second.previousEntrySha256).toBe(result.receipt.audit.first.entrySha256);
expect(result.receipt.audit.first.requestId).not.toBe(result.receipt.audit.second.requestId);
expect(result.receipt.audit.first.sourceSha256).toBe(result.receipt.audit.second.sourceSha256);
expect(result.receipt.audit.first.manifestSha256).toBe(result.receipt.audit.second.manifestSha256);
expect(result.replayError).toBe("SCRIPT_MANIFEST_INVALID");
expect(result.tamperError).toBe("SCRIPT_MANIFEST_INVALID");
});

View File

@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test("M13-03A exposes an all-deny sandbox scope contract", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-sandbox-scope-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.accepted).toEqual({ schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false });
expect(result.blocked).toEqual({ dom: "SCRIPT_POLICY_DENIED", hostWorker: "SCRIPT_POLICY_DENIED", opfs: "SCRIPT_POLICY_DENIED", indexedDB: "SCRIPT_POLICY_DENIED", network: "SCRIPT_POLICY_DENIED" });
expect(result.execution).toBe("DISABLED");
});

View File

@@ -0,0 +1,33 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = fs.readFileSync(path.join(root, "tests/files/web/script_scene.blend"));
const reportPath = path.join(root, "tests/golden/M13-01E/script-save-reopen-report.json");
const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex");
test("M13-01E preserves Text sources through save and reopen", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const first = new WebEngineClient({ timeoutMs: 30_000 });
const opened = await first.openBlend(Uint8Array.from(bytes).buffer);
const before = opened.snapshot.scriptSources;
const saved = await first.saveBlend();
const savedBytes = saved.byteLength;
first.terminate();
const second = new WebEngineClient({ timeoutMs: 30_000 });
const reopened = await second.openBlend(saved);
const after = reopened.snapshot.scriptSources;
second.terminate();
return { before, after, savedBytes };
}, Array.from(fixture));
expect(result.before?.sources).toHaveLength(3);
expect(result.after?.sources).toEqual(result.before?.sources);
expect(result.after?.sources.every((source: any) => source.readOnly && source.executionStatus === "BLOCKED")).toBe(true);
const report = { schemaVersion: 1, task: "M13-01E", operation: "SCRIPT_TEXT_SAVE_REOPEN", source: { fixtureSha256: sha256(fixture), fixtureBytes: fixture.byteLength }, before: result.before, after: result.after, savedBytes: result.savedBytes, exact: JSON.stringify(result.before) === JSON.stringify(result.after), nextTask: "M13-01F" };
if (process.env.UPDATE_SCRIPT_SAVE_REOPEN_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
});

View File

@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test("M13-02F blocks signer replay, expiry and key confusion cases", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, any>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-signature-negative-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, any>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
for (const key of ["missing", "expired", "notYetValid", "publisherMismatch"]) expect(result[key]).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" });
expect(result.swapped).toMatchObject({ status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID" });
});

View File

@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
test("M13-02D verifies declared script content and invalidates source hash changes", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-signature-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toMatchObject({ status: "VERIFIED", code: "SCRIPT_SIGNATURE_VERIFIED", keyId: "key:new", sourceSha256: "a".repeat(64) });
expect(result.sourceChanged).toMatchObject({ status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID", sourceSha256: "d".repeat(64) });
expect(result.signatureChanged).toMatchObject({ status: "BLOCKED", code: "SCRIPT_SIGNATURE_INVALID" });
expect(result.revoked).toMatchObject({ status: "BLOCKED", code: "SCRIPT_POLICY_DENIED" });
});

View File

@@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
test("M13-02C resolves signer identity and rotation policy without enabling execution", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/script-trust-policy-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.eligible).toEqual({ status: "ELIGIBLE", keyId: "key:new", publisher: "Team", trust: "ACTIVE", cryptographicVerification: "REQUIRED" });
expect(result.revoked).toBe("REVOKED");
expect(result.crossPublisher).toContain("SCRIPT_POLICY_DENIED");
expect(result.policyExpired).toBe("POLICY_EXPIRED");
expect(result.rotationSerialized).toBeGreaterThan(0);
});

View File

@@ -0,0 +1,62 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const edgeRoot = path.join(root, "tests/files/web/m12_stl_edges_v1");
const capabilityRoot = path.join(root, "tests/files/web/m12_stl_capability_v1");
const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/desktop-edge-report.json"), "utf8"));
const desktopCanonical = JSON.parse(JSON.stringify(desktop));
const reportPath = path.join(root, "tests/golden/M12-07E/web-edge-report.json");
test("M12-07E compares STL normal/unit/degenerate/trailing behavior in Chromium and Blender", async ({ page }) => {
await page.goto("/");
const fixtures = [
{ id: "BINARY_UNIT_1", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "capability-binary.stl"))), variant: "STL_BINARY", unitScale: 1 },
{ id: "BINARY_UNIT_001", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "capability-binary.stl"))), variant: "STL_BINARY", unitScale: 0.001 },
{ id: "ASCII_UNIT_1", bytes: Array.from(fs.readFileSync(path.join(capabilityRoot, "capability-ascii.stl"))), variant: "STL_ASCII", unitScale: 1 },
{ id: "DEGENERATE_TRIANGLE", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "degenerate-binary.stl"))), variant: "STL_BINARY", unitScale: 1 },
{ id: "TRAILING_BYTES", bytes: Array.from(fs.readFileSync(path.join(edgeRoot, "trailing-binary.stl"))), variant: "STL_BINARY", unitScale: 1 },
];
const browser = await page.evaluate(async (input) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/stl-edge-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const cases = input.map((candidate) => ({ ...candidate, bytes: Uint8Array.from(candidate.bytes).buffer }));
worker.postMessage({ cases }, cases.map((candidate) => candidate.bytes));
}), fixtures);
expect(browser.ok).toBe(true);
const byId = Object.fromEntries(browser.results.map((result: any) => [result.id, result]));
const desktopById = Object.fromEntries(desktop.cases.map((result: any) => [result.id, result]));
expect(byId.TRAILING_BYTES).toEqual({ id: "TRAILING_BYTES", status: "BLOCKED", code: "STL_TRAILING_BYTES" });
const unitOne = byId.BINARY_UNIT_1.result.bounds.max[0];
const unitSmall = byId.BINARY_UNIT_001.result.bounds.max[0];
const desktopUnitOne = desktopById.BINARY_UNIT_1.result.bounds.max[0];
const desktopUnitSmall = desktopById.BINARY_UNIT_001.result.bounds.max[0];
const report = {
schemaVersion: 1,
task: "M12-07E",
operation: "STL_NORMAL_UNIT_EDGE_PARITY",
browser: browser.results,
desktop: desktopCanonical,
comparisons: {
binaryAsciiNormalExact: JSON.stringify(byId.BINARY_UNIT_1.result.normals) === JSON.stringify(byId.ASCII_UNIT_1.result.normals),
desktopNormalExact: JSON.stringify(byId.BINARY_UNIT_1.result.normals) === JSON.stringify(desktopById.BINARY_UNIT_1.result.polygonNormals),
webUnitRatio: unitOne / unitSmall,
desktopUnitRatio: desktopUnitOne / desktopUnitSmall,
unitRatioExactWithinFloat32: Math.abs(unitOne / unitSmall - desktopUnitOne / desktopUnitSmall) < 0.001,
degenerateTriangleExact: byId.DEGENERATE_TRIANGLE.result.triangleCount === desktopById.DEGENERATE_TRIANGLE.result.triangleCount && byId.DEGENERATE_TRIANGLE.result.removedDegenerateTriangles === 1,
trailingBytes: { web: "BLOCKED/STL_TRAILING_BYTES", desktop: "ACCEPTED_EMPTY", parity: "STRICTER_WEB_BLOCK" },
},
nextTask: "M12-07F",
};
if (process.env.UPDATE_STL_EDGE_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
expect(report.comparisons.binaryAsciiNormalExact).toBe(true);
expect(report.comparisons.desktopNormalExact).toBe(true);
expect(report.comparisons.unitRatioExactWithinFloat32).toBe(true);
expect(report.comparisons.degenerateTriangleExact).toBe(true);
});

View File

@@ -0,0 +1,63 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_stl_capability_v1/capability-binary.stl"));
const reportPath = path.join(root, "tests/golden/M12-07F/web-roundtrip-report.json");
const sha256 = (bytes: Uint8Array | Buffer) => crypto.createHash("sha256").update(bytes).digest("hex");
test("M12-07F round-trips Web STL through desktop Blender with material loss report", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async (bytes) => new Promise<any>((resolve, reject) => {
const worker = new Worker("/src/workers/stl-roundtrip-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<any>) => { worker.terminate(); resolve({ ...event.data, output: event.data.output ? Array.from(new Uint8Array(event.data.output)) : null }); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const input = Uint8Array.from(bytes).buffer;
worker.postMessage({ bytes: input, unitScale: 1, sourceMaterialCount: 2 }, [input]);
}), Array.from(source));
expect(result.ok).toBe(true);
expect(result.lossReport).toEqual({
schemaVersion: 1,
operation: "STL_EXPORT_LOSS_REPORT",
canRoundTrip: true,
warningCount: 1,
warnings: [{ code: "STL_MATERIAL_UNSUPPORTED", severity: "warning", message: "STL has no material slots; 2 source material assignments are omitted" }],
});
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07f-stl-"));
try {
const outputPath = path.join(temporary, "web-output.stl");
fs.writeFileSync(outputPath, Buffer.from(result.output));
const desktopPath = path.join(temporary, "desktop-report.json");
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const command = spawnSync(blender, ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-stl-web-roundtrip.py"), "--", outputPath, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
expect(command.status, `${command.stdout}\n${command.stderr}`).toBe(0);
const desktop = JSON.parse(JSON.stringify(JSON.parse(fs.readFileSync(desktopPath, "utf8"))));
const report = {
schemaVersion: 1,
task: "M12-07F",
operation: "WEB_STL_TO_DESKTOP_ROUNDTRIP",
source: { sha256: sha256(source), bytes: source.byteLength },
browser: { imported: result.imported, outputSha256: sha256(Buffer.from(result.output)), outputBytes: result.output.length, lossReport: result.lossReport },
desktop,
comparison: {
triangleCountExact: result.imported.triangleCount === desktop.triangleCount,
normalExact: JSON.stringify(result.imported.normals) === JSON.stringify(desktop.polygonNormals),
materialLossExplicit: result.lossReport.warnings[0]?.code === "STL_MATERIAL_UNSUPPORTED",
},
nextTask: "M12-07G",
};
if (process.env.UPDATE_STL_ROUNDTRIP_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
expect(report).toEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")));
expect(report.comparison).toEqual({ triangleCountExact: true, normalExact: true, materialLossExplicit: true });
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
});