Checkpoint web parity through Chromium input tasks
This commit is contained in:
21
web/tests/e2e/archive-security-fixtures.spec.ts
Normal file
21
web/tests/e2e/archive-security-fixtures.spec.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
42
web/tests/e2e/glb-desktop-import.spec.ts
Normal file
42
web/tests/e2e/glb-desktop-import.spec.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
106
web/tests/e2e/glb-export-loss-report.spec.ts
Normal file
106
web/tests/e2e/glb-export-loss-report.spec.ts
Normal 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)));
|
||||
}
|
||||
});
|
||||
98
web/tests/e2e/glb-main-persistence.spec.ts
Normal file
98
web/tests/e2e/glb-main-persistence.spec.ts
Normal 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);
|
||||
});
|
||||
46
web/tests/e2e/glb-negative-cases.spec.ts
Normal file
46
web/tests/e2e/glb-negative-cases.spec.ts
Normal 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" },
|
||||
] });
|
||||
});
|
||||
127
web/tests/e2e/glb-recovery.spec.ts
Normal file
127
web/tests/e2e/glb-recovery.spec.ts
Normal 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 });
|
||||
});
|
||||
68
web/tests/e2e/io-format-recovery.spec.ts
Normal file
68
web/tests/e2e/io-format-recovery.spec.ts
Normal 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")));
|
||||
});
|
||||
28
web/tests/e2e/io-format-ui-gate.spec.ts
Normal file
28
web/tests/e2e/io-format-ui-gate.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
19
web/tests/e2e/library-link-chromium.spec.ts
Normal file
19
web/tests/e2e/library-link-chromium.spec.ts
Normal 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 });
|
||||
});
|
||||
35
web/tests/e2e/library-override-chromium.spec.ts
Normal file
35
web/tests/e2e/library-override-chromium.spec.ts
Normal 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 });
|
||||
});
|
||||
22
web/tests/e2e/malicious-script.spec.ts
Normal file
22
web/tests/e2e/malicious-script.spec.ts
Normal 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"]);
|
||||
});
|
||||
87
web/tests/e2e/obj-web-roundtrip.spec.ts
Normal file
87
web/tests/e2e/obj-web-roundtrip.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
23
web/tests/e2e/ply-negative.spec.ts
Normal file
23
web/tests/e2e/ply-negative.spec.ts
Normal 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 })));
|
||||
});
|
||||
89
web/tests/e2e/ply-web-roundtrip.spec.ts
Normal file
89
web/tests/e2e/ply-web-roundtrip.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
14
web/tests/e2e/script-host-call.spec.ts
Normal file
14
web/tests/e2e/script-host-call.spec.ts
Normal 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" });
|
||||
});
|
||||
18
web/tests/e2e/script-manifest-budgets.spec.ts
Normal file
18
web/tests/e2e/script-manifest-budgets.spec.ts
Normal 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");
|
||||
});
|
||||
12
web/tests/e2e/script-manifest-canonical.spec.ts
Normal file
12
web/tests/e2e/script-manifest-canonical.spec.ts
Normal 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 });
|
||||
});
|
||||
23
web/tests/e2e/script-open-metadata.spec.ts
Normal file
23
web/tests/e2e/script-open-metadata.spec.ts
Normal 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" });
|
||||
});
|
||||
17
web/tests/e2e/script-permission-policy.spec.ts
Normal file
17
web/tests/e2e/script-permission-policy.spec.ts
Normal 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");
|
||||
});
|
||||
14
web/tests/e2e/script-sandbox-budget.spec.ts
Normal file
14
web/tests/e2e/script-sandbox-budget.spec.ts
Normal 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");
|
||||
});
|
||||
27
web/tests/e2e/script-sandbox-cancellation.spec.ts
Normal file
27
web/tests/e2e/script-sandbox-cancellation.spec.ts
Normal 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 });
|
||||
});
|
||||
30
web/tests/e2e/script-sandbox-dispose.spec.ts
Normal file
30
web/tests/e2e/script-sandbox-dispose.spec.ts
Normal 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);
|
||||
});
|
||||
34
web/tests/e2e/script-sandbox-isolation.spec.ts
Normal file
34
web/tests/e2e/script-sandbox-isolation.spec.ts
Normal 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");
|
||||
});
|
||||
21
web/tests/e2e/script-sandbox-recovery.spec.ts
Normal file
21
web/tests/e2e/script-sandbox-recovery.spec.ts
Normal 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");
|
||||
});
|
||||
14
web/tests/e2e/script-sandbox-scope.spec.ts
Normal file
14
web/tests/e2e/script-sandbox-scope.spec.ts
Normal 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");
|
||||
});
|
||||
33
web/tests/e2e/script-save-reopen.spec.ts
Normal file
33
web/tests/e2e/script-save-reopen.spec.ts
Normal 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")));
|
||||
});
|
||||
13
web/tests/e2e/script-signature-negative.spec.ts
Normal file
13
web/tests/e2e/script-signature-negative.spec.ts
Normal 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" });
|
||||
});
|
||||
15
web/tests/e2e/script-signature.spec.ts
Normal file
15
web/tests/e2e/script-signature.spec.ts
Normal 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" });
|
||||
});
|
||||
16
web/tests/e2e/script-trust-policy.spec.ts
Normal file
16
web/tests/e2e/script-trust-policy.spec.ts
Normal 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);
|
||||
});
|
||||
62
web/tests/e2e/stl-edge-parity.spec.ts
Normal file
62
web/tests/e2e/stl-edge-parity.spec.ts
Normal 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);
|
||||
});
|
||||
63
web/tests/e2e/stl-web-roundtrip.spec.ts
Normal file
63
web/tests/e2e/stl-web-roundtrip.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user