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 });
|
||||
}
|
||||
});
|
||||
35
web/tests/unit/device-budget.test.mjs
Normal file
35
web/tests/unit/device-budget.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "../../node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/device-budget.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const budget = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
const base = { schemaVersion: 1, identitySha256: "a".repeat(64), webgl2: { status: "PASS", renderer: "ANGLE NVIDIA", vendor: "NVIDIA" }, webgpu: { status: "PASS", description: "NVIDIA RTX", isFallbackAdapter: false }, hardwareConcurrency: 16, deviceMemory: 16 };
|
||||
|
||||
test("M14-04A selects HIGH only from trusted observed capability", () => {
|
||||
const result = budget.selectDeviceBudget(base);
|
||||
assert.equal(result.tier, "HIGH");
|
||||
assert.equal(result.reason, "HIGH_CAPABILITY");
|
||||
assert.equal(result.limits.maxTextureGPUBytes, 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("M14-04A fails closed for missing WebGPU, SwiftShader, fallback and invalid identity", () => {
|
||||
for (const observation of [
|
||||
{ ...base, webgpu: { status: "BLOCKED" } },
|
||||
{ ...base, webgl2: { ...base.webgl2, renderer: "ANGLE SwiftShader" } },
|
||||
{ ...base, webgpu: { ...base.webgpu, isFallbackAdapter: true } },
|
||||
{ ...base, deviceMemory: null },
|
||||
]) assert.equal(budget.selectDeviceBudget(observation).tier, "CONSERVATIVE");
|
||||
assert.throws(() => budget.selectDeviceBudget({ ...base, identitySha256: "bad" }), /DEVICE_BUDGET_IDENTITY_INVALID/);
|
||||
});
|
||||
|
||||
test("M14-04A exposes fixed limits and never expands an unknown tier", () => {
|
||||
assert.equal(budget.deviceBudgetLimits("CONSERVATIVE").maxLights, 8);
|
||||
assert.throws(() => budget.deviceBudgetLimits("UNKNOWN"), /DEVICE_BUDGET_TIER_INVALID/);
|
||||
});
|
||||
63
web/tests/unit/glb-desktop-import.test.mjs
Normal file
63
web/tests/unit/glb-desktop-import.test.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-desktop-import-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/glb-import.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "glb-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
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");
|
||||
const arrayBuffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
|
||||
test("M12-06B imports every desktop fixture with exact Web semantics", () => {
|
||||
assert.deepEqual(report.fixtures.map((fixture) => fixture.id), ["mesh", "pbr", "uv", "skin", "animation"]);
|
||||
for (const fixture of report.fixtures) {
|
||||
const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file));
|
||||
assert.equal(crypto.createHash("sha256").update(bytes).digest("hex"), fixture.sha256);
|
||||
const imported = protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes));
|
||||
assert.deepEqual(protocol.compareGLBDesktopFixtureSemantics(fixture.semantic, imported), { compatible: true, mismatches: [] }, fixture.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-06B exposes topology, attributes, materials, nodes and animations separately", () => {
|
||||
const imported = Object.fromEntries(report.fixtures.map((fixture) => {
|
||||
const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file));
|
||||
return [fixture.id, protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes))];
|
||||
}));
|
||||
assert.deepEqual(Object.keys(imported.mesh.meshes[0].primitives[0].attributes), ["COLOR_0", "NORMAL", "POSITION"]);
|
||||
assert.equal(imported.mesh.meshes[0].primitives[0].indices.count, 6);
|
||||
assert.equal(Number(imported.pbr.materials[0].pbr.metallicFactor.toFixed(3)), 0.72);
|
||||
assert.ok(imported.uv.meshes[0].primitives[0].attributes.TEXCOORD_0);
|
||||
assert.equal(imported.uv.materials[0].pbr.baseColorTexture.index, 0);
|
||||
assert.deepEqual(imported.skin.nodeNames, ["Tip", "Root", "M12 Skin Fixture", "M12 Skin Armature"]);
|
||||
assert.equal(imported.skin.skins[0].inverseBindMatrices.type, "MAT4");
|
||||
assert.deepEqual(imported.animation.animations[0].channels.map((channel) => channel.target.path), ["translation", "rotation"]);
|
||||
assert.equal(imported.animation.animations[0].samplers[0].input.count, 25);
|
||||
});
|
||||
|
||||
test("M12-06B reports a field-level semantic mismatch", () => {
|
||||
const fixture = report.fixtures.find((candidate) => candidate.id === "pbr");
|
||||
const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file));
|
||||
const imported = protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes));
|
||||
const expected = structuredClone(fixture.semantic);
|
||||
expected.materials[0].pbr.metallicFactor = 0.5;
|
||||
const comparison = protocol.compareGLBDesktopFixtureSemantics(expected, imported);
|
||||
assert.equal(comparison.compatible, false);
|
||||
assert.deepEqual(comparison.mismatches, ["$.materials[0].pbr.metallicFactor: expected 0.5 got 0.7200000286102295"]);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
58
web/tests/unit/glb-loss-report.test.mjs
Normal file
58
web/tests/unit/glb-loss-report.test.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-loss-report-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/glb-loss-report.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "glb-loss-report.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
|
||||
test("M12-06D produces deterministic sorted machine loss entries", () => {
|
||||
const snapshot = {
|
||||
sceneId: "scene:test",
|
||||
revision: 7,
|
||||
nodes: [{}, {}],
|
||||
meshes: [{}],
|
||||
materials: [{}, {}],
|
||||
images: [{}],
|
||||
animations: [{}],
|
||||
nonMeshData: [{}],
|
||||
};
|
||||
const report = protocol.createGLBLossReport(snapshot, {
|
||||
canExport: false,
|
||||
warnings: [
|
||||
{ code: "Z_LOSS", severity: "warning", message: "z", id: "id:z" },
|
||||
{ code: "A_BLOCK", severity: "error", message: "a", id: "id:a" },
|
||||
{ code: "A_BLOCK", severity: "warning", message: "b", id: undefined },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(report, {
|
||||
schemaVersion: 1,
|
||||
operation: "GLB_EXPORT_LOSS_REPORT",
|
||||
sceneId: "scene:test",
|
||||
sourceRevision: 7,
|
||||
canExport: false,
|
||||
errorCount: 1,
|
||||
warningCount: 2,
|
||||
losses: [
|
||||
{ code: "A_BLOCK", severity: "error", message: "a", id: "id:a" },
|
||||
{ code: "A_BLOCK", severity: "warning", message: "b", id: null },
|
||||
{ code: "Z_LOSS", severity: "warning", message: "z", id: "id:z" },
|
||||
],
|
||||
surface: { nodeCount: 2, meshCount: 1, materialCount: 2, imageCount: 1, animationCount: 1, nonMeshCount: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
60
web/tests/unit/glb-negative-cases.test.mjs
Normal file
60
web/tests/unit/glb-negative-cases.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-negative-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/glb-import.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "glb-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/mesh.glb"));
|
||||
|
||||
function arrayBuffer(bytes) {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
function rewriteJson(mutator) {
|
||||
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("M12-06F rejects sparse accessors, extensions and external URIs", () => {
|
||||
assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.accessors[0].sparse = { count: 1 }; }))), /GLB_SPARSE_ACCESSOR_UNSUPPORTED/);
|
||||
assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.extensionsUsed = ["KHR_draco_mesh_compression"]; }))), /GLB_EXTENSION_UNSUPPORTED/);
|
||||
assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.buffers[0].uri = "external.bin"; }))), /GLB_EXTERNAL_URI_BLOCKED/);
|
||||
});
|
||||
|
||||
test("M12-06F rejects over-budget GLB bytes and table counts", () => {
|
||||
const oversized = Buffer.alloc(protocol.GLB_IMPORT_BUDGET.maxBytes + 1);
|
||||
source.copy(oversized);
|
||||
assert.throws(() => protocol.importGLBSemantics(arrayBuffer(oversized)), /GLB_IMPORT_BUDGET_EXCEEDED/);
|
||||
assert.throws(() => protocol.importGLBSemantics(arrayBuffer(rewriteJson((document) => { document.bufferViews = Array.from({ length: protocol.GLB_IMPORT_BUDGET.maxBufferViews + 1 }, () => ({ buffer: 0, byteLength: 0 })); }))), /GLB_IMPORT_BUDGET_EXCEEDED/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
65
web/tests/unit/glb-recovery.test.mjs
Normal file
65
web/tests/unit/glb-recovery.test.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "glb-recovery-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/glb-recovery.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "glb-recovery.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
|
||||
const hash = "a".repeat(64);
|
||||
const outputHash = "b".repeat(64);
|
||||
|
||||
test("M12-06G keeps cancellation and quota failure fail-closed", () => {
|
||||
const running = protocol.beginGLBRecoveryOperation({
|
||||
operationId: "import-1",
|
||||
operation: "IMPORT",
|
||||
workerGeneration: 1,
|
||||
baseRevision: 7,
|
||||
inputBytes: 128,
|
||||
inputSha256: hash,
|
||||
});
|
||||
assert.equal(running.status, "RUNNING");
|
||||
assert.equal(running.candidateRevision, 8);
|
||||
const cancelled = protocol.cancelGLBRecoveryOperation(running);
|
||||
assert.deepEqual(protocol.parseGLBRecoveryReceipt(cancelled), cancelled);
|
||||
assert.equal(cancelled.errorCode, "GLB_OPERATION_CANCELLED");
|
||||
assert.equal(cancelled.temporaryBytes, 0);
|
||||
assert.equal(cancelled.committed, false);
|
||||
assert.throws(() => protocol.commitGLBRecoveryOperation(cancelled, { bytes: 1, sha256: outputHash }), /GLB_RECOVERY_INVALID/);
|
||||
assert.equal(protocol.blockGLBRecoveryForQuota(running).errorCode, "GLB_OPFS_QUOTA");
|
||||
});
|
||||
|
||||
test("M12-06G binds committed output and worker-generation recovery", () => {
|
||||
const running = protocol.beginGLBRecoveryOperation({
|
||||
operationId: "export-1",
|
||||
operation: "EXPORT",
|
||||
workerGeneration: 1,
|
||||
baseRevision: 11,
|
||||
inputBytes: 256,
|
||||
inputSha256: hash,
|
||||
});
|
||||
const committed = protocol.commitGLBRecoveryOperation(running, { bytes: 512, sha256: outputHash });
|
||||
assert.equal(committed.status, "COMMITTED");
|
||||
assert.equal(committed.committed, true);
|
||||
assert.equal(committed.liveRequests, 0);
|
||||
const recovered = protocol.recoverGLBRecoveryOperation(committed, 2);
|
||||
assert.equal(recovered.status, "RECOVERED");
|
||||
assert.equal(recovered.workerGeneration, 2);
|
||||
assert.equal(recovered.errorCode, "GLB_WORKER_RESTARTED");
|
||||
assert.throws(() => protocol.recoverGLBRecoveryOperation(committed, 1), /GLB_RECOVERY_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
29
web/tests/unit/ime-composition.test.mjs
Normal file
29
web/tests/unit/ime-composition.test.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "../../node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/ime-composition.ts");
|
||||
const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const ime = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
|
||||
test("M14-04D blocks operators through composition start/update and reopens after end", () => {
|
||||
let state = ime.createIMECompositionState();
|
||||
assert.equal(ime.shouldBlockOperatorShortcuts(state), false);
|
||||
state = ime.reduceIMEComposition(state, { type: "compositionstart", data: "n" });
|
||||
assert.equal(ime.shouldBlockOperatorShortcuts(state), true);
|
||||
state = ime.reduceIMEComposition(state, { type: "compositionupdate", data: "ni" });
|
||||
assert.deepEqual([state.composing, state.pendingText, state.lastEvent], [true, "ni", "UPDATE"]);
|
||||
state = ime.reduceIMEComposition(state, { type: "compositionend", data: "你" });
|
||||
assert.equal(ime.shouldBlockOperatorShortcuts(state), false);
|
||||
assert.equal(ime.shouldBlockOperatorShortcuts(state, true), true);
|
||||
});
|
||||
|
||||
test("M14-04D ignores stray updates and rejects malformed state", () => {
|
||||
let state = ime.createIMECompositionState();
|
||||
assert.equal(ime.reduceIMEComposition(state, { type: "compositionupdate", data: "x" }), state);
|
||||
assert.throws(() => ime.shouldBlockOperatorShortcuts({ schemaVersion: 2 }), /IME_STATE_INVALID/);
|
||||
});
|
||||
27
web/tests/unit/input-modal.test.mjs
Normal file
27
web/tests/unit/input-modal.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "../../node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/input-modal.ts");
|
||||
const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const modal = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
|
||||
test("M14-04F cancels touch modal without commit and starts one two-finger navigation revision", () => {
|
||||
let state = modal.createInputModalState();
|
||||
state = modal.beginTouch(state, 4);
|
||||
state = modal.beginTouch(state, 2);
|
||||
assert.deepEqual([state.kind, state.activePointerIds, state.navigationRevision, state.mainCommitCount], ["TOUCH_NAVIGATION", [2, 4], 1, 0]);
|
||||
state = modal.cancelInputModal(state);
|
||||
assert.deepEqual([state.kind, state.activePointerIds, state.cancelled, state.mainCommitCount], ["NONE", [], true, 0]);
|
||||
});
|
||||
|
||||
test("M14-04F commits pen stroke once and ignores late up/cancel", () => {
|
||||
let state = modal.beginPenStroke(modal.createInputModalState(), 9);
|
||||
state = modal.commitPenStroke(state, 9);
|
||||
state = modal.commitPenStroke(state, 9);
|
||||
assert.deepEqual([state.kind, state.mainCommitCount, state.activePointerIds], ["NONE", 1, []]);
|
||||
});
|
||||
54
web/tests/unit/io-format-capability-matrix.test.mjs
Normal file
54
web/tests/unit/io-format-capability-matrix.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-capability-matrix-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/io-format-capability-matrix.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "io-format-capability-matrix.mjs"), transpiled.outputText);
|
||||
const matrix = await import(pathToFileURL(path.join(temporary, "io-format-capability-matrix.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05B/manifest.json"), "utf8"));
|
||||
const source = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05B/capability-matrix.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
test("M12-05B binds the capability matrix and runtime inventory artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-05B");
|
||||
assert.equal(manifest.parentTask, "M12-05A");
|
||||
assert.equal(manifest.nextTask, "M12-05C");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-05B accepts the seven-format matrix and keeps the bounded GLB export route explicit", () => {
|
||||
const parsed = matrix.parseIOFormatCapabilityMatrix(source);
|
||||
assert.deepEqual(parsed.formats.map((entry) => entry.format), ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"]);
|
||||
assert.equal(parsed.formats.find((entry) => entry.format === "GLB").operations.EXPORT.local.status, "READY");
|
||||
assert.equal(parsed.formats.find((entry) => entry.format === "GLB").operations.EXPORT.local.execution, "LOCAL");
|
||||
assert.equal(parsed.formats.find((entry) => entry.format === "GLB").operations.IMPORT.local.status, "BLOCKED");
|
||||
assert.ok(parsed.formats.filter((entry) => entry.operations.IMPORT.local.status === "BLOCKED").length >= 6);
|
||||
});
|
||||
|
||||
test("M12-05B rejects duplicate formats, ready routes without a real executor, and unverified ready features", () => {
|
||||
assert.throws(() => matrix.parseIOFormatCapabilityMatrix({ ...source, formats: [...source.formats.slice(0, 6), source.formats[0]] }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
const badRoute = structuredClone(source);
|
||||
badRoute.formats.find((entry) => entry.format === "OBJ").operations.IMPORT.local = { status: "READY", execution: "LOCAL", code: null };
|
||||
assert.throws(() => matrix.parseIOFormatCapabilityMatrix(badRoute), { code: "ASSET_MANIFEST_INVALID" });
|
||||
const badFeature = structuredClone(source);
|
||||
badFeature.formats.find((entry) => entry.format === "GLB").operations.EXPORT.geometry.status = "UNVERIFIED";
|
||||
assert.throws(() => matrix.parseIOFormatCapabilityMatrix(badFeature), { code: "ASSET_MANIFEST_INVALID" });
|
||||
const badCode = structuredClone(source);
|
||||
badCode.formats.find((entry) => entry.format === "PLY").operations.EXPORT.local.code = null;
|
||||
assert.throws(() => matrix.parseIOFormatCapabilityMatrix(badCode), { code: "IO_FORMAT_UNSUPPORTED" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
33
web/tests/unit/io-format-receipt-binding.test.mjs
Normal file
33
web/tests/unit/io-format-receipt-binding.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-binding-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/io-format-receipt-binding.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []); fs.writeFileSync(path.join(temporary, "protocol.mjs"), transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(path.join(temporary, "protocol.mjs")));
|
||||
const bound = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05E/bound-runtime-receipts.json"), "utf8"));
|
||||
|
||||
test("M12-05E accepts receipts with all three identity hashes", () => {
|
||||
const parsed = protocol.validateIOFormatBoundReceiptSet(bound, bound.parentReceiptSetSha256, bound.inventorySha256);
|
||||
assert.match(protocol.resolveBoundReceipt(parsed, "GLB", "EXPORT").sourceSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(protocol.resolveBoundReceipt(parsed, "GLB", "EXPORT").settingsSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(protocol.resolveBoundReceipt(parsed, "GLB", "EXPORT").runtimeSha256, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test("M12-05E rejects source, settings, runtime and parent hash drift", () => {
|
||||
for (const field of ["sourceSha256", "settingsSha256", "runtimeSha256"]) {
|
||||
const mutated = structuredClone(bound); mutated.receipts[0][field] = "invalid-hash";
|
||||
assert.throws(() => protocol.validateIOFormatBoundReceiptSet(mutated, mutated.parentReceiptSetSha256, mutated.inventorySha256), { code: "IO_FORMAT_UNSUPPORTED" });
|
||||
}
|
||||
const stale = structuredClone(bound); stale.parentReceiptSetSha256 = "0".repeat(64);
|
||||
assert.throws(() => protocol.validateIOFormatBoundReceiptSet(stale, bound.parentReceiptSetSha256, bound.inventorySha256), { code: "IO_FORMAT_UNSUPPORTED" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
86
web/tests/unit/io-format-receipt-freshness.test.mjs
Normal file
86
web/tests/unit/io-format-receipt-freshness.test.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-freshness-unit-"));
|
||||
const stableValue = (value) => Array.isArray(value) ? value.map(stableValue) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])) : value;
|
||||
const stableSha256 = (value) => crypto.createHash("sha256").update(JSON.stringify(stableValue(value))).digest("hex");
|
||||
|
||||
function transpile(sourcePath, outputName, replacements = []) {
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
let output = result.outputText;
|
||||
for (const [from, to] of replacements) output = output.replaceAll(from, to);
|
||||
const outputPath = path.join(temporary, outputName);
|
||||
fs.writeFileSync(outputPath, output);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
const parentPath = path.join(root, "tests/golden/M12-05E/bound-runtime-receipts.json");
|
||||
const freshnessPath = path.join(root, "tests/golden/M12-05F/fresh-runtime-receipts.json");
|
||||
const parentBytes = fs.readFileSync(parentPath);
|
||||
const bound = JSON.parse(parentBytes);
|
||||
const freshness = JSON.parse(fs.readFileSync(freshnessPath, "utf8"));
|
||||
const protocolPath = path.join(root, "web/protocol/io-format-receipt-freshness.ts");
|
||||
transpile(path.join(root, "web/protocol/io-format-runtime-receipt.ts"), "io-format-runtime-receipt.mjs");
|
||||
transpile(path.join(root, "web/protocol/io-format-receipt-binding.ts"), "io-format-receipt-binding.mjs");
|
||||
const protocol = await import(pathToFileURL(transpile(protocolPath, "io-format-receipt-freshness.mjs", [["./io-format-receipt-binding\"", "./io-format-receipt-binding.mjs\""], ["./io-format-runtime-receipt\"", "./io-format-runtime-receipt.mjs\""]])));
|
||||
const expected = {
|
||||
parentBindingSha256: crypto.createHash("sha256").update(parentBytes).digest("hex"),
|
||||
parentReceiptSetSha256: bound.parentReceiptSetSha256,
|
||||
inventorySha256: bound.inventorySha256,
|
||||
boundReceiptSetSha256: stableSha256(bound),
|
||||
runtimeSha256: stableSha256(bound.runtime),
|
||||
runtime: bound.runtime,
|
||||
receiptIdentities: bound.receipts,
|
||||
};
|
||||
|
||||
test("M12-05F artifact is deterministic and bound to M12-05E", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05F/manifest.json"), "utf8"));
|
||||
assert.equal(manifest.task, "M12-05F");
|
||||
assert.equal(manifest.parentTask, "M12-05E");
|
||||
assert.equal(manifest.nextTask, "M12-06A");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, artifact.path);
|
||||
}
|
||||
assert.equal(freshness.parentBindingSha256, expected.parentBindingSha256);
|
||||
assert.equal(freshness.boundReceiptSetSha256, expected.boundReceiptSetSha256);
|
||||
assert.equal(freshness.runtimeSha256, expected.runtimeSha256);
|
||||
});
|
||||
|
||||
test("M12-05F accepts only the exact trusted runtime receipt", async () => {
|
||||
const parsed = await protocol.verifyIOFormatReceiptFreshness(freshness, expected);
|
||||
assert.equal(parsed.bound.receipts.length, 14);
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(freshness, expected, { format: "GLB", operation: "EXPORT" }).status, "READY");
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(freshness, expected, { format: "USD", operation: "EXPORT" }).status, "BLOCKED");
|
||||
});
|
||||
|
||||
test("M12-05F rejects forged content before route execution", async () => {
|
||||
const forged = structuredClone(freshness);
|
||||
forged.bound.receipts[0].operator = "forged.operator";
|
||||
await assert.rejects(() => protocol.verifyIOFormatReceiptFreshness(forged, expected), (error) => error.reason === "RECEIPT_FORGED");
|
||||
const malformed = structuredClone(freshness);
|
||||
delete malformed.boundReceiptSetSha256;
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(malformed, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_FORGED");
|
||||
});
|
||||
|
||||
test("M12-05F rejects stale parent identity and cross-version runtime", () => {
|
||||
const stale = structuredClone(freshness);
|
||||
stale.bound.inventorySha256 = "a".repeat(64);
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(stale, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_STALE");
|
||||
const crossVersion = structuredClone(freshness);
|
||||
crossVersion.bound.runtime.versionTuple = [5, 3, 0];
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(crossVersion, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_CROSS_VERSION");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
40
web/tests/unit/io-format-recovery.test.mjs
Normal file
40
web/tests/unit/io-format-recovery.test.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-recovery-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/io-format-recovery.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "io-format-recovery.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const hash = "a".repeat(64);
|
||||
const outputHash = "b".repeat(64);
|
||||
|
||||
test("M12-07J keeps cancellation and OOM receipts unpublished", () => {
|
||||
const running = protocol.beginIOFormatRecoveryOperation({ operationId: "ply-cancel-1", format: "PLY", operation: "IMPORT", workerGeneration: 1, baseRevision: 3, inputBytes: 32, inputSha256: hash });
|
||||
const cancelled = protocol.cancelIOFormatRecoveryOperation(running);
|
||||
assert.deepEqual(cancelled, { ...running, status: "CANCELLED", errorCode: "IO_FORMAT_OPERATION_CANCELLED", temporaryBytes: 0, liveRequests: 0, publishedResults: 0, committed: false });
|
||||
const oomRunning = protocol.beginIOFormatRecoveryOperation({ operationId: "obj-oom-1", format: "OBJ", operation: "IMPORT", workerGeneration: 1, baseRevision: 3, inputBytes: 512 * 1024 + 1, inputSha256: hash });
|
||||
const blocked = protocol.blockIOFormatRecoveryOperation(oomRunning);
|
||||
assert.equal(blocked.errorCode, "IO_FORMAT_OOM");
|
||||
assert.equal(blocked.publishedResults, 0);
|
||||
assert.throws(() => protocol.commitIOFormatRecoveryOperation(cancelled, { bytes: 1, sha256: outputHash }), /IO_FORMAT_RECOVERY_INVALID/);
|
||||
});
|
||||
|
||||
test("M12-07J binds output identity across restart and rejects stale generation", () => {
|
||||
const running = protocol.beginIOFormatRecoveryOperation({ operationId: "stl-restart-1", format: "STL", operation: "EXPORT", workerGeneration: 1, baseRevision: 7, inputBytes: 64, inputSha256: hash });
|
||||
const committed = protocol.commitIOFormatRecoveryOperation(running, { bytes: 128, sha256: outputHash });
|
||||
const recovered = protocol.recoverIOFormatRecoveryOperation(committed, 2);
|
||||
assert.deepEqual(recovered, { ...committed, status: "RECOVERED", workerGeneration: 2, errorCode: "IO_FORMAT_WORKER_RESTARTED" });
|
||||
assert.equal(protocol.parseIOFormatRecoveryReceipt(recovered).outputSha256, outputHash);
|
||||
assert.throws(() => protocol.recoverIOFormatRecoveryOperation(committed, 1), /IO_FORMAT_RECOVERY_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
48
web/tests/unit/io-format-runtime-receipt.test.mjs
Normal file
48
web/tests/unit/io-format-runtime-receipt.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-runtime-receipt-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/io-format-runtime-receipt.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "protocol.mjs"), transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(path.join(temporary, "protocol.mjs")));
|
||||
const receiptSet = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-05D/runtime-receipts.json"), "utf8"));
|
||||
const inventorySha256 = receiptSet.inventorySha256;
|
||||
|
||||
test("M12-05D resolves capability from the runtime operator receipt", () => {
|
||||
const parsed = protocol.validateIOFormatRuntimeReceiptSet(receiptSet, inventorySha256);
|
||||
assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "GLB", operation: "EXPORT" }).status, "READY");
|
||||
assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "USD", operation: "EXPORT" }).status, "BLOCKED");
|
||||
});
|
||||
|
||||
test("M12-05D does not infer capability from a filename extension", () => {
|
||||
const mutated = structuredClone(receiptSet);
|
||||
const receipt = mutated.receipts.find((candidate) => candidate.format === "GLB" && candidate.operation === "EXPORT");
|
||||
receipt.extensions = [".usd"];
|
||||
const parsed = protocol.validateIOFormatRuntimeReceiptSet(mutated, inventorySha256);
|
||||
assert.deepEqual(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "GLB", operation: "EXPORT" }).status, "READY");
|
||||
assert.deepEqual(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "USD", operation: "EXPORT" }).status, "BLOCKED");
|
||||
});
|
||||
|
||||
test("M12-05D rejects receipt identity drift and consumes explicit receipt status", () => {
|
||||
const stale = structuredClone(receiptSet);
|
||||
stale.inventorySha256 = "0".repeat(64);
|
||||
assert.throws(() => protocol.validateIOFormatRuntimeReceiptSet(stale, inventorySha256), { code: "IO_FORMAT_UNSUPPORTED" });
|
||||
const forged = structuredClone(receiptSet);
|
||||
const usd = forged.receipts.find((candidate) => candidate.format === "USD" && candidate.operation === "EXPORT");
|
||||
usd.runtimeStatus = "AVAILABLE";
|
||||
usd.registered = true;
|
||||
usd.rnaIdentifier = "WM_OT_usd_export";
|
||||
usd.buildOptionEnabled = true;
|
||||
assert.doesNotThrow(() => protocol.parseIOFormatRuntimeReceiptSet(forged));
|
||||
assert.equal(protocol.resolveIOFormatRuntimeRoute(protocol.parseIOFormatRuntimeReceiptSet(forged), { format: "USD", operation: "EXPORT" }).status, "READY");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
53
web/tests/unit/io-format-ui-gate.test.mjs
Normal file
53
web/tests/unit/io-format-ui-gate.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-ui-gate-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/io-format-ui-gate.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "io-format-ui-gate.mjs"), transpiled.outputText);
|
||||
const gate = await import(pathToFileURL(path.join(temporary, "io-format-ui-gate.mjs")));
|
||||
const registry = JSON.parse(fs.readFileSync(path.join(root, "web/app/src/capabilities/io-format-ui-registry.json"), "utf8"));
|
||||
|
||||
test("M12-05C keeps only matrix-declared executable routes in UI", () => {
|
||||
const parsed = gate.parseIOFormatUIRegistry(registry);
|
||||
assert.deepEqual(parsed.importRoutes, []);
|
||||
assert.deepEqual(parsed.exportRoutes.map((route) => route.format), ["GLB"]);
|
||||
const commands = [
|
||||
{ id: "file.export-glb", ioFormat: { format: "GLB", operation: "EXPORT", execution: "LOCAL" } },
|
||||
{ id: "file.import-obj", ioFormat: { format: "OBJ", operation: "IMPORT", execution: "LOCAL" } },
|
||||
{ id: "file.export-usd", ioFormat: { format: "USD", operation: "EXPORT", execution: "SERVER" } },
|
||||
{ id: "edit.undo" },
|
||||
];
|
||||
assert.deepEqual(gate.filterIOFormatOperatorCommands(commands, parsed).map((command) => command.id), ["file.export-glb", "edit.undo"]);
|
||||
});
|
||||
|
||||
test("M12-05C file selection is project-only while import routes are blocked", () => {
|
||||
const parsed = gate.parseIOFormatUIRegistry(registry);
|
||||
assert.equal(gate.ioFormatUIAccept(parsed), ".blend,application/octet-stream");
|
||||
assert.deepEqual(gate.gateIOFormatFileSelection("scene.blend", parsed), { status: "READY", kind: "BLEND" });
|
||||
assert.deepEqual(gate.gateIOFormatFileSelection("mesh.obj", parsed), { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension: ".obj" });
|
||||
assert.deepEqual(gate.gateIOFormatFileSelection("scene.glb", parsed), { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension: ".glb" });
|
||||
});
|
||||
|
||||
test("M12-05C rejects malformed route metadata", () => {
|
||||
const bad = structuredClone(registry);
|
||||
bad.importRoutes = [{ format: "OBJ", operation: "IMPORT", execution: "LOCAL", extensions: [".obj"] }];
|
||||
assert.doesNotThrow(() => gate.parseIOFormatUIRegistry(bad));
|
||||
const parsed = gate.parseIOFormatUIRegistry(bad);
|
||||
assert.equal(gate.filterIOFormatOperatorCommands([{ id: "import-obj", ioFormat: { format: "OBJ", operation: "IMPORT", execution: "LOCAL" } }], parsed).length, 1);
|
||||
bad.importRoutes[0].extensions = [".not-obj"];
|
||||
assert.throws(() => gate.parseIOFormatUIRegistry(bad), { code: "IO_FORMAT_UNSUPPORTED" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
21
web/tests/unit/keyboard-contract.test.mjs
Normal file
21
web/tests/unit/keyboard-contract.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "../../node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/keyboard-contract.ts");
|
||||
const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const keyboard = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
|
||||
test("M14-04E preserves layout character, physical code, location and modifiers", () => {
|
||||
assert.deepEqual(keyboard.observeKeyboardEvent({ key: "ä", code: "Quote", location: 0, shiftKey: true, ctrlKey: false, altKey: true, metaKey: false, repeat: true, isComposing: false }), { schemaVersion: 1, key: "ä", code: "Quote", location: 0, shiftKey: true, ctrlKey: false, altKey: true, metaKey: false, repeat: true, isComposing: false, deadKey: false });
|
||||
assert.equal(keyboard.observeKeyboardEvent({ key: "Dead", code: "Quote", location: 0 }).deadKey, true);
|
||||
});
|
||||
|
||||
test("M14-04E rejects malformed key identity", () => {
|
||||
assert.throws(() => keyboard.observeKeyboardEvent({ key: "", code: "KeyA", location: 0 }), /KEY_IDENTITY_INVALID/);
|
||||
assert.throws(() => keyboard.observeKeyboardEvent({ key: "a", code: "KeyA", location: 4 }), /KEY_LOCATION_INVALID/);
|
||||
});
|
||||
64
web/tests/unit/library-append-wasm.test.mjs
Normal file
64
web/tests/unit/library-append-wasm.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-append-wasm-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-main-append.ts");
|
||||
const identityPath = path.join(root, "web/protocol/library-operation-identity.ts");
|
||||
const identityTranspiled = ts.transpileModule(fs.readFileSync(identityPath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: identityPath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(identityTranspiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-operation-identity.mjs"), identityTranspiled.outputText);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-main-append.mjs"), transpiled.outputText.replaceAll("./library-operation-identity\"", "./library-operation-identity.mjs\""));
|
||||
const append = await import(pathToFileURL(path.join(temporary, "library-main-append.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03N/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const closure = {
|
||||
object: "Object/M12 Append Object",
|
||||
mesh: "Mesh/M12 Append Mesh",
|
||||
material: "Material/M12 Append Material",
|
||||
image: "Image/M12 Append Image",
|
||||
};
|
||||
|
||||
test("M12-03N binds the independent append WASM command", () => {
|
||||
assert.equal(manifest.task, "M12-03N");
|
||||
assert.equal(manifest.nextTask, "M12-04A");
|
||||
assert.equal(sha256("web/protocol/library-main-append.ts"), manifest.artifacts.appendWasmProtocol.sha256);
|
||||
});
|
||||
|
||||
test("M12-03N validates the append closure and one-transaction receipt in the WASM lane", async () => {
|
||||
assert.deepEqual(append.parseLibraryAppendClosure(closure), closure);
|
||||
const request = {
|
||||
baseRevision: 11,
|
||||
binding: {
|
||||
source: { sourceLibraryId: "library:" + "a".repeat(64) },
|
||||
sourceDataBlockId: closure.object,
|
||||
dependencyClosureSha256: await append.computeLibraryAppendClosureSha256(closure),
|
||||
},
|
||||
expectedClosure: closure,
|
||||
};
|
||||
const receipt = append.createLibraryMainAppendReceipt(request, 12);
|
||||
assert.equal(receipt.operation, "APPEND");
|
||||
assert.equal(receipt.transactionCount, 1);
|
||||
assert.equal(receipt.baseRevision, 11);
|
||||
assert.equal(receipt.nextRevision, 12);
|
||||
assert.equal(receipt.mapping.length, 4);
|
||||
assert(receipt.mapping.every((item) => item.owner === "LOCAL_MAIN" && item.readOnly === false && item.source === item.local));
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
59
web/tests/unit/library-archive-budget.test.mjs
Normal file
59
web/tests/unit/library-archive-budget.test.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-budget-unit-"));
|
||||
for (const name of ["asset-path.ts", "capability-gates.ts", "error.ts", "asset-library-io.ts"]) {
|
||||
const sourcePath = path.join(root, "web/protocol", name);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
let output = transpiled.outputText;
|
||||
output = output.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"').replaceAll('from "./capability-gates"', 'from "./capability-gates.mjs"').replaceAll('from "./error"', 'from "./error.mjs"');
|
||||
fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), output);
|
||||
}
|
||||
const io = await import(pathToFileURL(path.join(temporary, "asset-library-io.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04F/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const base = { format: "GLB", operation: "IMPORT", externalUris: [], archiveEntries: [] };
|
||||
const entry = (pathName, compressedBytes = 2, uncompressedBytes = 2) => ({ path: pathName, compressedBytes, uncompressedBytes });
|
||||
|
||||
test("M12-04F binds archive budget constants and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04F");
|
||||
assert.equal(manifest.parentTask, "M12-04E");
|
||||
assert.equal(manifest.nextTask, "M12-04G");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
assert.equal(io.ASSET_LIBRARY_BUDGET.maxArchivePathDepth, 64);
|
||||
assert.equal(io.ASSET_LIBRARY_BUDGET.maxArchiveFileNameBytes, 255);
|
||||
});
|
||||
|
||||
test("M12-04F accepts entries at the per-entry, total, depth and filename limits", () => {
|
||||
const fileName = "é".repeat(125) + ".bin";
|
||||
const deepPath = `${Array.from({ length: io.ASSET_LIBRARY_BUDGET.maxArchivePathDepth }, (_, index) => `d${index}`).join("/")}/file.bin`;
|
||||
const result = io.parseIORequest({ ...base, byteLength: 10, archiveEntries: [entry("a.bin"), entry(deepPath), entry(fileName)] });
|
||||
assert.equal(result.archiveEntries.length, 3);
|
||||
});
|
||||
|
||||
test("M12-04F rejects per-entry, total, count, depth and UTF-8 filename overflow", () => {
|
||||
assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("huge.bin", 1, io.ASSET_LIBRARY_BUDGET.maxEntryBytes + 1)] }), { code: "ASSET_BUDGET_EXCEEDED" });
|
||||
assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("a.bin", io.ASSET_LIBRARY_BUDGET.maxEntryBytes, io.ASSET_LIBRARY_BUDGET.maxEntryBytes), entry("b.bin", io.ASSET_LIBRARY_BUDGET.maxEntryBytes, io.ASSET_LIBRARY_BUDGET.maxEntryBytes), entry("c.bin", 1, 1)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry(`${Array.from({ length: io.ASSET_LIBRARY_BUDGET.maxArchivePathDepth + 1 }, () => "d").join("/")}/file.bin`)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("é".repeat(128))] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => io.parseIORequest({ ...base, archiveEntries: Array.from({ length: io.ASSET_LIBRARY_BUDGET.maxArchiveEntries + 1 }, (_, index) => entry(`f${index}.bin`)) }), { code: "ASSET_BUDGET_EXCEEDED" });
|
||||
});
|
||||
|
||||
test("M12-04F keeps compression and declared source-byte budgets fail-closed", () => {
|
||||
assert.throws(() => io.parseIORequest({ ...base, archiveEntries: [entry("bomb.bin", 1, io.ASSET_LIBRARY_BUDGET.maxCompressionRatio + 1)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => io.parseIORequest({ ...base, byteLength: 1, archiveEntries: [entry("source.bin", 2, 2)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
209
web/tests/unit/library-archive-cancellation.test.mjs
Normal file
209
web/tests/unit/library-archive-cancellation.test.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-cancellation-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/archive-extraction-transaction.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "archive-extraction-transaction.mjs"), transpiled.outputText);
|
||||
const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-transaction.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04H/manifest.json"), "utf8"));
|
||||
const fileSha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const bytesSha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
class DirectoryExtractionStorage {
|
||||
constructor(directory, committed) {
|
||||
this.directory = directory;
|
||||
this.committed = committed;
|
||||
fs.mkdirSync(path.join(directory, "committed"), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "committed", "project.blend"), Buffer.from("old-project"));
|
||||
}
|
||||
|
||||
async readCommitted() { return { ...this.committed }; }
|
||||
|
||||
async createStaging(transactionId) {
|
||||
fs.mkdirSync(path.join(this.directory, "staging", transactionId), { recursive: true });
|
||||
}
|
||||
|
||||
async writeStaging(transactionId, entryPath, bytes) {
|
||||
const target = path.join(this.directory, "staging", transactionId, entryPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, bytes);
|
||||
}
|
||||
|
||||
async countStagingEntries(transactionId) {
|
||||
const staging = path.join(this.directory, "staging", transactionId);
|
||||
if (!fs.existsSync(staging)) return 0;
|
||||
const visit = (directory) => fs.readdirSync(directory, { withFileTypes: true }).reduce(
|
||||
(count, entry) => count + (entry.isDirectory() ? visit(path.join(directory, entry.name)) : 1), 0,
|
||||
);
|
||||
return visit(staging);
|
||||
}
|
||||
|
||||
async discardStaging(transactionId) {
|
||||
const count = await this.countStagingEntries(transactionId);
|
||||
fs.rmSync(path.join(this.directory, "staging", transactionId), { recursive: true, force: true });
|
||||
return count;
|
||||
}
|
||||
|
||||
async commitStaging(transactionId, expected, candidate) {
|
||||
assert.deepEqual(this.committed, expected);
|
||||
const source = path.join(this.directory, "staging", transactionId, "project.blend");
|
||||
const target = path.join(this.directory, "committed", "project.blend");
|
||||
fs.renameSync(source, target);
|
||||
await this.discardStaging(transactionId);
|
||||
this.committed = { ...candidate };
|
||||
return { ...this.committed };
|
||||
}
|
||||
}
|
||||
|
||||
const oldBytes = Buffer.from("old-project");
|
||||
const newBytes = Buffer.from("new-project");
|
||||
const metadataBytes = Buffer.from("metadata");
|
||||
const committed = { projectId: "project:m12-04h", revision: 8, sha256: bytesSha256(oldBytes) };
|
||||
const candidate = { projectId: committed.projectId, revision: 9, sha256: bytesSha256(newBytes) };
|
||||
const request = {
|
||||
schemaVersion: 1,
|
||||
transactionId: "transaction:m12-04h",
|
||||
archiveId: "archive:m12-04h",
|
||||
committed,
|
||||
candidate,
|
||||
entries: [
|
||||
{ path: "metadata/index.json", uncompressedBytes: metadataBytes.byteLength, sha256: bytesSha256(metadataBytes) },
|
||||
{ path: "project.blend", uncompressedBytes: newBytes.byteLength, sha256: bytesSha256(newBytes) },
|
||||
],
|
||||
};
|
||||
|
||||
function createStorage(name) {
|
||||
const directory = path.join(temporary, name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return new DirectoryExtractionStorage(directory, committed);
|
||||
}
|
||||
|
||||
test("M12-04H binds cancellation rollback and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04H");
|
||||
assert.equal(manifest.parentTask, "M12-04G");
|
||||
assert.equal(manifest.nextTask, "M12-04I");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04H removes partially written staging and preserves the committed project on cancellation", async () => {
|
||||
const storage = createStorage("cancel-after-write");
|
||||
const controller = new AbortController();
|
||||
let reads = 0;
|
||||
const receipt = await extraction.runArchiveExtractionTransaction(request, storage, async (entry) => {
|
||||
reads++;
|
||||
if (reads === 2) controller.abort();
|
||||
return entry.path === "project.blend" ? newBytes : metadataBytes;
|
||||
}, controller.signal);
|
||||
assert.deepEqual(receipt, {
|
||||
status: "CANCELLED",
|
||||
code: "IO_ARCHIVE_CANCELLED",
|
||||
transactionId: request.transactionId,
|
||||
committedBefore: committed,
|
||||
committedAfter: committed,
|
||||
removedStagingEntries: 1,
|
||||
stagingEntriesAfter: 0,
|
||||
publishedProjects: 0,
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project");
|
||||
assert.equal(await storage.countStagingEntries(request.transactionId), 0);
|
||||
});
|
||||
|
||||
test("M12-04H cancels before staging and after the last staged write without publishing", async () => {
|
||||
const beforeStorage = createStorage("cancel-before-stage");
|
||||
const beforeController = new AbortController();
|
||||
beforeController.abort();
|
||||
const before = await extraction.runArchiveExtractionTransaction(request, beforeStorage, async () => newBytes, beforeController.signal);
|
||||
assert.equal(before.status, "CANCELLED");
|
||||
assert.equal(before.removedStagingEntries, 0);
|
||||
|
||||
const finalStorage = createStorage("cancel-after-last-write");
|
||||
const finalController = new AbortController();
|
||||
const originalWrite = finalStorage.writeStaging.bind(finalStorage);
|
||||
finalStorage.writeStaging = async (transactionId, entryPath, bytes) => {
|
||||
await originalWrite(transactionId, entryPath, bytes);
|
||||
if (entryPath === "project.blend") finalController.abort();
|
||||
};
|
||||
const after = await extraction.runArchiveExtractionTransaction(request, finalStorage, async (entry) =>
|
||||
entry.path === "project.blend" ? newBytes : metadataBytes, finalController.signal);
|
||||
assert.equal(after.status, "CANCELLED");
|
||||
assert.equal(after.removedStagingEntries, 2);
|
||||
assert.deepEqual(await finalStorage.readCommitted(), committed);
|
||||
assert.equal(fs.readFileSync(path.join(finalStorage.directory, "committed", "project.blend"), "utf8"), "old-project");
|
||||
});
|
||||
|
||||
test("M12-04H commits only after all staged payloads pass identity checks", async () => {
|
||||
const storage = createStorage("commit");
|
||||
const receipt = await extraction.runArchiveExtractionTransaction(request, storage, async (entry) =>
|
||||
entry.path === "project.blend" ? newBytes : metadataBytes, new AbortController().signal);
|
||||
assert.deepEqual(receipt, {
|
||||
status: "COMMITTED",
|
||||
transactionId: request.transactionId,
|
||||
committed: candidate,
|
||||
stagingEntriesAfter: 0,
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "new-project");
|
||||
});
|
||||
|
||||
test("M12-04H cleans staging on payload failure and detects rollback identity drift", async () => {
|
||||
const failedStorage = createStorage("payload-failure");
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction(request, failedStorage, async () => Buffer.from("wrong"), new AbortController().signal),
|
||||
{ code: "ASSET_SOURCE_HASH_MISMATCH" },
|
||||
);
|
||||
assert.equal(await failedStorage.countStagingEntries(request.transactionId), 0);
|
||||
assert.deepEqual(await failedStorage.readCommitted(), committed);
|
||||
|
||||
const driftStorage = createStorage("rollback-drift");
|
||||
const originalDiscard = driftStorage.discardStaging.bind(driftStorage);
|
||||
driftStorage.discardStaging = async (transactionId) => {
|
||||
const removed = await originalDiscard(transactionId);
|
||||
driftStorage.committed = { ...committed, revision: committed.revision + 1 };
|
||||
return removed;
|
||||
};
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction(request, driftStorage, async (entry) => {
|
||||
const bytes = entry.path === "project.blend" ? newBytes : metadataBytes;
|
||||
queueMicrotask(() => controller.abort());
|
||||
return bytes;
|
||||
}, controller.signal),
|
||||
{ code: "STORAGE_TRANSACTION" },
|
||||
);
|
||||
});
|
||||
|
||||
test("M12-04H rejects stale commits, non-canonical paths, prefix conflicts, and undeclared fields", async () => {
|
||||
const storage = createStorage("invalid-requests");
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction({ ...request, committed: { ...committed, revision: 7 } }, storage, async () => newBytes, new AbortController().signal),
|
||||
{ code: "REVISION_CONFLICT" },
|
||||
);
|
||||
for (const unsafePath of ["../project.blend", "C:/project.blend", "https:project.blend", "dir\\project.blend"]) {
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction({ ...request, entries: [{ ...request.entries[0], path: unsafePath }] }, storage, async () => metadataBytes, new AbortController().signal),
|
||||
{ code: "IO_ARCHIVE_UNSAFE" },
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction({ ...request, entries: [request.entries[0], { ...request.entries[1], path: "metadata" }] }, storage, async () => metadataBytes, new AbortController().signal),
|
||||
{ code: "IO_ARCHIVE_UNSAFE" },
|
||||
);
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction({ ...request, future: true }, storage, async () => newBytes, new AbortController().signal),
|
||||
{ code: "IO_ARCHIVE_UNSAFE" },
|
||||
);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
46
web/tests/unit/library-archive-conflicts.test.mjs
Normal file
46
web/tests/unit/library-archive-conflicts.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-conflicts-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/archive-conflicts.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "archive-conflicts.mjs"), transpiled.outputText);
|
||||
const conflicts = await import(pathToFileURL(path.join(temporary, "archive-conflicts.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04G/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const range = (pathName, compressedOffset, compressedBytes = 2, uncompressedBytes = 2) => ({ path: pathName, compressedOffset, compressedBytes, uncompressedBytes });
|
||||
const valid = { schemaVersion: 1, byteLength: 10, ranges: [range("a.bin", 0), range("dir/b.bin", 2, 3, 3)] };
|
||||
|
||||
test("M12-04G binds range/conflict validation and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04G");
|
||||
assert.equal(manifest.parentTask, "M12-04F");
|
||||
assert.equal(manifest.nextTask, "M12-04H");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04G accepts deterministic non-overlapping ranges and reports totals", () => {
|
||||
assert.deepEqual(conflicts.validateArchiveConflicts(valid), { status: "VALID", totalCompressedBytes: 5, totalUncompressedBytes: 5, nonOverlapping: true, uniquePaths: true, noPrefixConflicts: true });
|
||||
});
|
||||
|
||||
test("M12-04G rejects compression bombs, overlaps, duplicate paths, and prefix conflicts", () => {
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("bomb.bin", 0, 1, conflicts.ARCHIVE_CONFLICT_BUDGET.maxCompressionRatio + 1)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("a.bin", 0, 4), range("b.bin", 3, 2)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("same.bin", 0), range("same.bin", 2)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [range("folder", 0), range("folder/file.bin", 2)] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
});
|
||||
|
||||
test("M12-04G rejects range/source bounds and undeclared fields", () => {
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, byteLength: 4 }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [{ ...valid.ranges[0], future: true }] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => conflicts.validateArchiveConflicts({ ...valid, ranges: [{ ...valid.ranges[0], compressedOffset: 9, compressedBytes: 2 }] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
163
web/tests/unit/library-archive-recovery.test.mjs
Normal file
163
web/tests/unit/library-archive-recovery.test.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-recovery-unit-"));
|
||||
for (const sourceName of ["archive-extraction-transaction.ts", "archive-extraction-recovery.ts"]) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(
|
||||
path.join(temporary, sourceName.replace(".ts", ".mjs")),
|
||||
transpiled.outputText.replaceAll('from "./archive-extraction-transaction"', 'from "./archive-extraction-transaction.mjs"'),
|
||||
);
|
||||
}
|
||||
const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-recovery.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04I/manifest.json"), "utf8"));
|
||||
const fileSha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const bytesSha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
class FaultingDirectoryStorage {
|
||||
constructor(directory, committed) {
|
||||
this.directory = directory;
|
||||
this.committed = { ...committed };
|
||||
this.fault = null;
|
||||
this.partialBytes = 0;
|
||||
fs.mkdirSync(path.join(directory, "committed"), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "committed", "project.blend"), Buffer.from("old-project"));
|
||||
}
|
||||
|
||||
async readCommitted() { return { ...this.committed }; }
|
||||
|
||||
async createStaging(transactionId) {
|
||||
fs.mkdirSync(path.join(this.directory, "staging", transactionId), { recursive: true });
|
||||
}
|
||||
|
||||
async writeStaging(transactionId, entryPath, bytes) {
|
||||
const target = path.join(this.directory, "staging", transactionId, entryPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
if (this.fault) {
|
||||
const partial = bytes.subarray(0, Math.max(1, Math.floor(bytes.byteLength / 2)));
|
||||
fs.writeFileSync(target, partial);
|
||||
this.partialBytes += partial.byteLength;
|
||||
const fault = this.fault;
|
||||
this.fault = null;
|
||||
throw fault;
|
||||
}
|
||||
fs.writeFileSync(target, bytes);
|
||||
}
|
||||
|
||||
async countStagingEntries(transactionId) {
|
||||
const staging = path.join(this.directory, "staging", transactionId);
|
||||
if (!fs.existsSync(staging)) return 0;
|
||||
const visit = (directory) => fs.readdirSync(directory, { withFileTypes: true }).reduce(
|
||||
(count, entry) => count + (entry.isDirectory() ? visit(path.join(directory, entry.name)) : 1), 0,
|
||||
);
|
||||
return visit(staging);
|
||||
}
|
||||
|
||||
async discardStaging(transactionId) {
|
||||
const count = await this.countStagingEntries(transactionId);
|
||||
fs.rmSync(path.join(this.directory, "staging", transactionId), { recursive: true, force: true });
|
||||
return count;
|
||||
}
|
||||
|
||||
async commitStaging(transactionId, expected, candidate) {
|
||||
assert.deepEqual(this.committed, expected);
|
||||
const source = path.join(this.directory, "staging", transactionId, "project.blend");
|
||||
const target = path.join(this.directory, "committed", "project.blend");
|
||||
fs.renameSync(source, target);
|
||||
await this.discardStaging(transactionId);
|
||||
this.committed = { ...candidate };
|
||||
return { ...this.committed };
|
||||
}
|
||||
}
|
||||
|
||||
const oldBytes = Buffer.from("old-project");
|
||||
const largeBytes = Buffer.alloc(4096, 0x51);
|
||||
const smallBytes = Buffer.from("small-project");
|
||||
const committed = { projectId: "project:m12-04i", revision: 9, sha256: bytesSha256(oldBytes) };
|
||||
|
||||
function request(transactionId, base, bytes) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transactionId,
|
||||
archiveId: `archive:${transactionId}`,
|
||||
committed: base,
|
||||
candidate: { projectId: base.projectId, revision: base.revision + 1, sha256: bytesSha256(bytes) },
|
||||
entries: [{ path: "project.blend", uncompressedBytes: bytes.byteLength, sha256: bytesSha256(bytes) }],
|
||||
};
|
||||
}
|
||||
|
||||
function createStorage(name) {
|
||||
const directory = path.join(temporary, name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return new FaultingDirectoryStorage(directory, committed);
|
||||
}
|
||||
|
||||
async function faultThenRecover(name, fault, expectedCode) {
|
||||
const storage = createStorage(name);
|
||||
storage.fault = fault;
|
||||
const failedRequest = request(`transaction:${name}:large`, committed, largeBytes);
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction(failedRequest, storage, async () => largeBytes, new AbortController().signal),
|
||||
{ code: expectedCode },
|
||||
);
|
||||
assert.ok(storage.partialBytes > 0);
|
||||
assert.equal(await storage.countStagingEntries(failedRequest.transactionId), 0);
|
||||
assert.deepEqual(await storage.readCommitted(), committed);
|
||||
assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project");
|
||||
|
||||
const recoveryRequest = request(`transaction:${name}:small`, committed, smallBytes);
|
||||
const receipt = await extraction.runArchiveExtractionTransaction(
|
||||
recoveryRequest,
|
||||
storage,
|
||||
async () => smallBytes,
|
||||
new AbortController().signal,
|
||||
);
|
||||
assert.deepEqual(receipt, {
|
||||
status: "COMMITTED",
|
||||
transactionId: recoveryRequest.transactionId,
|
||||
committed: recoveryRequest.candidate,
|
||||
stagingEntriesAfter: 0,
|
||||
});
|
||||
assert.equal(await storage.countStagingEntries(recoveryRequest.transactionId), 0);
|
||||
assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "small-project");
|
||||
}
|
||||
|
||||
test("M12-04I binds quota/OOM cleanup and recovery evidence", () => {
|
||||
assert.equal(manifest.task, "M12-04I");
|
||||
assert.equal(manifest.parentTask, "M12-04H");
|
||||
assert.equal(manifest.nextTask, "M12-04J");
|
||||
assert.deepEqual(manifest.assertions.faultCodes, ["STORAGE_QUOTA", "WASM_OUT_OF_MEMORY"]);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04I releases partial staging after quota and accepts a small archive in the same storage", async () => {
|
||||
await faultThenRecover("quota", new DOMException("quota exhausted", "QuotaExceededError"), "STORAGE_QUOTA");
|
||||
});
|
||||
|
||||
test("M12-04I releases partial staging after OOM and accepts a small archive in the same storage", async () => {
|
||||
await faultThenRecover("oom", Object.assign(new Error("deterministic allocation failure"), { code: "WASM_OUT_OF_MEMORY" }), "WASM_OUT_OF_MEMORY");
|
||||
});
|
||||
|
||||
test("M12-04I maps only declared resource faults", () => {
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(new DOMException("full", "QuotaExceededError")), "STORAGE_QUOTA");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "STORAGE_QUOTA" })), "STORAGE_QUOTA");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "WASM_OUT_OF_MEMORY" })), "WASM_OUT_OF_MEMORY");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { name: "OutOfMemoryError" })), "WASM_OUT_OF_MEMORY");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(new RangeError("array length is invalid")), undefined);
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(new Error("ordinary IO failure")), undefined);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
88
web/tests/unit/library-link-safety.test.mjs
Normal file
88
web/tests/unit/library-link-safety.test.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-link-safety-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/archive-link-safety.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "archive-link-safety.mjs"), transpiled.outputText);
|
||||
const safety = await import(pathToFileURL(path.join(temporary, "archive-link-safety.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04D/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
const valid = {
|
||||
schemaVersion: 1,
|
||||
temporaryRootId: "staging:archive-1",
|
||||
entries: [
|
||||
{ path: "payload/data.bin", type: "FILE", target: null },
|
||||
{ path: "payload/data-alias.bin", type: "SYMLINK", target: "./data.bin" },
|
||||
{ path: "payload/data-hard.bin", type: "HARDLINK", target: "payload/data.bin" },
|
||||
{ path: "payload", type: "DIRECTORY", target: null },
|
||||
{ path: "alias-dir", type: "SYMLINK", target: "payload" },
|
||||
],
|
||||
};
|
||||
|
||||
test("M12-04D binds the archive link gate and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04D");
|
||||
assert.equal(manifest.parentTask, "M12-04C");
|
||||
assert.equal(manifest.nextTask, "M12-04E");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04D resolves symlinks relative to their parent and hardlinks from the archive root", () => {
|
||||
const result = safety.resolveArchiveLinkEntries(valid);
|
||||
assert.equal(result.status, "READY");
|
||||
assert.equal(result.entries.find((entry) => entry.path === "payload/data-alias.bin").resolvedPath, "payload/data.bin");
|
||||
assert.equal(result.entries.find((entry) => entry.path === "payload/data-hard.bin").resolvedPath, "payload/data.bin");
|
||||
assert.equal(result.entries.find((entry) => entry.path === "alias-dir").resolvedPath, "payload");
|
||||
assert.ok(result.entries.every((entry) => entry.withinTemporaryRoot === true));
|
||||
});
|
||||
|
||||
test("M12-04D rejects absolute, drive, URI and traversal targets before writing", () => {
|
||||
for (const target of ["/outside", "\\\\server\\share", "C:/outside", "https://evil.example/a", "../../outside", "payload/../../outside"]) {
|
||||
assert.throws(() => safety.resolveArchiveLinkEntries({
|
||||
...valid,
|
||||
entries: [{ path: "payload/link", type: "SYMLINK", target }, { path: "payload", type: "DIRECTORY", target: null }],
|
||||
}), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-04D rejects missing targets, cycles and hardlinks to directories", () => {
|
||||
assert.throws(() => safety.resolveArchiveLinkEntries({
|
||||
...valid,
|
||||
entries: [{ path: "link", type: "SYMLINK", target: "missing.bin" }],
|
||||
}), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => safety.resolveArchiveLinkEntries({
|
||||
...valid,
|
||||
entries: [
|
||||
{ path: "a", type: "SYMLINK", target: "b" },
|
||||
{ path: "b", type: "HARDLINK", target: "a" },
|
||||
],
|
||||
}), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => safety.resolveArchiveLinkEntries({
|
||||
...valid,
|
||||
entries: [
|
||||
{ path: "dir", type: "DIRECTORY", target: null },
|
||||
{ path: "dir-hard", type: "HARDLINK", target: "dir" },
|
||||
],
|
||||
}), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
});
|
||||
|
||||
test("M12-04D rejects duplicate members and undeclared fields", () => {
|
||||
assert.throws(() => safety.parseArchiveLinkRequest({ ...valid, entries: [{ ...valid.entries[0], target: null }, { ...valid.entries[0], target: null }] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => safety.parseArchiveLinkRequest({ ...valid, future: true }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => safety.parseArchiveLinkRequest({ ...valid, entries: [{ ...valid.entries[0], mode: 0o644 }] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
107
web/tests/unit/library-linked-missing.test.mjs
Normal file
107
web/tests/unit/library-linked-missing.test.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-linked-missing-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-linked-missing.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-linked-missing.mjs"), transpiled.outputText);
|
||||
const missing = await import(pathToFileURL(path.join(temporary, "library-linked-missing.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03I/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const library = (value) => `library:${String(value).repeat(64).slice(0, 64)}`;
|
||||
const digest = (value) => crypto.createHash("sha256").update(String(value)).digest("hex");
|
||||
const reference = (sourceLibraryId, generation, revision, marker, status = "AVAILABLE") => ({
|
||||
sourceLibraryId,
|
||||
sourceLocator: `project://libraries/${marker}.blend`,
|
||||
sourceSha256: digest(`${marker}-source`),
|
||||
sourceGeneration: generation,
|
||||
sourceRevision: revision,
|
||||
dataBlockIds: [`Object/${marker}`, `Mesh/${marker}`],
|
||||
status,
|
||||
placeholder: status === "MISSING" ? {
|
||||
kind: "MISSING_LIBRARY",
|
||||
sourceLibraryId,
|
||||
dataBlockIds: [`Object/${marker}`, `Mesh/${marker}`],
|
||||
} : null,
|
||||
});
|
||||
|
||||
test("M12-03I binds the missing-library protocol and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03I");
|
||||
assert.equal(manifest.parentTask, "M12-03H");
|
||||
assert.equal(manifest.nextTask, "M12-03J");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-03I marks only the matching reference missing and preserves its original source", () => {
|
||||
const sourceLibraryId = library("a");
|
||||
const otherLibraryId = library("b");
|
||||
const current = reference(sourceLibraryId, 4, 9, "primary");
|
||||
const other = reference(otherLibraryId, 1, 2, "other");
|
||||
const state = { schemaVersion: 1, references: [current, other] };
|
||||
const decision = missing.markLinkedLibraryMissing(state, {
|
||||
schemaVersion: 1,
|
||||
operation: "MARK_MISSING",
|
||||
sourceLibraryId,
|
||||
sourceLocator: current.sourceLocator,
|
||||
sourceSha256: current.sourceSha256,
|
||||
expectedGeneration: 4,
|
||||
expectedRevision: 9,
|
||||
});
|
||||
assert.equal(decision.status, "MARKED");
|
||||
assert.equal(decision.code, null);
|
||||
assert.deepEqual(decision.state.references[0], { ...current, status: "MISSING", placeholder: {
|
||||
kind: "MISSING_LIBRARY", sourceLibraryId, dataBlockIds: current.dataBlockIds,
|
||||
} });
|
||||
assert.deepEqual(decision.state.references[1], other);
|
||||
assert.deepEqual(state.references, [current, other]);
|
||||
});
|
||||
|
||||
test("M12-03I preserves the reference on stale generation and source hash drift", () => {
|
||||
const sourceLibraryId = library("c");
|
||||
const current = reference(sourceLibraryId, 2, 5, "stable");
|
||||
const state = { schemaVersion: 1, references: [current] };
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
operation: "MARK_MISSING",
|
||||
sourceLibraryId,
|
||||
sourceLocator: current.sourceLocator,
|
||||
sourceSha256: current.sourceSha256,
|
||||
expectedGeneration: 2,
|
||||
expectedRevision: 5,
|
||||
};
|
||||
assert.equal(missing.markLinkedLibraryMissing(state, { ...base, expectedRevision: 6 }).code, "REVISION_CONFLICT");
|
||||
assert.equal(missing.markLinkedLibraryMissing(state, { ...base, sourceSha256: digest("different") }).code, "ASSET_SOURCE_HASH_MISMATCH");
|
||||
assert.deepEqual(state.references, [current]);
|
||||
});
|
||||
|
||||
test("M12-03I rejects undeclared fields, duplicate identities, and invalid placeholders", () => {
|
||||
const sourceLibraryId = library("d");
|
||||
const current = reference(sourceLibraryId, 1, 1, "invalid");
|
||||
assert.throws(() => missing.parseLinkedMissingRequest({
|
||||
schemaVersion: 1,
|
||||
operation: "MARK_MISSING",
|
||||
sourceLibraryId,
|
||||
sourceLocator: current.sourceLocator,
|
||||
sourceSha256: current.sourceSha256,
|
||||
expectedGeneration: 1,
|
||||
expectedRevision: 1,
|
||||
future: true,
|
||||
}), { code: "TASK_VALIDATION_FAILED" });
|
||||
assert.throws(() => missing.parseLinkedMissingState({ schemaVersion: 1, references: [current, current] }), { code: "TASK_VALIDATION_FAILED" });
|
||||
assert.throws(() => missing.parseLinkedLibraryReference({ ...current, status: "MISSING", placeholder: null }), { code: "TASK_VALIDATION_FAILED" });
|
||||
assert.throws(() => missing.parseLinkedLibraryReference({ ...current, status: "AVAILABLE", placeholder: { kind: "MISSING_LIBRARY", sourceLibraryId, dataBlockIds: current.dataBlockIds } }), { code: "TASK_VALIDATION_FAILED" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
60
web/tests/unit/library-linked-mutation.test.mjs
Normal file
60
web/tests/unit/library-linked-mutation.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-linked-mutation-unit-"));
|
||||
const transpile = (sourceName, outputName, replacements = []) => {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText));
|
||||
};
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("library-linked-mutation.ts", "library-linked-mutation.mjs", [["from \"./capability-gates\"", "from \"./capability-gates.mjs\""]]);
|
||||
const linked = await import(pathToFileURL(path.join(temporary, "library-linked-mutation.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03G/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const base = (operation = "MESH_GEOMETRY") => ({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
dataBlockId: "mesh:M12 Link Mesh",
|
||||
baseRevision: 7,
|
||||
owner: "SOURCE_LIBRARY",
|
||||
linkedLibrary: true,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
test("M12-03G binds the linked writer protocol and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03G");
|
||||
assert.equal(manifest.parentTask, "M12-03F");
|
||||
assert.equal(manifest.nextTask, "M12-03H");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-03G blocks every linked data writer with the stable mutation code", () => {
|
||||
assert.equal(linked.LINKED_DATA_WRITER_OPERATIONS.length, 6);
|
||||
for (const operation of linked.LINKED_DATA_WRITER_OPERATIONS) {
|
||||
const gate = linked.gateLinkedDataMutation(base(operation), 7);
|
||||
assert.equal(gate.status, "BLOCKED");
|
||||
assert.deepEqual(gate.issues.map((issue) => issue.code), ["LINKED_DATA_MUTATION_BLOCKED"]);
|
||||
assert.equal(gate.issues[0].recoverable, false);
|
||||
assert.deepEqual(linked.parseLinkedDataMutation(base(operation)), { ...base(operation) });
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-03G rejects stale, malformed, and ownership-substituted writes before Main", () => {
|
||||
assert.equal(linked.gateLinkedDataMutation(base(), 8).issues[0].code, "REVISION_CONFLICT");
|
||||
assert.equal(linked.gateLinkedDataMutation({ ...base(), owner: "LOCAL_MAIN", linkedLibrary: false, readOnly: false }, 7).issues[0].code, "LINKED_DATA_MUTATION_BLOCKED");
|
||||
assert.equal(linked.gateLinkedDataMutation({ ...base(), future: true }, 7).issues[0].code, "TASK_VALIDATION_FAILED");
|
||||
assert.equal(linked.gateLinkedDataMutation({ ...base(), operation: "UNKNOWN" }, 7).issues[0].code, "TASK_VALIDATION_FAILED");
|
||||
});
|
||||
111
web/tests/unit/library-linked-reload.test.mjs
Normal file
111
web/tests/unit/library-linked-reload.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-linked-reload-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-linked-reload.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-linked-reload.mjs"), transpiled.outputText);
|
||||
const reload = await import(pathToFileURL(path.join(temporary, "library-linked-reload.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03H/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const library = (value) => `library:${String(value).repeat(64).slice(0, 64)}`;
|
||||
const digest = (value) => crypto.createHash("sha256").update(String(value)).digest("hex");
|
||||
const snapshot = (sourceLibraryId, generation, revision, marker) => ({
|
||||
sourceLibraryId,
|
||||
sourceGeneration: generation,
|
||||
sourceRevision: revision,
|
||||
dependencyClosureSha256: digest(marker),
|
||||
graphSha256: digest(`${marker}-graph`),
|
||||
dataBlocks: [
|
||||
{ dataBlockId: `Object/${marker}`, owner: "SOURCE_LIBRARY", readOnly: true },
|
||||
{ dataBlockId: `Mesh/${marker}`, owner: "SOURCE_LIBRARY", readOnly: true },
|
||||
],
|
||||
});
|
||||
|
||||
test("M12-03H binds the reload protocol and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03H");
|
||||
assert.equal(manifest.parentTask, "M12-03G");
|
||||
assert.equal(manifest.nextTask, "M12-03I");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-03H replaces only the matching library generation and preserves every other snapshot", () => {
|
||||
const sourceLibraryId = library("a");
|
||||
const otherLibraryId = library("b");
|
||||
const current = snapshot(sourceLibraryId, 3, 7, "old");
|
||||
const otherGeneration = snapshot(sourceLibraryId, 9, 2, "future");
|
||||
const otherLibrary = snapshot(otherLibraryId, 1, 4, "other");
|
||||
const state = { schemaVersion: 1, snapshots: [current, otherGeneration, otherLibrary] };
|
||||
const replacement = snapshot(sourceLibraryId, 4, 8, "new");
|
||||
const decision = reload.reloadMatchingLinkedSnapshot(state, {
|
||||
schemaVersion: 1,
|
||||
operation: "RELOAD",
|
||||
sourceLibraryId,
|
||||
expectedGeneration: 3,
|
||||
expectedRevision: 7,
|
||||
replacement,
|
||||
});
|
||||
assert.equal(decision.status, "REPLACED");
|
||||
assert.equal(decision.code, null);
|
||||
assert.deepEqual(decision.state.snapshots, [replacement, otherGeneration, otherLibrary]);
|
||||
assert.deepEqual(state.snapshots, [current, otherGeneration, otherLibrary]);
|
||||
assert(decision.state.snapshots.every((item) => item.dataBlocks.every((block) => block.owner === "SOURCE_LIBRARY" && block.readOnly)));
|
||||
});
|
||||
|
||||
test("M12-03H rejects stale generations and keeps state byte-for-byte equivalent", () => {
|
||||
const sourceLibraryId = library("c");
|
||||
const state = { schemaVersion: 1, snapshots: [snapshot(sourceLibraryId, 5, 11, "stable")] };
|
||||
const request = {
|
||||
schemaVersion: 1,
|
||||
operation: "RELOAD",
|
||||
sourceLibraryId,
|
||||
expectedGeneration: 4,
|
||||
expectedRevision: 10,
|
||||
replacement: snapshot(sourceLibraryId, 5, 12, "late"),
|
||||
};
|
||||
const decision = reload.reloadMatchingLinkedSnapshot(state, request);
|
||||
assert.deepEqual(decision, {
|
||||
status: "STALE",
|
||||
code: "REVISION_CONFLICT",
|
||||
sourceLibraryId,
|
||||
replacedGeneration: 4,
|
||||
replacementGeneration: 5,
|
||||
state,
|
||||
});
|
||||
});
|
||||
|
||||
test("M12-03H fails closed for malformed, substituted, duplicate, and non-adjacent reloads", () => {
|
||||
const sourceLibraryId = library("d");
|
||||
const current = snapshot(sourceLibraryId, 1, 1, "current");
|
||||
const baseRequest = {
|
||||
schemaVersion: 1,
|
||||
operation: "RELOAD",
|
||||
sourceLibraryId,
|
||||
expectedGeneration: 1,
|
||||
expectedRevision: 1,
|
||||
replacement: snapshot(sourceLibraryId, 2, 2, "replacement"),
|
||||
};
|
||||
assert.throws(() => reload.parseLinkedReloadRequest({ ...baseRequest, future: true }), { code: "TASK_VALIDATION_FAILED" });
|
||||
assert.throws(() => reload.parseLinkedReloadRequest({ ...baseRequest, replacement: { ...baseRequest.replacement, sourceLibraryId: library("e") } }), { code: "TASK_VALIDATION_FAILED" });
|
||||
assert.throws(() => reload.parseLinkedReloadState({ schemaVersion: 1, snapshots: [current, current] }), { code: "TASK_VALIDATION_FAILED" });
|
||||
const decision = reload.reloadMatchingLinkedSnapshot({ schemaVersion: 1, snapshots: [current] }, {
|
||||
...baseRequest,
|
||||
replacement: snapshot(sourceLibraryId, 3, 3, "skip"),
|
||||
});
|
||||
assert.equal(decision.status, "STALE");
|
||||
assert.equal(decision.code, "REVISION_CONFLICT");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
67
web/tests/unit/library-metadata-first.test.mjs
Normal file
67
web/tests/unit/library-metadata-first.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-metadata-first-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/archive-metadata-first.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "archive-metadata-first.mjs"), transpiled.outputText);
|
||||
const metadata = await import(pathToFileURL(path.join(temporary, "archive-metadata-first.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04E/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const zip = { schemaVersion: 1, archiveId: "archive:zip-1", format: "ZIP", archiveByteLength: 4096, metadataOffset: 3072, metadataByteLength: 512 };
|
||||
const tar = { schemaVersion: 1, archiveId: "archive:tar-1", format: "TAR", archiveByteLength: 4096, metadataOffset: 0, metadataByteLength: 1024 };
|
||||
|
||||
test("M12-04E binds metadata-first planning and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04E");
|
||||
assert.equal(manifest.parentTask, "M12-04D");
|
||||
assert.equal(manifest.nextTask, "M12-04F");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04E plans ZIP central-directory and TAR manifest reads without payload ranges", () => {
|
||||
assert.deepEqual(metadata.planArchiveMetadataRead(zip), {
|
||||
status: "METADATA_ONLY", schemaVersion: 1, archiveId: "archive:zip-1", format: "ZIP",
|
||||
firstRead: { kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 }, payloadReads: [],
|
||||
});
|
||||
assert.deepEqual(metadata.planArchiveMetadataRead(tar).firstRead, { kind: "MANIFEST", byteOffset: 0, byteLength: 1024 });
|
||||
});
|
||||
|
||||
test("M12-04E accepts a trace only when metadata is the first exact read", () => {
|
||||
assert.deepEqual(metadata.validateArchiveReadTrace(zip, {
|
||||
schemaVersion: 1, archiveId: "archive:zip-1", reads: [
|
||||
{ sequence: 0, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 },
|
||||
{ sequence: 1, kind: "PAYLOAD", byteOffset: 32, byteLength: 128 },
|
||||
],
|
||||
}), { status: "VALID", metadataFirst: true, payloadReadsAfterMetadata: true });
|
||||
assert.deepEqual(metadata.validateArchiveReadTrace(tar, {
|
||||
schemaVersion: 1, archiveId: "archive:tar-1", reads: [{ sequence: 0, kind: "MANIFEST", byteOffset: 0, byteLength: 1024 }],
|
||||
}).metadataFirst, true);
|
||||
});
|
||||
|
||||
test("M12-04E rejects payload-first, wrong-range, duplicate-metadata and invalid ranges", () => {
|
||||
assert.throws(() => metadata.validateArchiveReadTrace(zip, { schemaVersion: 1, archiveId: "archive:zip-1", reads: [
|
||||
{ sequence: 0, kind: "PAYLOAD", byteOffset: 0, byteLength: 16 },
|
||||
{ sequence: 1, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 },
|
||||
] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => metadata.validateArchiveReadTrace(zip, { schemaVersion: 1, archiveId: "archive:zip-1", reads: [{ sequence: 0, kind: "CENTRAL_DIRECTORY", byteOffset: 3000, byteLength: 512 }] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => metadata.validateArchiveReadTrace(zip, { schemaVersion: 1, archiveId: "archive:zip-1", reads: [
|
||||
{ sequence: 0, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 },
|
||||
{ sequence: 1, kind: "CENTRAL_DIRECTORY", byteOffset: 3072, byteLength: 512 },
|
||||
] }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => metadata.planArchiveMetadataRead({ ...zip, metadataOffset: 4000, metadataByteLength: 200 }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
assert.throws(() => metadata.planArchiveMetadataRead({ ...zip, metadataByteLength: 64 * 1024 * 1024 + 1 }), { code: "IO_ARCHIVE_UNSAFE" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
56
web/tests/unit/library-negative-cases.test.mjs
Normal file
56
web/tests/unit/library-negative-cases.test.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-negative-cases-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-negative-cases.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-negative-cases.mjs"), transpiled.outputText);
|
||||
const negatives = await import(pathToFileURL(path.join(temporary, "library-negative-cases.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03M/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const a = "library:" + "a".repeat(64);
|
||||
const b = "library:" + "b".repeat(64);
|
||||
const c = "library:" + "c".repeat(64);
|
||||
const valid = {
|
||||
schemaVersion: 1,
|
||||
libraries: [{ libraryId: a, dependencyIds: [b] }, { libraryId: b, dependencyIds: [] }, { libraryId: c, dependencyIds: [] }],
|
||||
dataBlocks: [{ dataBlockId: "Object/A", sourceLibraryId: a }, { dataBlockId: "Object/B", sourceLibraryId: b }],
|
||||
crossReferences: [],
|
||||
reloads: [{ sourceLibraryId: a, generation: 1 }, { sourceLibraryId: a, generation: 2 }],
|
||||
};
|
||||
|
||||
test("M12-03M binds the library negative-case protocol and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03M");
|
||||
assert.equal(manifest.parentTask, "M12-03L");
|
||||
assert.equal(manifest.nextTask, "M12-03N");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-03M accepts an acyclic graph with unique data-block and reload identities", () => {
|
||||
assert.deepEqual(negatives.validateLibraryNegativeInput(valid), { status: "VALID" });
|
||||
});
|
||||
|
||||
test("M12-03M rejects dependency and cross-library cycles", () => {
|
||||
assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, libraries: [{ libraryId: a, dependencyIds: [b] }, { libraryId: b, dependencyIds: [a] }, { libraryId: c, dependencyIds: [] }] }), { code: "LIBRARY_DEPENDENCY_CYCLE" });
|
||||
assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, crossReferences: [{ fromLibraryId: a, toLibraryId: b }, { fromLibraryId: b, toLibraryId: a }] }), { code: "LIBRARY_DEPENDENCY_CYCLE" });
|
||||
});
|
||||
|
||||
test("M12-03M rejects ID collision, duplicate reload and missing library references", () => {
|
||||
assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, dataBlocks: [{ dataBlockId: "Object/A", sourceLibraryId: a }, { dataBlockId: "Object/A", sourceLibraryId: b }] }), { code: "TASK_VALIDATION_FAILED" });
|
||||
assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, reloads: [{ sourceLibraryId: a, generation: 1 }, { sourceLibraryId: a, generation: 1 }] }), { code: "REVISION_CONFLICT" });
|
||||
assert.throws(() => negatives.validateLibraryNegativeInput({ ...valid, dataBlocks: [{ dataBlockId: "Object/A", sourceLibraryId: "library:" + "d".repeat(64) }] }), { code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
86
web/tests/unit/library-override-freshness.test.mjs
Normal file
86
web/tests/unit/library-override-freshness.test.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-override-freshness-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-override-freshness.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-override-freshness.mjs"), transpiled.outputText);
|
||||
const freshness = await import(pathToFileURL(path.join(temporary, "library-override-freshness.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03L/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const library = "library:" + "a".repeat(64);
|
||||
const digest = "b".repeat(64);
|
||||
const token = "override-token:" + "c".repeat(64);
|
||||
const state = (revision = 7) => ({
|
||||
schemaVersion: 1,
|
||||
sourceLibraryId: library,
|
||||
sourceGeneration: 4,
|
||||
sourceRevision: revision,
|
||||
dependencyClosureSha256: digest,
|
||||
invalidationToken: token,
|
||||
localDataBlockId: "Object/M12 Override Object",
|
||||
referenceSourceDataBlockId: "Object/M12 Override Object",
|
||||
hierarchyRootDataBlockId: "Object/M12 Override Object",
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
readOnly: false,
|
||||
referenceReadOnly: true,
|
||||
});
|
||||
const request = (overrides = {}) => ({
|
||||
schemaVersion: 1,
|
||||
operation: "COMMIT_OVERRIDE",
|
||||
sourceLibraryId: library,
|
||||
sourceGeneration: 4,
|
||||
sourceRevision: 7,
|
||||
dependencyClosureSha256: digest,
|
||||
invalidationToken: token,
|
||||
baseRevision: 7,
|
||||
localDataBlockId: "Object/M12 Override Object",
|
||||
referenceSourceDataBlockId: "Object/M12 Override Object",
|
||||
hierarchyRootDataBlockId: "Object/M12 Override Object",
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
readOnly: false,
|
||||
referenceReadOnly: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("M12-03L binds the override freshness gate and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03L");
|
||||
assert.equal(manifest.parentTask, "M12-03K");
|
||||
assert.equal(manifest.nextTask, "M12-03M");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-03L allows only a fully matching source generation/revision commit", () => {
|
||||
assert.deepEqual(freshness.gateOverrideFreshness(state(), request()), { status: "READY", code: null });
|
||||
});
|
||||
|
||||
test("M12-03L blocks stale generation, revision, closure, token, and identity before Main", () => {
|
||||
for (const overrides of [
|
||||
{ sourceGeneration: 3 },
|
||||
{ sourceRevision: 6, baseRevision: 6 },
|
||||
{ dependencyClosureSha256: "d".repeat(64) },
|
||||
{ invalidationToken: "override-token:" + "e".repeat(64) },
|
||||
]) assert.deepEqual(freshness.gateOverrideFreshness(state(), request(overrides)), { status: "BLOCKED", code: "REVISION_CONFLICT" });
|
||||
assert.deepEqual(freshness.gateOverrideFreshness(state(), request({ localDataBlockId: "Object/Other" })), { status: "BLOCKED", code: "ASSET_SOURCE_HASH_MISMATCH" });
|
||||
});
|
||||
|
||||
test("M12-03L rejects linked ownership, alternate operations, malformed tokens, and extra fields", () => {
|
||||
assert.equal(freshness.gateOverrideFreshness(state(), request({ owner: "SOURCE_LIBRARY", readOnly: true })).code, "LINKED_DATA_MUTATION_BLOCKED");
|
||||
assert.equal(freshness.gateOverrideFreshness(state(), request({ operation: "SET_LOCATION" })).code, "TASK_VALIDATION_FAILED");
|
||||
assert.equal(freshness.gateOverrideFreshness(state(), request({ invalidationToken: "bad" })).code, "TASK_VALIDATION_FAILED");
|
||||
assert.equal(freshness.gateOverrideFreshness(state(), { ...request(), future: true }).code, "TASK_VALIDATION_FAILED");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
82
web/tests/unit/library-override-writer.test.mjs
Normal file
82
web/tests/unit/library-override-writer.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-override-writer-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/library-override-writer.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "library-override-writer.mjs"), transpiled.outputText);
|
||||
const writer = await import(pathToFileURL(path.join(temporary, "library-override-writer.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03K/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const state = (revision = 3, value = 2.5) => ({
|
||||
schemaVersion: 1,
|
||||
revision,
|
||||
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,
|
||||
});
|
||||
const request = (baseRevision = 3, value = 4.5) => ({
|
||||
schemaVersion: 1,
|
||||
operation: "SET_M12_OVERRIDE_VALUE",
|
||||
baseRevision,
|
||||
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,
|
||||
});
|
||||
|
||||
test("M12-03K binds the single-property writer and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-03K");
|
||||
assert.equal(manifest.parentTask, "M12-03J");
|
||||
assert.equal(manifest.nextTask, "M12-03L");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-03K applies exactly the verified override property and advances one revision", () => {
|
||||
const result = writer.applyOverrideWriter(state(), request());
|
||||
assert.equal(result.status, "APPLIED");
|
||||
assert.equal(result.code, null);
|
||||
assert.equal(result.state.revision, 4);
|
||||
assert.equal(result.state.value, 4.5);
|
||||
assert.equal(result.state.propertyPath, writer.LIBRARY_OVERRIDE_PROPERTY_PATH);
|
||||
assert.equal(result.state.owner, "LOCAL_OVERRIDE");
|
||||
assert.equal(result.state.referenceReadOnly, true);
|
||||
});
|
||||
|
||||
test("M12-03K blocks stale, linked-owner, identity, and second-property writes", () => {
|
||||
assert.equal(writer.applyOverrideWriter(state(3), request(2)).code, "REVISION_CONFLICT");
|
||||
assert.equal(writer.applyOverrideWriter(state(), { ...request(), owner: "SOURCE_LIBRARY", readOnly: true, referenceReadOnly: true }).code, "LINKED_DATA_MUTATION_BLOCKED");
|
||||
assert.equal(writer.applyOverrideWriter(state(), { ...request(), localDataBlockId: "Object/Other" }).code, "ASSET_SOURCE_HASH_MISMATCH");
|
||||
assert.equal(writer.applyOverrideWriter(state(), { ...request(), propertyPath: "location" }).code, "EDITOR_WRITER_UNAVAILABLE");
|
||||
assert.equal(writer.applyOverrideWriter(state(), { ...request(), operation: "SET_LOCATION" }).code, "TASK_VALIDATION_FAILED");
|
||||
});
|
||||
|
||||
test("M12-03K rejects malformed values and undeclared fields before the writer", () => {
|
||||
assert.equal(writer.applyOverrideWriter(state(), { ...request(), future: true }).code, "TASK_VALIDATION_FAILED");
|
||||
assert.equal(writer.applyOverrideWriter(state(), { ...request(), value: Number.NaN }).code, "TASK_VALIDATION_FAILED");
|
||||
assert.throws(() => writer.parseOverrideWriterState({ ...state(), propertyPath: "location" }), { code: "EDITOR_WRITER_UNAVAILABLE" });
|
||||
assert.throws(() => writer.parseOverrideWriterRequest({ ...request(), value: 1e9 }), { code: "TASK_VALIDATION_FAILED" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
67
web/tests/unit/library-path-normalization.test.mjs
Normal file
67
web/tests/unit/library-path-normalization.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-path-normalization-unit-"));
|
||||
for (const name of ["asset-path.ts", "library-source-origin.ts"]) {
|
||||
const sourcePath = path.join(root, "web/protocol", name);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"'));
|
||||
}
|
||||
const paths = await import(pathToFileURL(path.join(temporary, "asset-path.mjs")));
|
||||
const origin = await import(pathToFileURL(path.join(temporary, "library-source-origin.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04B/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const policy = { schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test"] };
|
||||
|
||||
test("M12-04B binds the shared path normalizer and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04B");
|
||||
assert.equal(manifest.nextTask, "M12-04C");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04B canonicalizes POSIX and Windows separators with dot segments", () => {
|
||||
const aliases = [
|
||||
"libraries/characters/main.blend",
|
||||
"libraries\\characters\\.\\hero\\..\\main.blend",
|
||||
"libraries//characters/models/../main.blend",
|
||||
"//libraries/characters/./main.blend",
|
||||
];
|
||||
for (const alias of aliases) assert.equal(paths.normalizeProjectAssetPath(alias), "libraries/characters/main.blend");
|
||||
assert.equal(paths.normalizeProjectAssetPath(paths.normalizeProjectAssetPath(aliases[1])), "libraries/characters/main.blend");
|
||||
});
|
||||
|
||||
test("M12-04B decodes percent octets and NFC-normalizes Unicode names", () => {
|
||||
const canonical = "libraries/角色/caf\u00e9.blend";
|
||||
const aliases = [
|
||||
"libraries/%E8%A7%92%E8%89%B2/caf%65%CC%81.blend",
|
||||
"libraries%2F%E8%A7%92%E8%89%B2%5Ccafe%CC%81.blend",
|
||||
"libraries/角色/cafe\u0301.blend",
|
||||
];
|
||||
for (const alias of aliases) assert.equal(paths.normalizeProjectAssetPath(alias), canonical);
|
||||
assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "PROJECT_ASSET", path: aliases[1] }), {
|
||||
status: "READY",
|
||||
kind: "PROJECT_ASSET",
|
||||
canonicalLocator: `project-assets/${canonical}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("M12-04B resolves encoded dot segments once and fails closed on escape or re-decoding", () => {
|
||||
assert.equal(paths.normalizeProjectAssetPath("libraries/temp/%2E%2E/main.blend"), "libraries/main.blend");
|
||||
assert.throws(() => paths.normalizeProjectAssetPath("libraries/%2E%2E/%2E%2E/outside.blend"), /ASSET_PATH_OUTSIDE_PROJECT/);
|
||||
assert.throws(() => paths.normalizeProjectAssetPath("libraries/%252E%252E/outside.blend"), /ASSET_PATH_OUTSIDE_PROJECT/);
|
||||
assert.throws(() => paths.normalizeProjectAssetPath("libraries/%GG/outside.blend"), /ASSET_PATH_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
84
web/tests/unit/library-path-security.test.mjs
Normal file
84
web/tests/unit/library-path-security.test.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-path-security-unit-"));
|
||||
for (const name of ["asset-path.ts", "library-source-origin.ts"]) {
|
||||
const sourcePath = path.join(root, "web/protocol", name);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"'));
|
||||
}
|
||||
const paths = await import(pathToFileURL(path.join(temporary, "asset-path.mjs")));
|
||||
const origin = await import(pathToFileURL(path.join(temporary, "library-source-origin.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04C/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const policy = { schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test"] };
|
||||
const source = (pathValue) => ({ schemaVersion: 1, kind: "PROJECT_ASSET", path: pathValue });
|
||||
const remote = (url) => ({ schemaVersion: 1, kind: "HTTPS_ORIGIN", url });
|
||||
|
||||
test("M12-04C binds the path security gate and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04C");
|
||||
assert.equal(manifest.parentTask, "M12-04B");
|
||||
assert.equal(manifest.nextTask, "M12-04D");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04C rejects absolute, UNC, drive, NUL, control, and origin-style project paths", () => {
|
||||
for (const value of [
|
||||
"/tmp/library.blend",
|
||||
"\\\\server\\share\\library.blend",
|
||||
"\\server\\library.blend",
|
||||
"C:\\libraries\\main.blend",
|
||||
"C:/libraries/main.blend",
|
||||
"libraries/\u0000main.blend",
|
||||
"libraries/\u0001main.blend",
|
||||
"libraries/%00main.blend",
|
||||
"//https://evil.example.test/library.blend",
|
||||
]) assert.throws(() => paths.normalizeProjectAssetPath(value), /ASSET_PATH_OUTSIDE_PROJECT|ASSET_PATH_INVALID/);
|
||||
for (const value of ["/tmp/library.blend", "\\\\server\\share\\library.blend", "C:/libraries/main.blend", "libraries/\u0000main.blend", "libraries/%00main.blend"]) {
|
||||
assert.throws(() => origin.acceptLibrarySource(policy, source(value)), { code: "IO_EXTERNAL_URI_BLOCKED" });
|
||||
}
|
||||
});
|
||||
|
||||
test("M12-04C rejects raw and encoded unsafe HTTPS URI characters before admission", () => {
|
||||
for (const value of [
|
||||
"https://assets.example.test\\evil.example.test/main.blend",
|
||||
"https://assets.example.test/\nmain.blend",
|
||||
"https://assets.example.test/%00main.blend",
|
||||
"https://assets.example.test/%01main.blend",
|
||||
"https://assets.example.test/%GGmain.blend",
|
||||
"https://user:pass@evil.example.test/main.blend",
|
||||
"https://evil.example.test/main.blend",
|
||||
]) assert.throws(() => origin.acceptLibrarySource(policy, remote(value)), { code: "IO_EXTERNAL_URI_BLOCKED" });
|
||||
assert.deepEqual(origin.acceptLibrarySource(policy, remote("https://assets.example.test/library/main.blend")), {
|
||||
status: "READY",
|
||||
kind: "HTTPS_ORIGIN",
|
||||
canonicalLocator: "https://assets.example.test/library/main.blend",
|
||||
});
|
||||
});
|
||||
|
||||
test("M12-04C fails closed on policy origin smuggling and duplicate declarations", () => {
|
||||
for (const value of [
|
||||
"https://assets.example.test\\evil.example.test",
|
||||
"https://assets.example.test/%00",
|
||||
"https://assets.example.test/%2e",
|
||||
"https://assets.example.test/..",
|
||||
"https://assets.example.test/library",
|
||||
"https://assets.example.test/?scope=library",
|
||||
"https://assets.example.test/#library",
|
||||
]) assert.throws(() => origin.parseLibrarySourcePolicy({ schemaVersion: 1, declaredHttpsOrigins: [value] }), { code: "IO_EXTERNAL_URI_BLOCKED" });
|
||||
assert.throws(() => origin.parseLibrarySourcePolicy({ schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test", "https://assets.example.test/"] }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
54
web/tests/unit/library-source-origin.test.mjs
Normal file
54
web/tests/unit/library-source-origin.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-source-origin-unit-"));
|
||||
const paths = ["asset-path.ts", "library-source-origin.ts"];
|
||||
for (const name of paths) {
|
||||
const sourcePath = path.join(root, "web/protocol", name);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, name.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./asset-path"', 'from "./asset-path.mjs"'));
|
||||
}
|
||||
const origin = await import(pathToFileURL(path.join(temporary, "library-source-origin.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04A/manifest.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const policy = { schemaVersion: 1, declaredHttpsOrigins: ["https://assets.example.test"] };
|
||||
const digest = "a".repeat(64);
|
||||
|
||||
test("M12-04A binds the declared source-origin protocol and evidence artifacts", () => {
|
||||
assert.equal(manifest.task, "M12-04A");
|
||||
assert.equal(manifest.nextTask, "M12-04B");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04A accepts declared HTTPS, project asset, and user-selected file sources", () => {
|
||||
assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "HTTPS_ORIGIN", url: "https://assets.example.test/library/main.blend" }), { status: "READY", kind: "HTTPS_ORIGIN", canonicalLocator: "https://assets.example.test/library/main.blend" });
|
||||
assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "PROJECT_ASSET", path: "libraries/main.blend" }), { status: "READY", kind: "PROJECT_ASSET", canonicalLocator: "project-assets/libraries/main.blend" });
|
||||
assert.deepEqual(origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "USER_SELECTED_FILE", selectionId: "file-selection:pick-1", fileName: "main.blend", byteLength: 128, sourceSha256: digest }), { status: "READY", kind: "USER_SELECTED_FILE", canonicalLocator: "user-file/file-selection:pick-1/main.blend" });
|
||||
});
|
||||
|
||||
test("M12-04A rejects undeclared origins, credentials, unsafe paths, and missing policy declarations", () => {
|
||||
assert.throws(() => origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "HTTPS_ORIGIN", url: "https://other.example.test/main.blend" }), { code: "IO_EXTERNAL_URI_BLOCKED" });
|
||||
assert.throws(() => origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "HTTPS_ORIGIN", url: "https://user:pass@assets.example.test/main.blend" }), { code: "IO_EXTERNAL_URI_BLOCKED" });
|
||||
assert.throws(() => origin.acceptLibrarySource(policy, { schemaVersion: 1, kind: "PROJECT_ASSET", path: "../outside.blend" }), { code: "IO_EXTERNAL_URI_BLOCKED" });
|
||||
assert.throws(() => origin.parseLibrarySourcePolicy({ schemaVersion: 1, declaredHttpsOrigins: [] }), { code: "PROTOCOL_MISMATCH" });
|
||||
});
|
||||
|
||||
test("M12-04A rejects malformed user-file identity and undeclared fields", () => {
|
||||
assert.throws(() => origin.parseLibrarySourceRequest({ schemaVersion: 1, kind: "USER_SELECTED_FILE", selectionId: "bad", fileName: "main.blend", byteLength: 1, sourceSha256: digest }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
assert.throws(() => origin.parseLibrarySourceRequest({ schemaVersion: 1, kind: "USER_SELECTED_FILE", selectionId: "file-selection:pick-1", fileName: "../main.blend", byteLength: 1, sourceSha256: digest }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
assert.throws(() => origin.parseLibrarySourceRequest({ schemaVersion: 1, kind: "PROJECT_ASSET", path: "main.blend", future: true }), { code: "ASSET_MANIFEST_INVALID" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
51
web/tests/unit/obj-import.test.mjs
Normal file
51
web/tests/unit/obj-import.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "obj-import-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/obj-import.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "obj-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
|
||||
const obj = new TextEncoder().encode(`# test\nmtllib materials.mtl\no Test\nv 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\ng TestGroup\nusemtl TestMaterial\nf -3/-3/-1 -2/-2/-1 -1/-1/-1\n`).buffer;
|
||||
const mtl = new TextEncoder().encode("newmtl TestMaterial\nmap_Kd texture.png\n").buffer;
|
||||
|
||||
test("M12-07C resolves negative indices and serializes deterministic OBJ", () => {
|
||||
const imported = protocol.importOBJ(obj, mtl);
|
||||
assert.deepEqual(imported.faces[0].vertices.map((vertex) => vertex.position), [1, 2, 3]);
|
||||
assert.deepEqual(imported.faces[0].vertices.map((vertex) => vertex.texcoord), [1, 2, 3]);
|
||||
assert.deepEqual(imported.materials, [{ name: "TestMaterial", mapKd: "texture.png" }]);
|
||||
const serialized = protocol.serializeOBJ(imported);
|
||||
assert.match(serialized.obj, /f 1\/1\/1 2\/2\/1 3\/3\/1/);
|
||||
assert.equal(serialized.mtl, "# Web Blender MTL export\n# schema 1\nnewmtl TestMaterial\nmap_Kd texture.png\n");
|
||||
});
|
||||
|
||||
test("M12-07C reports unresolved texture origin without blocking geometry", () => {
|
||||
const imported = protocol.importOBJ(obj, mtl);
|
||||
const missing = protocol.createOBJLossReport(imported);
|
||||
assert.equal(missing.canRoundTrip, true);
|
||||
assert.deepEqual(missing.warnings.map((warning) => warning.code), ["OBJ_TEXTURE_ORIGIN_UNRESOLVED"]);
|
||||
const bound = protocol.createOBJLossReport(imported, ["texture.png"]);
|
||||
assert.deepEqual(bound.warnings, []);
|
||||
});
|
||||
|
||||
test("M12-07C rejects malformed face arity and out-of-range indices", () => {
|
||||
const malformed = new TextEncoder().encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2\n").buffer;
|
||||
assert.throws(() => protocol.importOBJ(malformed), /OBJ_FACE_ARITY_INVALID/);
|
||||
const outOfRange = new TextEncoder().encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 4\n").buffer;
|
||||
assert.throws(() => protocol.importOBJ(outOfRange), /OBJ_INDEX_OUT_OF_RANGE/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
51
web/tests/unit/ply-import.test.mjs
Normal file
51
web/tests/unit/ply-import.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "ply-import-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/ply-import.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "ply-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_mapping_v1");
|
||||
const bytes = (name) => { const value = fs.readFileSync(path.join(fixtureRoot, name)); return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); };
|
||||
|
||||
test("M12-07H maps ASCII and binary little-endian PLY fields", () => {
|
||||
const ascii = protocol.importPLY(bytes("mapping-ascii.ply"), { format: "ascii" });
|
||||
const binary = protocol.importPLY(bytes("mapping-binary-le.ply"), { format: "binary_little_endian" });
|
||||
assert.equal(ascii.vertices.length, 4);
|
||||
assert.equal(ascii.faces.length, 2);
|
||||
assert.deepEqual(ascii.vertices[0].position, [-1, 0, 1]);
|
||||
assert.deepEqual(ascii.vertices[0].normal, [0, 1, 0]);
|
||||
assert.deepEqual(ascii.vertices[0].color, [254 / 255, 0, 0, 1]);
|
||||
assert.deepEqual(ascii.vertices[3].customProperties, { label: 4, temperature: 40 });
|
||||
assert.deepEqual(binary.vertices, ascii.vertices);
|
||||
assert.deepEqual(binary.faces, ascii.faces);
|
||||
assert.deepEqual(protocol.createPLYLossReport(ascii), { schemaVersion: 1, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: 0, warnings: [] });
|
||||
});
|
||||
|
||||
test("M12-07H reports unknown list properties without dropping mapped fields", () => {
|
||||
const imported = protocol.importPLY(bytes("unknown-property-ascii.ply"), { format: "ascii" });
|
||||
assert.equal(imported.vertices.length, 4);
|
||||
assert.equal(imported.vertices[0].customProperties.temperature, 10);
|
||||
assert.deepEqual(protocol.createPLYLossReport(imported).warnings.map((warning) => warning.code), ["PLY_UNKNOWN_PROPERTY"]);
|
||||
assert.equal(protocol.createPLYLossReport(imported).warnings[0].property, "unknown_values");
|
||||
});
|
||||
|
||||
test("M12-07H serializes mapped data and rejects an explicit format mismatch", () => {
|
||||
const document = protocol.importPLY(bytes("mapping-ascii.ply"));
|
||||
const output = protocol.serializePLYAscii(document);
|
||||
const reopened = protocol.importPLY(output, { format: "ascii" });
|
||||
assert.deepEqual(reopened.faces, document.faces);
|
||||
assert.deepEqual(reopened.vertices.map((vertex) => vertex.customProperties), document.vertices.map((vertex) => vertex.customProperties));
|
||||
assert.throws(() => protocol.importPLY(bytes("mapping-ascii.ply"), { format: "binary_little_endian" }), /PLY_FORMAT_MISMATCH/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
29
web/tests/unit/ply-negative.test.mjs
Normal file
29
web/tests/unit/ply-negative.test.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "ply-negative-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/ply-import.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "ply-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_negative_v1");
|
||||
const bytes = (name) => { const value = fs.readFileSync(path.join(fixtureRoot, name)); return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); };
|
||||
|
||||
test("M12-07I blocks big-endian PLY with a stable format code", () => {
|
||||
assert.throws(() => protocol.importPLY(bytes("big-endian.ply")), /PLY_FORMAT_UNSUPPORTED/);
|
||||
});
|
||||
|
||||
test("M12-07I blocks malformed lists and oversized element counts", () => {
|
||||
assert.throws(() => protocol.importPLY(bytes("malformed-list-ascii.ply")), /PLY_DATA_TRUNCATED/);
|
||||
assert.throws(() => protocol.importPLY(bytes("oversized-count-ascii.ply")), /PLY_IMPORT_BUDGET_EXCEEDED: vertex/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
21
web/tests/unit/pointer-contract.test.mjs
Normal file
21
web/tests/unit/pointer-contract.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "../../node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/pointer-contract.ts");
|
||||
const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const pointer = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
|
||||
test("M14-04C preserves pointer identity and bounded pen fields", () => {
|
||||
assert.deepEqual(pointer.observePointerEvent({ type: "pointerdown", pointerType: "pen", pointerId: 7, pressure: 1.4, tiltX: 120, tiltY: -100, button: 0, buttons: 1 }), { schemaVersion: 1, pointerType: "pen", pointerId: 7, pressure: 1, tiltX: 90, tiltY: -90, button: 0, buttons: 1, cancelled: false });
|
||||
assert.equal(pointer.observePointerEvent({ type: "pointercancel", pointerType: "touch", pointerId: 2, pressure: 0.4, button: 0, buttons: 0 }).cancelled, true);
|
||||
});
|
||||
|
||||
test("M14-04C rejects unknown pointer and invalid id", () => {
|
||||
assert.throws(() => pointer.observePointerEvent({ pointerType: "trackpad", pointerId: 1 }), /POINTER_TYPE_UNSUPPORTED/);
|
||||
assert.throws(() => pointer.observePointerEvent({ pointerType: "mouse", pointerId: -1 }), /POINTER_ID_INVALID/);
|
||||
});
|
||||
46
web/tests/unit/script-audit-integrity.test.mjs
Normal file
46
web/tests/unit/script-audit-integrity.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-audit-integrity-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const manifest = { schemaVersion: 1, scripts: [{ id: "script:audit", name: "Audit", entryPath: "scripts/audit.py", sourceByteLength: 32, sourceSha256: digest, publisher: "local", signature: "b".repeat(128), keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] };
|
||||
|
||||
test("M13-05H accepts a strictly ordered, chained audit log", async () => {
|
||||
const first = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:first", requestedAt: "2026-08-19T00:00:00.000Z" });
|
||||
const second = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:second", requestedAt: "2026-08-19T00:00:01.000Z" });
|
||||
const log = await protocol.appendScriptExecutionAudit(await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, first), second);
|
||||
assert.deepEqual(log.entries.map((entry) => entry.sequence), [1, 2]);
|
||||
assert.deepEqual(log.entries.map((entry) => entry.audit.requestId), ["audit:first", "audit:second"]);
|
||||
assert.equal(log.entries[0].previousEntrySha256, null);
|
||||
assert.equal(log.entries[1].previousEntrySha256, log.entries[0].entrySha256);
|
||||
assert.deepEqual((await protocol.parseScriptExecutionAuditLog(log)).entries, log.entries);
|
||||
});
|
||||
|
||||
test("M13-05H rejects replay, time, sequence and hash-chain drift", async () => {
|
||||
const first = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:one", requestedAt: "2026-08-19T00:00:00.000Z" });
|
||||
const second = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:two", requestedAt: "2026-08-19T00:00:01.000Z" });
|
||||
const log = await protocol.appendScriptExecutionAudit(await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, first), second);
|
||||
await assert.rejects(protocol.appendScriptExecutionAudit(log, first), /SCRIPT_MANIFEST_INVALID/);
|
||||
const earlier = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:earlier", requestedAt: "2026-08-18T23:59:59.000Z" });
|
||||
await assert.rejects(protocol.appendScriptExecutionAudit(log, earlier), /SCRIPT_MANIFEST_INVALID/);
|
||||
await assert.rejects(protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], sequence: 2 }, log.entries[1]] }), /SCRIPT_MANIFEST_INVALID/);
|
||||
await assert.rejects(protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], entrySha256: "c".repeat(64) }, log.entries[1]] }), /SCRIPT_MANIFEST_INVALID/);
|
||||
await assert.rejects(protocol.parseScriptExecutionAuditLog({ ...log, entries: [log.entries[0], { ...log.entries[1], previousEntrySha256: "d".repeat(64) }] }), /SCRIPT_MANIFEST_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
49
web/tests/unit/script-host-call.test.mjs
Normal file
49
web/tests/unit/script-host-call.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-host-call-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const permissions = new Set(protocol.SCRIPT_PERMISSIONS);
|
||||
const call = (name, parameters) => ({ schemaVersion: 1, requestId: `host:${name.toLowerCase()}`, scriptId: "clean", call: name, permission: name, parameters });
|
||||
|
||||
test("M13-03C parses all allowlisted host calls with structured parameters", () => {
|
||||
const inputs = [
|
||||
call("READ_MAIN", { revision: 3 }),
|
||||
call("READ_ASSET", { path: "//assets/model.bin", expectedSha256: digest }),
|
||||
call("WRITE_MAIN", { revision: 3, operation: "object.transform", payload: { objectId: "obj:1", x: 1 } }),
|
||||
call("WRITE_ASSET", { path: "assets/out.bin", byteLength: 4, sha256: digest }),
|
||||
call("SUBMIT_SERVER_JOB", { inputBlendSha256: digest, settingsSha256: digest }),
|
||||
];
|
||||
const parsed = inputs.map((input) => protocol.parseScriptHostCall(input, permissions));
|
||||
assert.deepEqual(parsed.map((item) => item.call), ["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"]);
|
||||
assert.equal(parsed[1].parameters.path, "assets/model.bin");
|
||||
assert.equal(parsed[2].execution, "DISABLED");
|
||||
});
|
||||
|
||||
test("M13-03C rejects non-allowlisted calls, permission confusion and unknown fields", () => {
|
||||
assert.throws(() => protocol.parseScriptHostCall(call("EXECUTE", {}), permissions), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptHostCall({ ...call("READ_MAIN", { revision: 3 }), permission: "WRITE_MAIN" }, permissions), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptHostCall(call("READ_MAIN", { revision: 3, extra: true }), permissions), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptHostCall(call("READ_ASSET", { path: "../escape", expectedSha256: digest }), permissions), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptHostCall(call("WRITE_MAIN", { revision: 3, operation: "x", payload: [] }), permissions), /SCRIPT_MANIFEST_INVALID/);
|
||||
});
|
||||
|
||||
test("M13-03C requires the declared permission set", () => {
|
||||
assert.throws(() => protocol.parseScriptHostCall(call("WRITE_MAIN", { revision: 0, operation: "x", payload: {} }), new Set(["READ_MAIN"])), /SCRIPT_POLICY_DENIED/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
85
web/tests/unit/script-manifest-budgets.test.mjs
Normal file
85
web/tests/unit/script-manifest-budgets.test.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-manifest-budgets-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: `${name}.ts`,
|
||||
}).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const signature = "b".repeat(128);
|
||||
const script = (id, overrides = {}) => ({
|
||||
id,
|
||||
name: id,
|
||||
entryPath: `scripts/${id}.py`,
|
||||
sourceByteLength: 128,
|
||||
sourceSha256: digest,
|
||||
publisher: "local",
|
||||
signature,
|
||||
keyId: "key:local",
|
||||
permissions: ["READ_MAIN"],
|
||||
dependencies: [],
|
||||
module: false,
|
||||
cpuMs: 1000,
|
||||
memoryBytes: 1024 * 1024,
|
||||
wallMs: 5000,
|
||||
network: false,
|
||||
autorun: false,
|
||||
driverExpressions: false,
|
||||
addonInstall: false,
|
||||
...overrides,
|
||||
});
|
||||
const manifest = (scripts = [script("clean")]) => ({ schemaVersion: 1, scripts });
|
||||
|
||||
test("M13-02A accepts bounded manifest fields and normalizes project paths", () => {
|
||||
const parsed = protocol.parseScriptingManifest(manifest([
|
||||
script("base", { entryPath: "//scripts/../scripts/base.py" }),
|
||||
script("clean", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//deps/base.py" }] }),
|
||||
]));
|
||||
assert.equal(parsed.scripts.length, 2);
|
||||
assert.equal(parsed.scripts[0].entryPath, "scripts/base.py");
|
||||
assert.equal(parsed.scripts[1].dependencies[0].sourcePath, "deps/base.py");
|
||||
assert.equal(parsed.scripts.reduce((total, item) => total + item.sourceByteLength, 0), 256);
|
||||
assert.equal(parsed.scripts.every((item) => item.module === false), true);
|
||||
});
|
||||
|
||||
test("M13-02A rejects text count and aggregate source byte budget overflow", () => {
|
||||
assert.throws(
|
||||
() => protocol.parseScriptingManifest(manifest(Array.from({ length: protocol.SCRIPTING_BUDGET.maxScripts + 1 }, (_, index) => script(`script-${index}`)))),
|
||||
/SCRIPT_BUDGET_EXCEEDED/,
|
||||
);
|
||||
assert.throws(
|
||||
() => protocol.parseScriptingManifest(manifest([script("large", { sourceByteLength: protocol.SCRIPTING_BUDGET.maxSourceBytes }), script("overflow", { sourceByteLength: 1 })])),
|
||||
/SCRIPT_BUDGET_EXCEEDED/,
|
||||
);
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("missing-bytes", { sourceByteLength: undefined })])), /SCRIPT_BUDGET_EXCEEDED/);
|
||||
});
|
||||
|
||||
test("M13-02A rejects module execution, unsafe paths, and malformed dependencies", () => {
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("module", { module: true })])), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("escape", { entryPath: "../escape.py" })])), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("dependency-escape", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//../escape.py" }] }), script("base")])), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("duplicate-dependency", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "deps/a.py" }, { id: "base", sourceSha256: digest, sourcePath: "deps/b.py" }] }), script("base")])), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("too-many-dependencies", { dependencies: Array.from({ length: protocol.SCRIPTING_BUDGET.maxDependencies + 1 }, (_, index) => ({ id: `dep-${index}`, sourceSha256: digest, sourcePath: `deps/${index}.py` })) })])), /SCRIPT_BUDGET_EXCEEDED/);
|
||||
});
|
||||
|
||||
test("M13-02A rejects unknown or over-budget permissions", () => {
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("unknown-permission", { permissions: ["EXECUTE"] })])), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest([script("permission-budget", { permissions: new Array(protocol.SCRIPTING_BUDGET.maxPermissions + 1).fill("READ_MAIN") })])), /SCRIPT_POLICY_DENIED/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
80
web/tests/unit/script-manifest-canonical.test.mjs
Normal file
80
web/tests/unit/script-manifest-canonical.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-manifest-canonical-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: `${name}.ts`,
|
||||
}).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const signature = "b".repeat(128);
|
||||
const script = (id, overrides = {}) => ({
|
||||
id,
|
||||
name: id,
|
||||
entryPath: `scripts/${id}.py`,
|
||||
sourceByteLength: 128,
|
||||
sourceSha256: digest,
|
||||
publisher: "local",
|
||||
signature,
|
||||
keyId: "key:local",
|
||||
permissions: ["READ_MAIN"],
|
||||
dependencies: [],
|
||||
module: false,
|
||||
cpuMs: 1000,
|
||||
memoryBytes: 1024 * 1024,
|
||||
wallMs: 5000,
|
||||
network: false,
|
||||
autorun: false,
|
||||
driverExpressions: false,
|
||||
addonInstall: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("M13-02B canonical serialization is invariant to manifest/script array order", () => {
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
scripts: [
|
||||
script("zeta", { permissions: ["WRITE_ASSET", "READ_MAIN"], dependencies: [{ id: "alpha", sourceSha256: digest, sourcePath: "deps/alpha.py" }, { id: "beta", sourceSha256: digest, sourcePath: "deps/beta.py" }] }),
|
||||
script("alpha", { permissions: ["SUBMIT_SERVER_JOB", "READ_ASSET"] }),
|
||||
script("beta"),
|
||||
],
|
||||
};
|
||||
const reordered = {
|
||||
schemaVersion: 1,
|
||||
scripts: [
|
||||
{ ...base.scripts[1], permissions: [...base.scripts[1].permissions].reverse(), ignored: "removed" },
|
||||
{ ...base.scripts[0], permissions: [...base.scripts[0].permissions].reverse(), dependencies: [...base.scripts[0].dependencies].reverse() },
|
||||
base.scripts[2],
|
||||
],
|
||||
};
|
||||
const first = protocol.serializeScriptingManifest(base);
|
||||
const second = protocol.serializeScriptingManifest(reordered);
|
||||
assert.equal(first, second);
|
||||
assert.match(first, /^\{"schemaVersion":1,"scripts":\[/);
|
||||
assert.equal(Object.keys(protocol.canonicalizeScriptingManifest(reordered).scripts[0]).includes("ignored"), false);
|
||||
});
|
||||
|
||||
test("M13-02B canonical serialization includes security-relevant declaration fields", () => {
|
||||
const base = { schemaVersion: 1, scripts: [script("clean")] };
|
||||
const changedBytes = { schemaVersion: 1, scripts: [script("clean", { sourceByteLength: 129 })] };
|
||||
const changedPermission = { schemaVersion: 1, scripts: [script("clean", { permissions: ["READ_ASSET"] })] };
|
||||
assert.notEqual(protocol.serializeScriptingManifest(base), protocol.serializeScriptingManifest(changedBytes));
|
||||
assert.notEqual(protocol.serializeScriptingManifest(base), protocol.serializeScriptingManifest(changedPermission));
|
||||
assert.throws(() => protocol.serializeScriptingManifest({ ...base, schemaVersion: 2 }), /PROTOCOL_MISMATCH/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
36
web/tests/unit/script-permission-policy.test.mjs
Normal file
36
web/tests/unit/script-permission-policy.test.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-permission-policy-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const script = (permissions = ["READ_MAIN"]) => ({ id: "clean", name: "clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature: "b".repeat(128), keyId: "key:new", permissions, dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false });
|
||||
const manifest = (permissions) => ({ schemaVersion: 1, scripts: [script(permissions)] });
|
||||
|
||||
test("M13-02E grants no undeclared permission by default", () => {
|
||||
assert.deepEqual(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean"), { status: "ALLOWED", code: "SCRIPT_PERMISSIONS_ALLOWED", scriptId: "clean", declared: ["READ_MAIN"], requested: [], granted: [] });
|
||||
assert.deepEqual(protocol.resolveScriptPermissions(manifest(["WRITE_ASSET", "READ_MAIN"]), "clean", ["READ_MAIN"]), { status: "ALLOWED", code: "SCRIPT_PERMISSIONS_ALLOWED", scriptId: "clean", declared: ["READ_MAIN", "WRITE_ASSET"], requested: ["READ_MAIN"], granted: ["READ_MAIN"] });
|
||||
});
|
||||
|
||||
test("M13-02E blocks escalation, unknown and duplicate permission requests", () => {
|
||||
assert.equal(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["WRITE_MAIN"]).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["EXECUTE"]).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["READ_MAIN", "READ_MAIN"]).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.throws(() => protocol.parseScriptingManifest(manifest(["EXECUTE"])), /SCRIPT_POLICY_DENIED/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
30
web/tests/unit/script-policy-codes.test.mjs
Normal file
30
web/tests/unit/script-policy-codes.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-policy-codes-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const base = { schemaVersion: 1, scripts: [{ id: "demo", name: "Demo", entryPath: "scripts/demo.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "local", signature: "b".repeat(128), keyId: "key", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 64 * 1024 * 1024, wallMs: 2000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] };
|
||||
|
||||
test("M13-01C returns stable default-deny codes for autorun, driver and add-on execution", () => {
|
||||
assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], autorun: true }] }), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], driverExpressions: true }] }), /DRIVER_EXECUTION_BLOCKED/);
|
||||
assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], addonInstall: true }] }), /ADDON_INSTALL_BLOCKED/);
|
||||
const gate = protocol.gateScriptExecution(base, "demo", new Set(["key"]));
|
||||
assert.equal(gate.status, "BLOCKED");
|
||||
assert.equal(gate.issues[0].code, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
30
web/tests/unit/script-sandbox-budget.test.mjs
Normal file
30
web/tests/unit/script-sandbox-budget.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-budget-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const budget = { schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1024 * 1024, maxMessageBytes: 4096, maxOutputBytes: 8192 };
|
||||
|
||||
test("M13-03B accepts bounded CPU, wall, memory, message and output budgets", () => {
|
||||
assert.deepEqual(protocol.parseScriptSandboxBudget(budget), budget);
|
||||
});
|
||||
|
||||
test("M13-03B rejects every budget overflow and schema drift", () => {
|
||||
for (const [field, limit] of Object.entries({ cpuMs: protocol.SCRIPT_SANDBOX_BUDGET.maxCpuMs, wallMs: protocol.SCRIPT_SANDBOX_BUDGET.maxWallMs, memoryBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMemoryBytes, maxMessageBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMessageBytes, maxOutputBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxOutputBytes })) assert.throws(() => protocol.parseScriptSandboxBudget({ ...budget, [field]: limit + 1 }), /SCRIPT_BUDGET_EXCEEDED/);
|
||||
assert.throws(() => protocol.parseScriptSandboxBudget({ ...budget, schemaVersion: 2 }), /PROTOCOL_MISMATCH/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
32
web/tests/unit/script-sandbox-cancellation.test.mjs
Normal file
32
web/tests/unit/script-sandbox-cancellation.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-cancellation-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const running = { schemaVersion: 1, jobId: "sandbox:cancel", workerGeneration: 5, baseRevision: 11, mainRevisionBefore: 11, status: "RUNNING" };
|
||||
|
||||
test("M13-03E cancellation receipt blocks late message and cache publication", () => {
|
||||
const cancelled = protocol.terminateScriptSandboxJob(running, "CANCEL");
|
||||
assert.deepEqual({ status: cancelled.status, errorCode: cancelled.errorCode, mainRevisionAfter: cancelled.mainRevisionAfter, temporaryBytes: cancelled.temporaryBytes, publishedResults: cancelled.publishedResults, lateResults: cancelled.lateResults, committed: cancelled.committed }, { status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", mainRevisionAfter: 11, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false });
|
||||
assert.throws(() => protocol.rejectLateScriptSandboxResult(cancelled), /SCRIPT_SANDBOX_LATE_RESULT/);
|
||||
});
|
||||
|
||||
test("M13-03E rejects a late result that is not tied to a terminated receipt", () => {
|
||||
assert.throws(() => protocol.rejectLateScriptSandboxResult({ ...running, status: "RUNNING" }), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, baseRevision: 10 }, "CANCEL"), /SCRIPT_MANIFEST_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
25
web/tests/unit/script-sandbox-dispose.test.mjs
Normal file
25
web/tests/unit/script-sandbox-dispose.test.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-dispose-"));
|
||||
const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-dispose.ts"), "utf8");
|
||||
fs.writeFileSync(path.join(temporary, "script-sandbox-dispose.cjs"), ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "script-sandbox-dispose.ts" }).outputText);
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-dispose.cjs"));
|
||||
|
||||
test("M13-03F emits zero-resource disposal receipts", () => {
|
||||
assert.deepEqual(protocol.createScriptSandboxDisposeReceipt(1), { schemaVersion: 1, disposeCount: 1, idempotent: false, resources: { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 }, lateTimerMessages: 0 });
|
||||
assert.deepEqual(protocol.createScriptSandboxDisposeReceipt(2), { schemaVersion: 1, disposeCount: 2, idempotent: true, resources: { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 }, lateTimerMessages: 0 });
|
||||
});
|
||||
|
||||
test("M13-03F rejects an invalid disposal count", () => {
|
||||
assert.throws(() => protocol.createScriptSandboxDisposeReceipt(0), /SCRIPT_SANDBOX_DISPOSE_INVALID/);
|
||||
assert.throws(() => protocol.createScriptSandboxDisposeReceipt(1.5), /SCRIPT_SANDBOX_DISPOSE_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
42
web/tests/unit/script-sandbox-isolation.test.mjs
Normal file
42
web/tests/unit/script-sandbox-isolation.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-isolation-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const running = { schemaVersion: 1, jobId: "sandbox:1", workerGeneration: 4, baseRevision: 9, mainRevisionBefore: 9, status: "RUNNING" };
|
||||
|
||||
test("M13-03D crash and timeout terminate jobs without changing Main revision", () => {
|
||||
const crashed = protocol.terminateScriptSandboxJob(running, "CRASH");
|
||||
const timedOut = protocol.terminateScriptSandboxJob(running, "TIMEOUT");
|
||||
assert.deepEqual({ status: crashed.status, errorCode: crashed.errorCode, mainRevisionAfter: crashed.mainRevisionAfter, temporaryBytes: crashed.temporaryBytes, publishedResults: crashed.publishedResults, committed: crashed.committed }, { status: "CRASHED", errorCode: "SCRIPT_SANDBOX_CRASHED", mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
|
||||
assert.deepEqual({ status: timedOut.status, errorCode: timedOut.errorCode, mainRevisionAfter: timedOut.mainRevisionAfter, temporaryBytes: timedOut.temporaryBytes, publishedResults: timedOut.publishedResults, committed: timedOut.committed }, { status: "TIMED_OUT", errorCode: "SCRIPT_SANDBOX_TIMEOUT", mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, committed: false });
|
||||
});
|
||||
|
||||
test("M13-03D cancellation and late results are fail-closed", () => {
|
||||
const cancelled = protocol.terminateScriptSandboxJob(running, "CANCEL");
|
||||
assert.equal(cancelled.errorCode, "SCRIPT_SANDBOX_CANCELLED");
|
||||
assert.throws(() => protocol.rejectLateScriptSandboxResult(cancelled), /SCRIPT_SANDBOX_LATE_RESULT/);
|
||||
assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, baseRevision: 8 }, "CRASH"), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, status: "CRASHED" }, "TIMEOUT"), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.rejectLateScriptSandboxResult(running), /SCRIPT_MANIFEST_INVALID/);
|
||||
});
|
||||
|
||||
test("M13-03D rejects malformed termination receipts", () => {
|
||||
assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, workerGeneration: 0 }, "CRASH"), /SCRIPT_BUDGET_EXCEEDED/);
|
||||
assert.throws(() => protocol.terminateScriptSandboxJob({ ...running, mainRevisionBefore: 10 }, "TIMEOUT"), /SCRIPT_MANIFEST_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
32
web/tests/unit/script-sandbox-recovery.test.mjs
Normal file
32
web/tests/unit/script-sandbox-recovery.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-recovery-"));
|
||||
const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-recovery.ts"), "utf8");
|
||||
fs.writeFileSync(path.join(temporary, "script-sandbox-recovery.cjs"), ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "script-sandbox-recovery.ts" }).outputText);
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-recovery.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const entry = (sequence, requestId, previousEntrySha256, entrySha256) => ({ sequence, requestId, previousEntrySha256, entrySha256, sourceSha256: digest, manifestSha256: "b".repeat(64) });
|
||||
|
||||
test("M13-03G accepts one-generation recovery with a continuous audit chain", () => {
|
||||
const receipt = protocol.createScriptSandboxRecoveryReceipt({ previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, sourceSha256: digest, manifestSha256: "b".repeat(64), audit: { entries: 2, first: entry(1, "sandbox-recovery:g4", null, digest), second: entry(2, "sandbox-recovery:g5", digest, "c".repeat(64)) } });
|
||||
assert.equal(receipt.recovered, true);
|
||||
assert.equal(receipt.execution, "DISABLED");
|
||||
assert.equal(receipt.audit.second.previousEntrySha256, receipt.audit.first.entrySha256);
|
||||
});
|
||||
|
||||
test("M13-03G rejects generation, revision, request and hash-chain drift", () => {
|
||||
const base = { previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, sourceSha256: digest, manifestSha256: "b".repeat(64), audit: { entries: 2, first: entry(1, "sandbox-recovery:g4", null, digest), second: entry(2, "sandbox-recovery:g5", digest, "c".repeat(64)) } };
|
||||
assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, nextGeneration: 7 }), /generation/);
|
||||
assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, mainRevisionAfter: 12 }), /revision/);
|
||||
assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, audit: { ...base.audit, second: entry(2, "sandbox-recovery:g5", "d".repeat(64), "c".repeat(64)) } }), /hash chain/);
|
||||
assert.throws(() => protocol.createScriptSandboxRecoveryReceipt({ ...base, audit: { ...base.audit, second: entry(2, "sandbox-recovery:g4", digest, "c".repeat(64)) } }), /replayed/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
34
web/tests/unit/script-sandbox-scope.test.mjs
Normal file
34
web/tests/unit/script-sandbox-scope.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-sandbox-scope-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const deniedScope = { schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false };
|
||||
|
||||
test("M13-03A accepts only the all-deny sandbox scope", () => {
|
||||
assert.deepEqual(protocol.parseScriptSandboxScope(deniedScope), deniedScope);
|
||||
for (const capability of ["dom", "hostWorker", "opfs", "indexedDB", "network"]) {
|
||||
assert.throws(() => protocol.parseScriptSandboxScope({ ...deniedScope, [capability]: true }), /SCRIPT_POLICY_DENIED/);
|
||||
}
|
||||
});
|
||||
|
||||
test("M13-03A rejects unknown scope versions and missing declarations", () => {
|
||||
assert.throws(() => protocol.parseScriptSandboxScope({ ...deniedScope, schemaVersion: 2 }), /PROTOCOL_MISMATCH/);
|
||||
assert.throws(() => protocol.parseScriptSandboxScope({ schemaVersion: 1 }), /SCRIPT_POLICY_DENIED/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
44
web/tests/unit/script-signature-negative.test.mjs
Normal file
44
web/tests/unit/script-signature-negative.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-signature-negative-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
|
||||
const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804";
|
||||
const script = (id = "clean", overrides = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const manifest = (scripts = [script()]) => ({ schemaVersion: 1, scripts });
|
||||
const key = (overrides = {}) => ({ keyId: "key:new", publisher: "Team", algorithm: "ED25519", publicKey, status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z", ...overrides });
|
||||
const policy = (overrides = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [key()], ...overrides });
|
||||
|
||||
test("M13-02F blocks missing, expired, not-yet-valid and publisher-confused signers", async () => {
|
||||
const at = "2026-08-18T12:00:00.000Z";
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [] }), at)).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ expiresAt: "2026-06-01T00:00:00.000Z" }), at)).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notBefore: "2026-09-01T00:00:00.000Z" })] }), at)).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notAfter: "2026-06-01T00:00:00.000Z" })] }), at)).code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ publisher: "Other" })] }), at)).code, "SCRIPT_POLICY_DENIED");
|
||||
});
|
||||
|
||||
test("M13-02F binds a signature to one script and does not accept reordered/swapped content", async () => {
|
||||
const at = "2026-08-18T12:00:00.000Z";
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest(), "clean", policy(), at)).status, "VERIFIED");
|
||||
const swapped = manifest([script("other")]);
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(swapped, "other", policy(), at)).code, "SCRIPT_SIGNATURE_INVALID");
|
||||
assert.equal((await protocol.verifyScriptManifestSignature(manifest([script("other"), script()]), "clean", policy(), at)).status, "VERIFIED");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
76
web/tests/unit/script-trust-policy.test.mjs
Normal file
76
web/tests/unit/script-trust-policy.test.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { createPrivateKey, createPublicKey, sign } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "script-trust-policy-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const signature = "b".repeat(128);
|
||||
const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "hex")]), format: "der", type: "pkcs8" });
|
||||
const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).subarray(-32).toString("hex");
|
||||
const script = (id = "clean", overrides = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: digest, publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const key = (keyId, overrides = {}) => ({ keyId, publisher: "Team", algorithm: "ED25519", publicKey: "c".repeat(64), status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z", ...overrides });
|
||||
const policy = (keys = [key("key:new")], overrides = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys, ...overrides });
|
||||
|
||||
test("M13-02C parses signer identity, active rotation and timestamp windows", () => {
|
||||
const parsed = protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "key:old" }), key("key:old", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]));
|
||||
assert.equal(parsed.keys.length, 2);
|
||||
assert.equal(protocol.canonicalizeScriptTrustPolicy(parsed).keys[0].keyId, "key:new");
|
||||
assert.match(protocol.serializeScriptTrustPolicy(parsed), /"algorithm":"ED25519"/);
|
||||
assert.deepEqual(protocol.resolveScriptSigner({ schemaVersion: 1, scripts: [script()] }, "clean", parsed, "2026-08-18T12:00:00.000Z"), { status: "ELIGIBLE", keyId: "key:new", publisher: "Team", trust: "ACTIVE", cryptographicVerification: "REQUIRED" });
|
||||
});
|
||||
|
||||
test("M13-02C rejects invalid rotation, revocation and timestamp policy declarations", () => {
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "missing" })])), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "key:old" }), key("key:old", { publisher: "Other" })])), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:a", { replaces: "key:b" }), key("key:b", { replaces: "key:a" })])), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { status: "REVOKED" })])), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { revokedAt: "2026-06-01T00:00:00.000Z" })])), /SCRIPT_POLICY_DENIED/);
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { notAfter: "2025-01-01T00:00:00.000Z" })])), /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.throws(() => protocol.parseScriptTrustPolicy(policy([key("key:new", { publicKey: "not-a-key" })])), /SCRIPT_SIGNATURE_INVALID/);
|
||||
});
|
||||
|
||||
test("M13-02C resolves revoked, publisher-mismatched and expired signers fail-closed", () => {
|
||||
const baseManifest = { schemaVersion: 1, scripts: [script()] };
|
||||
const revoked = policy([key("key:new", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]);
|
||||
assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", revoked, "2026-08-18T12:00:00.000Z").trust, "REVOKED");
|
||||
const mismatch = policy([key("key:new", { publisher: "Other" })]);
|
||||
assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", mismatch, "2026-08-18T12:00:00.000Z").trust, "PUBLISHER_MISMATCH");
|
||||
const expired = policy([key("key:new", { notAfter: "2026-06-01T00:00:00.000Z" })]);
|
||||
assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", expired, "2026-08-18T12:00:00.000Z").trust, "KEY_EXPIRED");
|
||||
const policyExpired = policy([key("key:new")], { expiresAt: "2026-06-01T00:00:00.000Z" });
|
||||
assert.equal(protocol.resolveScriptSigner(baseManifest, "clean", policyExpired, "2026-08-18T12:00:00.000Z").trust, "POLICY_EXPIRED");
|
||||
});
|
||||
|
||||
test("M13-02D verifies only the canonical declared content and source hash", async () => {
|
||||
const baseScript = script("clean", { signature: "0".repeat(128) });
|
||||
const unsigned = { schemaVersion: 1, scripts: [baseScript] };
|
||||
const signedScript = { ...baseScript, signature: sign(null, Buffer.from(protocol.serializeScriptSignatureInput(unsigned, "clean")), privateKey).toString("hex") };
|
||||
const signed = { schemaVersion: 1, scripts: [signedScript] };
|
||||
const trust = policy([key("key:new", { publicKey })]);
|
||||
const verified = await protocol.verifyScriptManifestSignature(signed, "clean", trust, "2026-08-18T12:00:00.000Z");
|
||||
assert.equal(verified.status, "VERIFIED");
|
||||
assert.equal(verified.code, "SCRIPT_SIGNATURE_VERIFIED");
|
||||
const changedSource = { schemaVersion: 1, scripts: [{ ...signedScript, sourceSha256: "d".repeat(64) }] };
|
||||
assert.deepEqual((await protocol.verifyScriptManifestSignature(changedSource, "clean", trust, "2026-08-18T12:00:00.000Z")).code, "SCRIPT_SIGNATURE_INVALID");
|
||||
const changedSignature = { schemaVersion: 1, scripts: [{ ...signedScript, signature: `${signedScript.signature.slice(0, -1)}${signedScript.signature.endsWith("0") ? "1" : "0"}` }] };
|
||||
assert.deepEqual((await protocol.verifyScriptManifestSignature(changedSignature, "clean", trust, "2026-08-18T12:00:00.000Z")).code, "SCRIPT_SIGNATURE_INVALID");
|
||||
const revoked = policy([key("key:new", { publicKey, status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]);
|
||||
assert.deepEqual((await protocol.verifyScriptManifestSignature(signed, "clean", revoked, "2026-08-18T12:00:00.000Z")).code, "SCRIPT_POLICY_DENIED");
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
24
web/tests/unit/server-job-fault.test.mjs
Normal file
24
web/tests/unit/server-job-fault.test.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { classifyServerJobFault, createServerJobFaultReceipt, SERVER_JOB_FAULT_CODES } from "../../../tools/web/server-job-fault.mjs";
|
||||
|
||||
test("M13-04H classifies faults with stable precedence", () => {
|
||||
assert.equal(classifyServerJobFault({ timedOut: true, oom: true, signal: "SIGKILL" }).code, SERVER_JOB_FAULT_CODES.TIMEOUT);
|
||||
assert.equal(classifyServerJobFault({ oom: true, signal: "SIGKILL" }).code, SERVER_JOB_FAULT_CODES.OOM);
|
||||
assert.equal(classifyServerJobFault({ signal: "SIGTERM" }).code, SERVER_JOB_FAULT_CODES.SIGNAL);
|
||||
assert.equal(classifyServerJobFault({ code: 7 }).code, SERVER_JOB_FAULT_CODES.EXIT_FAILED);
|
||||
assert.equal(classifyServerJobFault({ cancelRequested: true, timedOut: true }).code, SERVER_JOB_FAULT_CODES.CANCELLED);
|
||||
});
|
||||
|
||||
test("M13-04H preserves the old revision and blocks failure publication", () => {
|
||||
const receipt = createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, timedOut: true });
|
||||
assert.deepEqual(receipt, { schemaVersion: 1, state: "FAILED", code: "SERVER_JOB_TIMEOUT", stage: "PROCESS", exitCode: null, signal: null, timedOut: true, memoryExceeded: false, baseRevision: 11, currentRevision: 11, committedRevision: 11, publish: false, revisionPreserved: true, execution: "DISABLED" });
|
||||
assert.throws(() => createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 12, signal: "SIGTERM" }), /SERVER_JOB_REVISION_CONFLICT/);
|
||||
assert.equal(createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, code: 0 }).publish, true);
|
||||
});
|
||||
|
||||
test("M13-04H derives OOM from bounded memory usage", () => {
|
||||
const receipt = createServerJobFaultReceipt({ baseRevision: 3, currentRevision: 3, memoryBytes: 513, memoryLimitBytes: 512 });
|
||||
assert.equal(receipt.code, SERVER_JOB_FAULT_CODES.OOM);
|
||||
assert.equal(receipt.memoryExceeded, true);
|
||||
});
|
||||
48
web/tests/unit/server-job-idempotency.test.mjs
Normal file
48
web/tests/unit/server-job-idempotency.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { submitIdempotentServerJobResult } from "../../../tools/web/server-job-idempotency.mjs";
|
||||
|
||||
const h = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const identity = { requestId: "retry-1", projectId: "project-1", baseRevision: 4, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("build") };
|
||||
|
||||
test("M13-04J reuses the exact verified result for the same request", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-reuse-"));
|
||||
const options = { receiptDirectory: path.join(root, "receipts"), outputDirectory: path.join(root, "outputs") };
|
||||
try {
|
||||
const first = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 2]), options);
|
||||
const second = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 2]), options);
|
||||
assert.equal(first.reused, false);
|
||||
assert.equal(second.reused, true);
|
||||
assert.equal(first.outputSha256, second.outputSha256);
|
||||
assert.equal((await fs.readdir(options.receiptDirectory)).length, 1);
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("M13-04J rejects conflicting output/identity and isolates different requests", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-conflict-"));
|
||||
const options = { receiptDirectory: path.join(root, "receipts"), outputDirectory: path.join(root, "outputs") };
|
||||
try {
|
||||
await submitIdempotentServerJobResult(identity, new Uint8Array([3]), options);
|
||||
await assert.rejects(submitIdempotentServerJobResult(identity, new Uint8Array([4]), options), /SERVER_JOB_IDEMPOTENCY_CONFLICT/);
|
||||
await assert.rejects(submitIdempotentServerJobResult({ ...identity, settingsSha256: h("changed") }, new Uint8Array([3]), options), /SERVER_JOB_IDEMPOTENCY_CONFLICT/);
|
||||
const other = await submitIdempotentServerJobResult({ ...identity, requestId: "retry-2" }, new Uint8Array([4]), options);
|
||||
assert.equal(other.reused, false);
|
||||
assert.equal((await fs.readdir(options.receiptDirectory)).length, 2);
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("M13-04J serializes concurrent duplicate requests", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-concurrent-"));
|
||||
const options = { receiptDirectory: path.join(root, "receipts"), outputDirectory: path.join(root, "outputs") };
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
submitIdempotentServerJobResult({ ...identity, requestId: "retry-3" }, new Uint8Array([8]), options),
|
||||
submitIdempotentServerJobResult({ ...identity, requestId: "retry-3" }, new Uint8Array([8]), options),
|
||||
]);
|
||||
assert.deepEqual(results.map((result) => result.reused).sort(), [false, true]);
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
58
web/tests/unit/server-job-isolation.test.mjs
Normal file
58
web/tests/unit/server-job-isolation.test.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { cleanupServerJobDirectory, createServerJobDirectory, prepareServerJobWorkspace } from "../../../tools/web/server-job-isolation.mjs";
|
||||
|
||||
test("M13-04A creates unpredictable one-shot directories and cleans them once", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04a-job-root-"));
|
||||
try {
|
||||
const first = await createServerJobDirectory(root, "server:job-one");
|
||||
const second = await createServerJobDirectory(root, "server:job-two");
|
||||
assert.notEqual(first.directoryName, second.directoryName);
|
||||
assert.notEqual(first.directoryName, first.jobId);
|
||||
assert.match(first.directoryName, /^\.blender-job-[0-9a-f-]+-[A-Za-z0-9]+$/);
|
||||
assert.equal((await fs.stat(first.path)).mode & 0o777, 0o700);
|
||||
assert.equal((await fs.stat(second.path)).mode & 0o777, 0o700);
|
||||
const cleanedFirst = await cleanupServerJobDirectory(first);
|
||||
const cleanedSecond = await cleanupServerJobDirectory(second);
|
||||
assert.equal(cleanedFirst.state, "CLEANED");
|
||||
assert.equal(cleanedFirst.cleanupCount, 1);
|
||||
assert.equal(cleanedSecond.cleanupCount, 1);
|
||||
await assert.rejects(fs.stat(first.path), { code: "ENOENT" });
|
||||
await assert.rejects(fs.stat(second.path), { code: "ENOENT" });
|
||||
assert.equal((await cleanupServerJobDirectory(cleanedFirst)).cleanupCount, 1);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("M13-04A rejects unsafe roots, IDs and cleanup escapes", async () => {
|
||||
await assert.rejects(createServerJobDirectory("relative-root", "server:job"), /SERVER_JOB_DIRECTORY_INVALID/);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04a-invalid-root-"));
|
||||
try {
|
||||
await assert.rejects(createServerJobDirectory(root, "../escape"), /SERVER_JOB_DIRECTORY_INVALID/);
|
||||
await assert.rejects(cleanupServerJobDirectory({ schemaVersion: 1, root, path: path.join(root, "other"), state: "ALLOCATED" }), /SERVER_JOB_DIRECTORY_INVALID/);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("M13-04B isolates read-only source from writable output", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04b-workspace-"));
|
||||
try {
|
||||
const job = await createServerJobDirectory(root, "server:job-mount");
|
||||
const workspace = await prepareServerJobWorkspace(job, new Uint8Array([1, 2, 3]));
|
||||
assert.notEqual(path.dirname(workspace.sourcePath), workspace.outputDirectory);
|
||||
assert.equal((await fs.stat(workspace.sourceDirectory)).mode & 0o777, 0o555);
|
||||
assert.equal((await fs.stat(workspace.sourcePath)).mode & 0o777, 0o444);
|
||||
assert.equal((await fs.stat(workspace.outputDirectory)).mode & 0o777, 0o700);
|
||||
await assert.rejects(fs.writeFile(workspace.sourcePath, new Uint8Array([9])), { code: "EACCES" });
|
||||
await fs.writeFile(path.join(workspace.outputDirectory, "result.bin"), new Uint8Array([4, 5]));
|
||||
assert.deepEqual([...await fs.readFile(path.join(workspace.outputDirectory, "result.bin"))], [4, 5]);
|
||||
await cleanupServerJobDirectory(job);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
17
web/tests/unit/server-job-network-policy.test.mjs
Normal file
17
web/tests/unit/server-job-network-policy.test.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseServerJobNetworkPolicy, resolveServerJobNetwork } from "../../../tools/web/server-job-network-policy.mjs";
|
||||
|
||||
test("M13-04D defaults to deny and admits only declared safe origins", () => {
|
||||
const policy = parseServerJobNetworkPolicy({ schemaVersion: 1, allowedOrigins: ["https://example.com", "http://127.0.0.1:8787", "https://example.com"] });
|
||||
assert.deepEqual(policy, { schemaVersion: 1, defaultNetwork: "DENY", allowedOrigins: ["http://127.0.0.1:8787", "https://example.com"] });
|
||||
assert.deepEqual(resolveServerJobNetwork(policy), { status: "DENIED", code: "SERVER_NETWORK_DENIED", origin: null, network: "DISABLED" });
|
||||
assert.equal(resolveServerJobNetwork(policy, "https://example.com").status, "ALLOWED");
|
||||
assert.equal(resolveServerJobNetwork(policy, "https://other.example").code, "SERVER_NETWORK_DENIED");
|
||||
});
|
||||
|
||||
test("M13-04D rejects unsafe or non-canonical origins", () => {
|
||||
for (const origin of ["http://example.com", "file:///tmp/x", "https://example.com/path", "https://user:pass@example.com"]) {
|
||||
assert.throws(() => parseServerJobNetworkPolicy({ schemaVersion: 1, allowedOrigins: [origin] }), /SERVER_NETWORK_/);
|
||||
}
|
||||
});
|
||||
34
web/tests/unit/server-job-output.test.mjs
Normal file
34
web/tests/unit/server-job-output.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServerJobOutputReceipt, redactServerJobOutput, SERVER_JOB_OUTPUT_LIMITS } from "../../../tools/web/server-job-output.mjs";
|
||||
|
||||
test("M13-04F redacts credentials and filesystem paths before publishing output", () => {
|
||||
const receipt = createServerJobOutputReceipt({
|
||||
stdout: 'authorization: Bearer abc123 token="secret-value" source=/home/user/private.blend',
|
||||
stderr: "failed at C:\\Users\\alice\\job\\source.blend file:///tmp/internal.log",
|
||||
});
|
||||
assert.equal(receipt.execution, "DISABLED");
|
||||
assert.ok(receipt.totalRedactions >= 5);
|
||||
assert.doesNotMatch(receipt.stdout.text, /abc123|secret-value|\/home\/user|C:\\Users|file:\/\//u);
|
||||
assert.doesNotMatch(receipt.stderr.text, /alice|internal\.log/u);
|
||||
assert.match(receipt.stdout.text, /<redacted>/u);
|
||||
assert.match(receipt.stderr.text, /<internal-path>/u);
|
||||
});
|
||||
|
||||
test("M13-04F truncates at UTF-8 byte boundaries and reports the source size", () => {
|
||||
const value = "模型".repeat(100);
|
||||
const receipt = createServerJobOutputReceipt({ stdout: value, stderr: "" }, { ...SERVER_JOB_OUTPUT_LIMITS, stdoutBytes: 32, stderrBytes: 32, totalBytes: 64 });
|
||||
assert.equal(receipt.stdout.truncated, true);
|
||||
assert.ok(receipt.stdout.emittedBytes <= 32);
|
||||
assert.equal(Buffer.from(receipt.stdout.text, "utf8").toString("utf8"), receipt.stdout.text);
|
||||
assert.equal(receipt.stdout.originalBytes, Buffer.byteLength(value, "utf8"));
|
||||
assert.match(receipt.stdout.text, /<output-truncated>$/u);
|
||||
});
|
||||
|
||||
test("M13-04F rejects invalid stream budgets and leaves empty streams explicit", () => {
|
||||
assert.deepEqual(createServerJobOutputReceipt({ stdout: "", stderr: "" }).stdout, {
|
||||
text: "", originalBytes: 0, emittedBytes: 0, redactionCount: 0, truncated: false,
|
||||
});
|
||||
assert.throws(() => createServerJobOutputReceipt({ stdout: "x", stderr: "" }, { stdoutBytes: 10, stderrBytes: 10, totalBytes: 10 }), /SERVER_JOB_OUTPUT_INVALID/);
|
||||
assert.throws(() => redactServerJobOutput(42, 10), /SERVER_JOB_OUTPUT_INVALID/);
|
||||
});
|
||||
48
web/tests/unit/server-job-process.test.mjs
Normal file
48
web/tests/unit/server-job-process.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cleanupServerJobDirectory, createServerJobDirectory } from "../../../tools/web/server-job-isolation.mjs";
|
||||
import { cancelServerJobProcess, startServerJobProcess } from "../../../tools/web/server-job-process.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("M13-04G cancels the real process group and cleans the job directory", async () => {
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04g-process-"));
|
||||
const job = await createServerJobDirectory(temporary, "server:cancel");
|
||||
const childScript = "const {spawn}=require('node:child_process'); const c=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); setInterval(()=>{},1000);";
|
||||
const handle = startServerJobProcess(process.execPath, ["-e", childScript], { cwd: root });
|
||||
let cleanupCount = 0;
|
||||
try {
|
||||
const receipt = await cancelServerJobProcess(handle, async () => { cleanupCount += 1; await cleanupServerJobDirectory(job); });
|
||||
assert.equal(receipt.state, "CANCELLED");
|
||||
assert.equal(receipt.cleanupCount, 1);
|
||||
assert.equal(cleanupCount, 1);
|
||||
assert.match(receipt.treeSignal, /GROUP|ALREADY_EXITED/);
|
||||
await assert.rejects(fs.stat(job.path), { code: "ENOENT" });
|
||||
await assert.doesNotReject(handle.completion);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("M13-04G repeated cancellation is idempotent", async () => {
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04g-idempotent-"));
|
||||
const job = await createServerJobDirectory(temporary, "server:repeat");
|
||||
const handle = startServerJobProcess(process.execPath, ["-e", "setInterval(()=>{},1000)"], { cwd: root });
|
||||
let cleanupCount = 0;
|
||||
const cleanup = async () => { cleanupCount += 1; await cleanupServerJobDirectory(job); };
|
||||
try {
|
||||
const first = await cancelServerJobProcess(handle, cleanup);
|
||||
const second = await cancelServerJobProcess(handle, cleanup);
|
||||
assert.equal(first.state, "CANCELLED");
|
||||
assert.equal(second.state, "CANCELLED");
|
||||
assert.equal(first.cleanupCount, 1);
|
||||
assert.equal(second.cleanupCount, 1);
|
||||
assert.equal(cleanupCount, 1);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
18
web/tests/unit/server-job-resource-budget.test.mjs
Normal file
18
web/tests/unit/server-job-resource-budget.test.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServerJobResourceReceipt, parseServerJobResourceBudget, SERVER_JOB_RESOURCE_LIMITS } from "../../../tools/web/server-job-resource-budget.mjs";
|
||||
|
||||
const budget = { schemaVersion: 1, ...SERVER_JOB_RESOURCE_LIMITS };
|
||||
const usage = { cpuMs: 10, memoryBytes: 1024, processCount: 1, fileCount: 2, wallMs: 20, outputBytes: 512 };
|
||||
|
||||
test("M13-04C accepts bounded usage and marks enforcement", () => {
|
||||
assert.deepEqual(parseServerJobResourceBudget(budget), budget);
|
||||
assert.deepEqual(createServerJobResourceReceipt(budget, usage), { schemaVersion: 1, status: "SUCCEEDED", budget, usage, enforced: true, exceeded: [] });
|
||||
});
|
||||
|
||||
test("M13-04C rejects each resource overage with a stable code", () => {
|
||||
for (const field of Object.keys(SERVER_JOB_RESOURCE_LIMITS)) {
|
||||
assert.throws(() => createServerJobResourceReceipt(budget, { ...usage, [field]: SERVER_JOB_RESOURCE_LIMITS[field] + 1 }), new RegExp(`SERVER_JOB_BUDGET_EXCEEDED.*${field}`));
|
||||
}
|
||||
assert.throws(() => parseServerJobResourceBudget({ ...budget, unknown: 1 }), /SERVER_JOB_BUDGET_INVALID/);
|
||||
});
|
||||
33
web/tests/unit/server-job-result-binding.test.mjs
Normal file
33
web/tests/unit/server-job-result-binding.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { commitServerJobResult, verifyServerJobResultReceipt } from "../../../tools/web/server-job-result-binding.mjs";
|
||||
|
||||
const h = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const identity = { requestId: "request-1", projectId: "project-1", baseRevision: 7, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("build") };
|
||||
|
||||
test("M13-04I commits only a readback-verified result and binds four identity hashes", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04i-binding-"));
|
||||
try {
|
||||
const receipt = await commitServerJobResult(identity, new Uint8Array([1, 2, 3]), { outputDirectory: directory });
|
||||
assert.equal(receipt.status, "COMMITTED");
|
||||
assert.equal(receipt.publish, true);
|
||||
assert.equal((await verifyServerJobResultReceipt(receipt, identity, directory)).verified, true);
|
||||
assert.equal(receipt.outputByteLength, 3);
|
||||
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("M13-04I blocks tamper, stale identity and partial/quota failures", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04i-negative-"));
|
||||
try {
|
||||
await assert.rejects(commitServerJobResult({ ...identity, settingsSha256: h("changed") }, new Uint8Array([1]), { outputDirectory: directory, expectedIdentity: identity, faultAt: "AFTER_STAGE" }), /SERVER_JOB_RESULT_IDENTITY_MISMATCH/);
|
||||
assert.deepEqual(await fs.readdir(directory), []);
|
||||
const receipt = await commitServerJobResult(identity, new Uint8Array([4, 5]), { outputDirectory: directory });
|
||||
await fs.writeFile(receipt.outputPath, new Uint8Array([9]));
|
||||
await assert.rejects(verifyServerJobResultReceipt(receipt, identity, directory), /SERVER_JOB_RESULT_HASH_MISMATCH/);
|
||||
await assert.rejects(commitServerJobResult({ ...identity, requestId: "request-2" }, new Uint8Array([8]), { outputDirectory: directory, faultAt: "QUOTA" }), /SERVER_JOB_RESULT_STORAGE_QUOTA/);
|
||||
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
42
web/tests/unit/stl-export.test.mjs
Normal file
42
web/tests/unit/stl-export.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "stl-export-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/stl-export.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "stl-export.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const document = {
|
||||
schemaVersion: 1,
|
||||
variant: "STL_BINARY",
|
||||
unitScale: 1,
|
||||
declaredTriangleCount: 1,
|
||||
triangleCount: 1,
|
||||
removedDegenerateTriangles: 0,
|
||||
normals: [[0, 1, 0]],
|
||||
vertices: [[[-1, 0, 1], [1, 0, 1], [1, 0, -1]]],
|
||||
bounds: { min: [-1, 0, -1], max: [1, 0, 1] },
|
||||
};
|
||||
|
||||
test("M12-07F serializes binary STL and reports material loss", () => {
|
||||
const output = protocol.exportBinarySTL(document);
|
||||
assert.equal(output.byteLength, 134);
|
||||
assert.equal(new DataView(output).getUint32(80, true), 1);
|
||||
assert.deepEqual(protocol.createSTLLossReport(2), {
|
||||
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" }],
|
||||
});
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
49
web/tests/unit/stl-import.test.mjs
Normal file
49
web/tests/unit/stl-import.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "stl-import-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/stl-import.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "stl-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_stl_edges_v1");
|
||||
const bytes = (name) => {
|
||||
const value = fs.readFileSync(path.join(fixtureRoot, name));
|
||||
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||
};
|
||||
|
||||
test("M12-07E parses binary/ASCII normals and explicit unit scales", () => {
|
||||
const binary = protocol.importSTL(bytes("capability-binary.stl"), { variant: "STL_BINARY", unitScale: 1 });
|
||||
assert.equal(binary.triangleCount, 2);
|
||||
assert.deepEqual(binary.normals, [[0, 1, 0], [0, 1, 0]]);
|
||||
assert.deepEqual(binary.bounds, { min: [-1, 0, -1], max: [1, 0, 1] });
|
||||
const scaled = protocol.importSTL(bytes("capability-binary.stl"), { variant: "STL_BINARY", unitScale: 0.001 });
|
||||
assert.deepEqual(scaled.bounds, { min: [-0.001, 0, -0.001], max: [0.001, 0, 0.001] });
|
||||
const ascii = protocol.importSTL(bytes("../m12_stl_capability_v1/capability-ascii.stl"), { variant: "STL_ASCII", unitScale: 1 });
|
||||
assert.equal(ascii.triangleCount, 2);
|
||||
assert.deepEqual(ascii.normals, binary.normals);
|
||||
assert.deepEqual(ascii.bounds, binary.bounds);
|
||||
});
|
||||
|
||||
test("M12-07E matches Blender's degenerate removal and blocks trailing bytes", () => {
|
||||
const degenerate = protocol.importSTL(bytes("degenerate-binary.stl"), { variant: "STL_BINARY", unitScale: 1 });
|
||||
assert.equal(degenerate.declaredTriangleCount, 2);
|
||||
assert.equal(degenerate.removedDegenerateTriangles, 1);
|
||||
assert.equal(degenerate.triangleCount, 1);
|
||||
assert.throws(() => protocol.importSTL(bytes("trailing-binary.stl"), { variant: "STL_BINARY", unitScale: 1 }), /STL_TRAILING_BYTES/);
|
||||
assert.throws(() => protocol.importSTL(bytes("capability-binary.stl"), { variant: "STL_BINARY", unitScale: 0 }), /STL_UNIT_SCALE_INVALID/);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
24
web/tests/unit/viewport-dpr.test.mjs
Normal file
24
web/tests/unit/viewport-dpr.test.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "../../node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/viewport-dpr.ts");
|
||||
const output = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const dpr = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
|
||||
test("M14-04B clamps DPR and computes stable backing dimensions", () => {
|
||||
for (const [observed, expected] of [[1, 1], [1.5, 1.5], [2, 2], [3, 2], [0, 1], [Number.NaN, 1]]) {
|
||||
const metrics = dpr.resolveViewportPixelMetrics(101, 57, observed);
|
||||
assert.equal(metrics.pixelRatio, expected);
|
||||
assert.deepEqual([metrics.backingWidth, metrics.backingHeight], [Math.floor(101 * expected), Math.floor(57 * expected)]);
|
||||
}
|
||||
});
|
||||
|
||||
test("M14-04B uses CSS bounds for DPR-independent NDC", () => {
|
||||
assert.deepEqual(dpr.viewportNDC(150, 75, { left: 100, top: 25, width: 100, height: 100 }), { x: 0, y: 0 });
|
||||
assert.throws(() => dpr.viewportNDC(0, 0, { left: 0, top: 0, width: 0, height: 1 }), /VIEWPORT_BOUNDS_INVALID/);
|
||||
});
|
||||
Reference in New Issue
Block a user