Advance M8-M11 parity workflows
This commit is contained in:
81
web/tests/e2e/compositor-node-golden.spec.ts
Normal file
81
web/tests/e2e/compositor-node-golden.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8")) as {
|
||||
width: number;
|
||||
height: number;
|
||||
maxAbsoluteError: number;
|
||||
allowlist: string[];
|
||||
fixture: { path: string };
|
||||
cases: Array<{ scene: string; newNode: string; nodeTypes: string[]; pixel: number[]; float32Sha256: string }>;
|
||||
};
|
||||
const fixture = fs.readFileSync(path.join(root, golden.fixture.path));
|
||||
|
||||
test("M11-07 gives every allowlisted compositor node an independent CPU/WebGPU golden", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, cpu, gpu] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/compositor/CompositorExecutor.ts"),
|
||||
import("/src/compositor/CompositorWebGPU.ts"),
|
||||
]);
|
||||
const hash = async (data: Float32Array): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const device = await gpu.requestCompositorWebGPUDevice();
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input.bytes).buffer);
|
||||
const cases = [];
|
||||
for (const expected of input.cases) {
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === expected.scene);
|
||||
if (!scene?.compositorGraph) throw new Error(`Missing compositor graph ${expected.scene}`);
|
||||
const plan = cpu.compileCompositorWebGPUPlan(scene.compositorGraph);
|
||||
const cpuResult = cpu.executeCompositorGraph(scene.compositorGraph, new Map(), { width: input.width, height: input.height }).composite;
|
||||
const gpuResult = await gpu.executeCompositorGraphWebGPU(device, scene.compositorGraph, input.width, input.height);
|
||||
let maxAbsoluteError = 0;
|
||||
for (let index = 0; index < cpuResult.data.length; index++) maxAbsoluteError = Math.max(maxAbsoluteError, Math.abs(cpuResult.data[index] - gpuResult.data[index]));
|
||||
cases.push({
|
||||
scene: expected.scene,
|
||||
newNode: expected.newNode,
|
||||
instructionTypes: plan.instructions.map((instruction) => instruction.type),
|
||||
cpuPixel: Array.from(cpuResult.data.slice(0, 4)),
|
||||
gpuPixel: Array.from(gpuResult.data.slice(0, 4)),
|
||||
cpuSha256: await hash(cpuResult.data),
|
||||
gpuSha256: await hash(gpuResult.data),
|
||||
maxAbsoluteError,
|
||||
});
|
||||
}
|
||||
const base = opened.snapshot.scenes.find((candidate) => candidate.name === input.cases[0].scene)?.compositorGraph;
|
||||
if (!base) throw new Error("Missing base compositor graph");
|
||||
let blockedCode = "";
|
||||
try {
|
||||
cpu.compileCompositorWebGPUPlan({ ...base, nodes: [...base.nodes, { id: "blocked:viewer", type: "VIEWER", name: "Blocked Viewer", properties: {} }] });
|
||||
}
|
||||
catch (error) { blockedCode = error instanceof cpu.CompositorValidationError ? error.code : String(error); }
|
||||
return { allowlist: cpu.COMPOSITOR_WEBGPU_NODE_ALLOWLIST, cases, blockedCode };
|
||||
}
|
||||
finally {
|
||||
device.destroy();
|
||||
client.terminate();
|
||||
}
|
||||
}, { bytes: Array.from(fixture), cases: golden.cases, width: golden.width, height: golden.height });
|
||||
|
||||
expect(result.allowlist).toEqual(golden.allowlist);
|
||||
expect(result.blockedCode).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
for (const [index, candidate] of result.cases.entries()) {
|
||||
const expected = golden.cases[index];
|
||||
expect(candidate.scene).toBe(expected.scene);
|
||||
expect(candidate.newNode).toBe(expected.newNode);
|
||||
expect(candidate.instructionTypes).toEqual(expected.nodeTypes);
|
||||
expect(candidate.cpuPixel).toEqual(expected.pixel);
|
||||
expect(candidate.gpuPixel).toEqual(expected.pixel);
|
||||
expect(candidate.cpuSha256).toBe(expected.float32Sha256);
|
||||
expect(candidate.gpuSha256).toBe(expected.float32Sha256);
|
||||
expect(candidate.maxAbsoluteError).toBeLessThanOrEqual(golden.maxAbsoluteError);
|
||||
}
|
||||
});
|
||||
63
web/tests/e2e/compositor-unsupported-gate.spec.ts
Normal file
63
web/tests/e2e/compositor-unsupported-gate.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-08/compositor-unsupported-gate.json"), "utf8")) as {
|
||||
fixture: { path: string; sha256: string };
|
||||
scene: string;
|
||||
unsupportedNode: { name: string; type: string; blenderType: string };
|
||||
expectedErrorCode: string;
|
||||
expectedRevisionDelta: number;
|
||||
};
|
||||
const fixture = fs.readFileSync(path.join(root, golden.fixture.path));
|
||||
|
||||
test("M11-08 keeps the Blender graph intact while blocking unsupported compositor execution", async ({ page }) => {
|
||||
expect(crypto.createHash("sha256").update(fixture).digest("hex")).toBe(golden.fixture.sha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, compositor] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/compositor/CompositorExecutor.ts"),
|
||||
]);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
await client.init();
|
||||
const opened = await client.openBlend(input.bytes.buffer);
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === input.scene);
|
||||
if (!scene?.compositorGraph) throw new Error(`Missing compositor graph ${input.scene}`);
|
||||
const beforeGraph = JSON.stringify(scene.compositorGraph);
|
||||
const beforeRevision = opened.snapshot.revision;
|
||||
const unsupported = scene.compositorGraph.nodes.find((node) => node.type === input.unsupportedNode.type);
|
||||
let gateCode = "";
|
||||
let executeCode = "";
|
||||
let cachedCode = "";
|
||||
try { gateCode = compositor.gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code ?? ""; } catch (error) { gateCode = String(error); }
|
||||
try { compositor.executeCompositorGraph(scene.compositorGraph, new Map(), { width: 2, height: 2 }); }
|
||||
catch (error) { executeCode = error instanceof compositor.CompositorValidationError ? error.code : String(error); }
|
||||
try {
|
||||
await compositor.executeCompositorGraphCached(scene.compositorGraph, new Map(), new compositor.CompositorFrameCache(512), { frame: 1, width: 2, height: 2 });
|
||||
}
|
||||
catch (error) { cachedCode = error instanceof compositor.CompositorValidationError ? error.code : String(error); }
|
||||
const after = await client.snapshot();
|
||||
client.terminate();
|
||||
const afterScene = after.snapshot.scenes.find((candidate) => candidate.name === input.scene);
|
||||
return {
|
||||
beforeGraph,
|
||||
afterGraph: JSON.stringify(afterScene?.compositorGraph),
|
||||
beforeRevision,
|
||||
afterRevision: after.snapshot.revision,
|
||||
gateCode,
|
||||
executeCode,
|
||||
cachedCode,
|
||||
unsupported: unsupported && { name: unsupported.name, type: unsupported.type, blenderType: unsupported.blenderType },
|
||||
};
|
||||
}, { bytes: new Uint8Array(fixture), scene: golden.scene, unsupportedNode: golden.unsupportedNode });
|
||||
|
||||
expect(result.gateCode).toBe(golden.expectedErrorCode);
|
||||
expect(result.executeCode).toBe(golden.expectedErrorCode);
|
||||
expect(result.cachedCode).toBe(golden.expectedErrorCode);
|
||||
expect(result.unsupported).toEqual(golden.unsupportedNode);
|
||||
expect(result.afterGraph).toBe(result.beforeGraph);
|
||||
expect(result.afterRevision - result.beforeRevision).toBe(golden.expectedRevisionDelta);
|
||||
});
|
||||
23
web/tests/e2e/curve-topology-contract.spec.ts
Normal file
23
web/tests/e2e/curve-topology-contract.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M9-04 freezes Curve topology operators and budgets before Main or UI exposure", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/curve-topology-contract-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage(null);
|
||||
}));
|
||||
expect(result).toMatchObject({
|
||||
operatorCount: 14,
|
||||
blockedCount: 13,
|
||||
readyOperators: ["TOGGLE_CYCLIC"],
|
||||
sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc",
|
||||
atomicMainTransaction: true,
|
||||
unknownCode: "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED",
|
||||
staleCode: "REVISION_CONFLICT",
|
||||
budgetCode: "NON_MESH_DATA_BUDGET_EXCEEDED",
|
||||
stage: "ONE_VERIFIED_OPERATOR",
|
||||
});
|
||||
expect(result.accepted).toMatchObject({ operator: "ADD_SPLINE", outputSplineCount: 2, outputPointCount: 4 });
|
||||
});
|
||||
59
web/tests/e2e/curve-topology-operator.spec.ts
Normal file
59
web/tests/e2e/curve-topology-operator.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
const golden = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M9-05/curve-toggle-cyclic.json"), "utf8"));
|
||||
|
||||
test("M9-05 exposes TOGGLE_CYCLIC only after Main, undo, save and Blender golden verification", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const fixtureBytes = fs.readFileSync(fixture);
|
||||
expect(crypto.createHash("sha256").update(fixtureBytes).digest("hex")).toBe(golden.fixtureSha256);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
const curveRow = page.locator('[data-data-id="curve:WebCurveData"]');
|
||||
await expect(curveRow).toBeVisible();
|
||||
await curveRow.click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const editor = page.getByTestId("curve-topology-editor");
|
||||
const cyclic = page.getByRole("checkbox", { name: "Curve cyclic U" });
|
||||
await expect(editor).toHaveAttribute("data-ready-operators", "TOGGLE_CYCLIC");
|
||||
await expect(editor).toHaveAttribute("data-spline-types", golden.splineTypes.join(","));
|
||||
await expect(editor).toHaveAttribute("data-point-count", String(golden.splinePointCounts.reduce((sum: number, count: number) => sum + count, 0)));
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.beforeCyclicU.join(","));
|
||||
await expect(cyclic).not.toBeChecked();
|
||||
const selectedRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
|
||||
await cyclic.click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.afterCyclicU.join(","));
|
||||
await expect(cyclic).toBeChecked();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
const toggledRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(toggledRevision).toBe(selectedRevision + 1);
|
||||
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.beforeCyclicU.join(","));
|
||||
const undoRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(undoRevision).toBe(toggledRevision + 1);
|
||||
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.afterCyclicU.join(","));
|
||||
const redoRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(redoRevision).toBe(undoRevision + 1);
|
||||
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await page.getByRole("button", { name: "关闭项目" }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "恢复项目" }).click();
|
||||
await expect(curveRow).toBeVisible();
|
||||
await curveRow.click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.reopenedCyclicU.join(","));
|
||||
await expect(cyclic).toBeChecked();
|
||||
});
|
||||
87
web/tests/e2e/diagnostic-report.spec.ts
Normal file
87
web/tests/e2e/diagnostic-report.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
const materialBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M7-17 keeps Worker detail out of the UI and exports it in a diagnostics report", async ({ page }) => {
|
||||
await page.goto("/?worker-fault=engine");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator("main.blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
const revision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
|
||||
await page.getByTestId("inject-worker-crash").click();
|
||||
await expect(app).toHaveAttribute("data-worker-fault-code", "WORKER_TERMINATED");
|
||||
await expect(app).toHaveAttribute("data-last-diagnostic-code", "ENGINE_WORKER_TERMINATED");
|
||||
await expect(page.getByTestId("engine-status")).toHaveText("Engine: Worker stopped; project remains available");
|
||||
await expect(page.locator("body")).not.toContainText("WORKER_CRASH_INJECTED");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByTestId("export-diagnostics").click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("blender-web-diagnostics.json");
|
||||
const reportPath = await download.path();
|
||||
expect(reportPath).not.toBeNull();
|
||||
const report = JSON.parse(await fs.readFile(reportPath!, "utf8"));
|
||||
|
||||
expect(report).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
product: "Web Blender Modeler V1",
|
||||
runtime: {
|
||||
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/$/),
|
||||
userAgent: expect.any(String),
|
||||
language: expect.any(String),
|
||||
crossOriginIsolated: true,
|
||||
},
|
||||
project: { projectId: "basic_scene", revision },
|
||||
});
|
||||
expect(report.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(report.entries).toHaveLength(1);
|
||||
expect(report.entries[0]).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
sequence: 1,
|
||||
area: "ENGINE",
|
||||
code: "ENGINE_WORKER_TERMINATED",
|
||||
summary: "Engine: Worker stopped; project remains available",
|
||||
sourceCode: "WORKER_TERMINATED",
|
||||
context: { projectId: "basic_scene", revision },
|
||||
});
|
||||
expect(report.entries[0].detail).toContain("WORKER_CRASH_INJECTED");
|
||||
});
|
||||
|
||||
test("M7-17 records open and import details behind stable user messages", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator("main.blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
|
||||
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles({
|
||||
name: "invalid.blend",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: Buffer.from([0x42, 0x41, 0x44]),
|
||||
});
|
||||
await expect(page.getByTestId("engine-status")).toHaveText("Engine: .blend open failed");
|
||||
await expect(app).toHaveAttribute("data-last-diagnostic-code", "BLEND_OPEN_FAILED");
|
||||
|
||||
await page.locator("label.file-button input[type=file]").setInputFiles({
|
||||
name: "invalid.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("not-a-png"),
|
||||
});
|
||||
await expect(page.getByTestId("engine-status")).toHaveText("Image import failed");
|
||||
await expect(app).toHaveAttribute("data-last-diagnostic-code", "IMAGE_IMPORT_FAILED");
|
||||
await expect(app).toHaveAttribute("data-diagnostic-count", "2");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByTestId("export-diagnostics").click();
|
||||
const reportPath = await (await downloadPromise).path();
|
||||
const report = JSON.parse(await fs.readFile(reportPath!, "utf8"));
|
||||
expect(report.entries.map((entry: { code: string }) => entry.code)).toEqual(["BLEND_OPEN_FAILED", "IMAGE_IMPORT_FAILED"]);
|
||||
expect(report.entries.every((entry: { detail: string }) => entry.detail.length > 0)).toBe(true);
|
||||
expect(report.entries[0].summary).toBe("Engine: .blend open failed");
|
||||
expect(report.entries[1].summary).toBe("Image import failed");
|
||||
});
|
||||
34
web/tests/e2e/editing-domain-recovery.spec.ts
Normal file
34
web/tests/e2e/editing-domain-recovery.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixtures = {
|
||||
CURVE: path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"),
|
||||
GREASE_PENCIL: path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"),
|
||||
PAINT: path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"),
|
||||
};
|
||||
|
||||
test("M9-14 recovers Curve, Grease Pencil and Paint after Worker, OOM and GPU release faults", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const input = Object.fromEntries(Object.entries(fixtures).map(([domain, file]) => [domain, Array.from(fs.readFileSync(file))]));
|
||||
const reports = await page.evaluate(async (bytes) => {
|
||||
const { runEditingDomainRecoverySuite } = await import("/src/testing/editing-domain-recovery.ts");
|
||||
const buffers = Object.fromEntries(Object.entries(bytes).map(([domain, value]) => [domain, Uint8Array.from(value as number[]).buffer]));
|
||||
return runEditingDomainRecoverySuite(buffers);
|
||||
}, input);
|
||||
|
||||
expect(reports.map((report) => report.domain)).toEqual(["CURVE", "GREASE_PENCIL", "PAINT"]);
|
||||
for (const report of reports) {
|
||||
expect(report.schemaVersion).toBe(1);
|
||||
expect(report.workerRestart).toMatchObject({ status: "RECOVERED", workerGeneration: 2, temporaryResourcesAfter: 0 });
|
||||
expect(report.workerRestart.hashAfter).toBe(report.baseline.identityHash);
|
||||
expect(report.oom).toMatchObject({ status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", temporaryResourcesAfter: 0 });
|
||||
expect(report.oom.hashAfter).toBe(report.baseline.identityHash);
|
||||
expect(report.gpuRelease).toMatchObject({ status: "RECOVERED", backend: "WEBGL2", releaseCount: 1, reinitCount: 1 });
|
||||
expect(report.gpuRelease.disposedResources).toBeGreaterThan(0);
|
||||
expect(report.gpuRelease.visiblePixels).toBeGreaterThan(0);
|
||||
expect(report.smallScene).toMatchObject({ status: "RECOVERED", identityHash: report.baseline.identityHash, revision: report.baseline.revision, objectCount: report.baseline.objectCount });
|
||||
expect(report.smallScene.visiblePixels).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
239
web/tests/e2e/editing-soak.spec.ts
Normal file
239
web/tests/e2e/editing-soak.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const REQUIRED_DURATION_MS = 30 * 60 * 1_000;
|
||||
const configuredDurationMs = Number.parseInt(process.env.EDITING_SOAK_DURATION_MS ?? String(REQUIRED_DURATION_MS), 10);
|
||||
const allowShort = process.env.EDITING_SOAK_ALLOW_SHORT === "1";
|
||||
const reportPath = process.env.EDITING_SOAK_REPORT ? path.resolve(process.cwd(), process.env.EDITING_SOAK_REPORT) : null;
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
interface StoredProjectSample {
|
||||
revision: number;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
snapshotCount: number;
|
||||
}
|
||||
|
||||
interface ResourceSample {
|
||||
cycle: number;
|
||||
label: string;
|
||||
elapsedMs: number;
|
||||
jsHeapBytes: number;
|
||||
storageUsageBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
}
|
||||
|
||||
async function readStoredProject(page: Page): Promise<StoredProjectSample> {
|
||||
return page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
try {
|
||||
const [project, snapshots] = await Promise.all([
|
||||
storage.readProject("basic_scene"),
|
||||
storage.listSnapshots("basic_scene"),
|
||||
]);
|
||||
return { revision: project.revision, sha256: project.sha256, bytes: project.bytes, snapshotCount: snapshots.snapshots.length };
|
||||
}
|
||||
finally {
|
||||
storage.terminate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("M7-18 keeps high-frequency retained snapshots within a bounded origin budget", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
const projectId = `snapshot-soak-${Date.now()}`;
|
||||
const payload = new Uint8Array(256 * 1024);
|
||||
payload.fill(0x5a);
|
||||
const baseline = (await navigator.storage.estimate()).usage ?? 0;
|
||||
for (let revision = 1; revision <= 128; revision++) {
|
||||
payload[0] = revision & 0xff;
|
||||
await storage.saveSnapshot(projectId, revision, payload.slice().buffer, 5, 2 * 1024 * 1024);
|
||||
}
|
||||
const snapshots = await storage.listSnapshots(projectId);
|
||||
const latest = await storage.readSnapshot(projectId, 128);
|
||||
const final = (await navigator.storage.estimate()).usage ?? 0;
|
||||
storage.terminate();
|
||||
return {
|
||||
growthBytes: final - baseline,
|
||||
revisions: snapshots.snapshots.map((snapshot) => snapshot.revision),
|
||||
latestByte: new Uint8Array(latest.buffer)[0],
|
||||
};
|
||||
});
|
||||
expect(result.revisions).toEqual([128, 127, 126, 125, 124]);
|
||||
expect(result.latestByte).toBe(128);
|
||||
expect(result.growthBytes).toBeLessThanOrEqual(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("M7-18 sustains editing, autosave and OPFS reopen for 30 minutes", async ({ page }, testInfo) => {
|
||||
if (!Number.isSafeInteger(configuredDurationMs) || configuredDurationMs <= 0) throw new Error("EDITING_SOAK_DURATION_INVALID");
|
||||
if (!allowShort && configuredDurationMs < REQUIRED_DURATION_MS) throw new Error("EDITING_SOAK_DURATION_BELOW_30_MINUTES");
|
||||
test.setTimeout(configuredDurationMs + 5 * 60 * 1_000);
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
const resourceSamples: ResourceSample[] = [];
|
||||
const revisionSamples: number[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
let downloadCount = 0;
|
||||
page.on("download", () => { downloadCount += 1; });
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
let soakStarted = 0;
|
||||
let actualDurationMs = 0;
|
||||
let cycles = 0;
|
||||
let autosaves = 0;
|
||||
let reopens = 0;
|
||||
let initialRevision = 0;
|
||||
let finalStored: StoredProjectSample | null = null;
|
||||
let failure: string | null = null;
|
||||
|
||||
const app = page.locator("main.blender-app");
|
||||
const sampleResources = async (label: string): Promise<void> => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
let jsHeapBytes = 0;
|
||||
try {
|
||||
await cdp.send("HeapProfiler.collectGarbage");
|
||||
await cdp.send("Performance.enable");
|
||||
const metrics = await cdp.send("Performance.getMetrics") as { metrics: Array<{ name: string; value: number }> };
|
||||
jsHeapBytes = metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
|
||||
}
|
||||
finally {
|
||||
await cdp.detach();
|
||||
}
|
||||
const storage = await page.evaluate(async () => {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
return { usage: estimate.usage ?? 0, quota: estimate.quota ?? 0 };
|
||||
});
|
||||
resourceSamples.push({ cycle: cycles, label, elapsedMs: soakStarted ? Date.now() - soakStarted : 0, jsHeapBytes, storageUsageBytes: storage.usage, storageQuotaBytes: storage.quota });
|
||||
};
|
||||
|
||||
const reopen = async (expected: StoredProjectSample): Promise<void> => {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const recent = page.getByTestId("recent-projects");
|
||||
await expect(recent.locator('option[value="basic_scene"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
await recent.selectOption("basic_scene");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 });
|
||||
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", String(expected.revision));
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", String(expected.revision));
|
||||
await expect(app).toHaveAttribute("data-save-committed-hash", expected.sha256);
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await expect(app).toHaveAttribute("data-diagnostic-count", "0");
|
||||
await expect(app).not.toHaveAttribute("data-worker-fault-code", /.+/);
|
||||
reopens += 1;
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 });
|
||||
const firstDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await firstDownload;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
finalStored = await readStoredProject(page);
|
||||
initialRevision = finalStored.revision;
|
||||
revisionSamples.push(initialRevision);
|
||||
await sampleResources("baseline");
|
||||
|
||||
soakStarted = Date.now();
|
||||
const reopenEveryCycles = allowShort ? 3 : 12;
|
||||
const cyclePauseMs = allowShort ? 100 : 2_000;
|
||||
while (Date.now() - soakStarted < configuredDurationMs) {
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
await page.getByRole("button", { name: "删除对象" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
||||
await expect(app).toHaveAttribute("data-dirty", "false", { timeout: 15_000 });
|
||||
await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED");
|
||||
const currentRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
const committedRevision = Number(await app.getAttribute("data-committed-main-revision"));
|
||||
expect(currentRevision).toBe(committedRevision);
|
||||
expect(currentRevision).toBeGreaterThan(revisionSamples.at(-1) ?? 0);
|
||||
revisionSamples.push(currentRevision);
|
||||
finalStored = await readStoredProject(page);
|
||||
expect(finalStored.revision).toBe(currentRevision);
|
||||
expect(finalStored.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(finalStored.snapshotCount).toBeLessThanOrEqual(5);
|
||||
await expect(app).toHaveAttribute("data-save-committed-hash", finalStored.sha256);
|
||||
await expect(app).toHaveAttribute("data-diagnostic-count", "0");
|
||||
cycles += 1;
|
||||
autosaves += 1;
|
||||
|
||||
if (cycles % reopenEveryCycles === 0) {
|
||||
await reopen(finalStored);
|
||||
await sampleResources(`reopen-${reopens}`);
|
||||
console.log(`M7-18 soak progress elapsedMs=${Date.now() - soakStarted} cycles=${cycles} reopens=${reopens} revision=${finalStored.revision}`);
|
||||
}
|
||||
if (cyclePauseMs > 0) await page.waitForTimeout(cyclePauseMs);
|
||||
}
|
||||
actualDurationMs = Date.now() - soakStarted;
|
||||
finalStored = await readStoredProject(page);
|
||||
await reopen(finalStored);
|
||||
await sampleResources("final");
|
||||
|
||||
const baseline = resourceSamples[0];
|
||||
const final = resourceSamples.at(-1)!;
|
||||
const heapGrowthBytes = final.jsHeapBytes - baseline.jsHeapBytes;
|
||||
const storageGrowthBytes = final.storageUsageBytes - baseline.storageUsageBytes;
|
||||
expect(actualDurationMs).toBeGreaterThanOrEqual(configuredDurationMs);
|
||||
expect(cycles).toBeGreaterThan(allowShort ? 0 : 100);
|
||||
expect(reopens).toBeGreaterThanOrEqual(allowShort ? 1 : 20);
|
||||
expect(autosaves).toBe(cycles);
|
||||
expect(finalStored.revision).toBeGreaterThanOrEqual(initialRevision + cycles * 2);
|
||||
expect(finalStored.snapshotCount).toBeLessThanOrEqual(5);
|
||||
expect(downloadCount).toBe(1);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(heapGrowthBytes).toBeLessThanOrEqual(64 * 1024 * 1024);
|
||||
expect(storageGrowthBytes).toBeLessThanOrEqual(16 * 1024 * 1024);
|
||||
}
|
||||
catch (error) {
|
||||
failure = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
if (soakStarted && actualDurationMs === 0) actualDurationMs = Date.now() - soakStarted;
|
||||
const baseline = resourceSamples[0];
|
||||
const final = resourceSamples.at(-1);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
status: failure ? "FAILED" : "READY",
|
||||
profile: allowShort ? "DEBUG" : "FORMAL",
|
||||
requiredDurationMs: REQUIRED_DURATION_MS,
|
||||
configuredDurationMs,
|
||||
actualDurationMs,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
cycles,
|
||||
autosaves,
|
||||
reopens,
|
||||
initialRevision,
|
||||
finalRevision: finalStored?.revision ?? 0,
|
||||
finalSha256: finalStored?.sha256 ?? null,
|
||||
finalBytes: finalStored?.bytes ?? 0,
|
||||
finalSnapshotCount: finalStored?.snapshotCount ?? 0,
|
||||
downloadCount,
|
||||
pageErrors,
|
||||
failure,
|
||||
limits: { maxHeapGrowthBytes: 64 * 1024 * 1024, maxStorageGrowthBytes: 16 * 1024 * 1024, maxSnapshots: 5 },
|
||||
observed: {
|
||||
heapGrowthBytes: baseline && final ? final.jsHeapBytes - baseline.jsHeapBytes : null,
|
||||
storageGrowthBytes: baseline && final ? final.storageUsageBytes - baseline.storageUsageBytes : null,
|
||||
},
|
||||
resourceSamples,
|
||||
};
|
||||
const body = `${JSON.stringify(report, null, 2)}\n`;
|
||||
await testInfo.attach("editing-soak-report", { body, contentType: "application/json" });
|
||||
if (reportPath) {
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await fs.writeFile(reportPath, body);
|
||||
}
|
||||
}
|
||||
});
|
||||
218
web/tests/e2e/external-vfont.spec.ts
Normal file
218
web/tests/e2e/external-vfont.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fontPath = path.resolve(import.meta.dirname, "../../../blender-5.2.0/release/datafiles/bfont.pfb");
|
||||
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
|
||||
test("M9-01 validates a real external font in Chromium before any storage or Main call", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = fs.readFileSync(fontPath);
|
||||
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const result = await page.evaluate(({ bytes, sha256 }) => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/external-vfont-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
worker.postMessage({ data, sha256 }, [data]);
|
||||
}), { bytes: new Uint8Array(bytes), sha256 });
|
||||
expect(result.metadata).toMatchObject({ schemaVersion: 1, sourcePath: "//fonts/browser-bfont.pfb", format: "PFB", byteLength: 25181, sha256 });
|
||||
expect(result.copied).toBe(true);
|
||||
expect(result.spoofCode).toBe("NON_MESH_BINARY_INVALID");
|
||||
expect(result.stage).toBe("VALIDATED_BEFORE_STORAGE_OR_MAIN");
|
||||
});
|
||||
|
||||
test("M9-02 commits the verified font to OPFS before creating a packed Main VFont", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const fontBytes = fs.readFileSync(fontPath);
|
||||
const blendBytes = fs.readFileSync(nonMeshBlend);
|
||||
const sha256 = crypto.createHash("sha256").update(fontBytes).digest("hex");
|
||||
const result = await page.evaluate(async ({ fontBytes, blendBytes, sha256 }) => {
|
||||
const [{ StorageClient }, { WebEngineClient }, { importExternalVFontIntoMain }] = await Promise.all([
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/fonts/external-vfont-import.ts"),
|
||||
]);
|
||||
const projectId = "m9-vfont-browser";
|
||||
const storage = new StorageClient();
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const events: string[] = [];
|
||||
await engine.init();
|
||||
const blend = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer;
|
||||
const opened = await engine.openBlend(blend);
|
||||
const storagePort = {
|
||||
putAsset: async (...args: Parameters<StorageClient["putAsset"]>) => {
|
||||
events.push("storage:start");
|
||||
const stored = await storage.putAsset(...args);
|
||||
events.push("storage:committed");
|
||||
return stored;
|
||||
},
|
||||
};
|
||||
const enginePort = {
|
||||
applyCommand: async (...args: Parameters<WebEngineClient["applyCommand"]>) => {
|
||||
events.push("main:start");
|
||||
const applied = await engine.applyCommand(...args);
|
||||
events.push("main:committed");
|
||||
return applied;
|
||||
},
|
||||
};
|
||||
const data = fontBytes.buffer.slice(fontBytes.byteOffset, fontBytes.byteOffset + fontBytes.byteLength) as ArrayBuffer;
|
||||
const imported = await importExternalVFontIntoMain({
|
||||
projectId,
|
||||
request: { sourcePath: "//fonts/m9-browser-bfont.pfb", mimeType: "application/x-font-type1", byteLength: data.byteLength, sha256, data },
|
||||
storage: storagePort,
|
||||
engine: enginePort,
|
||||
});
|
||||
const restored = await storage.readAsset(projectId, sha256);
|
||||
let failedMainCalls = 0;
|
||||
let failureCode = "";
|
||||
try {
|
||||
await importExternalVFontIntoMain({
|
||||
projectId,
|
||||
request: { sourcePath: "//fonts/m9-storage-failure.pfb", mimeType: "application/x-font-type1", byteLength: restored.data.byteLength, sha256, data: restored.data.slice(0) },
|
||||
storage: { putAsset: async () => { throw new Error("STORAGE_TRANSACTION: injected before asset commit"); } },
|
||||
engine: { applyCommand: async () => { failedMainCalls += 1; throw new Error("unexpected Main call"); } },
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
failureCode = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const listed = await storage.listAssets(projectId);
|
||||
storage.terminate();
|
||||
engine.terminate();
|
||||
return {
|
||||
beforeCount: opened.snapshot.vfonts?.length ?? 0,
|
||||
afterCount: imported.snapshot.vfonts?.length ?? 0,
|
||||
events,
|
||||
asset: imported.asset,
|
||||
vfont: imported.vfont,
|
||||
restoredHash: restored.asset.sha256,
|
||||
restoredBytes: restored.data.byteLength,
|
||||
listedCount: listed.assets.length,
|
||||
failedMainCalls,
|
||||
failureCode,
|
||||
};
|
||||
}, { fontBytes: new Uint8Array(fontBytes), blendBytes: new Uint8Array(blendBytes), sha256 });
|
||||
expect(result.events).toEqual(["storage:start", "storage:committed", "main:start", "main:committed"]);
|
||||
expect(result.afterCount).toBe(result.beforeCount + 1);
|
||||
expect(result.asset).toMatchObject({ projectId: "m9-vfont-browser", sha256, bytes: fontBytes.byteLength, persisted: true });
|
||||
expect(result.asset.path).toBe(`projects/m9-vfont-browser/assets/sha256/${sha256.slice(0, 2)}/${sha256}`);
|
||||
expect(result.vfont).toMatchObject({ name: "m9-browser-bfont", sourcePath: "//fonts/m9-browser-bfont.pfb", builtin: false, packed: true });
|
||||
expect(result.restoredHash).toBe(sha256);
|
||||
expect(result.restoredBytes).toBe(fontBytes.byteLength);
|
||||
expect(result.listedCount).toBe(1);
|
||||
expect(result.failedMainCalls).toBe(0);
|
||||
expect(result.failureCode).toContain("STORAGE_TRANSACTION");
|
||||
});
|
||||
|
||||
test("M9-03 replaces, undoes, saves and reopens a packed font with missing-asset closure", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const fontBytes = fs.readFileSync(fontPath);
|
||||
const blendBytes = fs.readFileSync(nonMeshBlend);
|
||||
const sha256 = crypto.createHash("sha256").update(fontBytes).digest("hex");
|
||||
const result = await page.evaluate(async ({ fontBytes, blendBytes, sha256 }) => {
|
||||
const [{ StorageClient }, { WebEngineClient }, fontImport] = await Promise.all([
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/fonts/external-vfont-import.ts"),
|
||||
]);
|
||||
const projectId = "m9-vfont-roundtrip";
|
||||
const storage = new StorageClient();
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await engine.init();
|
||||
const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength));
|
||||
const fontData = opened.snapshot.nonMeshData?.find((candidate) => candidate.type === "FONT" && candidate.fontLinks);
|
||||
if (!fontData?.fontLinks) throw new Error("font fixture has no style links");
|
||||
const originalLinks = { ...fontData.fontLinks };
|
||||
const imported = await fontImport.importExternalVFontIntoMain({
|
||||
projectId,
|
||||
request: {
|
||||
sourcePath: "//fonts/m9-roundtrip-bfont.pfb",
|
||||
mimeType: "application/x-font-type1",
|
||||
byteLength: fontBytes.byteLength,
|
||||
sha256,
|
||||
data: fontBytes.buffer.slice(fontBytes.byteOffset, fontBytes.byteOffset + fontBytes.byteLength),
|
||||
},
|
||||
storage,
|
||||
engine,
|
||||
});
|
||||
const replaced = await fontImport.replaceExternalVFontStyleInMain({
|
||||
projectId,
|
||||
sha256,
|
||||
dataId: fontData.id,
|
||||
vfontId: imported.vfont.id,
|
||||
style: "regular",
|
||||
snapshot: imported.snapshot,
|
||||
storage,
|
||||
engine,
|
||||
});
|
||||
const undone = await engine.applyCommand({ type: "undo" });
|
||||
const redone = await engine.applyCommand({ type: "redo" });
|
||||
const undoLinks = undone.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id)?.fontLinks;
|
||||
const redoLinks = redone.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id)?.fontLinks;
|
||||
const savedBlend = await engine.saveBlend();
|
||||
await storage.saveProject(projectId, redone.snapshot.revision, savedBlend);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let assetDirectory = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", sha256.slice(0, 2)]) {
|
||||
assetDirectory = await assetDirectory.getDirectoryHandle(segment);
|
||||
}
|
||||
await assetDirectory.removeEntry(sha256);
|
||||
let missingCode = "";
|
||||
let missingMainCalls = 0;
|
||||
try {
|
||||
await fontImport.replaceExternalVFontStyleInMain({
|
||||
projectId,
|
||||
sha256,
|
||||
dataId: fontData.id,
|
||||
vfontId: imported.vfont.id,
|
||||
style: "bold",
|
||||
snapshot: redone.snapshot,
|
||||
storage,
|
||||
engine: { applyCommand: async () => { missingMainCalls += 1; throw new Error("unexpected Main call"); } },
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
missingCode = (error as { code?: string }).code ?? "";
|
||||
}
|
||||
engine.terminate();
|
||||
|
||||
const persisted = await storage.readProject(projectId);
|
||||
storage.terminate();
|
||||
const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await reopenedEngine.init();
|
||||
const reopened = await reopenedEngine.openBlend(persisted.buffer);
|
||||
reopenedEngine.terminate();
|
||||
const reopenedData = reopened.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id);
|
||||
const reopenedVFont = reopened.snapshot.vfonts?.find((candidate) => candidate.id === imported.vfont.id);
|
||||
return {
|
||||
originalLinks,
|
||||
replacementLinks: replaced.links,
|
||||
undoLinks,
|
||||
redoLinks,
|
||||
importedVFont: imported.vfont,
|
||||
reopenedLinks: reopenedData?.fontLinks,
|
||||
reopenedVFont,
|
||||
missingCode,
|
||||
missingMainCalls,
|
||||
};
|
||||
}, { fontBytes: new Uint8Array(fontBytes), blendBytes: new Uint8Array(blendBytes), sha256 });
|
||||
expect(result.replacementLinks.regular).toBe(result.importedVFont.id);
|
||||
expect(result.undoLinks).toEqual(result.originalLinks);
|
||||
expect(result.redoLinks).toEqual(result.replacementLinks);
|
||||
expect(result.missingCode).toBe("NON_MESH_RESOURCE_MISSING");
|
||||
expect(result.missingMainCalls).toBe(0);
|
||||
expect(result.reopenedLinks).toEqual(result.replacementLinks);
|
||||
expect(result.reopenedVFont).toMatchObject({
|
||||
id: result.importedVFont.id,
|
||||
sourcePath: "//fonts/m9-roundtrip-bfont.pfb",
|
||||
builtin: false,
|
||||
packed: true,
|
||||
packedByteLength: fontBytes.byteLength,
|
||||
sha256,
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,8 @@ test("M7-03 cancels a large streamed open before WebEngine and preserves the cur
|
||||
const actualTotal = Number(element?.getAttribute("data-total-bytes"));
|
||||
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
|
||||
if (bytesRead <= 0 || bytesRead >= expectedTotal || !cancel) return false;
|
||||
cancel.click();
|
||||
cancel.focus();
|
||||
cancel.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||
return { bytesRead, totalBytes: actualTotal };
|
||||
}, totalBytes, { polling: "raf", timeout: 10_000 });
|
||||
const observed = await observation.jsonValue() as { bytesRead: number; totalBytes: number };
|
||||
|
||||
67
web/tests/e2e/geometry-node-allowlist.spec.ts
Normal file
67
web/tests/e2e/geometry-node-allowlist.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(
|
||||
path.join(root, "tests/golden/M10-02/geometry-node-allowlist.json"),
|
||||
"utf8",
|
||||
));
|
||||
const blendBytes = fs.readFileSync(path.join(root, expected.fixture));
|
||||
|
||||
test("M10-02 blocks non-allowlisted Main nodes without replacing the preserved graph", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect((await import("node:crypto")).createHash("sha256").update(blendBytes).digest("hex"))
|
||||
.toBe(expected.fixtureSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes, expected }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await client.init();
|
||||
const opened = await client.openBlend(source);
|
||||
const supported = opened.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.supportedGraph);
|
||||
const unsupported = opened.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.unsupportedGraph);
|
||||
const meshId = opened.snapshot.meshes[0]?.id ?? "mesh:missing";
|
||||
if (!supported || !unsupported) throw new Error("Geometry Node allowlist fixture graphs are missing");
|
||||
const baseline = JSON.stringify(opened.snapshot.geometryNodeGraphs);
|
||||
const revision = opened.snapshot.revision;
|
||||
const unsupportedTypes = unsupported.nodes
|
||||
.filter((node) => !expected.allowlist.includes(node.type))
|
||||
.map((node) => node.type);
|
||||
const attempt = async (graph: typeof supported) => {
|
||||
try {
|
||||
await client.applyCommand({ type: "setGeometryNodeGraph", meshId, graph });
|
||||
return null;
|
||||
}
|
||||
catch (error) {
|
||||
return error as { code?: string; message?: string };
|
||||
}
|
||||
};
|
||||
const unsupportedError = await attempt(unsupported);
|
||||
const afterUnsupported = await client.snapshot();
|
||||
const supportedError = await attempt(supported);
|
||||
const afterSupported = await client.snapshot();
|
||||
client.terminate();
|
||||
return {
|
||||
unsupportedTypes,
|
||||
unsupportedErrorCode: unsupportedError?.code,
|
||||
supportedErrorCode: supportedError?.code,
|
||||
unsupportedPreserved: JSON.stringify(afterUnsupported.snapshot.geometryNodeGraphs) === baseline,
|
||||
supportedPreserved: JSON.stringify(afterSupported.snapshot.geometryNodeGraphs) === baseline,
|
||||
revisions: [revision, afterUnsupported.snapshot.revision, afterSupported.snapshot.revision],
|
||||
graphHashes: [unsupported.graphHash, afterUnsupported.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.unsupportedGraph)?.graphHash],
|
||||
};
|
||||
}, { bytes: new Uint8Array(blendBytes), expected });
|
||||
|
||||
expect(result).toEqual({
|
||||
unsupportedTypes: expected.unsupportedNodeTypes,
|
||||
unsupportedErrorCode: expected.unsupportedErrorCode,
|
||||
supportedErrorCode: expected.evaluatorUnavailableErrorCode,
|
||||
unsupportedPreserved: true,
|
||||
supportedPreserved: true,
|
||||
revisions: [result.revisions[0], result.revisions[0], result.revisions[0]],
|
||||
graphHashes: [result.graphHashes[0], result.graphHashes[0]],
|
||||
});
|
||||
expect(result.graphHashes[0]).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
164
web/tests/e2e/geometry-node-evaluator-golden.spec.ts
Normal file
164
web/tests/e2e/geometry-node-evaluator-golden.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
interface GoldenMesh {
|
||||
attributes: Record<string, {
|
||||
domain: "POINT";
|
||||
dataType: "FLOAT";
|
||||
values: number[];
|
||||
}>;
|
||||
bounds: { min: number[]; max: number[] };
|
||||
indices: number[];
|
||||
positions: number[];
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
}
|
||||
|
||||
interface GoldenCase {
|
||||
graph: string;
|
||||
mesh: GoldenMesh;
|
||||
name: string;
|
||||
nodeTypes: string[];
|
||||
}
|
||||
|
||||
interface Golden {
|
||||
allowlist: string[];
|
||||
cases: GoldenCase[];
|
||||
fixture: string;
|
||||
fixtureSha256: string;
|
||||
nodeCoverage: Record<string, string[]>;
|
||||
schemaVersion: number;
|
||||
tolerance: {
|
||||
boundsError: number;
|
||||
maxAttributeError: number;
|
||||
maxPositionError: number;
|
||||
rmsPositionError: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EvaluatedMesh {
|
||||
attributes?: GoldenMesh["attributes"];
|
||||
indices: number[];
|
||||
modifiers: Array<{ status: string }>;
|
||||
objectId: string;
|
||||
positions: number[];
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
}
|
||||
|
||||
interface EvaluationResult {
|
||||
graphs: Array<{ name: string; nodes: Array<{ type: string }> }>;
|
||||
report: { engine: string; status: string; meshes: EvaluatedMesh[] };
|
||||
}
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(
|
||||
path.join(root, "tests/golden/M10-03/geometry-node-evaluator.json"),
|
||||
"utf8",
|
||||
)) as Golden;
|
||||
const blendBytes = fs.readFileSync(path.join(root, golden.fixture));
|
||||
|
||||
function errorMetrics(expected: number[], actual: number[]) {
|
||||
expect(actual).toHaveLength(expected.length);
|
||||
const errors = actual.map((value, index) => value - expected[index]);
|
||||
return {
|
||||
maximum: Math.max(0, ...errors.map((value) => Math.abs(value))),
|
||||
rms: errors.length === 0 ? 0 : Math.sqrt(
|
||||
errors.reduce((sum, value) => sum + value * value, 0) / errors.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function bounds(positions: number[]) {
|
||||
if (positions.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
const result = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
result.min[axis] = Math.min(result.min[axis], positions[index + axis]);
|
||||
result.max[axis] = Math.max(result.max[axis], positions[index + axis]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function verifyEvaluation(result: EvaluationResult) {
|
||||
expect(result.report.engine).toBe("BlenderDepsgraph");
|
||||
expect(result.report.status).toBe("EVALUATED");
|
||||
const graphs = new Map(result.graphs.map((graph) => [graph.name, graph]));
|
||||
const meshes = new Map(result.report.meshes.map((mesh) => [mesh.objectId, mesh]));
|
||||
|
||||
for (const expectedCase of golden.cases) {
|
||||
const graph = graphs.get(expectedCase.graph);
|
||||
expect(graph, `${expectedCase.name} graph`).toBeDefined();
|
||||
expect(graph?.nodes.map((node) => node.type).sort(), `${expectedCase.name} node inventory`)
|
||||
.toEqual([...expectedCase.nodeTypes].sort());
|
||||
|
||||
const actual = meshes.get(`object:${expectedCase.name}`);
|
||||
expect(actual, `${expectedCase.name} evaluated mesh`).toBeDefined();
|
||||
if (!actual) continue;
|
||||
expect(actual.vertexCount, `${expectedCase.name} vertex count`).toBe(expectedCase.mesh.vertexCount);
|
||||
expect(actual.triangleCount, `${expectedCase.name} triangle count`).toBe(expectedCase.mesh.triangleCount);
|
||||
expect(actual.indices, `${expectedCase.name} topology`).toEqual(expectedCase.mesh.indices);
|
||||
expect(actual.modifiers).toHaveLength(1);
|
||||
expect(actual.modifiers[0].status, `${expectedCase.name} modifier status`).toBe("EVALUATED");
|
||||
|
||||
const positionError = errorMetrics(expectedCase.mesh.positions, actual.positions);
|
||||
expect(positionError.maximum, `${expectedCase.name} maximum position error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.maxPositionError);
|
||||
expect(positionError.rms, `${expectedCase.name} RMS position error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.rmsPositionError);
|
||||
|
||||
const actualBounds = bounds(actual.positions);
|
||||
expect(errorMetrics(expectedCase.mesh.bounds.min, actualBounds.min).maximum,
|
||||
`${expectedCase.name} minimum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError);
|
||||
expect(errorMetrics(expectedCase.mesh.bounds.max, actualBounds.max).maximum,
|
||||
`${expectedCase.name} maximum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError);
|
||||
|
||||
expect(Object.keys(actual.attributes ?? {}).sort(), `${expectedCase.name} attribute inventory`)
|
||||
.toEqual(Object.keys(expectedCase.mesh.attributes).sort());
|
||||
for (const [name, expectedAttribute] of Object.entries(expectedCase.mesh.attributes)) {
|
||||
const actualAttribute = actual.attributes?.[name];
|
||||
expect(actualAttribute, `${expectedCase.name}/${name}`).toBeDefined();
|
||||
expect(actualAttribute?.domain).toBe(expectedAttribute.domain);
|
||||
expect(actualAttribute?.dataType).toBe(expectedAttribute.dataType);
|
||||
expect(errorMetrics(expectedAttribute.values, actualAttribute?.values ?? []).maximum,
|
||||
`${expectedCase.name}/${name} value error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.maxAttributeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("M10-03 matches every allowlisted Geometry Node against Blender 5.2 desktop goldens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(golden.schemaVersion).toBe(1);
|
||||
expect(crypto.createHash("sha256").update(blendBytes).digest("hex")).toBe(golden.fixtureSha256);
|
||||
expect(Object.keys(golden.nodeCoverage).sort()).toEqual([...golden.allowlist].sort());
|
||||
expect(golden.allowlist).toHaveLength(16);
|
||||
|
||||
await page.goto("/");
|
||||
const evaluations = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const firstReport = await first.evaluateDepsgraph();
|
||||
const saved = await first.saveBlend();
|
||||
const initial = { graphs: opened.snapshot.geometryNodeGraphs ?? [], report: firstReport.depsgraph };
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const secondReport = await second.evaluateDepsgraph();
|
||||
const restored = { graphs: reopened.snapshot.geometryNodeGraphs ?? [], report: secondReport.depsgraph };
|
||||
second.terminate();
|
||||
return [initial, restored];
|
||||
}, { bytes: new Uint8Array(blendBytes) }) as EvaluationResult[];
|
||||
|
||||
expect(evaluations).toHaveLength(2);
|
||||
verifyEvaluation(evaluations[0]);
|
||||
verifyEvaluation(evaluations[1]);
|
||||
});
|
||||
108
web/tests/e2e/geometry-node-field-budget.spec.ts
Normal file
108
web/tests/e2e/geometry-node-field-budget.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
GEOMETRY_NODE_FIELD_BUDGET,
|
||||
parseGeometryNodeFieldMaterializationBatch,
|
||||
type GeometryNodeDomainCardinalityIR,
|
||||
} from "../../protocol/geometry-nodes";
|
||||
import { parseDepsgraphEvaluation, type DepsgraphEvaluationIR } from "../../protocol/depsgraph";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const fixture = fs.readFileSync(path.join(root, "tests/files/web/geometry_node_allowlist_evaluator.blend"));
|
||||
|
||||
test("M10-04 binds field conversion budgets to native mesh domain cardinality", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const client = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await client.init();
|
||||
await client.openBlend(source);
|
||||
const evaluated = await client.evaluateDepsgraph();
|
||||
client.terminate();
|
||||
const mesh = evaluated.depsgraph.meshes.find((candidate) =>
|
||||
candidate.objectId === "object:M10GN_StoreAttribute");
|
||||
if (!mesh?.domainCardinality) throw new Error("Store Named Attribute domain cardinality is missing");
|
||||
return {
|
||||
depsgraph: evaluated.depsgraph,
|
||||
domainCardinality: mesh.domainCardinality,
|
||||
fieldMaterializations: mesh.fieldMaterializations ?? [],
|
||||
attributes: mesh.attributes ?? {},
|
||||
};
|
||||
}, { bytes: new Uint8Array(fixture) });
|
||||
|
||||
expect(result.domainCardinality).toEqual({
|
||||
POINT: 8,
|
||||
EDGE: 12,
|
||||
FACE: 6,
|
||||
CORNER: 24,
|
||||
CURVE: 0,
|
||||
INSTANCE: 0,
|
||||
LAYER: 0,
|
||||
});
|
||||
expect(result.fieldMaterializations).toEqual([{
|
||||
schemaVersion: 1,
|
||||
fieldId: "attribute:m10_value",
|
||||
domain: "POINT",
|
||||
dataType: "FLOAT",
|
||||
elementCount: 8,
|
||||
scalarValueCount: 8,
|
||||
materializedByteLength: 32,
|
||||
transport: "JSON",
|
||||
}]);
|
||||
expect(result.attributes.m10_value.values).toHaveLength(8);
|
||||
expect(result.attributes.m10_value.values.every((value: number) => value === 0.375)).toBe(true);
|
||||
|
||||
const rejects = (mutate: (candidate: DepsgraphEvaluationIR) => void): void => {
|
||||
const candidate = structuredClone(result.depsgraph);
|
||||
mutate(candidate);
|
||||
expect(() => parseDepsgraphEvaluation(candidate)).toThrow();
|
||||
};
|
||||
rejects((candidate) => {
|
||||
const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!;
|
||||
target.domainCardinality!.POINT += 1;
|
||||
});
|
||||
rejects((candidate) => {
|
||||
const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!;
|
||||
target.fieldMaterializations![0].materializedByteLength += 4;
|
||||
});
|
||||
rejects((candidate) => {
|
||||
const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!;
|
||||
(target.fieldMaterializations![0] as unknown as Record<string, unknown>).values = [0];
|
||||
});
|
||||
|
||||
const common = {
|
||||
schemaVersion: 1 as const,
|
||||
graphId: "node-group:M10GN_StoreAttributeGraph",
|
||||
graphHash: "a".repeat(64),
|
||||
revision: 1,
|
||||
transport: "JSON" as const,
|
||||
domainCardinality: result.domainCardinality as GeometryNodeDomainCardinalityIR,
|
||||
};
|
||||
const batch = parseGeometryNodeFieldMaterializationBatch([
|
||||
{
|
||||
...common,
|
||||
fieldId: "field:point-to-corner",
|
||||
sourceDomain: "POINT",
|
||||
targetDomain: "CORNER",
|
||||
dataType: "FLOAT",
|
||||
},
|
||||
{
|
||||
...common,
|
||||
fieldId: "field:constant-offset",
|
||||
sourceDomain: "CONSTANT",
|
||||
targetDomain: "POINT",
|
||||
dataType: "VECTOR",
|
||||
},
|
||||
]);
|
||||
expect(batch).toMatchObject({
|
||||
fieldCount: 2,
|
||||
domainConversionCount: 1,
|
||||
materializedElementCount: 32,
|
||||
materializedByteLength: 192,
|
||||
});
|
||||
expect(batch.fields.every((field) => field.scalarValueCount <=
|
||||
GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField)).toBe(true);
|
||||
});
|
||||
57
web/tests/e2e/geometry-node-main-reader.spec.ts
Normal file
57
web/tests/e2e/geometry-node-main-reader.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const geometryNodesBlend = path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../../tests/files/web/modifier_geometry_nodes_scene.blend",
|
||||
);
|
||||
|
||||
test("M10-01 production Worker preserves Main Geometry Node topology across save and reopen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const blendBytes = fs.readFileSync(geometryNodesBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = blendBytes.buffer.slice(
|
||||
blendBytes.byteOffset,
|
||||
blendBytes.byteOffset + blendBytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const initial = opened.snapshot.geometryNodeGraphs ?? [];
|
||||
const saved = await first.saveBlend();
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const restored = reopened.snapshot.geometryNodeGraphs ?? [];
|
||||
second.terminate();
|
||||
return {
|
||||
graphNames: initial.map((graph) => graph.name),
|
||||
nodeCount: initial.reduce((count, graph) => count + graph.nodes.length, 0),
|
||||
linkCount: initial.reduce((count, graph) => count + graph.links.length, 0),
|
||||
defaultCount: initial.reduce((count, graph) => count + graph.nodes.reduce(
|
||||
(nodeCount, node) => nodeCount + node.sockets.filter((socket) => socket.defaultValue !== undefined).length,
|
||||
0,
|
||||
), 0),
|
||||
unsupportedPreserved: initial.some((graph) => graph.nodes.some((node) => node.type === "GeometryNodeSimulationOutput")),
|
||||
stable: JSON.stringify(initial) === JSON.stringify(restored),
|
||||
hashes: initial.map((graph) => graph.graphHash),
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blendBytes) });
|
||||
|
||||
expect(result).toEqual({
|
||||
graphNames: ["WebGeometryNodes", "WebGeometryNodesSetPosition", "WebGeometryNodesSimulation"],
|
||||
nodeCount: 10,
|
||||
linkCount: 7,
|
||||
defaultCount: 9,
|
||||
unsupportedPreserved: true,
|
||||
stable: true,
|
||||
hashes: result.hashes,
|
||||
});
|
||||
expect(result.hashes).toHaveLength(3);
|
||||
expect(result.hashes.every((hash) => /^[0-9a-f]{64}$/.test(hash ?? ""))).toBe(true);
|
||||
});
|
||||
37
web/tests/e2e/grease-pencil-marquee.spec.ts
Normal file
37
web/tests/e2e/grease-pencil-marquee.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const expectedPointIds = Array.from({ length: 4 }, (_, index) => `grease-pencil-point:GreasePencilData:0:0:${index}`);
|
||||
const expectedStrokeId = "grease-pencil-stroke:GreasePencilData:0:0";
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M9-06 marquee selects only the current drawing stable IDs in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const revision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
await page.getByRole("button", { name: "Grease Pencil 框选工具" }).click();
|
||||
const surface = page.getByTestId("grease-pencil-marquee-surface");
|
||||
await expect(surface).toHaveAttribute("data-drawing-id", "grease-pencil-drawing:GreasePencilData:0");
|
||||
const bounds = await surface.boundingBox();
|
||||
if (!bounds) throw new Error("Grease Pencil marquee surface has no bounds");
|
||||
await page.mouse.move(bounds.x + 3, bounds.y + 3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 3, bounds.y + bounds.height - 3, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-count", "4", { timeout: 20_000 });
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-drawing-id", "grease-pencil-drawing:GreasePencilData:0");
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-stroke-ids", expectedStrokeId);
|
||||
await expect.poll(async () => (await app.getAttribute("data-selected-grease-pencil-point-ids"))?.split(",").filter(Boolean).sort()).toEqual(expectedPointIds);
|
||||
expect(Number(await app.getAttribute("data-current-main-revision"))).toBe(revision);
|
||||
await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
|
||||
});
|
||||
}
|
||||
65
web/tests/e2e/grease-pencil-reorder.spec.ts
Normal file
65
web/tests/e2e/grease-pencil-reorder.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const golden = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M9-08/grease-pencil-reorder.json"), "utf8"));
|
||||
|
||||
test("M9-08 reorders Grease Pencil layers and frames through Main undo, save and reopen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect(crypto.createHash("sha256").update(fs.readFileSync(fixture)).digest("hex")).toBe(golden.fixtureSha256);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const editor = page.getByTestId("grease-pencil-editor");
|
||||
const revision = async () => Number(await app.getAttribute("data-current-main-revision"));
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.source.layerOrder.join(","));
|
||||
await editor.getByLabel("Grease Pencil new layer name").fill("Web Drafts");
|
||||
await editor.getByRole("button", { name: "Add Layer" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.beforeReorder.layerOrder.join(","));
|
||||
await editor.getByLabel("Grease Pencil layer", { exact: true }).selectOption({ label: "Web Drafts" });
|
||||
await editor.getByRole("button", { name: "Add Frame" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", "1");
|
||||
const drawingId = await editor.getAttribute("data-selected-layer-drawing-ids");
|
||||
expect(drawingId).toMatch(/^grease-pencil-drawing:GreasePencilData:\d+$/);
|
||||
|
||||
const beforeLayerMove = await revision();
|
||||
await editor.getByRole("button", { name: "Move layer down" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.afterReorder.layerOrder.join(","));
|
||||
expect(await revision()).toBe(beforeLayerMove + 1);
|
||||
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.beforeReorder.layerOrder.join(","));
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.afterReorder.layerOrder.join(","));
|
||||
|
||||
const beforeFrameMove = await revision();
|
||||
await editor.getByLabel("Grease Pencil target frame").fill("12");
|
||||
await editor.getByRole("button", { name: "Move Grease Pencil frame" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.afterReorder.framesByLayer["Web Drafts"].join(","));
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!);
|
||||
expect(await revision()).toBe(beforeFrameMove + 1);
|
||||
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.beforeReorder.framesByLayer["Web Drafts"].join(","));
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!);
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.afterReorder.framesByLayer["Web Drafts"].join(","));
|
||||
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await page.getByRole("button", { name: "关闭项目" }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "恢复项目" }).click();
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.reopened.layerOrder.join(","));
|
||||
await editor.getByLabel("Grease Pencil layer", { exact: true }).selectOption({ label: "Web Drafts" });
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.reopened.framesByLayer["Web Drafts"].join(","));
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!);
|
||||
});
|
||||
50
web/tests/e2e/grease-pencil-selection.spec.ts
Normal file
50
web/tests/e2e/grease-pencil-selection.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const expectedPointIds = Array.from({ length: 4 }, (_, index) => `grease-pencil-point:GreasePencilData:0:0:${index}`);
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M9-07 shares one Grease Pencil selection revision between 2D canvas and ${offscreen ? "OffscreenCanvas" : "main-thread"} 3D viewport`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const viewport = page.locator("canvas.viewport-canvas");
|
||||
const canvas = page.getByTestId("grease-pencil-canvas-2d");
|
||||
await expect(canvas).toHaveAttribute("data-selection-revision", "0");
|
||||
const mainRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
const first = await canvas.evaluate((element) => JSON.parse(element.dataset.pointLayout ?? "[]")[0] as { pointId: string; x: number; y: number });
|
||||
await canvas.scrollIntoViewIfNeeded();
|
||||
const bounds = await canvas.boundingBox();
|
||||
if (!bounds || !first) throw new Error("Grease Pencil 2D canvas point layout is unavailable");
|
||||
await page.mouse.click(bounds.x + (first.x / 280) * bounds.width, bounds.y + (first.y / 150) * bounds.height);
|
||||
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-revision", "1");
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-source", "CANVAS_2D");
|
||||
await expect(canvas).toHaveAttribute("data-selected-point-ids", first.pointId);
|
||||
await expect(viewport).toHaveAttribute("data-grease-pencil-selection-revision", "1", { timeout: 20_000 });
|
||||
await expect(viewport).toHaveAttribute("data-grease-pencil-selection-point-ids", first.pointId);
|
||||
|
||||
await page.getByRole("button", { name: "Grease Pencil 框选工具" }).click();
|
||||
const marquee = page.getByTestId("grease-pencil-marquee-surface");
|
||||
const marqueeBounds = await marquee.boundingBox();
|
||||
if (!marqueeBounds) throw new Error("Grease Pencil 3D marquee surface is unavailable");
|
||||
await page.mouse.move(marqueeBounds.x + 3, marqueeBounds.y + 3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(marqueeBounds.x + marqueeBounds.width - 3, marqueeBounds.y + marqueeBounds.height - 3, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-revision", "2", { timeout: 20_000 });
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-source", "VIEWPORT_3D");
|
||||
await expect.poll(async () => (await canvas.getAttribute("data-selected-point-ids"))?.split(",").filter(Boolean).sort()).toEqual(expectedPointIds);
|
||||
await expect(canvas).toHaveAttribute("data-selection-revision", "2");
|
||||
await expect(viewport).toHaveAttribute("data-grease-pencil-selection-revision", "2", { timeout: 20_000 });
|
||||
expect(Number(await app.getAttribute("data-current-main-revision"))).toBe(mainRevision);
|
||||
await expect(viewport).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
|
||||
});
|
||||
}
|
||||
100
web/tests/e2e/keyboard-accessibility.spec.ts
Normal file
100
web/tests/e2e/keyboard-accessibility.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
const app = (page: Page) => page.locator("main.blender-app");
|
||||
|
||||
async function focusWithKeyboard(page: Page, target: Locator, backwards = false): Promise<void> {
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
if (await target.evaluate((element) => element === document.activeElement)) return;
|
||||
await page.keyboard.press(backwards ? "Shift+Tab" : "Tab");
|
||||
}
|
||||
throw new Error(`Keyboard focus did not reach ${await target.getAttribute("aria-label") ?? await target.textContent() ?? "target"}`);
|
||||
}
|
||||
|
||||
async function expectVisibleKeyboardFocus(target: Locator): Promise<void> {
|
||||
await expect(target).toBeFocused();
|
||||
await expect.poll(() => target.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) >= 2;
|
||||
})).toBe(true);
|
||||
}
|
||||
|
||||
async function expectNoSeriousAccessibilityViolations(page: Page, checkpoint: string): Promise<void> {
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
|
||||
.analyze();
|
||||
const violations = results.violations
|
||||
.filter((violation) => violation.impact === "critical" || violation.impact === "serious")
|
||||
.map((violation) => ({
|
||||
id: violation.id,
|
||||
impact: violation.impact,
|
||||
help: violation.help,
|
||||
targets: violation.nodes.map((node) => node.target.join(" ")),
|
||||
}));
|
||||
expect(violations, `${checkpoint}: ${JSON.stringify(violations, null, 2)}`).toEqual([]);
|
||||
}
|
||||
|
||||
test("M7-16 completes the P0 project loop without pointer input and passes the accessibility gate", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await expectNoSeriousAccessibilityViolations(page, "empty project");
|
||||
|
||||
const fileMenu = page.getByRole("button", { name: "文件", exact: true });
|
||||
await focusWithKeyboard(page, fileMenu);
|
||||
await expectVisibleKeyboardFocus(fileMenu);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("menu", { name: "文件" })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.keyboard.press("Enter");
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(app(page)).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
||||
|
||||
await page.keyboard.press("F3");
|
||||
const search = page.getByRole("textbox", { name: "搜索操作" });
|
||||
await expectVisibleKeyboardFocus(search);
|
||||
await page.keyboard.insertText("Add Cube");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
await expect(app(page)).toHaveAttribute("data-dirty", "true");
|
||||
|
||||
const blendDownload = page.waitForEvent("download");
|
||||
await page.keyboard.press("Control+s");
|
||||
await expect((await blendDownload).suggestedFilename()).toBe("blender-web.blend");
|
||||
await expect(app(page)).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
|
||||
await expect(app(page)).toHaveAttribute("data-dirty", "false");
|
||||
|
||||
await page.keyboard.press("F3");
|
||||
await expect(search).toBeFocused();
|
||||
await page.keyboard.insertText("Close Project");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 0");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const recentProjects = page.getByRole("combobox", { name: "最近项目" });
|
||||
await focusWithKeyboard(page, recentProjects);
|
||||
await expectVisibleKeyboardFocus(recentProjects);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4", { timeout: 30_000 });
|
||||
await expect(app(page)).toHaveAttribute("data-dirty", "false");
|
||||
await expectNoSeriousAccessibilityViolations(page, "reopened project");
|
||||
|
||||
const renderMenu = page.getByRole("button", { name: "渲染", exact: true });
|
||||
await focusWithKeyboard(page, renderMenu, true);
|
||||
await expectVisibleKeyboardFocus(renderMenu);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("menuitem", { name: "导出 GLB" })).toBeFocused();
|
||||
const glbDownload = page.waitForEvent("download");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect((await glbDownload).suggestedFilename()).toBe("blender-web.glb");
|
||||
await expect(app(page)).toHaveAttribute("data-user-action-export-status", "SUCCEEDED");
|
||||
});
|
||||
143
web/tests/e2e/lighting-field-roundtrip.spec.ts
Normal file
143
web/tests/e2e/lighting-field-roundtrip.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend"));
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-02/lighting-field-roundtrip.json"), "utf8"));
|
||||
|
||||
function expectClose(actual: unknown, reference: unknown): void {
|
||||
if (typeof reference === "number") {
|
||||
expect(actual).toBeCloseTo(reference, 5);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(reference)) {
|
||||
expect(Array.isArray(actual)).toBe(true);
|
||||
expect(actual).toHaveLength(reference.length);
|
||||
reference.forEach((value, index) => expectClose((actual as unknown[])[index], value));
|
||||
return;
|
||||
}
|
||||
if (reference && typeof reference === "object") {
|
||||
expect(actual && typeof actual === "object").toBe(true);
|
||||
for (const [field, value] of Object.entries(reference)) {
|
||||
expectClose((actual as Record<string, unknown>)[field], value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(actual).toEqual(reference);
|
||||
}
|
||||
|
||||
test("M11-02 edits, undoes, redoes, reopens and maps supported lighting fields", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { configurePBRCamera, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
|
||||
const { Object3D, PerspectiveCamera } = await import("/src/vendor/three/three.module.js");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const reopened = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
const camera = opened.snapshot.cameras.find((item) => item.id === "camera:Camera.001")!;
|
||||
const light = opened.snapshot.lights.find((item) => item.id === "light:Area")!;
|
||||
const world = opened.snapshot.worlds.find((item) => item.id === "world:World")!;
|
||||
const revisions = [opened.snapshot.revision];
|
||||
|
||||
const cameraEdit = await client.applyCommand({ type: "setCameraProperties", dataId: camera.id, properties: {
|
||||
lensMm: 35, sensorWidthMm: 32, sensorHeightMm: 18, sensorFit: 2, shift: [0.1, -0.2],
|
||||
near: 0.2, far: 500, orthoScale: 8,
|
||||
depthOfField: { enabled: true, focusDistance: 4.5, apertureFStop: 1.8, apertureBlades: 7, apertureRotation: 0.25, apertureRatio: 1.2 },
|
||||
} });
|
||||
revisions.push(cameraEdit.snapshot.revision);
|
||||
const cameraUndo = await client.applyCommand({ type: "undo" });
|
||||
revisions.push(cameraUndo.snapshot.revision);
|
||||
const cameraRedo = await client.applyCommand({ type: "redo" });
|
||||
revisions.push(cameraRedo.snapshot.revision);
|
||||
|
||||
const lightEdit = await client.applyCommand({ type: "setLightProperties", dataId: light.id, properties: {
|
||||
color: [0.25, 0.5, 0.75], energy: 400, exposure: 1, temperature: 5000,
|
||||
useTemperature: true, castsShadow: false, radius: 0.3, spotAngle: 1.1,
|
||||
spotBlend: 0.25, areaSize: 3, areaSizeY: 2, areaSpread: 2.4, sunAngle: 0.1,
|
||||
} });
|
||||
revisions.push(lightEdit.snapshot.revision);
|
||||
const lightUndo = await client.applyCommand({ type: "undo" });
|
||||
revisions.push(lightUndo.snapshot.revision);
|
||||
const lightRedo = await client.applyCommand({ type: "redo" });
|
||||
revisions.push(lightRedo.snapshot.revision);
|
||||
|
||||
const worldEdit = await client.applyCommand({ type: "setWorldProperties", dataId: world.id, properties: {
|
||||
color: [0.1, 0.2, 0.3], exposure: 0.5,
|
||||
mist: { enabled: true, type: "LINEAR", start: 2, depth: 50, intensity: 0.2, height: 3 },
|
||||
} });
|
||||
revisions.push(worldEdit.snapshot.revision);
|
||||
const worldUndo = await client.applyCommand({ type: "undo" });
|
||||
revisions.push(worldUndo.snapshot.revision);
|
||||
const worldRedo = await client.applyCommand({ type: "redo" });
|
||||
revisions.push(worldRedo.snapshot.revision);
|
||||
|
||||
const saved = await client.saveBlend();
|
||||
const reopenedResult = await reopened.openBlend(saved);
|
||||
const reopenedCamera = reopenedResult.snapshot.cameras.find((item) => item.id === camera.id)!;
|
||||
const reopenedLight = reopenedResult.snapshot.lights.find((item) => item.id === light.id)!;
|
||||
const reopenedWorld = reopenedResult.snapshot.worlds.find((item) => item.id === world.id)!;
|
||||
const mappedCamera = new PerspectiveCamera();
|
||||
configurePBRCamera(mappedCamera, reopenedCamera);
|
||||
const horizontalCamera = new PerspectiveCamera();
|
||||
configurePBRCamera(horizontalCamera, { ...reopenedCamera, sensorFit: 1 });
|
||||
const spot = createPBRLight({ ...reopenedLight, lightType: 2 });
|
||||
const area = createPBRLight({ ...reopenedLight, lightType: 4 });
|
||||
const lightNode = reopenedResult.snapshot.nodes.find((item) => item.dataId === light.id)!;
|
||||
configurePBRLight(spot, lightNode, new Object3D());
|
||||
return {
|
||||
revisions,
|
||||
cameraUndoLens: cameraUndo.snapshot.cameras.find((item) => item.id === camera.id)!.lensMm,
|
||||
cameraRedoLens: cameraRedo.snapshot.cameras.find((item) => item.id === camera.id)!.lensMm,
|
||||
lightUndoEnergy: lightUndo.snapshot.lights.find((item) => item.id === light.id)!.energy,
|
||||
lightRedoEnergy: lightRedo.snapshot.lights.find((item) => item.id === light.id)!.energy,
|
||||
worldUndoColor: worldUndo.snapshot.worlds.find((item) => item.id === world.id)!.color,
|
||||
worldRedoColor: worldRedo.snapshot.worlds.find((item) => item.id === world.id)!.color,
|
||||
camera: reopenedCamera,
|
||||
light: reopenedLight,
|
||||
world: reopenedWorld,
|
||||
viewport: {
|
||||
camera: { fov: mappedCamera.fov, horizontalFov: horizontalCamera.fov, near: mappedCamera.near, far: mappedCamera.far, filmGauge: mappedCamera.filmGauge, filmOffset: mappedCamera.filmOffset },
|
||||
spot: { color: spot.color.toArray(), intensity: spot.intensity, angle: "angle" in spot ? spot.angle : 0, penumbra: "penumbra" in spot ? spot.penumbra : 0, castShadow: spot.castShadow },
|
||||
area: { intensity: area.intensity, width: "width" in area ? area.width : 0, height: "height" in area ? area.height : 0 },
|
||||
worldColor: reopenedWorld.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
reopened.terminate();
|
||||
}
|
||||
}, Array.from(blend));
|
||||
|
||||
expect(result.revisions.every((revision, index) => index === 0 || revision === result.revisions[index - 1] + 1)).toBe(true);
|
||||
expect(result.cameraUndoLens).toBe(50);
|
||||
expect(result.cameraRedoLens).toBe(expected.camera.lensMm);
|
||||
expect(result.lightUndoEnergy).toBe(800);
|
||||
expect(result.lightRedoEnergy).toBe(expected.light.energy);
|
||||
expect(result.worldUndoColor).not.toEqual(expected.world.color);
|
||||
expectClose(result.worldRedoColor, expected.world.color);
|
||||
for (const [field, value] of Object.entries(expected.camera).filter(([field]) => !field.startsWith("viewport"))) {
|
||||
expectClose(result.camera[field as keyof typeof result.camera], value);
|
||||
}
|
||||
for (const [field, value] of Object.entries(expected.light).filter(([field]) => !field.startsWith("viewport"))) {
|
||||
expectClose(result.light[field as keyof typeof result.light], value);
|
||||
}
|
||||
expectClose(result.world, expected.world);
|
||||
expect(result.viewport.camera.fov).toBeCloseTo(expected.camera.viewportFov, 5);
|
||||
expect(result.viewport.camera.horizontalFov).toBeCloseTo(expected.camera.viewportHorizontalFov, 5);
|
||||
expectClose(result.viewport.camera.near, expected.camera.near);
|
||||
expectClose(result.viewport.camera.far, expected.camera.far);
|
||||
expectClose(result.viewport.camera.filmGauge, expected.camera.viewportFilmGauge);
|
||||
expect(result.viewport.camera.filmOffset).toBeCloseTo(expected.camera.viewportFilmOffset, 6);
|
||||
expectClose(result.viewport.spot.intensity, expected.light.viewportIntensity);
|
||||
expectClose(result.viewport.spot.color, expected.light.viewportColor);
|
||||
expectClose(result.viewport.spot.angle, expected.light.spotAngle);
|
||||
expectClose(result.viewport.spot.penumbra, expected.light.spotBlend);
|
||||
expect(result.viewport.spot.castShadow).toBe(false);
|
||||
expectClose(result.viewport.area, { intensity: expected.light.viewportIntensity, width: expected.light.areaSize, height: expected.light.areaSizeY });
|
||||
expectClose(result.viewport.worldColor, expected.world.color);
|
||||
});
|
||||
@@ -183,7 +183,20 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
|
||||
const restartedDisposed = await restarted.dispose();
|
||||
restarted.terminate();
|
||||
|
||||
const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"]));
|
||||
const codecRequest = {
|
||||
schemaVersion: 1 as const,
|
||||
stripType: "MOVIE" as const,
|
||||
mimeType: "video/mp4",
|
||||
byteLength: 1,
|
||||
sourceSha256: "0".repeat(64),
|
||||
};
|
||||
const codec = gateSequencerCodec(codecRequest, {
|
||||
...codecRequest,
|
||||
status: "BLOCKED",
|
||||
backend: null,
|
||||
reason: "RUNTIME_UNAVAILABLE",
|
||||
decoded: null,
|
||||
});
|
||||
const runtime = sequencerRuntimeCapabilities();
|
||||
let corruptManifestCode = "";
|
||||
try { parseLongMediaSessionManifest({ ...manifest, assets: [{ ...manifest.assets[0], sha256: "bad" }, manifest.assets[1]] }); }
|
||||
|
||||
47
web/tests/e2e/m10-domain-browser-gates.spec.ts
Normal file
47
web/tests/e2e/m10-domain-browser-gates.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-15/domain-browser-gates.json"), "utf8"));
|
||||
|
||||
test("M10-15 gates GN, Shader, NLA and Simulation performance, OOM and malicious inputs", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const results = await page.evaluate(async () => {
|
||||
const domains = ["GN", "SHADER", "NLA", "SIMULATION"] as const;
|
||||
const run = (domain: typeof domains[number]) => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/m10-domain-gate-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: Record<string, unknown>; error?: string }>) => {
|
||||
worker.terminate();
|
||||
if (event.data.ok && event.data.result) resolve(event.data.result);
|
||||
else reject(new Error(event.data.error ?? `${domain} gate failed`));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ domain });
|
||||
});
|
||||
return Promise.all(domains.map(run));
|
||||
}) as Array<{
|
||||
domain: string;
|
||||
performanceMs: number;
|
||||
workUnits: number;
|
||||
oomCode: string;
|
||||
maliciousCode: string;
|
||||
recovered: boolean;
|
||||
performanceStatus: string;
|
||||
oomPreventedBeforeAllocation: boolean;
|
||||
}>;
|
||||
|
||||
expect(results.map((result) => result.domain)).toEqual(["GN", "SHADER", "NLA", "SIMULATION"]);
|
||||
for (const result of results) {
|
||||
const gate = expected.domains[result.domain];
|
||||
expect(result.workUnits).toBe(gate.workUnits);
|
||||
expect(result.performanceMs).toBeLessThan(gate.maximumMs);
|
||||
expect(result.performanceStatus).toBe(gate.performanceStatus);
|
||||
expect(result.oomCode).toBe(gate.oomCode);
|
||||
expect(result.maliciousCode).toBe(gate.maliciousCode);
|
||||
expect(result.oomPreventedBeforeAllocation).toBe(true);
|
||||
expect(result.recovered).toBe(true);
|
||||
}
|
||||
console.log("m10-domain-browser-gates", JSON.stringify(results));
|
||||
});
|
||||
101
web/tests/e2e/nanovdb-main-thread.spec.ts
Normal file
101
web/tests/e2e/nanovdb-main-thread.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-11 main-thread WebGPU loads a missing page and renders deterministic pixels", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [viewport, renderer, { NanoVDBFloat32Sampler }] = await Promise.all([
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/render/nanovdb-volume-renderer.ts"),
|
||||
import("/src/volume/nanovdb-float32.ts"),
|
||||
]);
|
||||
const manifest = await viewport.loadNanoVDBViewportAsset("main-thread-fixture", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
const density = manifest.manifest.grids.find((grid) => grid.name === manifest.manifest.material.densityGrid);
|
||||
if (!density || !manifest.manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: main-thread fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: main-thread fixture range was not served");
|
||||
const payload = await response.arrayBuffer();
|
||||
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.manifest.gpu.float32TreeLayout);
|
||||
const coordinate = [0, 0, 0] as const;
|
||||
const leafByteOffset = sampler.leafByteOffset(coordinate);
|
||||
if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread fixture has no leaf");
|
||||
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
|
||||
const leafPageId = Math.floor(leafByteOffset / pageByteLength);
|
||||
if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread leaf page is not pageable");
|
||||
const session = new renderer.NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(pageCount * pageByteLength, 6);
|
||||
const grid = renderer.createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength);
|
||||
for (let pageId = 0; pageId < pageCount; pageId++) {
|
||||
if (pageId === leafPageId) continue;
|
||||
grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength)));
|
||||
}
|
||||
|
||||
const feedbackBuffer = renderer.createNanoVDBPageFeedbackGPUBuffer(device, 4);
|
||||
const beforeLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedbackBuffer);
|
||||
const missing = await renderer.readNanoVDBPageFeedbackGPUBuffer(device, feedbackBuffer, 4, pageCount, 17);
|
||||
const pageSource = async (pageId: number, signal: AbortSignal): Promise<ArrayBuffer> => {
|
||||
if (signal.aborted) throw new DOMException("main-thread page request cancelled", "AbortError");
|
||||
const start = density.byteOffset + pageId * pageByteLength;
|
||||
const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1;
|
||||
const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` }, signal });
|
||||
if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: page range returned ${pageResponse.status}`);
|
||||
return pageResponse.arrayBuffer();
|
||||
};
|
||||
const loadedPages: number[] = [];
|
||||
const page = await pageSource(missing.pageIds[0], new AbortController().signal);
|
||||
const frames: Array<() => void> = [];
|
||||
let redraws = 0;
|
||||
const scheduler = new renderer.NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; });
|
||||
const uploader = new renderer.NanoVDBProgressivePageUploader(grid, scheduler);
|
||||
loadedPages.push(missing.pageIds[0]);
|
||||
const upload = uploader.upload(missing.pageIds[0], page.slice(0));
|
||||
const queuedBeforeRedraw = frames.length;
|
||||
frames.shift()?.();
|
||||
|
||||
const afterLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]);
|
||||
const material = { ...manifest.manifest.material, interpolation: "LINEAR" as const };
|
||||
const pixelsA = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64);
|
||||
const pixelsB = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64);
|
||||
const digest = async (pixels: Uint8Array): Promise<string> => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const visible = (pixels: Uint8Array): number => { let count = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 24) count++; return count; };
|
||||
const output = {
|
||||
pageCount,
|
||||
leafPageId,
|
||||
beforeLoad,
|
||||
missing,
|
||||
loadedPages,
|
||||
upload,
|
||||
queuedBeforeRedraw,
|
||||
redraws,
|
||||
afterLoad,
|
||||
pixels: { width: 64, height: 64, bytes: pixelsA.byteLength, visible: visible(pixelsA), sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) },
|
||||
scheduler: scheduler.stats(),
|
||||
};
|
||||
scheduler.dispose();
|
||||
grid.dispose();
|
||||
feedbackBuffer.destroy();
|
||||
session.dispose();
|
||||
return output;
|
||||
});
|
||||
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
expect(result.leafPageId).toBeGreaterThan(0);
|
||||
expect(result.beforeLoad[0].valid).toBe(true);
|
||||
expect(result.missing.status).toBe("READY");
|
||||
expect(result.missing.pageIds).toEqual([result.leafPageId]);
|
||||
expect(result.loadedPages).toEqual([result.leafPageId]);
|
||||
expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true });
|
||||
expect(result.queuedBeforeRedraw).toBe(1);
|
||||
expect(result.redraws).toBe(1);
|
||||
expect(result.afterLoad[0].valid).toBe(true);
|
||||
expect(result.pixels).toMatchObject({ width: 64, height: 64, bytes: 64 * 64 * 4 });
|
||||
expect(result.pixels.visible).toBeGreaterThan(0);
|
||||
expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c");
|
||||
expect(result.pixels.sha256A).toBe(result.pixels.sha256B);
|
||||
expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null });
|
||||
});
|
||||
25
web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts
Normal file
25
web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-12 Offscreen Worker repeats the main-thread NanoVDB page sequence and pixels", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-offscreen-page-feedback-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
expect(result.leafPageId).toBeGreaterThan(0);
|
||||
expect(result.beforeLoad[0].valid).toBe(true);
|
||||
expect(result.missing.status).toBe("READY");
|
||||
expect(result.missing.pageIds).toEqual([result.leafPageId]);
|
||||
expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true });
|
||||
expect(result.queuedBeforeRedraw).toBe(1);
|
||||
expect(result.redraws).toBe(1);
|
||||
expect(result.afterLoad[0].valid).toBe(true);
|
||||
expect(result.pixels.bytes).toBe(64 * 64 * 4);
|
||||
expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c");
|
||||
expect(result.pixels.sha256B).toBe(result.pixels.sha256A);
|
||||
expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null });
|
||||
});
|
||||
25
web/tests/e2e/nanovdb-opfs-restart.spec.ts
Normal file
25
web/tests/e2e/nanovdb-opfs-restart.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-14 Worker restart restores the OPFS manifest without trusting resident pages", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const run = (action: "prepare" | "reopen", state?: unknown): Promise<any> => page.evaluate(({ action, state }) => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-opfs-restart-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ action, state });
|
||||
}), { action, state });
|
||||
const prepared = await run("prepare");
|
||||
const reopened = await run("reopen", {
|
||||
projectId: prepared.projectId,
|
||||
bundleSha256: prepared.bundleSha256,
|
||||
claimedResidentPages: prepared.claimedResidentPages,
|
||||
});
|
||||
expect(prepared.residentBeforeRestart).toEqual([0]);
|
||||
expect(reopened.manifestSha256).toBe(prepared.bundleSha256);
|
||||
expect(reopened.claimedResidentPages).toEqual([0]);
|
||||
expect(reopened.residentBeforeRestore).toEqual([]);
|
||||
expect(reopened.residentAfterRestore).toEqual([0]);
|
||||
expect(reopened.pageBytes).toBe(64 * 1024);
|
||||
expect(reopened.tamperedError).toContain("NANOVDB_HASH_MISMATCH");
|
||||
expect(reopened.residentAfterTamperedRestore).toEqual([]);
|
||||
});
|
||||
197
web/tests/e2e/nanovdb-page-feedback.spec.ts
Normal file
197
web/tests/e2e/nanovdb-page-feedback.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-02 through M8-10 gates, pins and bounded redraws manifest-backed page faults", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-page-feedback-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.error ? reject(new Error(event.data.error)) : resolve(event.data);
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
|
||||
expect(result.leafByteOffset).toBeGreaterThan(0);
|
||||
expect(result.leafPageId).toBeGreaterThan(0);
|
||||
expect(result.leafPageId).toBeLessThan(result.pageCount);
|
||||
expect(result.manifestPageBytes).toBe(4 * 1024 * 1024);
|
||||
expect(result.leafSampleCount).toBe(256);
|
||||
expect(result.leafSamplesFallback).toBe(true);
|
||||
expect(result.leafResult).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [result.leafPageId],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.leafGuard).toEqual(result.guardWords);
|
||||
expect(result.staleDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
currentRenderRevision: 18,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
});
|
||||
expect(result.stalePageIoCount).toBe(0);
|
||||
expect(result.currentDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
currentRenderRevision: 17,
|
||||
status: "ACCEPTED",
|
||||
requestedPageIds: [result.leafPageId],
|
||||
requestedCount: 1,
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.pageIoRequests).toEqual([{ pageId: result.leafPageId, renderRevision: 17 }]);
|
||||
expect(result.manifestFeedbackResult).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 19,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [0],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.manifestWordsZero).toBe(true);
|
||||
expect(result.manifestStaleDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 19,
|
||||
currentRenderRevision: 20,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
});
|
||||
expect(result.staleManifestRangeCount).toBe(0);
|
||||
expect(result.manifestDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 19,
|
||||
currentRenderRevision: 19,
|
||||
status: "ACCEPTED",
|
||||
requestedPageIds: [0],
|
||||
requestedCount: 1,
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.manifestPageIoRequests).toEqual([{ pageId: 0, renderRevision: 19 }]);
|
||||
expect(result.manifestRanges.length).toBeGreaterThan(0);
|
||||
expect(result.manifestRanges).toEqual(result.declaredManifestRanges);
|
||||
expect(result.manifestRanges.every((range: { sha256: string }) => /^[a-f0-9]{64}$/.test(range.sha256))).toBe(true);
|
||||
expect(result.manifestPageMatchesPayload).toBe(true);
|
||||
expect(result.coalesced.rangeCalls).toBe(1);
|
||||
expect(result.coalesced.underlyingAborts).toBe(0);
|
||||
expect(result.coalesced.beforeCancel).toEqual({ pendingPages: 1, subscribers: 2, pageIds: [0] });
|
||||
expect(result.coalesced.afterFirstCancel).toEqual({ pendingPages: 1, subscribers: 1, pageIds: [0] });
|
||||
expect(result.coalesced.afterResolve).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
expect(result.coalesced.outcomes[0]).toEqual({ status: "REJECTED", name: "AbortError" });
|
||||
expect(result.coalesced.outcomes[1].status).toBe("RESOLVED");
|
||||
expect(result.coalesced.consumers).toEqual(["second:0"]);
|
||||
expect(result.lastCancel.rangeCalls).toBe(1);
|
||||
expect(result.lastCancel.underlyingAborts).toBe(1);
|
||||
expect(result.lastCancel.afterFirst).toEqual({
|
||||
stats: { pendingPages: 1, subscribers: 1, pageIds: [0] },
|
||||
underlyingAborts: 0,
|
||||
});
|
||||
expect(result.lastCancel.afterLast).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
expect(result.lastCancel.outcomes).toEqual([
|
||||
{ status: "REJECTED", name: "AbortError" },
|
||||
{ status: "REJECTED", name: "AbortError" },
|
||||
]);
|
||||
expect(result.tamperedPage).toEqual({
|
||||
rangeCalls: 1,
|
||||
residentWrites: 0,
|
||||
errorCode: "NANOVDB_HASH_MISMATCH",
|
||||
errorMessage: expect.stringContaining("NANOVDB_HASH_MISMATCH"),
|
||||
cache: {
|
||||
residentPageCount: 0,
|
||||
residentBytes: 0,
|
||||
residentVirtualPages: [],
|
||||
coordinator: { pendingPages: 0, subscribers: 0, pageIds: [] },
|
||||
},
|
||||
});
|
||||
expect(result.framePin).toEqual({
|
||||
pinnedPage0: true,
|
||||
pinnedMissingPage: false,
|
||||
afterPinnedEviction: {
|
||||
residentVirtualPages: [0, 2],
|
||||
evictionCount: 1,
|
||||
page0: true,
|
||||
page1: false,
|
||||
page2: true,
|
||||
},
|
||||
allPinnedError: expect.stringContaining("NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned"),
|
||||
afterAllPinned: {
|
||||
residentVirtualPages: [0, 2],
|
||||
evictionCount: 1,
|
||||
page3: false,
|
||||
},
|
||||
afterNextFrame: {
|
||||
residentVirtualPages: [2, 3],
|
||||
evictionCount: 2,
|
||||
page0: false,
|
||||
page2: true,
|
||||
page3: true,
|
||||
},
|
||||
pageTable: [0xffffffff, 0xffffffff, 1, 0],
|
||||
});
|
||||
expect(result.progressiveRedraw).toEqual({
|
||||
firstUpload: { pageId: 0, redrawScheduled: true },
|
||||
secondUpload: { pageId: 1, redrawScheduled: false },
|
||||
failedUpload: expect.stringContaining("NANOVDB_STREAM_INCOMPLETE"),
|
||||
beforeFirst: {
|
||||
queuedFrames: 1,
|
||||
callbacks: 0,
|
||||
stats: { pending: true, scheduledCount: 1, redrawCount: 0, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
afterFirst: {
|
||||
queuedFrames: 0,
|
||||
callbacks: 1,
|
||||
stats: { pending: false, scheduledCount: 1, redrawCount: 1, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
thirdUpload: { pageId: 2, redrawScheduled: true },
|
||||
beforeSecond: {
|
||||
queuedFrames: 1,
|
||||
callbacks: 1,
|
||||
stats: { pending: true, scheduledCount: 2, redrawCount: 1, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
afterSecond: {
|
||||
queuedFrames: 0,
|
||||
callbacks: 2,
|
||||
stats: { pending: false, scheduledCount: 2, redrawCount: 2, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.progressiveRedrawCap).toEqual({
|
||||
cappedRedraw: {
|
||||
uploads: [
|
||||
{ pageId: 0, redrawScheduled: true },
|
||||
{ pageId: 0, redrawScheduled: true },
|
||||
{ pageId: 0, redrawScheduled: false },
|
||||
{ pageId: 0, redrawScheduled: false },
|
||||
],
|
||||
callbacks: 2,
|
||||
queuedFrames: 0,
|
||||
stats: { pending: false, scheduledCount: 2, redrawCount: 2, maxRedraws: 2, capped: true, errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" },
|
||||
},
|
||||
afterCappedReset: { pending: false, scheduledCount: 0, redrawCount: 0, maxRedraws: 2, capped: false, errorCode: null },
|
||||
});
|
||||
|
||||
expect(result.missingWordsZero).toBe(true);
|
||||
expect(result.overflowResult.status).toBe("OVERFLOW");
|
||||
expect(result.overflowResult.renderRevision).toBe(18);
|
||||
expect(result.overflowResult.errorCode).toBe("NANOVDB_PAGE_FEEDBACK_OVERFLOW");
|
||||
expect(result.overflowResult.attemptedCount).toBeGreaterThan(2);
|
||||
expect(result.overflowResult.gpuStoredCount).toBe(2);
|
||||
expect(result.overflowResult.uniqueCount).toBe(2);
|
||||
expect(new Set(result.overflowResult.pageIds).size).toBe(2);
|
||||
expect(result.overflowResult.pageIds).toEqual([...result.overflowResult.pageIds].sort((left, right) => left - right));
|
||||
expect(result.overflowResult.pageIds.every((pageId: number) => result.missingPageIds.includes(pageId))).toBe(true);
|
||||
expect(result.overflowGuard).toEqual(result.guardWords);
|
||||
});
|
||||
20
web/tests/e2e/nanovdb-page-resume.spec.ts
Normal file
20
web/tests/e2e/nanovdb-page-resume.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-13 resumes a feedback page from the exact interrupted byte offset", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-page-resume-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.dispatch).toMatchObject({ status: "ACCEPTED", renderRevision: 17, requestedPageIds: [0], requestedCount: 1, errorCode: null });
|
||||
expect(result.ranges.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.ranges[0]).toMatch(/^bytes=\d+-\d+$/);
|
||||
expect(result.resumedRangeStart).toBe(Number(result.ranges[0].match(/^bytes=(\d+)-/)?.[1]) + 4096);
|
||||
expect(result.ifRanges[0]).toBe("");
|
||||
expect(result.ifRanges[1]).toMatch(/^"vdb-/);
|
||||
expect(result.consumedBytes).toBe(result.expectedBytes);
|
||||
expect(result.consumedSha256).toBe(result.expectedSha256);
|
||||
expect(result.coordinator).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
});
|
||||
88
web/tests/e2e/nanovdb-render-golden.spec.ts
Normal file
88
web/tests/e2e/nanovdb-render-golden.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { compareNanoVDBRenderGolden, type NanoVDBRenderGoldenThresholdsIR } from "../../protocol/nanovdb-render-golden";
|
||||
|
||||
const goldenRoot = path.resolve(import.meta.dirname, "../../../tests/golden/M8-19");
|
||||
const goldenManifest = JSON.parse(fs.readFileSync(path.join(goldenRoot, "manifest.json"), "utf8")) as {
|
||||
schemaVersion: number;
|
||||
source: { sha256: string };
|
||||
renderContract: {
|
||||
shaderSemanticVersion: string;
|
||||
width: number;
|
||||
height: number;
|
||||
axes: Array<"X" | "Y" | "Z">;
|
||||
thresholds: NanoVDBRenderGoldenThresholdsIR;
|
||||
};
|
||||
images: Array<{ axis: "X" | "Y" | "Z"; file: string; sha256: string }>;
|
||||
};
|
||||
const referenceImages = Object.fromEntries(goldenManifest.images.map((image) => [image.axis, new Uint8Array(fs.readFileSync(path.join(goldenRoot, image.file))) ])) as Record<"X" | "Y" | "Z", Uint8Array>;
|
||||
|
||||
test("M8-19 compares desktop OpenVDB with main-thread and Offscreen WebGPU goldens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const main = await page.evaluate(async () => {
|
||||
const [viewport, renderer] = await Promise.all([
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/render/nanovdb-volume-renderer.ts"),
|
||||
]);
|
||||
const asset = await viewport.loadNanoVDBViewportAsset("m8-19-main", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid);
|
||||
const payload = asset.grids.find((grid) => grid.name === density?.name)?.data;
|
||||
if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing");
|
||||
const session = new renderer.NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(payload.byteLength, 4);
|
||||
const uploaded = renderer.uploadNanoVDBFloat32Grid(device, payload);
|
||||
const material = { ...asset.manifest.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, interpolation: "LINEAR" as const };
|
||||
const images: Record<string, number[]> = {};
|
||||
const hashes: Record<string, string> = {};
|
||||
for (const axis of ["X", "Y", "Z"] as const) {
|
||||
const pixels = await renderer.renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis);
|
||||
images[axis] = Array.from(pixels);
|
||||
hashes[axis] = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
uploaded.dispose();
|
||||
session.dispose();
|
||||
return { sourceSha256: asset.manifest.sourceSha256, shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion, hashes, images };
|
||||
});
|
||||
const offscreen = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-render-golden-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
if (event.data.error) { reject(new Error(event.data.error)); return; }
|
||||
resolve({
|
||||
...event.data,
|
||||
images: Object.fromEntries(Object.entries(event.data.images).map(([axis, buffer]) => [axis, Array.from(new Uint8Array(buffer as ArrayBuffer))])),
|
||||
});
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
|
||||
expect(goldenManifest.schemaVersion).toBe(1);
|
||||
expect(main.sourceSha256).toBe(goldenManifest.source.sha256);
|
||||
expect(offscreen.sourceSha256).toBe(goldenManifest.source.sha256);
|
||||
expect(main.shaderSemanticVersion).toBe(goldenManifest.renderContract.shaderSemanticVersion);
|
||||
expect(offscreen.shaderSemanticVersion).toBe(goldenManifest.renderContract.shaderSemanticVersion);
|
||||
const report: Record<string, unknown> = {};
|
||||
for (const image of goldenManifest.images) {
|
||||
const reference = referenceImages[image.axis];
|
||||
const mainPixels = Uint8Array.from(main.images[image.axis]);
|
||||
const offscreenPixels = Uint8Array.from(offscreen.images[image.axis]);
|
||||
const mainComparison = compareNanoVDBRenderGolden(reference, mainPixels, goldenManifest.renderContract.thresholds);
|
||||
const offscreenComparison = compareNanoVDBRenderGolden(reference, offscreenPixels, goldenManifest.renderContract.thresholds);
|
||||
const backendComparison = compareNanoVDBRenderGolden(mainPixels, offscreenPixels, {
|
||||
maxChannelError: 0,
|
||||
meanAbsoluteError: 0,
|
||||
rmsError: 0,
|
||||
alphaCoverageDeltaRatio: 0,
|
||||
});
|
||||
expect(main.hashes[image.axis]).toBe(image.sha256);
|
||||
expect(offscreen.hashes[image.axis]).toBe(image.sha256);
|
||||
expect(mainComparison.status).toBe("READY");
|
||||
expect(offscreenComparison.status).toBe("READY");
|
||||
expect(backendComparison.status).toBe("READY");
|
||||
report[image.axis] = { main: mainComparison, offscreen: offscreenComparison, backend: backendComparison, sha256: image.sha256 };
|
||||
}
|
||||
console.log("nanovdb-render-golden", JSON.stringify(report));
|
||||
});
|
||||
53
web/tests/e2e/nanovdb-sparse-performance.spec.ts
Normal file
53
web/tests/e2e/nanovdb-sparse-performance.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-17 streams a 64 MiB sparse bundle with bounded paging and cancellation", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-sparse-performance-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.error ? reject(new Error(event.data.error)) : resolve(event.data);
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
worker.terminate();
|
||||
reject(new Error(event.message));
|
||||
};
|
||||
worker.postMessage({});
|
||||
}));
|
||||
|
||||
console.log("nanovdb-sparse-performance", JSON.stringify({
|
||||
bundleBytes: result.bundleBytes,
|
||||
requestedPageIds: result.requestedPageIds,
|
||||
loadedPageCount: result.loadedPageCount,
|
||||
transferredBytes: result.transferredBytes,
|
||||
peakRangeBytes: result.peakRangeBytes,
|
||||
firstPageMs: result.firstPageMs,
|
||||
successElapsedMs: result.successElapsedMs,
|
||||
cancel: result.cancel,
|
||||
}));
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
expect(result.bundleBytes).toBe(64 * mib);
|
||||
expect(result.chunkBytes).toBe(4 * mib);
|
||||
expect(result.pageBytes).toBe(256 * 1024);
|
||||
expect(result.requestedPageIds).toEqual([0, 16, 128, 192]);
|
||||
expect(result.loadedPageCount).toBe(result.requestedPageIds.length);
|
||||
expect(result.transferredBytes).toBe(4 * result.chunkBytes);
|
||||
expect(result.rangeCalls).toBe(result.requestedPageIds.length);
|
||||
expect(result.peakRangeBytes).toBe(result.chunkBytes);
|
||||
expect(result.firstPageMs).toBeLessThan(10_000);
|
||||
expect(result.successElapsedMs).toBeLessThan(30_000);
|
||||
expect(result.successCoordinatorStats).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
|
||||
expect(result.cancel.status).toBe("CANCELLED");
|
||||
expect(result.cancel.errorName).toBe("AbortError");
|
||||
expect(result.cancel.consumerCalls).toBe(0);
|
||||
expect(result.cancel.rangeCalls).toBe(1);
|
||||
expect(result.cancel.abortedRequests).toBe(1);
|
||||
expect(result.cancel.transferredBytes).toBeGreaterThan(0);
|
||||
expect(result.cancel.transferredBytes).toBeLessThan(result.chunkBytes);
|
||||
expect(result.cancel.peakRangeBytes).toBe(result.chunkBytes);
|
||||
expect(result.cancel.cancelLatencyMs).toBeLessThan(2_000);
|
||||
expect(result.cancel.coordinatorStats).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
});
|
||||
156
web/tests/e2e/nanovdb-volume-roundtrip.spec.ts
Normal file
156
web/tests/e2e/nanovdb-volume-roundtrip.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
|
||||
test("M8-18 saves Volume Main and its NanoVDB binding, then reopens both production viewports", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.goto("/");
|
||||
const blend = fs.readFileSync(nonMeshBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes }) => {
|
||||
const [engineModule, storageModule, volumeModule, opfsModule, viewportModule, offscreenModule] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/volume/nanovdb-opfs.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const { WebEngineClient } = engineModule;
|
||||
const { StorageClient } = storageModule;
|
||||
const {
|
||||
loadAndCommitNanoVDBViewportAsset,
|
||||
reopenNanoVDBViewportAssetFromOPFS,
|
||||
} = volumeModule;
|
||||
const { listVDBProjectBindings, pruneNanoVDBOPFS } = opfsModule;
|
||||
const { ViewportRenderer } = viewportModule;
|
||||
const { OffscreenViewportRenderer } = offscreenModule;
|
||||
const projectId = "m8-volume-roundtrip";
|
||||
const mainSourcePath = "//volumes/generated-smoke.vdb";
|
||||
const bindingSourcePath = "volumes/generated-smoke.vdb";
|
||||
const data = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer;
|
||||
const digest = async (value: ArrayBuffer): Promise<string> => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", value)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 120_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("M8-18 viewport volume timeout");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
};
|
||||
const renderViewport = async (
|
||||
backend: "main" | "offscreen",
|
||||
snapshot: any,
|
||||
geometryBuffers: any[],
|
||||
nonMeshGeometryBuffers: any[],
|
||||
asset: any,
|
||||
): Promise<any> => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000";
|
||||
document.body.append(canvas);
|
||||
const renderer: any = backend === "main" ? new ViewportRenderer(canvas) : new OffscreenViewportRenderer(canvas);
|
||||
renderer.setSnapshot(snapshot, geometryBuffers, nonMeshGeometryBuffers);
|
||||
renderer.setVolumeAssets([asset]);
|
||||
await waitFor(() => ["ready", "blocked"].includes(canvas.dataset.volumeStatus ?? ""));
|
||||
if (canvas.dataset.volumeStatus !== "ready") throw new Error(`${backend} volume ${canvas.dataset.volumeErrorCode ?? "blocked"}`);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const visible = backend === "offscreen"
|
||||
? Number(canvas.dataset.rendererPixels ?? 0)
|
||||
: (() => {
|
||||
const gl = renderer.renderer.getContext();
|
||||
const pixels = new Uint8Array(64 * 64 * 4);
|
||||
gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
return Array.from({ length: 64 * 64 }, (_, index) => pixels[index * 4 + 3] > 0 && pixels[index * 4] + pixels[index * 4 + 1] + pixels[index * 4 + 2] > 40).filter(Boolean).length;
|
||||
})();
|
||||
let volumeObjects = 0;
|
||||
if (backend === "main") renderer.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) volumeObjects += 1; });
|
||||
const status = { backend, volumeStatus: canvas.dataset.volumeStatus, volumeCount: Number(canvas.dataset.volumeCount), volumeObjects, visible };
|
||||
renderer.dispose();
|
||||
canvas.remove();
|
||||
return status;
|
||||
};
|
||||
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const firstEngine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await firstEngine.init();
|
||||
const opened = await firstEngine.openBlend(data.slice(0));
|
||||
const volume = opened.snapshot.nonMeshData.find((candidate: any) => candidate.type === "VOLUME");
|
||||
if (!volume) throw new Error("M8-18 nonmesh fixture has no Volume Main data");
|
||||
const properties = { displayDensity: 1.75, interpolation: "NEAREST" as const, stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 };
|
||||
const changed = await firstEngine.applyCommand({ type: "setVolumeProperties", dataId: volume.id, sourcePath: mainSourcePath, ...properties });
|
||||
const saved = await firstEngine.saveBlend();
|
||||
const savedHash = await digest(saved);
|
||||
const firstVolume = changed.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id);
|
||||
if (!firstVolume || firstVolume.sourcePath !== mainSourcePath) throw new Error("M8-18 Volume Main source path did not commit");
|
||||
const storage = new StorageClient();
|
||||
const savedProject = await storage.saveProject(projectId, 7, saved.slice(0));
|
||||
storage.terminate();
|
||||
firstEngine.terminate();
|
||||
|
||||
const committedStorage = new StorageClient();
|
||||
const persisted = await committedStorage.readProject(projectId);
|
||||
committedStorage.terminate();
|
||||
const committedAsset = await loadAndCommitNanoVDBViewportAsset(
|
||||
volume.id,
|
||||
bindingSourcePath,
|
||||
"/volumes/generated-smoke.nanovdb.json",
|
||||
"/volumes/generated-smoke.nvdb",
|
||||
{ projectId, sourceBlendSha256: savedHash },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const bindings = await listVDBProjectBindings(projectId);
|
||||
const binding = bindings.find((candidate: any) => candidate.bundleSha256 === committedAsset.manifest.bundleSha256);
|
||||
if (!binding) throw new Error("M8-18 VDB binding was not discoverable after commit");
|
||||
|
||||
const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await reopenedEngine.init();
|
||||
const reopened = await reopenedEngine.openBlend(persisted.buffer.slice(0));
|
||||
const reopenedVolume = reopened.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id);
|
||||
if (!reopenedVolume) throw new Error("M8-18 reopened Main has no Volume data");
|
||||
const reopenedAsset = await reopenNanoVDBViewportAssetFromOPFS(
|
||||
volume.id,
|
||||
bindingSourcePath,
|
||||
{ projectId, sourceBlendSha256: savedHash },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const assetHashes = async (asset: any): Promise<string[]> => Promise.all(asset.grids.map((grid: any) => digest(grid.data)));
|
||||
const committedHashes = await assetHashes(committedAsset);
|
||||
const reopenedHashes = await assetHashes(reopenedAsset);
|
||||
const mainViewport = await renderViewport("main", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset);
|
||||
const offscreenViewport = await renderViewport("offscreen", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset);
|
||||
const reopenedBytesHash = await digest(persisted.buffer);
|
||||
reopenedEngine.terminate();
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
return {
|
||||
project: { revision: savedProject.revision, bytes: savedProject.bytes, persisted: savedProject.persisted, backend: savedProject.backend, hash: savedProject.sha256 },
|
||||
savedHash,
|
||||
reopenedStorage: { revision: persisted.revision, bytes: persisted.bytes, sha256: persisted.sha256, backend: persisted.backend, recovered: persisted.recovered, bytesHash: reopenedBytesHash },
|
||||
main: { id: firstVolume.id, sourcePath: firstVolume.sourcePath, properties: firstVolume.volumeProperties },
|
||||
reopenedMain: { id: reopenedVolume.id, sourcePath: reopenedVolume.sourcePath, properties: reopenedVolume.volumeProperties, revision: reopened.snapshot.revision },
|
||||
binding: { projectId: binding.projectId, sourcePath: binding.sourcePath, sourceBlendSha256: binding.sourceBlendSha256, bundleSha256: binding.bundleSha256, manifestSha256: binding.manifestSha256 },
|
||||
asset: { dataId: reopenedAsset.dataId, bundleSha256: reopenedAsset.manifest.bundleSha256, committedHashes, reopenedHashes, gridCount: reopenedAsset.grids.length },
|
||||
viewports: { main: mainViewport, offscreen: offscreenViewport },
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blend) });
|
||||
|
||||
console.log("nanovdb-volume-roundtrip", JSON.stringify({
|
||||
project: result.project,
|
||||
savedHash: result.savedHash,
|
||||
reopenedStorage: result.reopenedStorage,
|
||||
binding: result.binding,
|
||||
asset: result.asset,
|
||||
viewports: result.viewports,
|
||||
}));
|
||||
expect(result.project).toMatchObject({ revision: 7, persisted: true, backend: "opfs", hash: result.savedHash });
|
||||
expect(result.project.bytes).toBeGreaterThan(0);
|
||||
expect(result.reopenedStorage).toMatchObject({ revision: 7, sha256: result.savedHash, backend: "opfs", recovered: false, bytesHash: result.savedHash });
|
||||
expect(result.main).toMatchObject({ id: result.reopenedMain.id, sourcePath: "//volumes/generated-smoke.vdb", properties: { displayDensity: 1.75, interpolation: "NEAREST", stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 } });
|
||||
expect(result.reopenedMain).toMatchObject(result.main);
|
||||
expect(result.binding).toMatchObject({ projectId: "m8-volume-roundtrip", sourcePath: "volumes/generated-smoke.vdb", sourceBlendSha256: result.savedHash });
|
||||
expect(result.asset).toMatchObject({ dataId: result.main.id, committedHashes: result.asset.reopenedHashes });
|
||||
expect(result.asset.gridCount).toBeGreaterThan(0);
|
||||
expect(result.viewports.main).toMatchObject({ backend: "main", volumeStatus: "ready", volumeCount: 1, volumeObjects: 1 });
|
||||
expect(result.viewports.main.visible).toBeGreaterThan(50);
|
||||
expect(result.viewports.offscreen).toMatchObject({ backend: "offscreen", volumeStatus: "ready", volumeCount: 1 });
|
||||
expect(result.viewports.offscreen.visible).toBeGreaterThan(10);
|
||||
});
|
||||
42
web/tests/e2e/nla-evaluation-golden.spec.ts
Normal file
42
web/tests/e2e/nla-evaluation-golden.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-11/nla-evaluation.json"), "utf8"));
|
||||
const blendBytes = fs.readFileSync(path.join(root, golden.fixture));
|
||||
|
||||
test("M10-11 evaluates NLA track, strip and time mapping through the production Worker", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes, expected }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await client.init();
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const opened = await client.openBlend(source);
|
||||
const initial = JSON.stringify({ nlaTracks: opened.snapshot.nlaTracks, animations: opened.snapshot.animations });
|
||||
const samples = [];
|
||||
for (const frame of expected.frames) {
|
||||
await client.applyCommand({ type: "setFrame", frame: frame.frame });
|
||||
const report = await client.evaluateDepsgraph();
|
||||
const mesh = report.depsgraph.meshes.find((candidate) => candidate.objectId === `object:${expected.object}`);
|
||||
samples.push({ frame: frame.frame, status: report.depsgraph.status, worldMatrix: mesh?.worldMatrix ?? null });
|
||||
}
|
||||
const after = await client.snapshot();
|
||||
client.terminate();
|
||||
return { snapshot: opened.snapshot, initial, after: JSON.stringify({ nlaTracks: after.snapshot.nlaTracks, animations: after.snapshot.animations }), samples };
|
||||
}, { bytes: new Uint8Array(blendBytes), expected: golden });
|
||||
|
||||
expect(result.snapshot.nlaTracks).toHaveLength(golden.tracks.length);
|
||||
expect(result.snapshot.nlaTracks[0].strips).toHaveLength(golden.tracks[0].strips.length);
|
||||
expect(result.after).toBe(result.initial);
|
||||
expect(result.samples).toHaveLength(golden.frames.length);
|
||||
for (const [index, sample] of result.samples.entries()) {
|
||||
expect(sample.status, `frame ${sample.frame}`).toBe("EVALUATED");
|
||||
expect(sample.worldMatrix, `frame ${sample.frame}`).not.toBeNull();
|
||||
const maximumError = Math.max(...sample.worldMatrix.map((value: number, matrixIndex: number) =>
|
||||
Math.abs(value - golden.frames[index].worldMatrix[matrixIndex])));
|
||||
expect(maximumError, `frame ${sample.frame} matrix error`).toBeLessThanOrEqual(golden.tolerance.maxMatrixError);
|
||||
}
|
||||
});
|
||||
100
web/tests/e2e/nla-operator.spec.ts
Normal file
100
web/tests/e2e/nla-operator.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = fs.readFileSync(path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../../tests/files/web/nla_time_mapping_scene.blend",
|
||||
));
|
||||
|
||||
test("M10-12 moves one NLA strip through Main, undo, redo and save/reopen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const track = opened.snapshot.nlaTracks?.find((candidate) => candidate.name === "M10 Time Mapping");
|
||||
const strip = track?.strips.find((candidate) => candidate.id === "M10 Scaled Clip");
|
||||
if (!track || !strip) throw new Error("NLA move fixture is incomplete");
|
||||
const initialRevision = opened.snapshot.revision;
|
||||
const initialTracks = JSON.stringify(opened.snapshot.nlaTracks);
|
||||
|
||||
let staleCode = "";
|
||||
try {
|
||||
await first.applyCommand({
|
||||
type: "moveNLAStrip",
|
||||
objectId: track.ownerId,
|
||||
trackId: track.id,
|
||||
stripId: strip.id,
|
||||
frameStart: 5,
|
||||
baseRevision: initialRevision - 1,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
staleCode = (error as { code?: string }).code ?? "";
|
||||
}
|
||||
const afterStale = await first.snapshot();
|
||||
|
||||
const moved = await first.applyCommand({
|
||||
type: "moveNLAStrip",
|
||||
objectId: track.ownerId,
|
||||
trackId: track.id,
|
||||
stripId: strip.id,
|
||||
frameStart: 5,
|
||||
baseRevision: initialRevision,
|
||||
});
|
||||
const movedStrip = moved.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
|
||||
const undone = await first.applyCommand({ type: "undo" });
|
||||
const undoneStrip = undone.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
const redone = await first.applyCommand({ type: "redo" });
|
||||
const redoneStrip = redone.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
const saved = await first.saveBlend();
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const reopenedStrip = reopened.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
await second.applyCommand({ type: "setFrame", frame: 10 });
|
||||
const report = await second.evaluateDepsgraph();
|
||||
const mesh = report.depsgraph.meshes.find((candidate) => candidate.objectId === track.ownerId);
|
||||
second.terminate();
|
||||
|
||||
return {
|
||||
staleCode,
|
||||
stalePreserved: JSON.stringify(afterStale.snapshot.nlaTracks) === initialTracks,
|
||||
revisions: [initialRevision, moved.snapshot.revision, undone.snapshot.revision, redone.snapshot.revision],
|
||||
delta: [moved.delta.baseRevision, moved.delta.nextRevision],
|
||||
moved: movedStrip ? [movedStrip.frameStart, movedStrip.frameEnd] : null,
|
||||
undone: undoneStrip ? [undoneStrip.frameStart, undoneStrip.frameEnd] : null,
|
||||
redone: redoneStrip ? [redoneStrip.frameStart, redoneStrip.frameEnd] : null,
|
||||
reopened: reopenedStrip ? [reopenedStrip.frameStart, reopenedStrip.frameEnd] : null,
|
||||
evaluatedX: mesh?.worldMatrix[3] ?? null,
|
||||
evaluationStatus: report.depsgraph.status,
|
||||
};
|
||||
}, { bytes: new Uint8Array(fixture) });
|
||||
|
||||
expect(result.staleCode).toBe("REVISION_CONFLICT");
|
||||
expect(result.stalePreserved).toBe(true);
|
||||
expect(result.revisions).toEqual([
|
||||
result.revisions[0],
|
||||
result.revisions[0] + 1,
|
||||
result.revisions[0] + 2,
|
||||
result.revisions[0] + 3,
|
||||
]);
|
||||
expect(result.delta).toEqual([result.revisions[0], result.revisions[0] + 1]);
|
||||
expect(result.moved).toEqual([5, 25]);
|
||||
expect(result.undone).toEqual([20, 40]);
|
||||
expect(result.redone).toEqual([5, 25]);
|
||||
expect(result.reopened).toEqual([5, 25]);
|
||||
expect(result.evaluationStatus).toBe("EVALUATED");
|
||||
expect(result.evaluatedX).toBeCloseTo(2.5, 5);
|
||||
});
|
||||
@@ -75,9 +75,17 @@ test("recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation fau
|
||||
|
||||
const nanoVdb = result.reports.find((report) => report.scenario === "NANOVDB_RESIDENT")!;
|
||||
expect(nanoVdb.faults[0]).toMatchObject({ point: "NANOVDB_PAGE_TABLE", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" });
|
||||
expect(nanoVdb.memory.releasedBytes).toBe(64 * 1024);
|
||||
expect(nanoVdb.state.temporaryResourcesPeak).toBe(1);
|
||||
expect(nanoVdb.checks).toEqual(expect.arrayContaining(["resident-buffer-destroyed-once", "same-device-recovers", "lru-eviction-recovers"]));
|
||||
expect(nanoVdb.faults[1]).toMatchObject({ point: "NANOVDB_FEEDBACK_BUFFER", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_FEEDBACK_BUFFER" });
|
||||
expect(nanoVdb.memory.releasedBytes).toBe(2 * 64 * 1024 + 8 + 32);
|
||||
expect(nanoVdb.state.temporaryResourcesPeak).toBe(3);
|
||||
expect(nanoVdb.checks).toEqual(expect.arrayContaining([
|
||||
"resident-buffer-destroyed-once",
|
||||
"page-table-destroyed-once",
|
||||
"feedback-buffer-destroyed-once",
|
||||
"resource-group-dispose-idempotent",
|
||||
"same-device-recovers",
|
||||
"lru-eviction-recovers",
|
||||
]));
|
||||
|
||||
console.log("oom-recovery", JSON.stringify(result.reports.map((report) => ({
|
||||
scenario: report.scenario,
|
||||
|
||||
116
web/tests/e2e/paint-depth-visibility.spec.ts
Normal file
116
web/tests/e2e/paint-depth-visibility.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M9-09 derives the same occlusion set from real main-thread and Offscreen GPU depth passes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts");
|
||||
const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts");
|
||||
|
||||
const normalize = (value: number[]) => {
|
||||
const length = Math.hypot(...value);
|
||||
return value.map((component) => component / length);
|
||||
};
|
||||
const cross = (left: number[], right: number[]) => [
|
||||
left[1] * right[2] - left[2] * right[1],
|
||||
left[2] * right[0] - left[0] * right[2],
|
||||
left[0] * right[1] - left[1] * right[0],
|
||||
];
|
||||
const add = (...values: number[][]) => values[0].map((_, axis) => values.reduce((sum, value) => sum + value[axis], 0));
|
||||
const scale = (value: number[], factor: number) => value.map((component) => component * factor);
|
||||
const camera = [
|
||||
7 * Math.cos(0.55) * Math.cos(-Math.PI / 4),
|
||||
7 * Math.cos(0.55) * Math.sin(-Math.PI / 4),
|
||||
7 * Math.sin(0.55),
|
||||
];
|
||||
const direction = normalize(camera.map((component) => -component));
|
||||
const horizontal = normalize(cross(direction, [0, 0, 1]));
|
||||
const vertical = normalize(cross(direction, horizontal));
|
||||
const front = [
|
||||
add(scale(horizontal, -2), scale(vertical, -2)),
|
||||
add(scale(horizontal, -2), scale(vertical, 2)),
|
||||
add(scale(horizontal, 2), scale(vertical, 2)),
|
||||
add(scale(horizontal, 2), scale(vertical, -2)),
|
||||
];
|
||||
const behind = scale(direction, 1.5);
|
||||
const back = [
|
||||
add(behind, scale(horizontal, -0.12), scale(vertical, -0.08)),
|
||||
add(behind, scale(horizontal, 0.12), scale(vertical, -0.08)),
|
||||
add(behind, scale(vertical, 0.12)),
|
||||
];
|
||||
const toBlender = (value: number[]) => [value[0], -value[2], value[1]];
|
||||
const positions = new Float32Array([...front, ...back].flatMap(toBlender));
|
||||
const indices = new Uint32Array([0, 1, 2, 0, 2, 3, 4, 6, 5]);
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const snapshot = {
|
||||
schemaVersion: 1 as const,
|
||||
revision: 9,
|
||||
sceneId: "scene:paint-depth",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
nodes: [{
|
||||
id: "object:PaintDepth",
|
||||
name: "PaintDepth",
|
||||
type: "MESH" as const,
|
||||
parentId: null,
|
||||
dataId: "mesh:PaintDepth",
|
||||
visible: true,
|
||||
selectable: true,
|
||||
localMatrix: identity,
|
||||
worldMatrix: identity,
|
||||
transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 },
|
||||
}],
|
||||
meshes: [{ id: "mesh:PaintDepth", name: "PaintDepth", vertexCount: 7, edgeCount: 0, faceCount: 3, cornerCount: 9, triangleCount: 3, geometryStatus: "binary" as const }],
|
||||
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
|
||||
activeObjectId: "object:PaintDepth",
|
||||
frame: { current: 1, start: 1, end: 1 },
|
||||
};
|
||||
const geometry = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId: "mesh:PaintDepth",
|
||||
byteLength: positions.byteLength + indices.byteLength,
|
||||
positions: positions.buffer,
|
||||
indices: indices.buffer,
|
||||
};
|
||||
const request = { schemaVersion: 1 as const, objectId: "object:PaintDepth", meshId: "mesh:PaintDepth", revision: 9, vertexIndices: [0, 1, 2, 3, 4, 5, 6] };
|
||||
|
||||
const createCanvas = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = "320px";
|
||||
canvas.style.height = "240px";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const mainCanvas = createCanvas();
|
||||
const mainRenderer = new ViewportRenderer(mainCanvas);
|
||||
mainRenderer.setSnapshot(snapshot, [geometry]);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const main = await mainRenderer.samplePaintVisibility(request);
|
||||
mainRenderer.dispose();
|
||||
mainCanvas.remove();
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreenRenderer = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreenRenderer.setSnapshot(snapshot, [geometry]);
|
||||
let stale = "";
|
||||
try { await offscreenRenderer.samplePaintVisibility({ ...request, revision: 8 }); }
|
||||
catch (error) { stale = error instanceof Error ? error.message : String(error); }
|
||||
const offscreen = await offscreenRenderer.samplePaintVisibility(request);
|
||||
offscreenRenderer.dispose();
|
||||
offscreenCanvas.remove();
|
||||
|
||||
return { main, offscreen, stale };
|
||||
});
|
||||
|
||||
expect(result.main.backend).toBe("MAIN_THREAD_WEBGL2");
|
||||
expect(result.offscreen.backend).toBe("OFFSCREEN_WEBGL2");
|
||||
expect(result.main.source).toBe("GPU_RGBA_DEPTH_READBACK");
|
||||
expect(result.main.occluderPixelCount).toBeGreaterThan(0);
|
||||
expect(result.offscreen.occluderPixelCount).toBeGreaterThan(0);
|
||||
expect(result.main.depthReadbackBytes).toBe(result.main.width * result.main.height * 4);
|
||||
expect(result.offscreen.depthReadbackBytes).toBe(result.offscreen.width * result.offscreen.height * 4);
|
||||
expect(result.main.visibleVertexIndices).toEqual([0, 1, 2, 3]);
|
||||
expect(result.offscreen.visibleVertexIndices).toEqual(result.main.visibleVertexIndices);
|
||||
expect(result.stale).toContain("REVISION_CONFLICT");
|
||||
});
|
||||
96
web/tests/e2e/paint-pbvh-capability.spec.ts
Normal file
96
web/tests/e2e/paint-pbvh-capability.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { paintPBVHBrushInventory } from "../../protocol/paint-pbvh-capability";
|
||||
|
||||
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M9-13 production Worker blocks the full PBVH brush inventory without changing Main", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const blendBytes = fs.readFileSync(attributeBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes, inventory }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await engine.init();
|
||||
const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength));
|
||||
const object = opened.snapshot.nodes.find((candidate) => candidate.id === opened.snapshot.activeObjectId && candidate.type === "MESH");
|
||||
if (!object?.dataId) throw new Error("PBVH fixture has no active Mesh object");
|
||||
const before = await engine.snapshot();
|
||||
const gates = [];
|
||||
for (const entry of inventory) {
|
||||
gates.push(await engine.queryPaintPBVHCapability({
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: entry.domain,
|
||||
brush: entry.brush,
|
||||
objectId: object.id,
|
||||
meshId: object.dataId,
|
||||
baseRevision: before.snapshot.revision,
|
||||
}));
|
||||
}
|
||||
const stale = await engine.queryPaintPBVHCapability({
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: "WEIGHT",
|
||||
brush: "DRAW",
|
||||
objectId: object.id,
|
||||
meshId: object.dataId,
|
||||
baseRevision: before.snapshot.revision + 1,
|
||||
});
|
||||
let malformed: { code?: string; severity?: string; recoverable?: boolean } = {};
|
||||
try {
|
||||
await engine.queryPaintPBVHCapability({
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: "WEIGHT",
|
||||
brush: "DRAW",
|
||||
objectId: object.id,
|
||||
meshId: object.dataId,
|
||||
baseRevision: before.snapshot.revision,
|
||||
proxySuccess: true,
|
||||
} as never);
|
||||
}
|
||||
catch (error) {
|
||||
malformed = error as typeof malformed;
|
||||
}
|
||||
const after = await engine.snapshot();
|
||||
engine.terminate();
|
||||
return {
|
||||
count: gates.length,
|
||||
statuses: [...new Set(gates.map((gate) => gate.status))],
|
||||
taskIds: [...new Set(gates.map((gate) => gate.taskId))],
|
||||
issueCodes: [...new Set(gates.flatMap((gate) => gate.issues.map((issue) => issue.code)))],
|
||||
capabilities: new Set(gates.map((gate) => gate.capability)).size,
|
||||
staleCode: stale.issues[0]?.code,
|
||||
malformed,
|
||||
beforeRevision: before.snapshot.revision,
|
||||
afterRevision: after.snapshot.revision,
|
||||
beforeHandles: before.status.liveHandles,
|
||||
afterHandles: after.status.liveHandles,
|
||||
beforeBytes: before.status.allocatedBytes,
|
||||
afterBytes: after.status.allocatedBytes,
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blendBytes), inventory: paintPBVHBrushInventory() });
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 46,
|
||||
statuses: ["BLOCKED"],
|
||||
taskIds: ["N-017"],
|
||||
issueCodes: ["PAINT_PBVH_UNAVAILABLE"],
|
||||
capabilities: 46,
|
||||
staleCode: "REVISION_CONFLICT",
|
||||
malformed: {
|
||||
code: "PAINT_SCHEMA_INVALID",
|
||||
severity: "error",
|
||||
recoverable: true,
|
||||
message: "PBVH capability request contains unsupported field proxySuccess",
|
||||
},
|
||||
beforeRevision: result.beforeRevision,
|
||||
afterRevision: result.beforeRevision,
|
||||
beforeHandles: result.beforeHandles,
|
||||
afterHandles: result.beforeHandles,
|
||||
beforeBytes: result.beforeBytes,
|
||||
afterBytes: result.beforeBytes,
|
||||
});
|
||||
});
|
||||
76
web/tests/e2e/paint-stroke-session.spec.ts
Normal file
76
web/tests/e2e/paint-stroke-session.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M9-10 commits pointer chunks as one Main revision and one undo step", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const blendBytes = fs.readFileSync(attributeBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await engine.init();
|
||||
const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength));
|
||||
const mesh = opened.snapshot.meshes.find((candidate) => candidate.id === "mesh:AttributeMesh");
|
||||
if (!mesh || mesh.vertexCount < 4) throw new Error("paint fixture is missing its vertex domain");
|
||||
const pointerSessionId = "paint-pointer:chromium-main-1";
|
||||
const session = {
|
||||
schemaVersion: 1 as const,
|
||||
pointerSessionId,
|
||||
baseRevision: opened.snapshot.revision,
|
||||
target: { mode: "VERTEX_COLOR" as const, meshId: mesh.id, attributeName: "M9StrokeColor", domain: "POINT" as const },
|
||||
};
|
||||
const started = await engine.beginPaintStroke(session);
|
||||
const first = await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, chunkIndex: 0, indices: [0, 1], values: [1, 0, 0, 1, 0, 1, 0, 1] });
|
||||
const during = await engine.snapshot();
|
||||
const second = await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, chunkIndex: 1, indices: [1, 2, 3], values: [0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1] });
|
||||
const committed = await engine.commitPaintStroke({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, expectedChunkCount: 2 });
|
||||
const committedMesh = committed.snapshot.meshes.find((candidate) => candidate.id === mesh.id);
|
||||
const undone = await engine.applyCommand({ type: "undo" });
|
||||
const undoneMesh = undone.snapshot.meshes.find((candidate) => candidate.id === mesh.id);
|
||||
let secondUndoCode = "";
|
||||
try { await engine.applyCommand({ type: "undo" }); }
|
||||
catch (error) { secondUndoCode = (error as { code?: string }).code ?? String(error); }
|
||||
const redone = await engine.applyCommand({ type: "redo" });
|
||||
const redoneMesh = redone.snapshot.meshes.find((candidate) => candidate.id === mesh.id);
|
||||
|
||||
const cancelId = "paint-pointer:chromium-cancel-2";
|
||||
const cancelBase = redone.snapshot.revision;
|
||||
await engine.beginPaintStroke({ ...session, pointerSessionId: cancelId, baseRevision: cancelBase });
|
||||
await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId: cancelId, baseRevision: cancelBase, chunkIndex: 0, indices: [0], values: [0, 0, 0, 1] });
|
||||
const cancelled = await engine.cancelPaintStroke({ schemaVersion: 1, pointerSessionId: cancelId, baseRevision: cancelBase });
|
||||
const afterCancel = await engine.snapshot();
|
||||
engine.terminate();
|
||||
const hasAttribute = (candidate: typeof mesh | undefined) => candidate?.attributes?.some((attribute) => attribute.name === "M9StrokeColor" && attribute.domain === "POINT") ?? false;
|
||||
return {
|
||||
started,
|
||||
first,
|
||||
second,
|
||||
duringRevision: during.snapshot.revision,
|
||||
committedRevision: committed.snapshot.revision,
|
||||
committedReceipt: committed.paintStrokeSession,
|
||||
committedAttribute: hasAttribute(committedMesh),
|
||||
undoneAttribute: hasAttribute(undoneMesh),
|
||||
redoneAttribute: hasAttribute(redoneMesh),
|
||||
secondUndoCode,
|
||||
cancelled,
|
||||
cancelBase,
|
||||
afterCancelRevision: afterCancel.snapshot.revision,
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blendBytes) });
|
||||
|
||||
expect(result.started).toMatchObject({ state: "OPEN", chunkCount: 0 });
|
||||
expect(result.first).toMatchObject({ state: "OPEN", chunkCount: 1, receivedEntryCount: 2, uniqueEntryCount: 2 });
|
||||
expect(result.second).toMatchObject({ state: "OPEN", chunkCount: 2, receivedEntryCount: 5, uniqueEntryCount: 4 });
|
||||
expect(result.duringRevision).toBe(result.started.baseRevision);
|
||||
expect(result.committedRevision).toBe(result.started.baseRevision + 1);
|
||||
expect(result.committedReceipt).toMatchObject({ state: "COMMITTED", chunkCount: 2, receivedEntryCount: 5, uniqueEntryCount: 4, committedRevision: result.committedRevision });
|
||||
expect(result.committedAttribute).toBe(true);
|
||||
expect(result.undoneAttribute).toBe(false);
|
||||
expect(result.redoneAttribute).toBe(true);
|
||||
expect(result.secondUndoCode).toBe("INVALID_ARGUMENT");
|
||||
expect(result.cancelled.state).toBe("CANCELLED");
|
||||
expect(result.afterCancelRevision).toBe(result.cancelBase);
|
||||
});
|
||||
30
web/tests/e2e/physics-cache-family.spec.ts
Normal file
30
web/tests/e2e/physics-cache-family.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-14/physics-cache-family.json"), "utf8"));
|
||||
|
||||
test("M10-14 verifies every Physics family cache before playback", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/physics-cache-family-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({});
|
||||
})) as {
|
||||
verified: Array<{ family: string; source: string; bytes: number; frames: number }>;
|
||||
sourceMismatch: string;
|
||||
payloadMismatch: string;
|
||||
versionMismatch: string;
|
||||
budgetExceeded: string;
|
||||
};
|
||||
expect(result.verified.map((entry) => entry.family)).toEqual(expected.families);
|
||||
expect(result.verified.map((entry) => entry.source)).toEqual(expected.sources);
|
||||
expect(result.verified.every((entry) => entry.bytes === expected.byteLength)).toBe(true);
|
||||
expect(result.verified.every((entry) => entry.frames === expected.frameCount)).toBe(true);
|
||||
expect(result.sourceMismatch).toBe(expected.sourceMismatch);
|
||||
expect(result.payloadMismatch).toBe(expected.payloadMismatch);
|
||||
expect(result.versionMismatch).toBe(expected.versionMismatch);
|
||||
expect(result.budgetExceeded).toBe(expected.budgetExceeded);
|
||||
});
|
||||
34
web/tests/e2e/physics-solver-probe.spec.ts
Normal file
34
web/tests/e2e/physics-solver-probe.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-13/physics-solver-probe.json"), "utf8"));
|
||||
|
||||
test("M10-13 probes each Physics solver family and keeps failures bake-only", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/physics-solver-probe-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({});
|
||||
})) as {
|
||||
familyOrder: string[];
|
||||
defaultProbe: string[];
|
||||
probes: Record<string, string>;
|
||||
routes: Record<string, string>;
|
||||
localGate: { status: string; issues: number };
|
||||
fallbackGate: { status: string; code: string; message: string };
|
||||
};
|
||||
expect(result.familyOrder).toEqual(expected.familyOrder);
|
||||
expect(result.defaultProbe).toEqual(expected.familyOrder.map(() => expected.defaultProbe));
|
||||
expect(result.probes).toEqual(expected.probes);
|
||||
expect(result.routes.RIGID_BODY).toBe(expected.localRoute);
|
||||
for (const family of expected.familyOrder.filter((family: string) => family !== "RIGID_BODY")) {
|
||||
expect(result.routes[family]).toBe(expected.fallbackRoute);
|
||||
}
|
||||
expect(result.localGate).toEqual({ status: "READY", issues: 0 });
|
||||
expect(result.fallbackGate.status).toBe("BLOCKED");
|
||||
expect(result.fallbackGate.code).toBe(expected.fallbackErrorCode);
|
||||
expect(result.fallbackGate.message).toContain("desktop/server bake");
|
||||
});
|
||||
198
web/tests/e2e/recent-projects-recovery.spec.ts
Normal file
198
web/tests/e2e/recent-projects-recovery.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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("M7-09 persists recent projects and restores UI state after a damaged-index Worker restart", async ({ page }) => {
|
||||
await page.goto("/?worker-fault=storage");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
|
||||
await page.getByRole("button", { name: "Modeling" }).click();
|
||||
await page.getByRole("slider", { name: "当前帧" }).fill("12");
|
||||
await expect(app).toHaveAttribute("data-current-frame", "12");
|
||||
await page.locator('.tree-row.child').filter({ hasText: "Camera" }).click();
|
||||
await page.locator('.tree-row.child').filter({ hasText: "Cube" }).click({ modifiers: ["Shift"] });
|
||||
const selectedBefore = await app.getAttribute("data-selected-object-ids");
|
||||
const revisionBefore = await app.getAttribute("data-current-main-revision");
|
||||
expect(selectedBefore?.split(",")).toHaveLength(2);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects: [{ broken: true }] } });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
|
||||
await page.getByTestId("inject-worker-crash").click();
|
||||
await expect(app).toHaveAttribute("data-worker-fault-source", "storage");
|
||||
await page.getByTestId("restart-and-recover").click();
|
||||
await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 });
|
||||
await expect(app).toHaveAttribute("data-workspace", "Modeling");
|
||||
await expect(app).toHaveAttribute("data-current-frame", "12");
|
||||
await expect(app).toHaveAttribute("data-selected-object-ids", selectedBefore ?? "");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", revisionBefore ?? "");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-recent-project-quarantined", "1");
|
||||
const persistedIndex = await page.evaluate(async () => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readonly");
|
||||
const row = await new Promise<{ value?: { projects?: Array<{ projectId?: string }> } } | undefined>((resolve, reject) => {
|
||||
const request = transaction.objectStore("setting").get("recent-projects:v1");
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
database.close();
|
||||
return row?.value?.projects?.map((project) => project.projectId) ?? [];
|
||||
});
|
||||
expect(persistedIndex).toEqual(["basic_scene"]);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await page.getByTestId("recent-projects").selectOption("basic_scene");
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Recovery:");
|
||||
});
|
||||
|
||||
test("M7-09 StorageClient keeps deterministic recent projects across Worker instances", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const base = {
|
||||
schemaVersion: 1 as const,
|
||||
revision: 1,
|
||||
bytes: 8,
|
||||
sha256: "a".repeat(64),
|
||||
updatedAt: "2026-08-15T12:00:00.000Z",
|
||||
backend: "unknown" as const,
|
||||
};
|
||||
const first = new StorageClient();
|
||||
await Promise.all([
|
||||
first.touchRecentProject({ ...base, projectId: "older", displayName: "Older", lastOpenedAt: "2026-08-15T11:00:00.000Z" }),
|
||||
first.touchRecentProject({ ...base, projectId: "newer", displayName: "Newer", lastOpenedAt: "2026-08-15T13:00:00.000Z" }),
|
||||
first.touchRecentProject({ ...base, projectId: "older", displayName: "Older latest", revision: 2, lastOpenedAt: "2026-08-15T14:00:00.000Z" }),
|
||||
]);
|
||||
first.terminate();
|
||||
const restarted = new StorageClient();
|
||||
const listed = await restarted.listRecentProjects();
|
||||
restarted.terminate();
|
||||
return listed;
|
||||
});
|
||||
|
||||
expect(result.quarantined).toBe(0);
|
||||
expect(result.projects.map(({ projectId, revision }) => [projectId, revision])).toEqual([["older", 2], ["newer", 1]]);
|
||||
});
|
||||
|
||||
test("M7-10 isolates a missing recent project and removes only its reference", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
await storage.touchRecentProject({
|
||||
schemaVersion: 1,
|
||||
projectId: "missing_recent",
|
||||
displayName: "Missing recent.blend",
|
||||
revision: 1,
|
||||
bytes: 8,
|
||||
sha256: "a".repeat(64),
|
||||
updatedAt: "2026-08-15T12:00:00.000Z",
|
||||
lastOpenedAt: "2026-08-15T12:00:00.000Z",
|
||||
backend: "opfs",
|
||||
});
|
||||
storage.terminate();
|
||||
});
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1");
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "missing_recent");
|
||||
await expect(page.getByTestId("recent-project-repair-banner")).toContainText("项目内容缺失");
|
||||
await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="MISSING"]')).toBeVisible();
|
||||
|
||||
await page.getByTestId("remove-recent-project-missing_recent").click();
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await expect(page.getByTestId("recent-project-repair-banner")).toHaveCount(0);
|
||||
const listed = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
const result = await storage.listRecentProjects();
|
||||
storage.terminate();
|
||||
return result;
|
||||
});
|
||||
expect(listed.projects.map(({ projectId }) => projectId)).toEqual(["basic_scene"]);
|
||||
expect(listed.issues).toEqual([]);
|
||||
});
|
||||
|
||||
test("M7-10 isolates a hash-mismatched entry while retaining other recent projects", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
const store = transaction.objectStore("setting");
|
||||
const row = await new Promise<{ value?: { projects?: Array<Record<string, unknown>> } } | undefined>((resolve, reject) => {
|
||||
const request = store.get("recent-projects:v1");
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const projects = (row?.value?.projects ?? []).map((project) => project.projectId === "basic_scene" ? { ...project, sha256: "f".repeat(64) } : project);
|
||||
store.put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects } });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1");
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "");
|
||||
await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="HASH_MISMATCH"]')).toBeVisible();
|
||||
|
||||
await page.getByTestId("remove-recent-project-basic_scene").click();
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "");
|
||||
await expect(page.getByText("Cube", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
103
web/tests/e2e/render-reference.spec.ts
Normal file
103
web/tests/e2e/render-reference.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const goldenRoot = path.join(root, "tests/golden/M11-04");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(goldenRoot, "manifest.json"), "utf8"));
|
||||
const blend = fs.readFileSync(path.join(root, manifest.source.fixture));
|
||||
const referencePng = fs.readFileSync(path.join(goldenRoot, manifest.reference.file));
|
||||
|
||||
async function decodeAndCompare(page: import("@playwright/test").Page, reference: Buffer, actual: Buffer) {
|
||||
return page.evaluate(async ({ referenceBytes, actualBytes, thresholds }) => {
|
||||
const { compareRenderImages } = await import("/src/three-adapter/render-image-comparison.ts");
|
||||
const decode = async (bytes: number[]) => {
|
||||
const bitmap = await createImageBitmap(new Blob([Uint8Array.from(bytes)], { type: "image/png" }), {
|
||||
colorSpaceConversion: "none",
|
||||
premultiplyAlpha: "none",
|
||||
});
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("2D decode context is unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const pixels = new Uint8Array(context.getImageData(0, 0, bitmap.width, bitmap.height).data);
|
||||
const result = { width: bitmap.width, height: bitmap.height, pixels };
|
||||
bitmap.close();
|
||||
return result;
|
||||
};
|
||||
const [referenceFrame, actualFrame] = await Promise.all([decode(referenceBytes), decode(actualBytes)]);
|
||||
if (referenceFrame.width !== actualFrame.width || referenceFrame.height !== actualFrame.height) {
|
||||
throw new Error("render frame dimensions differ");
|
||||
}
|
||||
return compareRenderImages(
|
||||
referenceFrame.pixels,
|
||||
actualFrame.pixels,
|
||||
referenceFrame.width,
|
||||
referenceFrame.height,
|
||||
thresholds,
|
||||
);
|
||||
}, { referenceBytes: Array.from(reference), actualBytes: Array.from(actual), thresholds: manifest.thresholds });
|
||||
}
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M11-04 ${offscreen ? "Offscreen" : "main-thread"} production render matches the Blender reference metrics`, async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
await page.evaluate(async ({ input, useOffscreen }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts");
|
||||
const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts");
|
||||
document.body.replaceChildren();
|
||||
document.body.style.margin = "0";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "m11-reference-canvas";
|
||||
canvas.style.width = "256px";
|
||||
canvas.style.height = "256px";
|
||||
document.body.append(canvas);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
opened.snapshot.activeObjectId = null;
|
||||
const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas);
|
||||
renderer.setSnapshot(opened.snapshot, opened.geometryBuffers, opened.nonMeshGeometryBuffers ?? []);
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
if (!useOffscreen || (Number(canvas.dataset.rendererPixels) > 0 && canvas.dataset.renderBudgetStatus === "ready")) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
(globalThis as typeof globalThis & { __m11Reference?: { renderer: { dispose(): void }; client: { terminate(): void } } }).__m11Reference = { renderer, client };
|
||||
}, { input: Array.from(blend), useOffscreen: offscreen });
|
||||
|
||||
const canvas = page.locator("#m11-reference-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-render-budget-status", "ready");
|
||||
const actualPng = await canvas.screenshot({ animations: "disabled" });
|
||||
const report = await decodeAndCompare(page, referencePng, actualPng);
|
||||
console.log(`m11-render-reference-${offscreen ? "offscreen" : "main"}`, JSON.stringify(report));
|
||||
expect(report.status).toBe("READY");
|
||||
expect(report.errorCode).toBeNull();
|
||||
expect(report.checks.every((item) => item.passed)).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const state = (globalThis as typeof globalThis & { __m11Reference?: { renderer: { dispose(): void }; client: { terminate(): void } } }).__m11Reference;
|
||||
state?.renderer.dispose();
|
||||
state?.client.terminate();
|
||||
delete (globalThis as typeof globalThis & { __m11Reference?: unknown }).__m11Reference;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("M11-04 rejects a non-empty frame with the wrong composition", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const wrong = await page.evaluate(async () => {
|
||||
const canvas = new OffscreenCanvas(256, 256);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "rgb(58,58,58)";
|
||||
context.fillRect(0, 0, 256, 256);
|
||||
context.fillStyle = "rgb(255,0,0)";
|
||||
context.fillRect(0, 0, 32, 32);
|
||||
return Array.from(new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer()));
|
||||
});
|
||||
const report = await decodeAndCompare(page, referencePng, Buffer.from(wrong));
|
||||
expect(report.status).toBe("BLOCKED");
|
||||
expect(report.errorCode).toBe("RENDER_REFERENCE_MISMATCH");
|
||||
expect(report.checks.some((item) => !item.passed)).toBe(true);
|
||||
});
|
||||
187
web/tests/e2e/render-resource-budget.spec.ts
Normal file
187
web/tests/e2e/render-resource-budget.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend"));
|
||||
const texturePng = fs.readFileSync(path.join(root, "tests/files/web/resources/udim_1001.png"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-03/render-resource-budget.json"), "utf8"));
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M11-03 ${offscreen ? "Offscreen" : "main-thread"} renderer enforces light, shadow and texture budgets`, async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ input, useOffscreen, requestedLights }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts");
|
||||
const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = "256px";
|
||||
canvas.style.height = "256px";
|
||||
document.body.append(canvas);
|
||||
const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas);
|
||||
const waitFor = async (attribute: string): Promise<string> => {
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
const value = canvas.getAttribute(attribute);
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`${attribute} was not published`);
|
||||
};
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
const snapshot = structuredClone(opened.snapshot);
|
||||
const sourceNode = snapshot.nodes.find((node) => node.type === "LIGHT")!;
|
||||
const sourceLight = snapshot.lights.find((light) => light.id === sourceNode.dataId)!;
|
||||
snapshot.nodes = [
|
||||
...snapshot.nodes.filter((node) => node.type !== "LIGHT"),
|
||||
...Array.from({ length: requestedLights }, (_, index) => ({
|
||||
...sourceNode,
|
||||
id: `object:M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
name: `M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
dataId: `light:M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
visible: true,
|
||||
})),
|
||||
];
|
||||
snapshot.lights = Array.from({ length: requestedLights }, (_, index) => ({
|
||||
...sourceLight,
|
||||
id: `light:M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
name: `M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
lightType: 2,
|
||||
castsShadow: true,
|
||||
}));
|
||||
renderer.setSnapshot(snapshot, [], []);
|
||||
await waitFor("data-render-budget-status");
|
||||
|
||||
const textures = Array.from({ length: 257 }, (_, index) => ({
|
||||
schemaVersion: 1 as const,
|
||||
assetId: `asset:m11-budget-${index}`,
|
||||
imageId: `image:m11-budget-${index}`,
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
usage: "BASE_COLOR" as const,
|
||||
colorSpace: "SRGB" as const,
|
||||
sha256: "0".repeat(64),
|
||||
byteLength: 1,
|
||||
data: Uint8Array.of(index & 0xff).buffer,
|
||||
}));
|
||||
renderer.setTextureAssets(textures);
|
||||
await waitFor("data-texture-budget-status");
|
||||
|
||||
let actualLights: number | null = null;
|
||||
let actualShadows: number | null = null;
|
||||
if (!useOffscreen) {
|
||||
actualLights = 0;
|
||||
actualShadows = 0;
|
||||
(renderer as InstanceType<typeof ViewportRenderer>).scene.traverse((object) => {
|
||||
if (!("isLight" in object) || !object.isLight) return;
|
||||
actualLights! += 1;
|
||||
if ("castShadow" in object && object.castShadow) actualShadows! += 1;
|
||||
});
|
||||
}
|
||||
return {
|
||||
render: {
|
||||
backend: canvas.dataset.renderBudgetBackend,
|
||||
status: canvas.dataset.renderBudgetStatus,
|
||||
code: canvas.dataset.renderBudgetCode,
|
||||
requestedLights: Number(canvas.dataset.renderBudgetLights),
|
||||
renderedLights: Number(canvas.dataset.renderBudgetRenderedLights),
|
||||
droppedLights: Number(canvas.dataset.renderBudgetDroppedLights),
|
||||
requestedShadows: Number(canvas.dataset.renderBudgetShadows),
|
||||
renderedShadows: Number(canvas.dataset.renderBudgetRenderedShadows),
|
||||
blockedShadows: Number(canvas.dataset.renderBudgetBlockedShadows),
|
||||
dimension: Number(canvas.dataset.renderBudgetShadowMapDimension),
|
||||
},
|
||||
texture: {
|
||||
status: canvas.dataset.textureBudgetStatus,
|
||||
code: canvas.dataset.textureBudgetCode,
|
||||
assets: Number(canvas.dataset.textureBudgetAssets),
|
||||
loaded: Number(canvas.dataset.textureLoaded),
|
||||
bytes: Number(canvas.dataset.textureBytes),
|
||||
},
|
||||
actualLights,
|
||||
actualShadows,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
renderer.dispose();
|
||||
client.terminate();
|
||||
canvas.remove();
|
||||
}
|
||||
}, { input: Array.from(blend), useOffscreen: offscreen, requestedLights: golden.overflow.requestedLights });
|
||||
|
||||
expect(result.render).toEqual({
|
||||
backend: "THREE_WEBGL2",
|
||||
status: "blocked",
|
||||
code: "GPU_LIGHT_BUDGET_EXCEEDED",
|
||||
requestedLights: golden.overflow.requestedLights,
|
||||
renderedLights: golden.overflow.renderedLights,
|
||||
droppedLights: golden.overflow.droppedLights,
|
||||
requestedShadows: golden.overflow.requestedShadowMaps,
|
||||
renderedShadows: golden.overflow.renderedShadowMaps,
|
||||
blockedShadows: golden.overflow.blockedShadowMaps,
|
||||
dimension: golden.webgl2.shadowMapDimension,
|
||||
});
|
||||
expect(result.texture).toEqual({
|
||||
status: "blocked",
|
||||
code: "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
assets: 257,
|
||||
loaded: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
if (!offscreen) {
|
||||
expect(result.actualLights).toBe(golden.webgl2.maxLights);
|
||||
expect(result.actualShadows).toBe(golden.webgl2.maxShadowMaps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("M11-03 keeps the previous texture set when an aggregate batch is over budget", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
|
||||
const store = new GPUTextureStore("THREE_WEBGL2");
|
||||
try {
|
||||
const data = Uint8Array.from(input).buffer;
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
const kept = {
|
||||
schemaVersion: 1 as const,
|
||||
assetId: "asset:kept",
|
||||
imageId: "image:kept",
|
||||
mimeType: "image/png",
|
||||
width: 8,
|
||||
height: 8,
|
||||
usage: "BASE_COLOR",
|
||||
colorSpace: "SRGB",
|
||||
byteLength: data.byteLength,
|
||||
sha256: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""),
|
||||
data,
|
||||
} as const;
|
||||
const accepted = await store.upload([kept]);
|
||||
const before = store.getAsset("image:kept", "BASE_COLOR");
|
||||
const overBudget = Array.from({ length: 257 }, (_, index) => ({
|
||||
...kept,
|
||||
assetId: `asset:overflow:${index}`,
|
||||
imageId: `image:overflow:${index}`,
|
||||
data: kept.data.slice(0),
|
||||
}));
|
||||
const blocked = await store.upload(overBudget);
|
||||
const after = store.getAsset("image:kept", "BASE_COLOR");
|
||||
return {
|
||||
accepted: [accepted.loaded, accepted.rejected, accepted.budget.status],
|
||||
blocked: [blocked.loaded, blocked.rejected, blocked.budget.status, blocked.budget.requestedAssets, blocked.errorCodes[0]],
|
||||
retained: before === after && after?.sha256 === kept.sha256,
|
||||
revision: store.getRevision(),
|
||||
};
|
||||
}
|
||||
finally {
|
||||
store.dispose();
|
||||
}
|
||||
}, Array.from(texturePng));
|
||||
expect(result.accepted).toEqual([1, 0, "READY"]);
|
||||
expect(result.blocked).toEqual([0, 257, "BLOCKED", 258, "GPU_TEXTURE_BUDGET_EXCEEDED"]);
|
||||
expect(result.retained).toBe(true);
|
||||
expect(result.revision).toBe(2);
|
||||
});
|
||||
43
web/tests/e2e/render-routing.spec.ts
Normal file
43
web/tests/e2e/render-routing.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/m11_render_reference.blend"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-05/render-routing.json"), "utf8"));
|
||||
|
||||
test("M11-05 exposes fail-closed render routing for Web, server and hardware capabilities", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { routeRenderExecution } = await import("/src/three-adapter/render-routing.ts");
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
const sourceRenderEngine = opened.snapshot.scenes[0]?.renderEngine;
|
||||
if (sourceRenderEngine !== "BLENDER_EEVEE" && sourceRenderEngine !== "BLENDER_EEVEE_NEXT") throw new Error(`Unexpected fixture render engine: ${sourceRenderEngine}`);
|
||||
const base = { schemaVersion: 1 as const, renderEngine: sourceRenderEngine, backend: "WEBGL2" as const, complexity: "BOUNDED" as const };
|
||||
const bounded = routeRenderExecution(base);
|
||||
const cycles = routeRenderExecution({ ...base, renderEngine: "BLENDER_CYCLES", backend: "CYCLES" });
|
||||
const complex = routeRenderExecution({ ...base, backend: "EEVEE_COMPLEX", complexity: "COMPLEX" });
|
||||
const hardware = routeRenderExecution({ ...base, hardwareBackend: "OPTIX" });
|
||||
const availableServer = routeRenderExecution({ ...base, renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }, { serverRenderAvailable: true });
|
||||
const unavailableGpu = routeRenderExecution({ ...base, backend: "WEBGPU" });
|
||||
const unknownEngine = routeRenderExecution({ ...base, renderEngine: "UNKNOWN_ENGINE" });
|
||||
return { sourceRenderEngine, bounded, cycles, complex, hardware, availableServer, unavailableGpu, unknownEngine };
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, Array.from(blend));
|
||||
|
||||
expect(result.sourceRenderEngine).toBe("BLENDER_EEVEE");
|
||||
expect(result.bounded).toMatchObject(golden.boundedEevee);
|
||||
for (const serverResult of [result.cycles, result.complex, result.hardware]) {
|
||||
expect(serverResult).toMatchObject({ target: "SERVER_JOB", status: "BLOCKED" });
|
||||
expect(serverResult.issues[0].code).toBe("SERVER_JOB_UNAVAILABLE");
|
||||
}
|
||||
expect(result.availableServer).toMatchObject({ target: golden.cycles.target, ...golden.cycles.withEndpoint });
|
||||
expect(result.unavailableGpu).toMatchObject({ target: golden.webgpu.target, status: golden.webgpu.status });
|
||||
expect(result.unavailableGpu.issues[0].code).toBe(golden.webgpu.code);
|
||||
expect(result.unknownEngine).toMatchObject({ target: golden.unknownEngine.target, status: golden.unknownEngine.status, capability: golden.unknownEngine.capability });
|
||||
expect(result.unknownEngine.issues[0].code).toBe(golden.unknownEngine.code);
|
||||
});
|
||||
50
web/tests/e2e/responsive-layout.spec.ts
Normal file
50
web/tests/e2e/responsive-layout.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const viewports = [
|
||||
{ name: "desktop-1440x900", width: 1440, height: 900 },
|
||||
{ name: "desktop-1280x720", width: 1280, height: 720 },
|
||||
{ name: "mobile-narrow", width: 360, height: 640 },
|
||||
{ name: "mobile-extra-narrow", width: 320, height: 568 },
|
||||
] as const;
|
||||
|
||||
test.describe("M7-14 responsive layout", () => {
|
||||
for (const viewport of viewports) {
|
||||
test(`${viewport.name} keeps controls inside their layout bands`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const visible = (element: Element): element is HTMLElement => {
|
||||
const node = element as HTMLElement;
|
||||
const style = getComputedStyle(node);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0;
|
||||
};
|
||||
const bands = [".topbar", ".workspace-toolbar", ".storage-budget-panel", ".status-bar"]
|
||||
.map((selector) => document.querySelector<HTMLElement>(selector))
|
||||
.filter((element): element is HTMLElement => Boolean(element) && visible(element));
|
||||
const bandViolations = bands.flatMap((band) => {
|
||||
const bandRect = band.getBoundingClientRect();
|
||||
const horizontalConstrained = !["auto", "scroll", "hidden"].includes(getComputedStyle(band).overflowX);
|
||||
return [...band.querySelectorAll<HTMLElement>("button, input, select, output, span")]
|
||||
.filter(visible)
|
||||
.filter((control) => {
|
||||
const rect = control.getBoundingClientRect();
|
||||
return rect.top < bandRect.top - 1 || rect.bottom > bandRect.bottom + 1 || (horizontalConstrained && (rect.left < bandRect.left - 1 || rect.right > bandRect.right + 1));
|
||||
})
|
||||
.map((control) => `${band.className}:${control.textContent?.trim() || control.getAttribute("aria-label") || control.tagName}`);
|
||||
});
|
||||
const textOverflow = [...document.querySelectorAll<HTMLElement>(".topbar button, .topbar select, .workspace-toolbar button, .workspace-toolbar > span, .storage-budget-panel span, .storage-budget-panel output, .editor-header button")]
|
||||
.filter(visible)
|
||||
.filter((element) => element.scrollWidth > element.clientWidth + 1)
|
||||
.map((element) => element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName);
|
||||
const root = document.documentElement;
|
||||
return { bandViolations, textOverflow, rootScrollWidth: root.scrollWidth, viewportWidth: window.innerWidth };
|
||||
});
|
||||
|
||||
expect(report.bandViolations, JSON.stringify(report)).toEqual([]);
|
||||
expect(report.textOverflow, JSON.stringify(report)).toEqual([]);
|
||||
expect(report.rootScrollWidth, JSON.stringify(report)).toBeLessThanOrEqual(report.viewportWidth);
|
||||
});
|
||||
}
|
||||
});
|
||||
72
web/tests/e2e/sequencer-audio-recovery.spec.ts
Normal file
72
web/tests/e2e/sequencer-audio-recovery.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8")) as {
|
||||
outputGain: number;
|
||||
lifecycle: Array<[string, string, boolean, number, string | null]>;
|
||||
missingDevice: [string, string, string];
|
||||
};
|
||||
|
||||
test("M11-13 suspends, resumes, mutes, recovers, and closes a Chromium AudioContext", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate((outputGain) => {
|
||||
const button = document.createElement("button");
|
||||
button.id = "m11-audio-user-gesture";
|
||||
button.textContent = "Start audio test";
|
||||
document.body.append(button);
|
||||
const result = new Promise((resolve, reject) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const { SequencerAudioSession } = await import("/src/sequencer/SequencerAudioSession.ts");
|
||||
const summarize = (report: import("/src/sequencer/SequencerAudioSession.ts").SequencerAudioSessionReportIR) => [
|
||||
report.contextState,
|
||||
report.outputState,
|
||||
report.muted,
|
||||
report.outputGain,
|
||||
report.issueCode,
|
||||
];
|
||||
const supported = "AudioContext" in globalThis;
|
||||
const session = new SequencerAudioSession({ outputGain });
|
||||
const initialized = await session.initialize();
|
||||
const lifecycle = [];
|
||||
lifecycle.push(summarize(await session.suspend()));
|
||||
lifecycle.push(summarize(session.setMuted(true)));
|
||||
lifecycle.push(summarize(await session.resume()));
|
||||
lifecycle.push(summarize(session.setMuted(false)));
|
||||
lifecycle.push(summarize(await session.suspend()));
|
||||
lifecycle.push(summarize(await session.resume()));
|
||||
lifecycle.push(summarize(await session.close()));
|
||||
|
||||
const missingSession = new SequencerAudioSession({ scope: {}, outputGain });
|
||||
const missing = await missingSession.initialize();
|
||||
const missingDevice = [missing.contextState, missing.outputState, missing.issueCode];
|
||||
const missingClosed = await missingSession.close();
|
||||
resolve({ supported, initialized, lifecycle, missingDevice, missingClosed });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
(globalThis as typeof globalThis & { __m11AudioResult?: Promise<unknown> }).__m11AudioResult = result;
|
||||
}, golden.outputGain);
|
||||
|
||||
await page.locator("#m11-audio-user-gesture").click();
|
||||
const result = await page.evaluate(() =>
|
||||
(globalThis as typeof globalThis & { __m11AudioResult: Promise<unknown> }).__m11AudioResult) as {
|
||||
supported: boolean;
|
||||
initialized: { contextState: string; issueCode: string | null };
|
||||
lifecycle: Array<[string, string, boolean, number, string | null]>;
|
||||
missingDevice: [string, string, string];
|
||||
missingClosed: { contextState: string; outputState: string; outputGain: number };
|
||||
};
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(["RUNNING", "SUSPENDED"]).toContain(result.initialized.contextState);
|
||||
expect(result.initialized.issueCode).toBeNull();
|
||||
expect(result.lifecycle).toEqual(golden.lifecycle);
|
||||
expect(result.missingDevice).toEqual(golden.missingDevice);
|
||||
expect(result.missingClosed).toMatchObject({ contextState: "CLOSED", outputState: "SILENT", outputGain: 0 });
|
||||
});
|
||||
84
web/tests/e2e/sequencer-codec-probe.spec.ts
Normal file
84
web/tests/e2e/sequencer-codec-probe.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as {
|
||||
assets: Array<{
|
||||
stripType: "IMAGE" | "SOUND" | "MOVIE";
|
||||
mimeType: string;
|
||||
path: string;
|
||||
sourcePath: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
backend: string;
|
||||
decoded: Record<string, number>;
|
||||
}>;
|
||||
movieGenerator: { path: string; sha256: string };
|
||||
blockedCode: string;
|
||||
};
|
||||
|
||||
test("M11-09 probes IMAGE, SOUND and MOVIE bytes at runtime without extension guessing", async ({ page }) => {
|
||||
const assets = golden.assets.map((asset) => {
|
||||
const bytes = fs.readFileSync(path.join(root, asset.path));
|
||||
expect(bytes.byteLength).toBe(asset.byteLength);
|
||||
expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(asset.sha256);
|
||||
return { ...asset, bytes: new Uint8Array(bytes) };
|
||||
});
|
||||
expect(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, golden.movieGenerator.path))).digest("hex"))
|
||||
.toBe(golden.movieGenerator.sha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ assets }) => {
|
||||
const [runtime, protocol] = await Promise.all([
|
||||
import("/src/sequencer/SequencerCodecProbe.ts"),
|
||||
import("/src/sequencer/SequencerTimeline.ts"),
|
||||
]);
|
||||
const ready = [];
|
||||
for (const asset of assets) {
|
||||
const request = runtime.createSequencerCodecProbeRequest(asset.stripType, asset.mimeType, asset.byteLength, asset.sha256);
|
||||
const receipt = await runtime.probeSequencerCodec(request, asset.bytes.buffer);
|
||||
const gate = protocol.gateSequencerCodec(request, receipt);
|
||||
ready.push({ sourcePath: asset.sourcePath, receipt, gate: gate.status });
|
||||
}
|
||||
|
||||
const source = assets[0];
|
||||
const corrupted = source.bytes.slice();
|
||||
corrupted.fill(0, 0, Math.min(32, corrupted.length));
|
||||
const corruptedHash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", corrupted.buffer)),
|
||||
(byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const corruptRequest = runtime.createSequencerCodecProbeRequest("IMAGE", "image/png", corrupted.byteLength, corruptedHash);
|
||||
const corruptReceipt = await runtime.probeSequencerCodec(corruptRequest, corrupted.buffer);
|
||||
const mismatchRequest = runtime.createSequencerCodecProbeRequest("IMAGE", "image/png", source.byteLength, source.sha256);
|
||||
const mismatchReceipt = await runtime.probeSequencerCodec(mismatchRequest, corrupted.buffer);
|
||||
const spoofRequest = runtime.createSequencerCodecProbeRequest("MOVIE", "video/mp4", source.byteLength, source.sha256);
|
||||
const spoofReceipt = await runtime.probeSequencerCodec(spoofRequest, source.bytes.buffer);
|
||||
const forgedReceipt = { ...ready[0].receipt, sourceSha256: "f".repeat(64) };
|
||||
const forgedGate = protocol.gateSequencerCodec(mismatchRequest, forgedReceipt);
|
||||
return {
|
||||
ready,
|
||||
corrupt: { status: corruptReceipt.status, reason: corruptReceipt.reason },
|
||||
mismatch: { status: mismatchReceipt.status, reason: mismatchReceipt.reason },
|
||||
spoof: { status: spoofReceipt.status, reason: spoofReceipt.reason },
|
||||
forged: { status: forgedGate.status, code: forgedGate.issues[0]?.code },
|
||||
};
|
||||
}, { assets });
|
||||
|
||||
expect(result.ready.map((item) => ({
|
||||
sourcePath: item.sourcePath,
|
||||
status: item.receipt.status,
|
||||
backend: item.receipt.backend,
|
||||
decoded: item.receipt.decoded,
|
||||
gate: item.gate,
|
||||
}))).toEqual(golden.assets.map((asset) => ({
|
||||
sourcePath: asset.sourcePath,
|
||||
status: "READY",
|
||||
backend: asset.backend,
|
||||
decoded: asset.decoded,
|
||||
gate: "READY",
|
||||
})));
|
||||
expect(result.corrupt).toEqual({ status: "BLOCKED", reason: "DECODE_FAILED" });
|
||||
expect(result.mismatch).toEqual({ status: "BLOCKED", reason: "SOURCE_IDENTITY_MISMATCH" });
|
||||
expect(result.spoof.status).toBe("BLOCKED");
|
||||
expect(result.forged).toEqual({ status: "BLOCKED", code: golden.blockedCode });
|
||||
});
|
||||
94
web/tests/e2e/sequencer-final-export.spec.ts
Normal file
94
web/tests/e2e/sequencer-final-export.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/sequencer_scene.blend"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-12/sequencer-final-export.json"), "utf8")) as {
|
||||
sourceBlendSha256: string;
|
||||
settingsSha256: string;
|
||||
requestSha256: string;
|
||||
};
|
||||
|
||||
test("M11-12 routes a real Main timeline to server export even when VideoEncoder exists", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, finalExport] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/sequencer/SequencerFinalExport.ts"),
|
||||
]);
|
||||
const source = Uint8Array.from(input).buffer;
|
||||
const sourceBlendSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", source)), (byte) =>
|
||||
byte.toString(16).padStart(2, "0")).join("");
|
||||
const client = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(source);
|
||||
const timeline = opened.snapshot.scenes.find((scene) => scene.name === "SequencerScene")?.sequencerTimeline;
|
||||
if (!timeline) throw new Error("Real Sequencer Main timeline is missing");
|
||||
const request = {
|
||||
schemaVersion: 1 as const,
|
||||
timelineId: timeline.id,
|
||||
timelineRevision: timeline.revision,
|
||||
sourceBlendSha256,
|
||||
frameStart: timeline.frameStart,
|
||||
frameEnd: timeline.frameEnd,
|
||||
fpsNumerator: timeline.fpsNumerator,
|
||||
fpsDenominator: timeline.fpsDenominator,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container: "MPEG4" as const,
|
||||
videoCodec: "H264" as const,
|
||||
audioCodec: "AAC" as const,
|
||||
};
|
||||
const withoutServer = await finalExport.routeSequencerFinalExportInBrowser(request, false, {});
|
||||
const serverWithoutEncoder = await finalExport.routeSequencerFinalExportInBrowser(request, true, {});
|
||||
const serverWithEncoder = await finalExport.routeSequencerFinalExportInBrowser(
|
||||
request,
|
||||
true,
|
||||
{ VideoEncoder: class TestVideoEncoder {} },
|
||||
);
|
||||
const runtime = await finalExport.routeSequencerFinalExportInBrowser(request, true);
|
||||
return { timeline, sourceBlendSha256, withoutServer, serverWithoutEncoder, serverWithEncoder, runtime };
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
}
|
||||
}, Array.from(blend));
|
||||
|
||||
expect(result.timeline).toMatchObject({
|
||||
id: "sequencer:scene:SequencerScene",
|
||||
revision: 1,
|
||||
frameStart: 1,
|
||||
frameEnd: 250,
|
||||
fpsNumerator: 24000,
|
||||
fpsDenominator: 1001,
|
||||
});
|
||||
expect(result.sourceBlendSha256).toBe(golden.sourceBlendSha256);
|
||||
expect(result.withoutServer).toMatchObject({
|
||||
requestSha256: golden.requestSha256,
|
||||
settingsSha256: golden.settingsSha256,
|
||||
route: "SERVER_EXPORT",
|
||||
status: "BLOCKED",
|
||||
code: "SEQUENCER_EXPORT_SERVER_UNAVAILABLE",
|
||||
localEncoding: "BLOCKED",
|
||||
browserVideoEncoderDetected: false,
|
||||
});
|
||||
expect(result.serverWithoutEncoder).toMatchObject({
|
||||
requestSha256: golden.requestSha256,
|
||||
settingsSha256: golden.settingsSha256,
|
||||
route: "SERVER_EXPORT",
|
||||
status: "SERVER_EXPORT_REQUIRED",
|
||||
code: null,
|
||||
localEncoding: "BLOCKED",
|
||||
browserVideoEncoderDetected: false,
|
||||
});
|
||||
expect(result.serverWithEncoder).toMatchObject({
|
||||
...result.serverWithoutEncoder,
|
||||
browserVideoEncoderDetected: true,
|
||||
});
|
||||
expect(result.runtime).toMatchObject({
|
||||
route: "SERVER_EXPORT",
|
||||
status: "SERVER_EXPORT_REQUIRED",
|
||||
localEncoding: "BLOCKED",
|
||||
});
|
||||
});
|
||||
132
web/tests/e2e/sequencer-media-cache.spec.ts
Normal file
132
web/tests/e2e/sequencer-media-cache.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const codecGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as {
|
||||
assets: Array<{
|
||||
stripType: "IMAGE" | "SOUND" | "MOVIE";
|
||||
mimeType: string;
|
||||
path: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
decoded: Record<string, number>;
|
||||
}>;
|
||||
};
|
||||
const cacheGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-10/sequencer-media-cache.json"), "utf8")) as {
|
||||
sourceSha256: string;
|
||||
profile: { kind: "MOVIE_RGBA8_FRAME"; width: number; height: number; colorSpace: "SRGB8"; alphaMode: "STRAIGHT" };
|
||||
identitySha256: string;
|
||||
proxyByteLength: number;
|
||||
};
|
||||
|
||||
test("M11-10 binds a real movie proxy cache to source hash and runtime decode capability", async ({ page }) => {
|
||||
const movie = codecGolden.assets.find((asset) => asset.stripType === "MOVIE")!;
|
||||
const bytes = fs.readFileSync(path.join(root, movie.path));
|
||||
expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(cacheGolden.sourceSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ movie, profile, bytes }) => {
|
||||
const [probeModule, cacheModule, storageModule] = await Promise.all([
|
||||
import("/src/sequencer/SequencerCodecProbe.ts"),
|
||||
import("/src/sequencer/SequencerMediaProxyCache.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
]);
|
||||
const request = probeModule.createSequencerCodecProbeRequest("MOVIE", movie.mimeType, movie.byteLength, movie.sha256);
|
||||
const sourceData = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const capability = await probeModule.probeSequencerCodec(request, sourceData.slice(0));
|
||||
const generated = await cacheModule.generateInitialSequencerMovieProxyFrame(request, capability, profile, sourceData.slice(0));
|
||||
const cache = new cacheModule.SequencerMediaProxyCache(generated.data.byteLength);
|
||||
const firstKey = await cache.put(generated.manifest, generated.data.slice(0), request, capability);
|
||||
const hit = await cache.get(request, capability, profile, 0);
|
||||
const secondManifest = await import("/src/sequencer/SequencerTimeline.ts").then((module) =>
|
||||
module.createSequencerMediaCacheManifest(request, capability, profile, 1, generated.data.slice(0)));
|
||||
const secondKey = await cache.put(secondManifest, generated.data.slice(0), request, capability);
|
||||
const evicted = await cache.get(request, capability, profile, 0);
|
||||
|
||||
const projectId = `m11-10-${Date.now()}`;
|
||||
const writer = new storageModule.StorageClient();
|
||||
const storedPayload = await writer.putAsset(
|
||||
projectId,
|
||||
generated.data.slice(0),
|
||||
"application/vnd.blender.sequencer-proxy-rgba8",
|
||||
"cache/sequencer/proxy-frame-0.rgba8",
|
||||
);
|
||||
const manifestData = new TextEncoder().encode(JSON.stringify(generated.manifest)).buffer as ArrayBuffer;
|
||||
const storedManifest = await writer.putAsset(
|
||||
projectId,
|
||||
manifestData,
|
||||
"application/vnd.blender.sequencer-proxy-cache+json",
|
||||
"cache/sequencer/proxy-frame-0.json",
|
||||
);
|
||||
writer.terminate();
|
||||
|
||||
const reader = new storageModule.StorageClient();
|
||||
const [reopenedPayload, reopenedManifestAsset] = await Promise.all([
|
||||
reader.readAsset(projectId, storedPayload.sha256),
|
||||
reader.readAsset(projectId, storedManifest.sha256),
|
||||
]);
|
||||
reader.terminate();
|
||||
const reopenedManifest = JSON.parse(new TextDecoder().decode(reopenedManifestAsset.data));
|
||||
const verified = await import("/src/sequencer/SequencerTimeline.ts").then((module) =>
|
||||
module.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, request, capability));
|
||||
|
||||
const changedSource = { ...request, sourceSha256: "e".repeat(64) };
|
||||
const changedSourceCapability = { ...capability, sourceSha256: changedSource.sourceSha256 };
|
||||
const changedCapability = {
|
||||
...capability,
|
||||
decoded: { ...capability.decoded!, durationMicros: capability.decoded!.durationMicros! + 1 },
|
||||
};
|
||||
const corruptPayload = reopenedPayload.data.slice(0);
|
||||
new Uint8Array(corruptPayload)[0] ^= 0xff;
|
||||
const code = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return "unexpected-success"; }
|
||||
catch (error) { return (error as { code?: string }).code ?? String(error); }
|
||||
};
|
||||
const protocol = await import("/src/sequencer/SequencerTimeline.ts");
|
||||
const errors = {
|
||||
source: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, changedSource, changedSourceCapability)),
|
||||
capability: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, request, changedCapability)),
|
||||
payload: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, corruptPayload, request, capability)),
|
||||
sourceBytes: await code(() => cacheModule.generateInitialSequencerMovieProxyFrame(request, capability, profile, new ArrayBuffer(request.byteLength))),
|
||||
};
|
||||
const statsBeforeClear = cache.stats();
|
||||
const releasedBytes = cache.clear();
|
||||
return {
|
||||
capability,
|
||||
manifest: generated.manifest,
|
||||
firstKey,
|
||||
secondKey,
|
||||
hitBytes: hit?.data.byteLength,
|
||||
evicted: evicted === undefined,
|
||||
statsBeforeClear,
|
||||
releasedBytes,
|
||||
statsAfterClear: cache.stats(),
|
||||
storedPayloadSha256: storedPayload.sha256,
|
||||
verifiedIdentity: verified.identitySha256,
|
||||
errors,
|
||||
};
|
||||
}, { movie, profile: cacheGolden.profile, bytes: new Uint8Array(bytes) });
|
||||
|
||||
expect(result.capability).toMatchObject({ status: "READY", backend: "HTML_MEDIA", decoded: movie.decoded });
|
||||
expect(result.manifest).toMatchObject({
|
||||
identitySha256: cacheGolden.identitySha256,
|
||||
payloadByteLength: cacheGolden.proxyByteLength,
|
||||
profile: cacheGolden.profile,
|
||||
});
|
||||
expect(result.manifest.payloadSha256).toBe(result.storedPayloadSha256);
|
||||
expect(result.verifiedIdentity).toBe(cacheGolden.identitySha256);
|
||||
expect(result.firstKey).toBe(`sequencer-media-cache:v1:${cacheGolden.identitySha256}`);
|
||||
expect(result.secondKey).not.toBe(result.firstKey);
|
||||
expect(result.hitBytes).toBe(cacheGolden.proxyByteLength);
|
||||
expect(result.evicted).toBe(true);
|
||||
expect(result.statsBeforeClear).toMatchObject({ entries: 1, bytes: cacheGolden.proxyByteLength, hits: 1, misses: 1, evictions: 1 });
|
||||
expect(result.releasedBytes).toBe(cacheGolden.proxyByteLength);
|
||||
expect(result.statsAfterClear).toMatchObject({ entries: 0, bytes: 0 });
|
||||
expect(result.errors).toEqual({
|
||||
source: "SEQUENCER_CACHE_SOURCE_MISMATCH",
|
||||
capability: "SEQUENCER_CACHE_CAPABILITY_MISMATCH",
|
||||
payload: "SEQUENCER_CACHE_HASH_MISMATCH",
|
||||
sourceBytes: "SEQUENCER_CACHE_SOURCE_MISMATCH",
|
||||
});
|
||||
});
|
||||
94
web/tests/e2e/sequencer-media-revision.spec.ts
Normal file
94
web/tests/e2e/sequencer-media-revision.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const codecGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as {
|
||||
assets: Array<{ stripType: "IMAGE" | "SOUND" | "MOVIE"; mimeType: string; path: string; byteLength: number; sha256: string }>;
|
||||
};
|
||||
const revisionGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-11/sequencer-media-revision.json"), "utf8")) as {
|
||||
sourceSha256: string;
|
||||
decisions: Array<[string, number, "PUBLISH" | "STALE", string | null]>;
|
||||
published: string[];
|
||||
cacheWrites: string[];
|
||||
};
|
||||
|
||||
test("M11-11 gates late seek, scrub and real decode results before publish or cache", async ({ page }) => {
|
||||
const movie = codecGolden.assets.find((asset) => asset.stripType === "MOVIE")!;
|
||||
const bytes = fs.readFileSync(path.join(root, movie.path));
|
||||
expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(revisionGolden.sourceSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ movie, bytes }) => {
|
||||
const [probeModule, revisionModule] = await Promise.all([
|
||||
import("/src/sequencer/SequencerCodecProbe.ts"),
|
||||
import("/src/sequencer/SequencerMediaRevisionGate.ts"),
|
||||
]);
|
||||
const gate = new revisionModule.SequencerMediaRevisionGate("sequencer:main", 7);
|
||||
const published: string[] = [];
|
||||
const cacheWrites: string[] = [];
|
||||
const decisions: Array<[string, number, string, string | null]> = [];
|
||||
const complete = (
|
||||
request: import("/src/sequencer/SequencerMediaRevisionGate.ts").SequencerMediaRevisionRequestIR,
|
||||
payloadSha256 = movie.sha256,
|
||||
) => ({ ...request, status: "COMPLETED" as const, sourceFrame: request.frame, payloadSha256 });
|
||||
const resolve = (
|
||||
request: import("/src/sequencer/SequencerMediaRevisionGate.ts").SequencerMediaRevisionRequestIR,
|
||||
response = complete(request),
|
||||
) => {
|
||||
const decision = gate.resolve(request, response, (accepted) => {
|
||||
const label = `${accepted.operation}@${accepted.requestRevision}`;
|
||||
published.push(label);
|
||||
if (accepted.operation === "DECODE") cacheWrites.push(label);
|
||||
});
|
||||
decisions.push([decision.operation, decision.requestRevision, decision.status, decision.code]);
|
||||
return decision;
|
||||
};
|
||||
|
||||
const oldSeek = gate.begin("SEEK", 10);
|
||||
const oldSeekResult = new Promise<ReturnType<typeof complete>>((resolveResult) =>
|
||||
setTimeout(() => resolveResult(complete(oldSeek)), 30));
|
||||
const currentScrub = gate.begin("SCRUB", 20);
|
||||
resolve(currentScrub);
|
||||
resolve(oldSeek, await oldSeekResult);
|
||||
|
||||
const oldDecode = gate.begin("DECODE", 30);
|
||||
const request = probeModule.createSequencerCodecProbeRequest("MOVIE", movie.mimeType, movie.byteLength, movie.sha256);
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const lateDecode = new Promise<Awaited<ReturnType<typeof probeModule.probeSequencerCodec>>>((resolveReceipt) =>
|
||||
setTimeout(() => { void probeModule.probeSequencerCodec(request, source.slice(0)).then(resolveReceipt); }, 20));
|
||||
const replacedState = gate.replaceTimeline("sequencer:main", 8);
|
||||
const lateReceipt = await lateDecode;
|
||||
resolve(oldDecode, complete(oldDecode, lateReceipt.sourceSha256));
|
||||
|
||||
const currentDecode = gate.begin("DECODE", 40);
|
||||
const currentReceipt = await probeModule.probeSequencerCodec(request, source.slice(0));
|
||||
resolve(currentDecode, complete(currentDecode, currentReceipt.sourceSha256));
|
||||
|
||||
const forgedSeek = gate.begin("SEEK", 50);
|
||||
resolve(forgedSeek, { ...complete(forgedSeek), requestId: "media:forged" });
|
||||
|
||||
let monotonicCode = "";
|
||||
try { gate.replaceTimeline("sequencer:main", 8); }
|
||||
catch (error) { monotonicCode = (error as { code?: string }).code ?? String(error); }
|
||||
return {
|
||||
decisions,
|
||||
published,
|
||||
cacheWrites,
|
||||
replacedState,
|
||||
finalState: gate.state(),
|
||||
lateReceipt: { status: lateReceipt.status, backend: lateReceipt.backend },
|
||||
currentReceipt: { status: currentReceipt.status, backend: currentReceipt.backend },
|
||||
monotonicCode,
|
||||
};
|
||||
}, { movie, bytes: new Uint8Array(bytes) });
|
||||
|
||||
expect(result.decisions).toEqual(revisionGolden.decisions);
|
||||
expect(result.published).toEqual(revisionGolden.published);
|
||||
expect(result.cacheWrites).toEqual(revisionGolden.cacheWrites);
|
||||
expect(result.replacedState).toEqual({ schemaVersion: 1, timelineId: "sequencer:main", timelineRevision: 8, latestRequestRevision: 4 });
|
||||
expect(result.finalState).toEqual({ schemaVersion: 1, timelineId: "sequencer:main", timelineRevision: 8, latestRequestRevision: 6 });
|
||||
expect(result.lateReceipt).toEqual({ status: "READY", backend: "HTML_MEDIA" });
|
||||
expect(result.currentReceipt).toEqual({ status: "READY", backend: "HTML_MEDIA" });
|
||||
expect(result.monotonicCode).toBe("REVISION_CONFLICT");
|
||||
});
|
||||
30
web/tests/e2e/shader-capability-block.spec.ts
Normal file
30
web/tests/e2e/shader-capability-block.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-10/shader-capability-block.json"), "utf8"));
|
||||
|
||||
test("M10-10 returns a stable block for every unknown Shader node", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
await client.init();
|
||||
const first = await client.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["VORONOI", "CUSTOM_OSL"] });
|
||||
const second = await client.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["CUSTOM_OSL", "VORONOI"] });
|
||||
client.terminate();
|
||||
return {
|
||||
first: { taskId: first.taskId, capability: first.capability, status: first.status, code: first.issues[0]?.code, recoverable: first.issues[0]?.recoverable, message: first.issues[0]?.message },
|
||||
second: { taskId: second.taskId, capability: second.capability, status: second.status, code: second.issues[0]?.code, recoverable: second.issues[0]?.recoverable, message: second.issues[0]?.message },
|
||||
};
|
||||
});
|
||||
expect(result.first.taskId).toBe("PBR-012");
|
||||
expect(result.first.capability).toBe(expected.capability);
|
||||
expect(result.first.status).toBe(expected.status);
|
||||
expect(result.first.code).toBe(expected.errorCode);
|
||||
expect(result.first.recoverable).toBe(expected.recoverable);
|
||||
expect(result.first.message).toContain("VORONOI");
|
||||
expect(result.first.message).toContain("CUSTOM_OSL");
|
||||
expect(result.second).toEqual(result.first);
|
||||
});
|
||||
48
web/tests/e2e/shader-compile-key.spec.ts
Normal file
48
web/tests/e2e/shader-compile-key.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-08/shader-compile-key.json"), "utf8"));
|
||||
|
||||
test("M10-08 compileKey changes with texture identity and color space", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
|
||||
const source = {
|
||||
id: "material:key",
|
||||
name: "Key",
|
||||
baseColor: [0.2, 0.3, 0.4, 1] as [number, number, number, number],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1] as [number, number, number, number],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [
|
||||
{ id: "image", type: "IMAGE_TEXTURE" as const, name: "Image", imageId: "image:key" },
|
||||
{ id: "normal", type: "NORMAL_MAP" as const, name: "Normal" },
|
||||
{ id: "principled", type: "PRINCIPLED" as const, name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT" as const, name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
||||
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
};
|
||||
const identity = { assetId: "asset:key-v1", sha256: "d".repeat(64), colorSpace: "NON_COLOR" as const };
|
||||
const first = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", identity]]) });
|
||||
const changed = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", { ...identity, sha256: "e".repeat(64) }]]) });
|
||||
const linear = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", { ...identity, colorSpace: "LINEAR" as const }]]) });
|
||||
const firstReport = first.userData.shaderCompile;
|
||||
const changedReport = changed.userData.shaderCompile;
|
||||
const linearReport = linear.userData.shaderCompile;
|
||||
first.dispose(); changed.dispose(); linear.dispose();
|
||||
return { status: firstReport.status, key: firstReport.compileKey, changed: changedReport.compileKey, linear: linearReport.compileKey };
|
||||
});
|
||||
expect(result.status).toBe("COMPILED");
|
||||
expect(result.key).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.changed).not.toBe(result.key);
|
||||
expect(result.linear).not.toBe(result.key);
|
||||
expect(expected.keyDigest).toBe("sha256");
|
||||
});
|
||||
157
web/tests/e2e/shader-compile.spec.ts
Normal file
157
web/tests/e2e/shader-compile.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-07/shader-compile.json"), "utf8"));
|
||||
const blendBytes = fs.readFileSync(path.join(root, expected.fixture));
|
||||
|
||||
function sockets() {
|
||||
return {
|
||||
rgb: [{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.15, 0.25, 0.35, 1] }],
|
||||
value: [{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.2 }],
|
||||
math: [
|
||||
{ id: "a", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "b", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE" },
|
||||
],
|
||||
principled: [
|
||||
{ id: "base", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.2, 0.3, 0.4, 1] },
|
||||
{ id: "roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.5 },
|
||||
{ id: "bsdf", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" },
|
||||
],
|
||||
output: [{ id: "surface", name: "Surface", direction: "INPUT", dataType: "SHADER" }],
|
||||
};
|
||||
}
|
||||
|
||||
test("M10-07 produces a bounded compile report from the real Main Shader graph", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect(createHash("sha256").update(blendBytes).digest("hex")).toBe(expected.fixtureSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes, socketSet }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await client.init();
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const opened = await client.openBlend(source);
|
||||
const material = opened.snapshot.materials[0];
|
||||
if (!material) throw new Error("basic fixture has no editable material");
|
||||
const graph = {
|
||||
schemaVersion: 1 as const,
|
||||
id: "shader:m10-07",
|
||||
materialId: material.id,
|
||||
outputNodeId: "output",
|
||||
nodes: [
|
||||
{ id: "rgb", type: "RGB" as const, name: "RGB", sockets: socketSet.rgb },
|
||||
{ id: "a", type: "VALUE" as const, name: "A", sockets: socketSet.value },
|
||||
{ id: "b", type: "VALUE" as const, name: "B", sockets: [{ ...socketSet.value[0], defaultValue: 0.22 }] },
|
||||
{ id: "math", type: "MATH" as const, name: "Add", properties: { operation: "ADD" as const }, sockets: socketSet.math },
|
||||
{ id: "principled", type: "PRINCIPLED" as const, name: "Principled", sockets: socketSet.principled },
|
||||
{ id: "output", type: "MATERIAL_OUTPUT" as const, name: "Output", sockets: socketSet.output },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "rgb", fromSocketId: "color", toNodeId: "principled", toSocketId: "base" },
|
||||
{ fromNodeId: "a", fromSocketId: "value", toNodeId: "math", toSocketId: "a" },
|
||||
{ fromNodeId: "b", fromSocketId: "value", toNodeId: "math", toSocketId: "b" },
|
||||
{ fromNodeId: "math", fromSocketId: "value", toNodeId: "principled", toSocketId: "roughness" },
|
||||
{ fromNodeId: "principled", fromSocketId: "bsdf", toNodeId: "output", toSocketId: "surface" },
|
||||
],
|
||||
};
|
||||
const applied = await client.applyCommand({ type: "setShaderGraph", materialId: material.id, graph });
|
||||
const report = applied.shaderCompile;
|
||||
const after = await client.snapshot();
|
||||
client.terminate();
|
||||
return {
|
||||
openedHash: material.shaderGraphHash,
|
||||
status: report?.status,
|
||||
taskId: report?.taskId,
|
||||
backend: report?.backend,
|
||||
graphHash: report?.graphHash,
|
||||
roughness: report?.material?.roughness,
|
||||
nodeTypes: report?.compiledNodeTypes,
|
||||
revisionDelta: after.snapshot.revision - opened.snapshot.revision,
|
||||
};
|
||||
}, { bytes: new Uint8Array(blendBytes), socketSet: sockets() });
|
||||
|
||||
expect(result.status).toBe("COMPILED");
|
||||
expect(result.openedHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.taskId).toBe("M10-07");
|
||||
expect(result.backend).toBe(expected.backend);
|
||||
expect(result.graphHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.roughness).toBeCloseTo(expected.expectedMathResult, 5);
|
||||
expect(result.nodeTypes).toEqual(expect.arrayContaining(expected.allowlist.filter((type: string) => !["IMAGE_TEXTURE", "NORMAL_MAP"].includes(type))));
|
||||
expect(result.revisionDelta).toBe(1);
|
||||
});
|
||||
|
||||
test("M10-07 shared viewport material path compiles Image/Normal/Math bindings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
|
||||
const material = createPBRMaterial({
|
||||
id: "material:viewport",
|
||||
name: "Viewport",
|
||||
baseColor: [0.8, 0.8, 0.8, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [
|
||||
{ id: "a", type: "VALUE", name: "A", defaultValue: [0.2] },
|
||||
{ id: "b", type: "VALUE", name: "B", defaultValue: [0.22] },
|
||||
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
|
||||
{ id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:normal" },
|
||||
{ id: "normal", type: "NORMAL_MAP", name: "Normal" },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
||||
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
});
|
||||
const compile = material.userData.shaderCompile;
|
||||
const report = {
|
||||
status: compile.status,
|
||||
graphHash: compile.graphHash,
|
||||
roughness: material.roughness,
|
||||
textures: compile.textureBindings,
|
||||
};
|
||||
material.dispose();
|
||||
return report;
|
||||
});
|
||||
expect(result.status).toBe("COMPILED");
|
||||
expect(result.graphHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.roughness).toBeCloseTo(expected.expectedMathResult, 5);
|
||||
expect(result.textures).toEqual([{ imageId: "image:normal", usage: "NORMAL" }]);
|
||||
});
|
||||
|
||||
test("M10-07 compiler blocks a non-declared node without mutating the input graph", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
|
||||
const material = {
|
||||
id: "material:blocked",
|
||||
name: "Blocked",
|
||||
baseColor: [1, 1, 1, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [{ id: "mix", type: "UNSUPPORTED", name: "Mix" }, { id: "output", type: "OUTPUT", name: "Output" }],
|
||||
links: [],
|
||||
} as const;
|
||||
const before = JSON.stringify(material);
|
||||
const threeMaterial = createPBRMaterial(material);
|
||||
const report = threeMaterial.userData.shaderCompile;
|
||||
threeMaterial.dispose();
|
||||
return { status: report.status, code: report.issues[0]?.code, preserved: JSON.stringify(material) === before };
|
||||
});
|
||||
expect(result).toEqual({ status: "BLOCKED", code: expected.unsupportedNodeCode, preserved: true });
|
||||
});
|
||||
56
web/tests/e2e/shader-pipeline.spec.ts
Normal file
56
web/tests/e2e/shader-pipeline.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-09/shader-pipeline.json"), "utf8"));
|
||||
|
||||
test("M10-09 keeps the previous material pipeline after compile failure", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { PBRMaterialPipeline } = await import("/src/three-adapter/pbr.ts");
|
||||
const valid = {
|
||||
id: "material:pipeline",
|
||||
name: "Pipeline",
|
||||
baseColor: [0.2, 0.3, 0.4, 1] as [number, number, number, number],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1] as [number, number, number, number],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [
|
||||
{ id: "principled", type: "PRINCIPLED" as const, name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT" as const, name: "Output" },
|
||||
],
|
||||
links: [{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }],
|
||||
};
|
||||
const invalid = { ...valid, nodes: [{ id: "mix", type: "UNSUPPORTED" as const, name: "Mix" }, ...valid.nodes] };
|
||||
const pipeline = new PBRMaterialPipeline();
|
||||
const first = pipeline.update(valid);
|
||||
const failed = pipeline.update(invalid);
|
||||
const preserved = failed.material === first.material;
|
||||
const failureReport = first.material.userData.shaderCompileFailure;
|
||||
const replacement = pipeline.update({ ...valid, baseColor: [0.8, 0.2, 0.1, 1] });
|
||||
const replaced = replacement.material !== first.material;
|
||||
const oldDisposed = first.material.userData.shaderCompile?.status !== "COMPILED";
|
||||
pipeline.dispose();
|
||||
return {
|
||||
first: first.report?.status,
|
||||
failure: failed.report?.status,
|
||||
preserved,
|
||||
failureCode: failureReport?.issues?.[0]?.code,
|
||||
replaced,
|
||||
replacedOnSuccess: replacement.replaced,
|
||||
oldPipelineStillCompiled: !oldDisposed,
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({
|
||||
first: "COMPILED",
|
||||
failure: expected.failureStatus,
|
||||
preserved: expected.preservedPipeline,
|
||||
failureCode: "SHADER_NODE_UNSUPPORTED",
|
||||
replaced: true,
|
||||
replacedOnSuccess: expected.replacedOnSuccess,
|
||||
oldPipelineStillCompiled: expected.failureDoesNotDisposePrevious,
|
||||
});
|
||||
});
|
||||
115
web/tests/e2e/simulation-cache-identity.spec.ts
Normal file
115
web/tests/e2e/simulation-cache-identity.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M10-05 isolates Simulation caches by committed graph/source/revision identity", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const revisionHash = async (binding: {
|
||||
graphId: string; graphHash: string; sourceBlendSha256: string; sourceRevision: number;
|
||||
inputHash: string; blenderVersion: string; frameStart: number; frameEnd: number;
|
||||
}): Promise<string> => digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const errorCode = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
|
||||
};
|
||||
|
||||
const projectId = `m10-05-${Date.now()}`;
|
||||
const sourceOne = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 1]).buffer;
|
||||
const sourceTwo = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 2]).buffer;
|
||||
const payload = Uint8Array.from([11, 12, 13, 14]).buffer;
|
||||
const frameHash = await digest(payload);
|
||||
const common = {
|
||||
graphId: "node-group:M10SimulationIdentity",
|
||||
graphHash: await digest(Uint8Array.from([21]).buffer),
|
||||
inputHash: await digest(Uint8Array.from([22]).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
};
|
||||
const makeManifest = async (source: ArrayBuffer, sourceRevision: number) => {
|
||||
const binding = { ...common, sourceBlendSha256: await digest(source), sourceRevision };
|
||||
return {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash: await revisionHash(binding),
|
||||
cacheSha256: frameHash,
|
||||
byteLength: payload.byteLength,
|
||||
frames: [{ frame: 1, byteOffset: 0, byteLength: payload.byteLength, sha256: frameHash }],
|
||||
};
|
||||
};
|
||||
|
||||
const firstManifest = await makeManifest(sourceOne, 7);
|
||||
const first = new StorageClient();
|
||||
await first.saveProject(projectId, 7, sourceOne.slice(0));
|
||||
const storedOne = await first.putSimulationCache(projectId, firstManifest, payload.slice(0));
|
||||
const forgedGraphCode = await errorCode(() => first.putSimulationCache(
|
||||
projectId,
|
||||
{ ...firstManifest, graphHash: "0".repeat(64) },
|
||||
payload.slice(0),
|
||||
));
|
||||
const forgedSourceBinding = { ...firstManifest, sourceBlendSha256: "1".repeat(64) };
|
||||
const forgedSourceManifest = {
|
||||
...forgedSourceBinding,
|
||||
revisionHash: await revisionHash(forgedSourceBinding),
|
||||
};
|
||||
const forgedSourceCode = await errorCode(() => first.putSimulationCache(
|
||||
projectId,
|
||||
forgedSourceManifest,
|
||||
payload.slice(0),
|
||||
));
|
||||
first.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
await restarted.prepareSimulationCachePlayback(projectId, storedOne.cacheKey);
|
||||
const recovered = await restarted.readSimulationCacheFrame(projectId, storedOne.cacheKey, 1);
|
||||
const listedBefore = await restarted.listSimulationCaches(projectId);
|
||||
await restarted.saveProject(projectId, 8, sourceTwo.slice(0));
|
||||
const staleReadCode = await errorCode(() => restarted.readSimulationCache(projectId, storedOne.cacheKey));
|
||||
const listedAfter = await restarted.listSimulationCaches(projectId);
|
||||
const stalePutCode = await errorCode(() => restarted.putSimulationCache(
|
||||
projectId,
|
||||
firstManifest,
|
||||
payload.slice(0),
|
||||
));
|
||||
|
||||
const secondManifest = await makeManifest(sourceTwo, 8);
|
||||
const storedTwo = await restarted.putSimulationCache(projectId, secondManifest, payload.slice(0));
|
||||
const listedCurrent = await restarted.listSimulationCaches(projectId);
|
||||
restarted.terminate();
|
||||
return {
|
||||
firstKey: storedOne.cacheKey,
|
||||
secondKey: storedTwo.cacheKey,
|
||||
recoveredBytes: Array.from(new Uint8Array(recovered.data)),
|
||||
listedBefore: listedBefore.caches.map((cache) => cache.cacheKey),
|
||||
listedAfter: listedAfter.caches.map((cache) => cache.cacheKey),
|
||||
listedCurrent: listedCurrent.caches.map((cache) => cache.cacheKey),
|
||||
forgedGraphCode,
|
||||
forgedSourceCode,
|
||||
staleReadCode,
|
||||
stalePutCode,
|
||||
sourceRevision: storedTwo.manifest.sourceRevision,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.firstKey).toMatch(/^sim2-[a-f0-9]{64}$/);
|
||||
expect(result.secondKey).toMatch(/^sim2-[a-f0-9]{64}$/);
|
||||
expect(result.secondKey).not.toBe(result.firstKey);
|
||||
expect(result.recoveredBytes).toEqual([11, 12, 13, 14]);
|
||||
expect(result.listedBefore).toEqual([result.firstKey]);
|
||||
expect(result.listedAfter).toEqual([]);
|
||||
expect(result.listedCurrent).toEqual([result.secondKey]);
|
||||
expect(result.forgedGraphCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH");
|
||||
expect(result.forgedSourceCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
expect(result.staleReadCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH");
|
||||
expect(result.stalePutCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH");
|
||||
expect(result.sourceRevision).toBe(8);
|
||||
});
|
||||
211
web/tests/e2e/simulation-cache-lifecycle.spec.ts
Normal file
211
web/tests/e2e/simulation-cache-lifecycle.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M10-06 gates playback on verification and enforces cancel, LRU, restart and corruption quarantine", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
|
||||
const { upgradeStorageSchema } = await import("/src/storage/migrations.ts");
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const errorCode = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) {
|
||||
const code = (error as Error & { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : (error as Error).name;
|
||||
}
|
||||
};
|
||||
const projectId = `m10-06-${Date.now()}`;
|
||||
const migrationDatabase = `m10-06-migration-${Date.now()}`;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(migrationDatabase, 6);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("migration", { keyPath: "id" });
|
||||
request.result.createObjectStore("simulation_manifest", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => { request.result.close(); resolve(); };
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const migration = await new Promise<{ version: number; stores: string[]; record?: { version: number } }>((resolve, reject) => {
|
||||
const request = indexedDB.open(migrationDatabase, 7);
|
||||
request.onupgradeneeded = (event) => upgradeStorageSchema(request.result, request.transaction!, (event as IDBVersionChangeEvent).oldVersion);
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
const transaction = database.transaction("migration", "readonly");
|
||||
const record = transaction.objectStore("migration").get("schema-7");
|
||||
record.onsuccess = () => resolve({ version: database.version, stores: [...database.objectStoreNames], record: record.result as { version: number } | undefined });
|
||||
record.onerror = () => reject(record.error);
|
||||
transaction.oncomplete = () => database.close();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
indexedDB.deleteDatabase(migrationDatabase);
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 6]).buffer;
|
||||
const sourceBlendSha256 = await digest(sourceBlend);
|
||||
const makeManifest = async (name: string, payload: ArrayBuffer, frameCount = 1) => {
|
||||
const frameBytes = payload.byteLength / frameCount;
|
||||
const binding = {
|
||||
graphId: `node-group:${name}`,
|
||||
graphHash: await digest(new TextEncoder().encode(`graph:${name}`).buffer),
|
||||
sourceBlendSha256,
|
||||
sourceRevision: 6,
|
||||
inputHash: await digest(new TextEncoder().encode(`input:${name}`).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({
|
||||
frame: index + 1,
|
||||
byteOffset: index * frameBytes,
|
||||
byteLength: frameBytes,
|
||||
sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)),
|
||||
})));
|
||||
return {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
};
|
||||
};
|
||||
|
||||
const first = new StorageClient();
|
||||
await first.saveProject(projectId, 6, sourceBlend.slice(0));
|
||||
const payloadA = Uint8Array.from([1, 2, 3, 4]).buffer;
|
||||
const manifestA = await makeManifest("lru-a", payloadA);
|
||||
const storedA = await first.putSimulationCache(projectId, manifestA, payloadA.slice(0));
|
||||
first.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
const notReadyAfterRestart = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1));
|
||||
const prepared = await restarted.prepareSimulationCachePlayback(projectId, storedA.cacheKey);
|
||||
const recovered = await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1);
|
||||
let cancelledPublishedFrames = 0;
|
||||
const playback = new BrowserTransformCachePlaybackSession({
|
||||
schemaVersion: 1 as const,
|
||||
revision: 6,
|
||||
sceneId: "scene:M10-06",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
activeObjectId: null,
|
||||
frame: { current: 1, start: 1, end: 1 },
|
||||
nodes: [], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
|
||||
}, {
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, frame, signal)).data,
|
||||
}, () => { cancelledPublishedFrames += 1; });
|
||||
const cancellingPlayback = playback.play();
|
||||
playback.cancel();
|
||||
const playbackCancellation = await cancellingPlayback;
|
||||
const pendingAfterPlaybackCancel = restarted.getPendingRequestCount();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const payloadB = Uint8Array.from([5, 6, 7, 8]).buffer;
|
||||
const manifestB = await makeManifest("lru-b", payloadB);
|
||||
const storedB = await restarted.putSimulationCache(projectId, manifestB, payloadB.slice(0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const payloadC = Uint8Array.from([9, 10, 11, 12]).buffer;
|
||||
const manifestC = await makeManifest("lru-c", payloadC);
|
||||
const storedC = await restarted.putSimulationCache(projectId, manifestC, payloadC.slice(0));
|
||||
const activePrune = await restarted.pruneSimulationCaches(projectId, 4);
|
||||
const afterActivePrune = await restarted.listSimulationCaches(projectId);
|
||||
await restarted.releaseSimulationCachePlayback(projectId, storedA.cacheKey);
|
||||
const releasedCode = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1));
|
||||
const finalPrune = await restarted.pruneSimulationCaches(projectId, 0);
|
||||
|
||||
const cancelledPayload = new ArrayBuffer(256 * 32);
|
||||
const cancelledBytes = new Uint8Array(cancelledPayload);
|
||||
for (let index = 0; index < cancelledBytes.length; index += 1) cancelledBytes[index] = index % 251;
|
||||
const cancelledManifest = await makeManifest("cancelled", cancelledPayload, 256);
|
||||
const controller = new AbortController();
|
||||
const cancelledWrite = restarted.putSimulationCache(projectId, cancelledManifest, cancelledPayload, controller.signal);
|
||||
controller.abort();
|
||||
const cancelledCode = await errorCode(() => cancelledWrite);
|
||||
const afterCancel = await restarted.listSimulationCaches(projectId);
|
||||
const assetsAfterCancel = await restarted.listAssets(projectId);
|
||||
|
||||
const corruptPayload = Uint8Array.from([31, 32, 33, 34]).buffer;
|
||||
const corruptManifest = await makeManifest("corrupt", corruptPayload);
|
||||
const corruptStored = await restarted.putSimulationCache(projectId, corruptManifest, corruptPayload.slice(0));
|
||||
restarted.terminate();
|
||||
const pathSegments = corruptStored.path.split("/");
|
||||
const fileName = pathSegments.pop()!;
|
||||
let directory = await navigator.storage.getDirectory();
|
||||
for (const segment of pathSegments) directory = await directory.getDirectoryHandle(segment);
|
||||
const handle = await directory.getFileHandle(fileName);
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(Uint8Array.from([99, 98, 97, 96]));
|
||||
await writable.close();
|
||||
|
||||
const quarantineReader = new StorageClient();
|
||||
const corruptNotReady = await errorCode(() => quarantineReader.readSimulationCacheFrame(projectId, corruptStored.cacheKey, 1));
|
||||
const corruptionCode = await errorCode(() => quarantineReader.prepareSimulationCachePlayback(projectId, corruptStored.cacheKey));
|
||||
const afterCorruption = await quarantineReader.listSimulationCaches(projectId);
|
||||
const info = await quarantineReader.info();
|
||||
const pending = quarantineReader.getPendingRequestCount();
|
||||
quarantineReader.terminate();
|
||||
|
||||
return {
|
||||
notReadyAfterRestart,
|
||||
verifiedAt: prepared.verifiedAt,
|
||||
recovered: Array.from(new Uint8Array(recovered.data)),
|
||||
playbackCancellation,
|
||||
cancelledPublishedFrames,
|
||||
pendingAfterPlaybackCancel,
|
||||
activePrune,
|
||||
activeKeys: afterActivePrune.caches.map((cache) => cache.cacheKey),
|
||||
expectedActiveKey: storedA.cacheKey,
|
||||
evictedKeys: [storedB.cacheKey, storedC.cacheKey].sort(),
|
||||
releasedCode,
|
||||
finalPrune,
|
||||
cancelledCode,
|
||||
cancelledPublished: afterCancel.caches.some((cache) => cache.cacheKey === `sim2-${cancelledManifest.revisionHash}`),
|
||||
cancelledAssetPresent: assetsAfterCancel.assets.some((asset) => asset.sha256 === cancelledManifest.cacheSha256),
|
||||
corruptNotReady,
|
||||
corruptionCode,
|
||||
cachesAfterCorruption: afterCorruption.caches.length,
|
||||
quarantined: afterCorruption.quarantined,
|
||||
issues: afterCorruption.issues,
|
||||
schemaVersion: info.schemaVersion,
|
||||
stores: info.stores,
|
||||
pending,
|
||||
migration,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.notReadyAfterRestart).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result.recovered).toEqual([1, 2, 3, 4]);
|
||||
expect(result.playbackCancellation).toEqual({ status: "CANCELLED", appliedFrames: 0, lastFrame: null });
|
||||
expect(result.cancelledPublishedFrames).toBe(0);
|
||||
expect(result.pendingAfterPlaybackCancel).toBe(0);
|
||||
expect(result.activePrune).toMatchObject({ beforeBytes: 12, remainingBytes: 4, removedBytes: 8, removed: 2, budgetSatisfied: true });
|
||||
expect(result.activePrune.cacheKeys.sort()).toEqual(result.evictedKeys);
|
||||
expect(result.activePrune.protectedCacheKeys).toContain(result.expectedActiveKey);
|
||||
expect(result.activeKeys).toEqual([result.expectedActiveKey]);
|
||||
expect(result.releasedCode).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.finalPrune).toMatchObject({ beforeBytes: 4, remainingBytes: 0, removedBytes: 4, removed: 1, budgetSatisfied: true });
|
||||
expect(result.cancelledCode).toBe("AbortError");
|
||||
expect(result.cancelledPublished).toBe(false);
|
||||
expect(result.cancelledAssetPresent).toBe(false);
|
||||
expect(result.corruptNotReady).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.corruptionCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
expect(result.cachesAfterCorruption).toBe(0);
|
||||
expect(result.quarantined).toBe(1);
|
||||
expect(result.issues).toEqual([expect.objectContaining({ code: "SIMULATION_CACHE_HASH_MISMATCH" })]);
|
||||
expect(result.schemaVersion).toBe(7);
|
||||
expect(result.stores).toContain("simulation_quarantine");
|
||||
expect(result.pending).toBe(0);
|
||||
expect(result.migration).toMatchObject({ version: 7, record: { version: 7 } });
|
||||
expect(result.migration.stores).toContain("simulation_quarantine");
|
||||
});
|
||||
@@ -35,16 +35,26 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
})));
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer;
|
||||
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
const binding = {
|
||||
graphId: "geometry-node-tree:simulation-performance",
|
||||
graphHash: fixedHash,
|
||||
sourceBlendSha256: await digest(sourceBlend),
|
||||
sourceRevision: 1,
|
||||
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
};
|
||||
@@ -58,6 +68,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
const storedAt = performance.now();
|
||||
|
||||
const reader = new StorageClient();
|
||||
await reader.prepareSimulationCachePlayback(projectId, stored.cacheKey);
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const scene = {
|
||||
schemaVersion: 1 as const,
|
||||
@@ -77,7 +88,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
frameEnd: frameCount,
|
||||
readFrame: async (frame, signal) => {
|
||||
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
|
||||
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame);
|
||||
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal);
|
||||
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
|
||||
return read.data;
|
||||
},
|
||||
@@ -86,10 +97,12 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
lastTranslation = preview.nodes[0].transform.translation[0];
|
||||
});
|
||||
const playbackResult = await playback.play();
|
||||
await reader.releaseSimulationCachePlayback(projectId, stored.cacheKey);
|
||||
const finished = performance.now();
|
||||
reader.terminate();
|
||||
const readerPendingAfterTerminate = reader.getPendingRequestCount();
|
||||
const recovery = new StorageClient();
|
||||
await recovery.prepareSimulationCachePlayback(projectId, stored.cacheKey);
|
||||
const recovered = await recovery.readSimulationCacheFrame(projectId, stored.cacheKey, frameCount);
|
||||
recovery.terminate();
|
||||
const recoveryPendingAfterTerminate = recovery.getPendingRequestCount();
|
||||
|
||||
@@ -87,8 +87,8 @@ test("migrates IndexedDB metadata and creates a validated OPFS project layout",
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ requestId: infoId, command: { type: "info" } });
|
||||
}));
|
||||
expect(result.schemaVersion).toBe(6);
|
||||
expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"]));
|
||||
expect(result.schemaVersion).toBe(7);
|
||||
expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"]));
|
||||
expect(result.projectPath).toBe("projects/layout-e2e/scene.blend");
|
||||
});
|
||||
|
||||
@@ -454,8 +454,8 @@ test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", a
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts");
|
||||
const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
|
||||
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] });
|
||||
const point = (drawingId: string, x: number, index: number) => ({ id: `grease-pencil-point:onion:${drawingId}:${index}`, position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
|
||||
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ id: `grease-pencil-stroke:onion:${id}`, cyclic: false, pointCount: 2, materialIndex: 0, points: [point(id, x, 0), point(id, x + 1, 1)] }] });
|
||||
const object = createGreasePencilObject({
|
||||
id: "grease-pencil:onion",
|
||||
name: "Onion",
|
||||
@@ -464,7 +464,8 @@ test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", a
|
||||
frameCount: 3,
|
||||
strokeCount: 3,
|
||||
pointCount: 6,
|
||||
layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
|
||||
activeLayerId: "grease-pencil-layer:onion:1",
|
||||
layers: [{ id: "grease-pencil-layer:onion:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
|
||||
{ frame: 1, drawing: drawing("drawing:1", -2) },
|
||||
{ frame: 5, drawing: drawing("drawing:5", 0) },
|
||||
{ frame: 9, drawing: drawing("drawing:9", 2) },
|
||||
@@ -531,7 +532,7 @@ test("raycasts and highlights N-016 Grease Pencil points with stable drawing ide
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, strokeIndex: 0, pointIndex: 1 });
|
||||
expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, drawingId: "grease-pencil-drawing:Viewport", strokeId: "grease-pencil-stroke:Viewport:0:0", pointId: "grease-pencil-point:Viewport:0:0:1", strokeIndex: 0, pointIndex: 1 });
|
||||
expect(result.selectedColor).toEqual([expect.closeTo(1), expect.closeTo(0.38), expect.closeTo(0.08)]);
|
||||
expect(result.preview).toEqual([[0.5, 2, -1], [0.5, 2, -1]]);
|
||||
expect(result.restored).toEqual([[0, 0, -0], [0, 0, -0]]);
|
||||
@@ -849,7 +850,7 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes
|
||||
expect(result.cache).toEqual([false, true, false, true, true, 0.25, 2, 256]);
|
||||
});
|
||||
|
||||
test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 graph", async ({ page }) => {
|
||||
test("blocks the N-020 Main graph when it also preserves an unsupported Blender node", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
@@ -860,19 +861,24 @@ test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2
|
||||
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "CompositorScene");
|
||||
if (!scene?.compositorGraph) throw new Error("Real compositor graph is missing");
|
||||
const execution = executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 });
|
||||
const before = JSON.stringify(scene.compositorGraph);
|
||||
let executionCode = "";
|
||||
try { executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 }); }
|
||||
catch (error) { executionCode = (error as { code?: string }).code ?? String(error); }
|
||||
return {
|
||||
status: scene.compositorStatus,
|
||||
pixel: Array.from(execution.composite.data),
|
||||
evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name),
|
||||
executionCode,
|
||||
preserved: JSON.stringify(scene.compositorGraph) === before,
|
||||
unsupported: scene.compositorGraph.nodes.find((node) => node.type === "UNSUPPORTED")?.blenderType,
|
||||
gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code,
|
||||
};
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, new Uint8Array(bytes));
|
||||
expect(result.status).toBe("AVAILABLE");
|
||||
expect(result.pixel).toEqual([0.75, 0.5, 0, 0.75]);
|
||||
expect(result.evaluated).toEqual(["WebConstantColor", "WebExposure", "WebInvert", "WebComposite"]);
|
||||
expect(result.executionCode).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
expect(result.preserved).toBe(true);
|
||||
expect(result.unsupported).toBe("CompositorNodeGlare");
|
||||
expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
});
|
||||
|
||||
@@ -1121,8 +1127,7 @@ for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag
|
||||
const { PerspectiveCamera, Vector3 } = await import("/src/vendor/three/three.module.js");
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const camera = new PerspectiveCamera(45, bounds.width / bounds.height, 0.01, 1000);
|
||||
if (input.offscreen) camera.position.set(7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), 7 * Math.sin(0.55));
|
||||
else camera.position.set(4.5, -4.5, 3.5);
|
||||
camera.position.set(4.219781, -4.219781, 3.658811);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
@@ -1383,7 +1388,15 @@ test("recovers NanoVDB paging from network, Worker and WebGPU device faults", as
|
||||
expect(gpu.paging.pageCount).toBeGreaterThan(1);
|
||||
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
|
||||
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
|
||||
expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.demandPaging.expectedWords.slice(0, 2));
|
||||
expect(gpu.deviceLoss.residentBeforeReplay).toEqual([]);
|
||||
expect(gpu.deviceLoss.visiblePageIds).toEqual([0, 1, 2]);
|
||||
expect(gpu.deviceLoss.replayedPageIds).toEqual([0, 1]);
|
||||
expect(gpu.deviceLoss.skippedPageIds).toEqual([2]);
|
||||
expect(gpu.deviceLoss.recoveredResidentPages).toEqual([0, 1]);
|
||||
expect(gpu.deviceLoss.recoveredResidentPages).not.toContain(gpu.deviceLoss.oldNonVisiblePage);
|
||||
expect(gpu.deviceLoss.recoveredPageTable.slice(0, 2)).toEqual([0, 1]);
|
||||
expect(gpu.deviceLoss.recoveredPageTable.slice(2).every((slot: number) => slot === 0xffffffff)).toBe(true);
|
||||
expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.deviceLoss.expectedDemandWords);
|
||||
expect(gpu.samplesStable).toBe(true);
|
||||
|
||||
const interrupted = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
@@ -1549,7 +1562,7 @@ test("retains bounded snapshots and returns a validated operation replay plan",
|
||||
await client.appendOperation("op-r5", projectId, 5, { type: "setFrame", frame: 5 });
|
||||
await client.appendOperation("op-r4", projectId, 4, { type: "setFrame", frame: 4 });
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 6);
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -1559,18 +1572,50 @@ test("retains bounded snapshots and returns a validated operation replay plan",
|
||||
db.close();
|
||||
const snapshots = await client.listSnapshots(projectId);
|
||||
const latest = await client.readSnapshot(projectId, snapshots.snapshots[0].revision);
|
||||
const snapshotRows = await new Promise<Array<{ backend?: string; buffer?: ArrayBuffer }>>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => {
|
||||
const snapshotDb = request.result;
|
||||
const transaction = snapshotDb.transaction("snapshot", "readonly");
|
||||
const rows = transaction.objectStore("snapshot").getAll();
|
||||
rows.onsuccess = () => resolve((rows.result as Array<{ projectId: string; backend?: string; buffer?: ArrayBuffer }>).filter((row) => row.projectId === projectId));
|
||||
rows.onerror = () => reject(rows.error);
|
||||
transaction.oncomplete = () => snapshotDb.close();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
const project = await projects.getDirectoryHandle(projectId);
|
||||
const snapshotDirectory = await project.getDirectoryHandle("snapshots");
|
||||
const snapshotFiles: string[] = [];
|
||||
for await (const [name, handle] of (snapshotDirectory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries()) {
|
||||
if (handle.kind === "file") snapshotFiles.push(name);
|
||||
}
|
||||
const replay = await client.listOperations(projectId, 3);
|
||||
const pruned = await client.pruneOperations(projectId, 4);
|
||||
client.terminate();
|
||||
return {
|
||||
snapshotRevisions: snapshots.snapshots.map((item) => item.revision),
|
||||
latest: Array.from(new Uint8Array(latest.buffer)),
|
||||
snapshotBackends: snapshotRows.map((row) => row.backend).sort(),
|
||||
inlineSnapshotBuffers: snapshotRows.filter((row) => row.buffer).length,
|
||||
snapshotFiles: snapshotFiles.sort(),
|
||||
replayRevisions: replay.operations.map((item) => item.revision),
|
||||
quarantined: replay.quarantined,
|
||||
pruned: pruned.removed,
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({ snapshotRevisions: [4, 3], latest: [4, 5], replayRevisions: [4, 5], quarantined: 1, pruned: 2 });
|
||||
expect(result).toEqual({
|
||||
snapshotRevisions: [4, 3],
|
||||
latest: [4, 5],
|
||||
snapshotBackends: ["opfs", "opfs"],
|
||||
inlineSnapshotBuffers: 0,
|
||||
snapshotFiles: ["3.blend", "4.blend"],
|
||||
replayRevisions: [4, 5],
|
||||
quarantined: 1,
|
||||
pruned: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports quota exhaustion without replacing the committed project", async ({ page }) => {
|
||||
@@ -1654,16 +1699,26 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
payloadBytes.set(new Uint8Array(frameTwo), frameOne.byteLength);
|
||||
const payload = payloadBytes.buffer;
|
||||
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
const binding = {
|
||||
graphId: "geometry-node-tree:simulation-e2e",
|
||||
graphHash: fixedHash,
|
||||
sourceBlendSha256: await digest(sourceBlend),
|
||||
sourceRevision: 1,
|
||||
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) },
|
||||
@@ -1683,7 +1738,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
const scene = { schemaVersion: 1 as const, revision: 1, sceneId: "scene:Cache", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: 2 }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] };
|
||||
let publishedFrame = 0;
|
||||
let publishedTranslation: number[] = [];
|
||||
const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; });
|
||||
const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; });
|
||||
await playback.seek(2);
|
||||
let missingFrameCode = "";
|
||||
try {
|
||||
@@ -1715,7 +1770,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
corruptCode,
|
||||
};
|
||||
});
|
||||
expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/);
|
||||
expect(result.cacheKey).toMatch(/^sim2-[a-f0-9]{64}$/);
|
||||
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
|
||||
expect(result.listed).toContain(result.cacheKey);
|
||||
expect(result.bytes).toBe(176);
|
||||
|
||||
42
web/tests/e2e/storage-budget.spec.ts
Normal file
42
web/tests/e2e/storage-budget.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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("M7-11 reports project, snapshot, LOD, media and VDB byte categories", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
||||
|
||||
const budget = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
await storage.putAsset("basic_scene", new Uint8Array(7).buffer, "image/png", "media/test.png");
|
||||
await storage.putAsset("basic_scene", new Uint8Array(17).buffer, "application/x-blender-simulation-cache", "cache/simulation.bin");
|
||||
await storage.saveLOD("basic_scene", "budget-lod", new Uint8Array(13).buffer);
|
||||
const result = await storage.getBudget("basic_scene");
|
||||
storage.terminate();
|
||||
return result;
|
||||
});
|
||||
expect(budget.projectBytes).toBeGreaterThan(0);
|
||||
expect(budget.snapshotBytes).toBeGreaterThan(0);
|
||||
expect(budget.lodBytes).toBe(13);
|
||||
expect(budget.mediaBytes).toBe(7);
|
||||
expect(budget.vdbBytes).toBe(17);
|
||||
expect(budget.totalBytes).toBe(budget.projectBytes + budget.snapshotBytes + 13 + 7 + 17);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const panel = page.getByTestId("storage-budget-panel");
|
||||
await expect(panel).toHaveAttribute("data-project-id", "basic_scene");
|
||||
await expect(panel.locator('[data-category="LOD"]')).toHaveAttribute("data-bytes", "13");
|
||||
await expect(panel.locator('[data-category="媒体"]')).toHaveAttribute("data-bytes", "7");
|
||||
await expect(panel.locator('[data-category="VDB"]')).toHaveAttribute("data-bytes", "17");
|
||||
await expect(panel).toHaveAttribute("data-total-bytes", String(budget.totalBytes));
|
||||
});
|
||||
55
web/tests/e2e/storage-cleanup.spec.ts
Normal file
55
web/tests/e2e/storage-cleanup.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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("M7-12 removes only one project's unreferenced content-addressed assets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
|
||||
const orphanA = "a".repeat(64);
|
||||
const orphanB = "b".repeat(64);
|
||||
const referenced = await page.evaluate(async ({ orphanA, orphanB }) => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
const keptA = await storage.putAsset("basic_scene", new Uint8Array(5).buffer, "image/png", "media/kept.png");
|
||||
await storage.putAsset("cleanup-other", new Uint8Array(6).buffer, "image/png", "media/other.png");
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const writeOrphan = async (projectId: string, hash: string, size: number) => {
|
||||
let current = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", hash.slice(0, 2)]) current = await current.getDirectoryHandle(segment, { create: true });
|
||||
const handle = await current.getFileHandle(hash, { create: true });
|
||||
const writer = await handle.createWritable();
|
||||
await writer.write(new Uint8Array(size));
|
||||
await writer.close();
|
||||
};
|
||||
await writeOrphan("basic_scene", orphanA, 19);
|
||||
await writeOrphan("cleanup-other", orphanB, 23);
|
||||
storage.terminate();
|
||||
return { keptA: keptA.sha256, orphanA, orphanB };
|
||||
}, { orphanA, orphanB });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("cleanup-project-assets").click();
|
||||
await expect(page.locator(".status-bar")).toContainText("removed 1 orphan asset(s), 19 bytes");
|
||||
const files = await page.evaluate(async ({ orphanA, orphanB, keptA }) => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const read = async (projectId: string, hash: string) => {
|
||||
try {
|
||||
let current = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", hash.slice(0, 2)]) current = await current.getDirectoryHandle(segment);
|
||||
await current.getFileHandle(hash);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
};
|
||||
return { orphanA: await read("basic_scene", orphanA), orphanB: await read("cleanup-other", orphanB), keptA: await read("basic_scene", keptA) };
|
||||
}, referenced);
|
||||
expect(files).toEqual({ orphanA: false, orphanB: true, keptA: true });
|
||||
});
|
||||
146
web/tests/e2e/texture-paint-asset.spec.ts
Normal file
146
web/tests/e2e/texture-paint-asset.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M9-11 atomically publishes packed and UDIM dirty tiles after verified OPFS writes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const projectId = "m9-texture-paint-atomic";
|
||||
const hash = async (bytes: Uint8Array | ArrayBuffer) => {
|
||||
const data = bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes;
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const encode = async (pixels: Uint8Array) => {
|
||||
const canvas = new OffscreenCanvas(2, 2);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.putImageData(new ImageData(new Uint8ClampedArray(pixels), 2, 2), 0, 0);
|
||||
return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
|
||||
};
|
||||
const decode = async (data: ArrayBuffer) => {
|
||||
const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" }));
|
||||
const canvas = new OffscreenCanvas(2, 2);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
return new Uint8Array(context.getImageData(0, 0, 2, 2).data);
|
||||
};
|
||||
const commit = async ({
|
||||
client,
|
||||
textureAssetId,
|
||||
kind,
|
||||
tile,
|
||||
revision,
|
||||
sourcePath,
|
||||
baseAssetSha256,
|
||||
basePixels,
|
||||
offset,
|
||||
dirty,
|
||||
faultAt,
|
||||
}: {
|
||||
client: InstanceType<typeof StorageClient>;
|
||||
textureAssetId: string;
|
||||
kind: "PACKED" | "UDIM";
|
||||
tile: number;
|
||||
revision: number;
|
||||
sourcePath: string;
|
||||
baseAssetSha256: string;
|
||||
basePixels: Uint8Array;
|
||||
offset: number;
|
||||
dirty: number[];
|
||||
faultAt?: "after-asset-write";
|
||||
}) => {
|
||||
const next = basePixels.slice();
|
||||
next.set(dirty, offset);
|
||||
return client.commitTexturePaintTile({
|
||||
schemaVersion: 1,
|
||||
target: {
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
imageId: "image:M9Paint",
|
||||
textureAssetId,
|
||||
kind,
|
||||
tile,
|
||||
revision,
|
||||
width: 2,
|
||||
height: 2,
|
||||
mimeType: "image/png",
|
||||
colorSpace: "SRGB",
|
||||
sourcePath,
|
||||
baseAssetSha256,
|
||||
},
|
||||
patch: {
|
||||
schemaVersion: 1,
|
||||
textureAssetId,
|
||||
tile,
|
||||
revision,
|
||||
width: 2,
|
||||
height: 2,
|
||||
format: "RGBA8",
|
||||
colorSpace: "SRGB",
|
||||
baseSha256: await hash(basePixels),
|
||||
resultSha256: await hash(next),
|
||||
byteOffset: offset,
|
||||
bytes: new Uint8Array(dirty),
|
||||
},
|
||||
faultAt,
|
||||
});
|
||||
};
|
||||
|
||||
const opaqueBlack = new Uint8Array(Array.from({ length: 4 }, () => [0, 0, 0, 255]).flat());
|
||||
const client = new StorageClient();
|
||||
const packedBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/packed.png");
|
||||
const packedId = "image:M9Paint:packed";
|
||||
const packedFirstPixels = opaqueBlack.slice();
|
||||
packedFirstPixels.set([255, 0, 0, 255], 0);
|
||||
const packedFirst = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 4, sourcePath: "textures/packed.png", baseAssetSha256: packedBase.sha256, basePixels: opaqueBlack, offset: 0, dirty: [255, 0, 0, 255] });
|
||||
const firstAsset = await client.readAsset(projectId, packedFirst.binding.assetSha256);
|
||||
const firstPixels = await decode(firstAsset.data);
|
||||
|
||||
let injected = "";
|
||||
try {
|
||||
await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255], faultAt: "after-asset-write" });
|
||||
}
|
||||
catch (error) { injected = error instanceof Error ? error.message : String(error); }
|
||||
const afterFailure = await client.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 });
|
||||
const packedSecondPixels = packedFirstPixels.slice();
|
||||
packedSecondPixels.set([0, 255, 0, 255], 4);
|
||||
const packedSecond = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255] });
|
||||
|
||||
const udimBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/paint.1002.png");
|
||||
const udimId = "image:M9Paint:tile:1002";
|
||||
const udim = await commit({ client, textureAssetId: udimId, kind: "UDIM", tile: 1002, revision: 5, sourcePath: "textures/paint.1002.png", baseAssetSha256: udimBase.sha256, basePixels: opaqueBlack, offset: 12, dirty: [0, 0, 255, 255] });
|
||||
client.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
const reopenedPacked = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 });
|
||||
const reopenedUdim = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: udimId, tile: 1002 });
|
||||
const reopenedAsset = await restarted.readAsset(projectId, reopenedPacked.binding!.assetSha256);
|
||||
const reopenedPixels = await decode(reopenedAsset.data);
|
||||
restarted.terminate();
|
||||
return {
|
||||
packedBase: packedBase.sha256,
|
||||
packedFirst: packedFirst.binding,
|
||||
firstPixels: Array.from(firstPixels),
|
||||
injected,
|
||||
afterFailure: afterFailure.binding,
|
||||
packedSecond: packedSecond.binding,
|
||||
udim: udim.binding,
|
||||
reopenedPacked: reopenedPacked.binding,
|
||||
reopenedUdim: reopenedUdim.binding,
|
||||
reopenedPixels: Array.from(reopenedPixels),
|
||||
expectedPackedPixels: Array.from(packedSecondPixels),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.packedFirst).toMatchObject({ kind: "PACKED", tile: 1001, generation: 1, pixelSha256: expect.stringMatching(/^[a-f0-9]{64}$/) });
|
||||
expect(result.packedFirst.assetSha256).not.toBe(result.packedBase);
|
||||
expect(result.firstPixels.slice(0, 4)).toEqual([255, 0, 0, 255]);
|
||||
expect(result.injected).toContain("STORAGE_TRANSACTION");
|
||||
expect(result.afterFailure).toEqual(result.packedFirst);
|
||||
expect(result.packedSecond).toMatchObject({ kind: "PACKED", tile: 1001, generation: 2 });
|
||||
expect(result.packedSecond.assetSha256).not.toBe(result.packedFirst.assetSha256);
|
||||
expect(result.udim).toMatchObject({ kind: "UDIM", tile: 1002, generation: 1 });
|
||||
expect(result.reopenedPacked).toEqual(result.packedSecond);
|
||||
expect(result.reopenedUdim).toEqual(result.udim);
|
||||
expect(result.reopenedPixels).toEqual(result.expectedPackedPixels);
|
||||
});
|
||||
47
web/tests/e2e/ui-context.spec.ts
Normal file
47
web/tests/e2e/ui-context.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M7-13 keeps menu, modal Escape and focus return consistent", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
|
||||
const searchTrigger = page.getByRole("button", { name: "操作搜索" });
|
||||
const fileMenu = page.getByRole("button", { name: "文件", exact: true });
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(fileMenu).toBeFocused();
|
||||
await searchTrigger.focus();
|
||||
await page.keyboard.press("F3");
|
||||
const dialog = page.getByRole("dialog", { name: "Operator Search" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "搜索操作" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(page.getByRole("textbox", { name: "搜索操作" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(searchTrigger).toBeFocused();
|
||||
|
||||
await fileMenu.click();
|
||||
const menu = page.getByRole("menu", { name: "文件" });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitem", { name: "保存" })).toBeFocused();
|
||||
await page.keyboard.press("End");
|
||||
await expect(page.getByRole("menuitem", { name: "关闭" })).toBeFocused();
|
||||
await page.keyboard.press("Home");
|
||||
await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(fileMenu).toBeFocused();
|
||||
|
||||
await fileMenu.click();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(fileMenu).toBeFocused();
|
||||
await fileMenu.click();
|
||||
await page.getByRole("button", { name: "操作搜索" }).click();
|
||||
await expect(page.getByRole("menu", { name: "文件" })).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(searchTrigger).toBeFocused();
|
||||
});
|
||||
61
web/tests/e2e/viewport-consistency.spec.ts
Normal file
61
web/tests/e2e/viewport-consistency.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M7-15 ${offscreen ? "Offscreen" : "main-thread"} viewport uses the shared camera and selection contract`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
|
||||
if (offscreen) await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
|
||||
|
||||
const initial = await canvas.evaluate((element) => ({
|
||||
position: element.getAttribute("data-camera-position"),
|
||||
target: element.getAttribute("data-camera-target"),
|
||||
yaw: element.getAttribute("data-camera-yaw"),
|
||||
pitch: element.getAttribute("data-camera-pitch"),
|
||||
distance: element.getAttribute("data-camera-distance"),
|
||||
}));
|
||||
expect(initial.position).toBe("4.219781,-4.219781,3.658811");
|
||||
expect(initial.target).toBe("0,0,0");
|
||||
expect(initial.yaw).toBe("-0.785398");
|
||||
expect(initial.pitch).toBe("0.550000");
|
||||
expect(initial.distance).toBe("7.000000");
|
||||
|
||||
await canvas.evaluate((element, useOffscreen) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const x = bounds.left + bounds.width / 2;
|
||||
const y = bounds.top + bounds.height / 2;
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }));
|
||||
if (useOffscreen) {
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: x, clientY: y, pointerId: 11, buttons: 1 }));
|
||||
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId: 11, buttons: 0 }));
|
||||
}
|
||||
}, offscreen);
|
||||
await expect(page.locator(".blender-app")).toHaveAttribute("data-selected-object-ids", /.+/);
|
||||
|
||||
await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 120 })));
|
||||
await expect.poll(async () => await canvas.getAttribute("data-camera-distance"), { timeout: 5_000 }).not.toBe(initial.distance);
|
||||
const zoomed = await canvas.getAttribute("data-camera-distance");
|
||||
expect(Number(zoomed)).toBeCloseTo(7 * Math.exp(0.12), 4);
|
||||
});
|
||||
}
|
||||
|
||||
test("M7-15 main and Offscreen camera state stays identical for the same orbit input", async ({ browser }) => {
|
||||
const states: Array<Record<string, string | null>> = [];
|
||||
for (const offscreen of [false, true]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
if (offscreen) await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
|
||||
await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -80 })));
|
||||
await expect.poll(async () => await canvas.getAttribute("data-camera-distance"), { timeout: 5_000 }).not.toBe("7.000000");
|
||||
states.push(await canvas.evaluate((element) => ({ position: element.getAttribute("data-camera-position"), target: element.getAttribute("data-camera-target"), yaw: element.getAttribute("data-camera-yaw"), pitch: element.getAttribute("data-camera-pitch"), distance: element.getAttribute("data-camera-distance") })));
|
||||
await page.close();
|
||||
}
|
||||
expect(states[0]).toEqual(states[1]);
|
||||
});
|
||||
41
web/tests/e2e/worker-crash-recovery.spec.ts
Normal file
41
web/tests/e2e/worker-crash-recovery.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
for (const source of ["engine", "storage"] as const) {
|
||||
test("M7-08 " + source + " Worker crash keeps the project visible and restores the operation log", async ({ page }) => {
|
||||
await page.goto("/?worker-fault=" + source);
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
|
||||
const initialDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await initialDownload;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
const committedRevision = await app.getAttribute("data-committed-main-revision");
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
const editedRevision = await app.getAttribute("data-current-main-revision");
|
||||
const editedStats = await page.getByTestId("scene-stats").textContent();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await page.getByTestId("inject-worker-crash").click();
|
||||
await expect(app).toHaveAttribute("data-worker-fault-source", source);
|
||||
await expect(app).toHaveAttribute("data-worker-fault-code", "WORKER_TERMINATED");
|
||||
await expect(page.getByTestId("worker-fault-banner")).toContainText("current project list and scene are retained");
|
||||
await expect(app).toHaveAttribute("data-project-snapshot-revision", editedRevision ?? "");
|
||||
await expect(page.getByTestId("scene-stats")).toHaveText(editedStats ?? "");
|
||||
|
||||
await page.getByTestId("restart-and-recover").click();
|
||||
await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("worker-fault-banner")).toHaveCount(0);
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", committedRevision ?? "");
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
await expect(page.getByTestId("scene-stats")).toHaveText(editedStats ?? "");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("project restored");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user