Advance M7 workflows and release operations
This commit is contained in:
147
web/tests/e2e/archive-offline.spec.ts
Normal file
147
web/tests/e2e/archive-offline.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { expect, test, type BrowserContext, type Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { importGLBSemantics } from "../../protocol/glb-import";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
function isLoopbackRequest(value: string): boolean {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol)) return true;
|
||||
return url.hostname === "localhost" || url.hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
|
||||
}
|
||||
|
||||
async function isolateExternalNetwork(context: BrowserContext, page: Page) {
|
||||
const externalAttempts: string[] = [];
|
||||
const requested: string[] = [];
|
||||
page.on("request", (request) => requested.push(request.url()));
|
||||
await context.route("**/*", async (route) => {
|
||||
if (isLoopbackRequest(route.request().url())) await route.continue();
|
||||
else {
|
||||
externalAttempts.push(route.request().url());
|
||||
await route.abort("internetdisconnected");
|
||||
}
|
||||
});
|
||||
return { externalAttempts, requested };
|
||||
}
|
||||
|
||||
async function waitForArchiveBoot(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Web Blender Modeler V1", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file");
|
||||
await expect(page.locator(".status-bar")).toContainText("Storage: IndexedDB + OPFS");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
expect(await page.evaluate(() => document.fonts.status)).toBe("loaded");
|
||||
}
|
||||
|
||||
async function objectCount(page: Page): Promise<number> {
|
||||
const text = await page.getByTestId("scene-stats").innerText();
|
||||
const match = text.match(/Objects (\d+)/);
|
||||
if (!match) throw new Error(`scene stats omit object count: ${text}`);
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
async function stableIds(page: Page) {
|
||||
return page.locator(".outliner-content .tree-row.child").evaluateAll((rows) => rows.map((row) => ({
|
||||
objectId: (row as HTMLElement).dataset.nodeId ?? "",
|
||||
dataId: (row as HTMLElement).dataset.dataId ?? "",
|
||||
name: row.querySelector("span:nth-of-type(3)")?.textContent ?? "",
|
||||
})).sort((left, right) => left.objectId.localeCompare(right.objectId)));
|
||||
}
|
||||
|
||||
async function opfsCommit(page: Page, projectId: string) {
|
||||
return page.evaluate(async (id) => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
const project = await projects.getDirectoryHandle(id);
|
||||
const manifestFile = await (await project.getFileHandle("scene.blend.meta.json")).getFile();
|
||||
const manifest = JSON.parse(await manifestFile.text()) as { revision: number; bytes: number; sha256: string };
|
||||
const blend = await (await project.getFileHandle("scene.blend")).getFile();
|
||||
const bytes = await blend.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const sha256 = Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
return { ...manifest, actualBytes: bytes.byteLength, actualSha256: sha256 };
|
||||
}, projectId);
|
||||
}
|
||||
|
||||
test("M6-09 cold boots the extracted binary archive with no external network", async ({ context, page }) => {
|
||||
const network = await isolateExternalNetwork(context, page);
|
||||
await waitForArchiveBoot(page);
|
||||
const paths = network.requested.filter((value) => value.startsWith(page.url().replace(/\/$/, ""))).map((value) => new URL(value).pathname);
|
||||
expect(paths).toContain("/engine-manifest.json");
|
||||
expect(paths.some((value) => value.endsWith(".wasm"))).toBe(true);
|
||||
expect(paths.some((value) => value.endsWith(".css"))).toBe(true);
|
||||
expect(paths.some((value) => value.includes(".worker-") && value.endsWith(".js"))).toBe(true);
|
||||
expect(network.externalAttempts).toEqual([]);
|
||||
});
|
||||
|
||||
test("M6-10 reopens and continues an OPFS project offline after Worker reconstruction", async ({ context, page }) => {
|
||||
const network = await isolateExternalNetwork(context, page);
|
||||
await waitForArchiveBoot(page);
|
||||
const input = fs.readFileSync(basicBlend);
|
||||
const projectId = `m6-offline-${Date.now()}`;
|
||||
await page.getByTestId("blend-file-input").setInputFiles({
|
||||
name: `${projectId}.blend`,
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: input,
|
||||
});
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Engine: SceneIR r");
|
||||
const initialCount = await objectCount(page);
|
||||
const initialIds = await stableIds(page);
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
|
||||
const editedIds = await stableIds(page);
|
||||
const firstCreated = editedIds.find((entry) => !initialIds.some((initial) => initial.objectId === entry.objectId));
|
||||
expect(firstCreated?.objectId).toBeTruthy();
|
||||
expect(firstCreated?.dataId).toBeTruthy();
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect.poll(() => objectCount(page)).toBe(initialCount);
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
|
||||
|
||||
const firstSave = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await firstSave;
|
||||
const firstCommit = await opfsCommit(page, projectId);
|
||||
expect(firstCommit.revision).toBeGreaterThan(0);
|
||||
expect(firstCommit.bytes).toBe(firstCommit.actualBytes);
|
||||
expect(firstCommit.sha256).toBe(firstCommit.actualSha256);
|
||||
expect(firstCommit.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file");
|
||||
await page.getByRole("button", { name: "恢复项目" }).click();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Recovery: 0 operation(s), 0 quarantined");
|
||||
expect(await stableIds(page)).toEqual(editedIds);
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect.poll(() => objectCount(page)).toBe(initialCount + 2);
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect.poll(() => objectCount(page)).toBe(initialCount + 2);
|
||||
|
||||
const secondSave = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await secondSave;
|
||||
const secondCommit = await opfsCommit(page, projectId);
|
||||
expect(secondCommit.revision).toBeGreaterThan(firstCommit.revision);
|
||||
expect(secondCommit.sha256).toBe(secondCommit.actualSha256);
|
||||
expect(secondCommit.sha256).not.toBe(firstCommit.sha256);
|
||||
|
||||
const glbDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出 GLB" }).click();
|
||||
const glb = await glbDownload;
|
||||
const glbPath = await glb.path();
|
||||
expect(glbPath).toBeTruthy();
|
||||
const glbBytes = fs.readFileSync(glbPath!);
|
||||
const glbBuffer = glbBytes.buffer.slice(glbBytes.byteOffset, glbBytes.byteOffset + glbBytes.byteLength);
|
||||
const imported = importGLBSemantics(glbBuffer);
|
||||
expect(imported.meshCount).toBeGreaterThan(0);
|
||||
expect(imported.primitiveCount).toBeGreaterThan(0);
|
||||
expect(imported.meshes.some((mesh) => mesh.blenderId === firstCreated?.dataId)).toBe(true);
|
||||
expect(network.externalAttempts).toEqual([]);
|
||||
});
|
||||
|
||||
69
web/tests/e2e/dirty-state.spec.ts
Normal file
69
web/tests/e2e/dirty-state.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M7-06 changes dirty only for accepted Main transactions and matching saves", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(attributeBlend);
|
||||
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
const openedRevision = await app.getAttribute("data-current-main-revision");
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", openedRevision ?? "");
|
||||
|
||||
await page.getByRole("button", { name: "Modeling" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
|
||||
|
||||
await page.getByRole("button", { name: "预览 Decimate" }).click();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Preview");
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
|
||||
|
||||
await page.locator("label.file-button input[type=file]").setInputFiles({
|
||||
name: "invalid.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("not-a-png"),
|
||||
});
|
||||
await expect(app).toHaveAttribute("data-user-action-import-status", "FAILED");
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
await expect(page.getByTestId("dirty-status")).toHaveText("未保存");
|
||||
const editedRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(editedRevision).toBeGreaterThan(Number(openedRevision));
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", openedRevision ?? "");
|
||||
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await expect(page.getByTestId("dirty-status")).toHaveText("已保存");
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", String(editedRevision));
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
const undoRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(undoRevision).toBeGreaterThan(editedRevision);
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
const redoRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(redoRevision).toBeGreaterThan(undoRevision);
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles({
|
||||
name: "invalid.blend",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: Buffer.from([0x42, 0x41, 0x44]),
|
||||
});
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "FAILED");
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
const finalUndoRevision = await app.getAttribute("data-current-main-revision");
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", finalUndoRevision ?? "");
|
||||
});
|
||||
92
web/tests/e2e/engine-upgrade-safety.spec.ts
Normal file
92
web/tests/e2e/engine-upgrade-safety.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("refreshes on a release switch and rejects tampered WASM without fallback or open", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".status-bar")).toContainText("Manifest: verified");
|
||||
|
||||
const versionGate = await page.evaluate(async () => {
|
||||
const { bootstrapWebEngineRelease } = await import("/src/engine-client/engine-variant-bootstrap.ts");
|
||||
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
|
||||
let initializationCount = 0;
|
||||
const outcome = await bootstrapWebEngineRelease(
|
||||
"blender-wasm-previous",
|
||||
manifest,
|
||||
"AUTO",
|
||||
{ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true },
|
||||
{
|
||||
initialize: async () => {
|
||||
initializationCount += 1;
|
||||
throw new Error("release mismatch initialized an engine");
|
||||
},
|
||||
},
|
||||
new ArrayBuffer(8),
|
||||
);
|
||||
return { result: outcome.result, hasSession: outcome.session !== null, initializationCount };
|
||||
});
|
||||
|
||||
expect(versionGate).toEqual({
|
||||
result: {
|
||||
status: "REFRESH_REQUIRED",
|
||||
expectedReleaseId: "blender-wasm-previous",
|
||||
actualReleaseId: "blender-wasm-0.1.0-rc.1",
|
||||
manifest: null,
|
||||
},
|
||||
hasSession: false,
|
||||
initializationCount: 0,
|
||||
});
|
||||
|
||||
const variantRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname.startsWith("/vendor/blender/single/") || pathname.startsWith("/vendor/blender/pthread/")) {
|
||||
variantRequests.push(pathname);
|
||||
}
|
||||
});
|
||||
await page.route("**/vendor/blender/pthread/web_engine.wasm", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/wasm",
|
||||
body: Buffer.from("tampered-pthread-wasm"),
|
||||
});
|
||||
});
|
||||
|
||||
const integrityGate = await page.evaluate(async () => {
|
||||
const { detectBrowserCapabilities } = await import("/src/platform/capabilities.ts");
|
||||
const { bootstrapWebEngineRelease } = await import("/src/engine-client/engine-variant-bootstrap.ts");
|
||||
const { createBrowserEngineVariantSession } = await import("/src/engine-client/browser-engine-variant-session.ts");
|
||||
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
|
||||
try {
|
||||
await bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
detectBrowserCapabilities(),
|
||||
{ initialize: createBrowserEngineVariantSession },
|
||||
new ArrayBuffer(8),
|
||||
);
|
||||
return { rejected: false };
|
||||
}
|
||||
catch (error) {
|
||||
const failure = error as Error & {
|
||||
code?: string;
|
||||
attempted?: string[];
|
||||
cause?: { code?: string };
|
||||
};
|
||||
return {
|
||||
rejected: true,
|
||||
code: failure.code,
|
||||
causeCode: failure.cause?.code,
|
||||
attempted: failure.attempted,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
expect(integrityGate).toEqual({
|
||||
rejected: true,
|
||||
code: "ENGINE_VARIANT_INTEGRITY_FAILED",
|
||||
causeCode: "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH",
|
||||
attempted: ["pthread"],
|
||||
});
|
||||
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.wasm"))).toHaveLength(1);
|
||||
expect(variantRequests.some((url) => url.includes("/single/"))).toBe(false);
|
||||
});
|
||||
111
web/tests/e2e/file-import-progress.spec.ts
Normal file
111
web/tests/e2e/file-import-progress.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
const largeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/image_resource_matrix.blend");
|
||||
|
||||
test("M7-03 records exact streamed byte progress for a large valid blend", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const expectedBytes = fs.statSync(largeBlend).size;
|
||||
expect(expectedBytes).toBeGreaterThanOrEqual(512 * 1024);
|
||||
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(largeBlend);
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED", { timeout: 30_000 });
|
||||
await expect(app).toHaveAttribute("data-open-read-phase", "COMPLETED");
|
||||
await expect(app).toHaveAttribute("data-open-read-bytes", String(expectedBytes));
|
||||
await expect(app).toHaveAttribute("data-open-read-total", String(expectedBytes));
|
||||
expect(Number(await app.getAttribute("data-open-read-events"))).toBeGreaterThan(2);
|
||||
expect(await app.getAttribute("data-open-read-action-id")).toBe(await app.getAttribute("data-user-action-open-id"));
|
||||
});
|
||||
|
||||
test("M7-03 cancels a large streamed open before WebEngine and preserves the current project", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
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("BasicCube", { exact: true })).toBeVisible();
|
||||
const previousStats = await page.getByTestId("scene-stats").textContent();
|
||||
|
||||
const totalBytes = 40 * 1024 * 1024;
|
||||
const syntheticLargeBlend = Buffer.alloc(totalBytes, 0x7f);
|
||||
fs.readFileSync(basicBlend).copy(syntheticLargeBlend);
|
||||
await page.getByTestId("blend-file-input").setInputFiles({
|
||||
name: "cancelled-large.blend",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: syntheticLargeBlend,
|
||||
});
|
||||
|
||||
const observation = await page.waitForFunction((expectedTotal) => {
|
||||
const element = document.querySelector<HTMLElement>('[data-testid="open-progress"]');
|
||||
const bytesRead = Number(element?.getAttribute("data-bytes-read"));
|
||||
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();
|
||||
return { bytesRead, totalBytes: actualTotal };
|
||||
}, totalBytes, { polling: "raf", timeout: 10_000 });
|
||||
const observed = await observation.jsonValue() as { bytesRead: number; totalBytes: number };
|
||||
expect(observed.bytesRead).toBeLessThan(totalBytes);
|
||||
expect(observed.totalBytes).toBe(totalBytes);
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "CANCELLED");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_CANCELLED");
|
||||
await expect(app).toHaveAttribute("data-open-read-phase", "CANCELLED");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-reader-count", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-reader-bytes", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-reader-staging", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-requests", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-bytes", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-handles", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-staging", "0");
|
||||
const cancelledBytes = Number(await app.getAttribute("data-open-read-bytes"));
|
||||
expect(cancelledBytes).toBeGreaterThan(0);
|
||||
expect(cancelledBytes).toBeLessThan(totalBytes);
|
||||
await expect(page.getByTestId("scene-stats")).toHaveText(previousStats ?? "");
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("basic_scene");
|
||||
});
|
||||
|
||||
test("M7-04 destroys isolated native open resources before reporting cancellation", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
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("BasicCube", { exact: true })).toBeVisible();
|
||||
const previousStats = await page.getByTestId("scene-stats").textContent();
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles(largeBlend);
|
||||
const cancellation = await page.waitForFunction(() => {
|
||||
const progress = document.querySelector<HTMLElement>('[data-testid="open-progress"]');
|
||||
const stage = progress?.dataset.stage;
|
||||
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
|
||||
if (!cancel || (stage !== "NATIVE_INITIALIZE" && stage !== "NATIVE_OPENED")) return false;
|
||||
cancel.click();
|
||||
return stage;
|
||||
}, undefined, { polling: "raf", timeout: 10_000 });
|
||||
expect(["NATIVE_INITIALIZE", "NATIVE_OPENED"]).toContain(await cancellation.jsonValue());
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "CANCELLED");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_CANCELLED");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-reader-count", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-reader-bytes", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-reader-staging", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-requests", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-bytes", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-handles", "0");
|
||||
await expect(app).toHaveAttribute("data-open-cleanup-engine-staging", "0");
|
||||
await expect(page.getByTestId("scene-stats")).toHaveText(previousStats ?? "");
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
});
|
||||
95
web/tests/e2e/project-action-mutex.spec.ts
Normal file
95
web/tests/e2e/project-action-mutex.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
async function waitForEngine(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
}
|
||||
|
||||
test("M7-02 rejects a repeated open before it can replace the first project owner", async ({ page }) => {
|
||||
await waitForEngine(page);
|
||||
const bytes = Array.from(fs.readFileSync(basicBlend));
|
||||
await page.evaluate(({ blendBytes }) => {
|
||||
const input = document.querySelector<HTMLInputElement>("[data-testid=blend-file-input]");
|
||||
if (!input) throw new Error("blend input missing");
|
||||
const dispatchOpen = (name: string): void => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array(blendBytes)], name, { type: "application/octet-stream" }));
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
dispatchOpen("first-open.blend");
|
||||
dispatchOpen("second-open.blend");
|
||||
}, { blendBytes: bytes });
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "REPEATED_OPEN");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "OPEN");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "OPEN");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("first-open");
|
||||
});
|
||||
|
||||
test("M7-02 grants only one concurrent manual save and emits one download", async ({ page }) => {
|
||||
await waitForEngine(page);
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
let downloadCount = 0;
|
||||
page.on("download", () => { downloadCount += 1; });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const save = document.querySelector<HTMLButtonElement>('button[aria-label="保存项目"]');
|
||||
if (!save) throw new Error("save button missing");
|
||||
save.click();
|
||||
save.click();
|
||||
});
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CONCURRENT_SAVE");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "SAVE");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE");
|
||||
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED");
|
||||
await expect.poll(() => downloadCount).toBe(1);
|
||||
});
|
||||
|
||||
test("M7-02 blocks close until save commit completes without terminating either worker", async ({ page }) => {
|
||||
await waitForEngine(page);
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
const firstDownload = page.waitForEvent("download");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const save = document.querySelector<HTMLButtonElement>('button[aria-label="保存项目"]');
|
||||
const close = document.querySelector<HTMLButtonElement>('button[aria-label="关闭项目"]');
|
||||
if (!save || !close) throw new Error("project action button missing");
|
||||
save.click();
|
||||
close.click();
|
||||
});
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CLOSE_DURING_SAVE");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "CLOSE");
|
||||
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE");
|
||||
await firstDownload;
|
||||
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
const secondDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await secondDownload;
|
||||
await expect(app).not.toHaveAttribute("data-project-action-conflict");
|
||||
|
||||
await page.getByRole("button", { name: "关闭项目" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 0");
|
||||
await expect(page.getByRole("button", { name: "关闭项目" })).toBeDisabled();
|
||||
});
|
||||
78
web/tests/e2e/pthread-engine-isolated.spec.ts
Normal file
78
web/tests/e2e/pthread-engine-isolated.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("loads the selected pthread engine with shared memory and its worker pool", async ({ page }) => {
|
||||
const pthreadRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname.startsWith("/vendor/blender/pthread/")) pthreadRequests.push(pathname);
|
||||
});
|
||||
|
||||
const documentResponse = await page.goto("/");
|
||||
expect(documentResponse).not.toBeNull();
|
||||
const headers = await documentResponse!.allHeaders();
|
||||
expect(headers["cross-origin-opener-policy"]).toBe("same-origin");
|
||||
expect(headers["cross-origin-embedder-policy"]).toBe("require-corp");
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const { detectBrowserCapabilities, selectWebEngineVariant } = await import("/src/platform/capabilities.ts");
|
||||
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
|
||||
const selection = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", detectBrowserCapabilities());
|
||||
if (!selection.selectedVariant) {
|
||||
return { selected: null, gate: selection.pthreadGate, shared: false, handle: 0, liveAfterDestroy: -1 };
|
||||
}
|
||||
|
||||
const variant = selection.selectedVariant;
|
||||
const wasmResponse = await fetch(variant.resources.wasm.url, { cache: "no-store" });
|
||||
if (!wasmResponse.ok) throw new Error(`pthread WASM request failed: ${wasmResponse.status}`);
|
||||
const wasmBinary = await wasmResponse.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", wasmBinary);
|
||||
const actualHash = [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
if (actualHash !== variant.resources.wasm.sha256) throw new Error("pthread WASM hash mismatch");
|
||||
|
||||
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as {
|
||||
default: (options: { wasmBinary: ArrayBuffer }) => Promise<{
|
||||
HEAPU8: Uint8Array;
|
||||
_web_engine_create(): number;
|
||||
_web_engine_destroy(handle: number): void;
|
||||
_web_engine_get_live_handles(): number;
|
||||
PThread: {
|
||||
unusedWorkers: Worker[];
|
||||
runningWorkers: Worker[];
|
||||
terminateAllThreads(): void;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const module = await imported.default({ wasmBinary });
|
||||
const handle = module._web_engine_create();
|
||||
const shared = module.HEAPU8.buffer instanceof SharedArrayBuffer;
|
||||
const liveBeforeDestroy = module._web_engine_get_live_handles();
|
||||
const poolWorkersBeforeDispose = module.PThread.unusedWorkers.length + module.PThread.runningWorkers.length;
|
||||
module._web_engine_destroy(handle);
|
||||
module.PThread.terminateAllThreads();
|
||||
return {
|
||||
selected: variant.id,
|
||||
gate: selection.pthreadGate,
|
||||
shared,
|
||||
handle,
|
||||
liveBeforeDestroy,
|
||||
liveAfterDestroy: module._web_engine_get_live_handles(),
|
||||
poolWorkersBeforeDispose,
|
||||
poolWorkersAfterDispose: module.PThread.unusedWorkers.length + module.PThread.runningWorkers.length,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
selected: "pthread",
|
||||
gate: { taskId: "M6-02", capability: "WASM_PTHREAD_ENGINE", status: "READY", issues: [] },
|
||||
shared: true,
|
||||
liveBeforeDestroy: 1,
|
||||
liveAfterDestroy: 0,
|
||||
poolWorkersBeforeDispose: 1,
|
||||
poolWorkersAfterDispose: 0,
|
||||
});
|
||||
expect(result.handle).toBeGreaterThan(0);
|
||||
expect(pthreadRequests.filter((url) => url.endsWith("/web_engine.wasm"))).toHaveLength(1);
|
||||
expect(pthreadRequests.filter((url) => url.endsWith("/web_engine.js")).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
58
web/tests/e2e/pthread-fallback.spec.ts
Normal file
58
web/tests/e2e/pthread-fallback.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("cleans an initialized pthread attempt before one single fallback", async ({ page }) => {
|
||||
const variantRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname.startsWith("/vendor/blender/single/") || pathname.startsWith("/vendor/blender/pthread/")) {
|
||||
variantRequests.push(pathname);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".status-bar")).toContainText("Manifest: verified");
|
||||
variantRequests.length = 0;
|
||||
const report = await page.evaluate(async () => {
|
||||
const { detectBrowserCapabilities } = await import("/src/platform/capabilities.ts");
|
||||
const {
|
||||
bootstrapWebEngineRelease,
|
||||
engineVariantStatusLabel,
|
||||
} = await import("/src/engine-client/engine-variant-bootstrap.ts");
|
||||
const { createBrowserEngineVariantSession } = await import("/src/engine-client/browser-engine-variant-session.ts");
|
||||
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
|
||||
const outcome = await bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
detectBrowserCapabilities(),
|
||||
{ initialize: createBrowserEngineVariantSession },
|
||||
undefined,
|
||||
{ failPthreadAfterInitialize: true, seedTrackedPthreadResources: true },
|
||||
);
|
||||
if (!outcome.session) throw new Error("current release unexpectedly requires refresh");
|
||||
const activeBeforeDispose = outcome.session.resourceState();
|
||||
const label = engineVariantStatusLabel(outcome.result);
|
||||
const activeAfterDispose = await outcome.session.dispose();
|
||||
return { result: outcome.result, activeBeforeDispose, activeAfterDispose, label };
|
||||
});
|
||||
|
||||
expect(report.result).toMatchObject({
|
||||
status: "READY",
|
||||
selected: "single",
|
||||
attempted: ["pthread", "single"],
|
||||
fallbackReason: {
|
||||
code: "PTHREAD_INITIALIZATION_FAILED",
|
||||
message: "TEST_PTHREAD_INITIALIZATION_FAILURE",
|
||||
},
|
||||
openCount: 0,
|
||||
failedAttemptCleanup: { handles: 0, workers: 0, timers: 0, pendingRequests: 0 },
|
||||
});
|
||||
expect(report.activeBeforeDispose).toEqual({ handles: 1, workers: 0, timers: 0, pendingRequests: 0 });
|
||||
expect(report.activeAfterDispose).toEqual({ handles: 0, workers: 0, timers: 0, pendingRequests: 0 });
|
||||
expect(report.label).toBe("Runtime: single (pthread fallback: PTHREAD_INITIALIZATION_FAILED)");
|
||||
expect(report.label).not.toMatch(/pthread\s+ready/i);
|
||||
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.wasm"))).toHaveLength(1);
|
||||
expect(variantRequests.filter((url) => url.endsWith("/single/web_engine.wasm"))).toHaveLength(1);
|
||||
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.js")).length).toBeGreaterThanOrEqual(3);
|
||||
expect(variantRequests.filter((url) => url.endsWith("/single/web_engine.js")).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
62
web/tests/e2e/save-interruption.spec.ts
Normal file
62
web/tests/e2e/save-interruption.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M7-05 restores old revision, hash and bytes after every storage save interruption", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const projectId = `m7-save-interrupt-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const oldBytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 7, 7, 7]);
|
||||
const nextBytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 8, 8, 8, 8]);
|
||||
const initial = new StorageClient();
|
||||
const baseline = await initial.saveProject(projectId, 7, oldBytes.slice().buffer);
|
||||
initial.terminate();
|
||||
|
||||
const observations = [];
|
||||
for (const faultAt of ["after-stage", "after-scene-commit", "before-metadata-commit"] as const) {
|
||||
const attempt = new StorageClient();
|
||||
let error = "";
|
||||
try {
|
||||
await attempt.saveProject(projectId, 8, nextBytes.slice().buffer, faultAt);
|
||||
}
|
||||
catch (reason) {
|
||||
error = reason instanceof Error ? reason.message : String(reason);
|
||||
}
|
||||
attempt.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
const restored = await restarted.readProject(projectId);
|
||||
restarted.terminate();
|
||||
observations.push({
|
||||
faultAt,
|
||||
error,
|
||||
revision: restored.revision,
|
||||
sha256: restored.sha256,
|
||||
bytes: Array.from(new Uint8Array(restored.buffer)),
|
||||
recovered: restored.recovered,
|
||||
});
|
||||
}
|
||||
|
||||
const completed = new StorageClient();
|
||||
const committed = await completed.saveProject(projectId, 8, nextBytes.slice().buffer);
|
||||
const reopened = await completed.readProject(projectId);
|
||||
completed.terminate();
|
||||
return {
|
||||
baseline: { revision: baseline.revision, sha256: baseline.sha256, bytes: Array.from(oldBytes) },
|
||||
observations,
|
||||
committed: { revision: committed.revision, sha256: committed.sha256, reopenedRevision: reopened.revision, reopenedSha256: reopened.sha256 },
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.observations).toHaveLength(3);
|
||||
for (const item of result.observations) {
|
||||
expect(item.error).toContain(`PROJECT_SAVE_FAULT_INJECTED: ${item.faultAt}`);
|
||||
expect(item.revision).toBe(result.baseline.revision);
|
||||
expect(item.sha256).toBe(result.baseline.sha256);
|
||||
expect(item.bytes).toEqual(result.baseline.bytes);
|
||||
expect(item.recovered).toBe(false);
|
||||
}
|
||||
expect(result.committed.revision).toBe(8);
|
||||
expect(result.committed.reopenedRevision).toBe(8);
|
||||
expect(result.committed.sha256).toBe(result.committed.reopenedSha256);
|
||||
expect(result.committed.sha256).not.toBe(result.baseline.sha256);
|
||||
});
|
||||
110
web/tests/e2e/single-thread-unisolated.spec.ts
Normal file
110
web/tests/e2e/single-thread-unisolated.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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("boots and edits with the single-thread engine without cross-origin isolation", async ({ page }) => {
|
||||
const engineAssetUrls: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (/web_engine|pthread/i.test(url.pathname)) engineAssetUrls.push(url.pathname);
|
||||
});
|
||||
|
||||
const documentResponse = await page.goto("/");
|
||||
expect(documentResponse).not.toBeNull();
|
||||
const headers = await documentResponse!.allHeaders();
|
||||
expect(headers["cross-origin-opener-policy"]).toBeUndefined();
|
||||
expect(headers["cross-origin-embedder-policy"]).toBeUndefined();
|
||||
expect(headers["cross-origin-resource-policy"]).toBeUndefined();
|
||||
|
||||
const platform = await page.evaluate(async () => {
|
||||
const {
|
||||
detectBrowserCapabilities,
|
||||
gateWasmThreadingCapability,
|
||||
selectWebEngineVariant,
|
||||
} = await import("/src/platform/capabilities.ts");
|
||||
const capabilities = detectBrowserCapabilities();
|
||||
const pthreadGate = gateWasmThreadingCapability(capabilities);
|
||||
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json()) as {
|
||||
schemaVersion: number;
|
||||
memory?: { shared: boolean };
|
||||
variants?: Array<{
|
||||
id: "single" | "pthread";
|
||||
memory: { shared: boolean };
|
||||
resources: { js: { url: string }; wasm: { url: string } };
|
||||
}>;
|
||||
};
|
||||
const singleMemory = manifest.schemaVersion === 2
|
||||
? manifest.variants?.find((variant) => variant.id === "single")?.memory
|
||||
: manifest.memory;
|
||||
const selection = selectWebEngineVariant(
|
||||
manifest as Parameters<typeof selectWebEngineVariant>[0],
|
||||
"AUTO",
|
||||
capabilities,
|
||||
);
|
||||
if (!selection.selectedVariant) throw new Error("AUTO did not select a single-thread variant");
|
||||
const selected = selection.selectedVariant;
|
||||
const wasmBinary = await fetch(selected.resources.wasm.url, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`single WASM request failed: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
});
|
||||
const imported = await import(/* @vite-ignore */ selected.resources.js.url) as {
|
||||
default: (options: { wasmBinary: ArrayBuffer }) => Promise<{
|
||||
HEAPU8: Uint8Array;
|
||||
_web_engine_create(): number;
|
||||
_web_engine_destroy(handle: number): void;
|
||||
_web_engine_get_live_handles(): number;
|
||||
}>;
|
||||
};
|
||||
const module = await imported.default({ wasmBinary });
|
||||
const variantHandle = module._web_engine_create();
|
||||
const variantSharedMemory = typeof SharedArrayBuffer !== "undefined" &&
|
||||
module.HEAPU8.buffer instanceof SharedArrayBuffer;
|
||||
module._web_engine_destroy(variantHandle);
|
||||
return {
|
||||
crossOriginIsolated: capabilities.crossOriginIsolated,
|
||||
sharedArrayBuffer: capabilities.sharedArrayBuffer,
|
||||
worker: capabilities.worker,
|
||||
pthreadGate,
|
||||
autoSelectedVariant: selected.id,
|
||||
variantHandle,
|
||||
variantSharedMemory,
|
||||
variantLiveAfterDestroy: module._web_engine_get_live_handles(),
|
||||
manifestSchemaVersion: manifest.schemaVersion,
|
||||
manifestSharedMemory: singleMemory?.shared,
|
||||
};
|
||||
});
|
||||
|
||||
expect(platform).toMatchObject({
|
||||
crossOriginIsolated: false,
|
||||
sharedArrayBuffer: false,
|
||||
worker: true,
|
||||
autoSelectedVariant: "single",
|
||||
variantSharedMemory: false,
|
||||
variantLiveAfterDestroy: 0,
|
||||
manifestSchemaVersion: 2,
|
||||
manifestSharedMemory: false,
|
||||
pthreadGate: {
|
||||
taskId: "M6-02",
|
||||
capability: "WASM_PTHREAD_ENGINE",
|
||||
status: "BLOCKED",
|
||||
},
|
||||
});
|
||||
expect(platform.variantHandle).toBeGreaterThan(0);
|
||||
expect(platform.pthreadGate.issues.map((issue) => issue.path)).toEqual([
|
||||
"crossOriginIsolated",
|
||||
"sharedArrayBuffer",
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 30_000 });
|
||||
await expect(page.locator(".status-bar")).toContainText("WASM ABI: ready");
|
||||
expect(engineAssetUrls.some((url) => url.endsWith("/vendor/blender/web_engine.wasm"))).toBe(true);
|
||||
expect(engineAssetUrls.some((url) => url.endsWith("/vendor/blender/single/web_engine.wasm"))).toBe(true);
|
||||
expect(engineAssetUrls.some((url) => /pthread/i.test(url))).toBe(false);
|
||||
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3 · Vertices 8 · Faces 6");
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4 · Vertices 16 · Faces 18");
|
||||
});
|
||||
@@ -1511,9 +1511,9 @@ test("autosaves a dirty project without starting a download", async ({ page }) =
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "已保存" }).click();
|
||||
await expect(page.getByRole("button", { name: "未保存" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("dirty-status")).toHaveText("未保存");
|
||||
await expect(page.getByTestId("dirty-status")).toHaveText("已保存", { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("persists an operation log entry with an inverse payload", async ({ page }) => {
|
||||
|
||||
69
web/tests/e2e/user-action-state.spec.ts
Normal file
69
web/tests/e2e/user-action-state.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const materialBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
const exportableBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
const image = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-frame.png");
|
||||
|
||||
test("M7-01 records import, open, save, save-as and export success", 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(exportableBlend);
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-id", /^OPEN:\d+$/);
|
||||
|
||||
const glbDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出 GLB" }).click();
|
||||
expect((await glbDownload).suggestedFilename()).toBe("blender-web.glb");
|
||||
await expect(app).toHaveAttribute("data-user-action-export-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-user-action-export-id", /^EXPORT:\d+$/);
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
|
||||
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
|
||||
await page.locator("label.file-button input[type=file]").setInputFiles(image);
|
||||
await expect(app).toHaveAttribute("data-user-action-import-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-user-action-import-id", /^IMPORT:\d+$/);
|
||||
|
||||
const blendDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
expect((await blendDownload).suggestedFilename()).toBe("blender-web.blend");
|
||||
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-user-action-save-id", /^SAVE:\d+$/);
|
||||
await expect(app).toHaveAttribute("data-user-action-save-as-id", /^SAVE_AS:\d+$/);
|
||||
await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED");
|
||||
await expect(app).toHaveAttribute("data-save-transaction-stage", "METADATA_COMMIT");
|
||||
await expect(app).toHaveAttribute("data-save-committed-hash", /^[a-f0-9]{64}$/);
|
||||
|
||||
});
|
||||
|
||||
test("M7-01 exposes stable failure codes without replacing the last valid scene", 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.getByRole("button", { name: "导出 GLB" }).click();
|
||||
await expect(app).toHaveAttribute("data-user-action-export-status", "FAILED");
|
||||
await expect(app).toHaveAttribute("data-user-action-export-error", "EXPORT_PROJECT_UNAVAILABLE");
|
||||
|
||||
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(app).toHaveAttribute("data-user-action-open-status", "FAILED");
|
||||
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_ENGINE_FAILED");
|
||||
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
|
||||
|
||||
await page.locator("label.file-button input[type=file]").setInputFiles({
|
||||
name: "invalid.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("not-a-png"),
|
||||
});
|
||||
await expect(app).toHaveAttribute("data-user-action-import-status", "FAILED");
|
||||
await expect(app).toHaveAttribute("data-user-action-import-error", "IMPORT_DECODE_FAILED");
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("gates only the pthread WASM engine on its three platform requirements", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const documentResponse = await page.goto("/");
|
||||
expect(documentResponse).not.toBeNull();
|
||||
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-opener-policy"]).toBe("same-origin");
|
||||
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-embedder-policy"]).toBe("require-corp");
|
||||
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-resource-policy"]).toBe("same-origin");
|
||||
const gates = await page.evaluate(async () => {
|
||||
const { gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
|
||||
const { detectBrowserCapabilities, gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
|
||||
return {
|
||||
actual: gateWasmThreadingCapability(detectBrowserCapabilities()),
|
||||
ready: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true }),
|
||||
noIsolation: gateWasmThreadingCapability({ crossOriginIsolated: false, sharedArrayBuffer: true, worker: true }),
|
||||
noSharedArrayBuffer: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: false, worker: true }),
|
||||
@@ -13,6 +18,11 @@ test("gates only the pthread WASM engine on its three platform requirements", as
|
||||
};
|
||||
});
|
||||
|
||||
expect(gates.actual).toMatchObject({
|
||||
capability: "WASM_PTHREAD_ENGINE",
|
||||
status: "READY",
|
||||
issues: [],
|
||||
});
|
||||
expect(gates.ready).toEqual({
|
||||
taskId: "M6-02",
|
||||
capability: "WASM_PTHREAD_ENGINE",
|
||||
|
||||
55
web/tests/unit/dirty-state.test.mjs
Normal file
55
web/tests/unit/dirty-state.test.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/dirty-state.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
|
||||
const { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } = await import(moduleUrl);
|
||||
|
||||
test("M7-06 only marks dirty after an accepted Main transaction", () => {
|
||||
const clean = createDirtyState(7);
|
||||
const stale = acceptMainTransaction(clean, 7);
|
||||
assert.deepEqual(stale, { ok: false, state: clean, errorCode: "DIRTY_REVISION_STALE" });
|
||||
const edited = acceptMainTransaction(clean, 8);
|
||||
assert.equal(edited.ok, true);
|
||||
assert.deepEqual(edited.state, { currentMainRevision: 8, committedMainRevision: 7, dirty: true });
|
||||
});
|
||||
|
||||
test("M7-06 failed, preview and UI-only work preserve the same dirty object", () => {
|
||||
const clean = createDirtyState(3);
|
||||
const failedCommandState = clean;
|
||||
const previewState = failedCommandState;
|
||||
const uiOnlyState = previewState;
|
||||
assert.equal(failedCommandState, clean);
|
||||
assert.equal(previewState, clean);
|
||||
assert.equal(uiOnlyState, clean);
|
||||
});
|
||||
|
||||
test("M7-06 clears dirty only for a save matching the accepted Main revision", () => {
|
||||
const edited = recoverDirtyState(9, 7);
|
||||
assert.deepEqual(acceptMainSave(edited, 8), { ok: false, state: edited, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" });
|
||||
const saved = acceptMainSave(edited, 9);
|
||||
assert.equal(saved.ok, true);
|
||||
assert.deepEqual(saved.state, { currentMainRevision: 9, committedMainRevision: 9, dirty: false });
|
||||
});
|
||||
|
||||
test("M7-07 keeps transaction revisions monotonic while undo content toggles dirty", () => {
|
||||
const saved = createDirtyState(10);
|
||||
const edited = acceptMainTransaction(saved, 11);
|
||||
assert.equal(edited.ok, true);
|
||||
const undone = acceptHistoryTransaction(edited.state, 12, true);
|
||||
assert.equal(undone.ok, true);
|
||||
assert.deepEqual(undone.state, { currentMainRevision: 12, committedMainRevision: 12, dirty: false });
|
||||
const redone = acceptHistoryTransaction(undone.state, 13, false);
|
||||
assert.equal(redone.ok, true);
|
||||
assert.deepEqual(redone.state, { currentMainRevision: 13, committedMainRevision: 12, dirty: true });
|
||||
});
|
||||
97
web/tests/unit/engine-manifest-v2.test.mjs
Normal file
97
web/tests/unit/engine-manifest-v2.test.mjs
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const protocolPath = path.join(repoRoot, "web/protocol/manifest.ts");
|
||||
const protocolSource = fs.readFileSync(protocolPath, "utf8");
|
||||
const transpiled = ts.transpileModule(protocolSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: protocolPath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`);
|
||||
const { validateWebEngineManifestV2 } = protocol;
|
||||
const goldenPath = path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json");
|
||||
const golden = JSON.parse(fs.readFileSync(goldenPath, "utf8"));
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === "object") {
|
||||
Object.freeze(value);
|
||||
for (const item of Object.values(value)) deepFreeze(item);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function invalidCase(name, mutate, code, errorPath) {
|
||||
return { name, mutate, code, errorPath };
|
||||
}
|
||||
|
||||
test("M6-04A accepts an immutable single/pthread manifest without mutating it", () => {
|
||||
const input = deepFreeze(structuredClone(golden));
|
||||
const parsed = validateWebEngineManifestV2(input);
|
||||
|
||||
assert.deepEqual(parsed, golden);
|
||||
assert.notEqual(parsed, input);
|
||||
assert.notEqual(parsed.variants[0], input.variants[0]);
|
||||
assert.notEqual(parsed.variants[0].resources.js, input.variants[0].resources.js);
|
||||
assert.equal(parsed.variants[1].resources.pthreadWorker.url, parsed.variants[1].resources.js.url);
|
||||
assert.equal(protocol.WEB_ENGINE_WASM_PAGE_BYTES, 65_536);
|
||||
assert.deepEqual(protocol.WEB_ENGINE_MEMORY_LIMITS, {
|
||||
minimumInitialPages: 256,
|
||||
maximumPages: 32_768,
|
||||
});
|
||||
|
||||
const reversed = validateWebEngineManifestV2({ ...golden, variants: [...golden.variants].reverse() });
|
||||
assert.deepEqual(reversed.variants.map((variant) => variant.id), ["single", "pthread"]);
|
||||
});
|
||||
|
||||
test("M6-04A rejects malformed variants, resources and memory declarations", async (t) => {
|
||||
const cases = [
|
||||
invalidCase("old schema", (value) => { value.schemaVersion = 1; }, "PROTOCOL_MISMATCH", "schemaVersion"),
|
||||
invalidCase("unsupported protocol", (value) => { value.protocolVersion = 2; }, "PROTOCOL_MISMATCH", "protocolVersion"),
|
||||
invalidCase("missing release ID", (value) => { delete value.releaseId; }, "ENGINE_MANIFEST_INVALID", "releaseId"),
|
||||
invalidCase("invalid release ID", (value) => { value.releaseId = "release/id"; }, "ENGINE_MANIFEST_INVALID", "releaseId"),
|
||||
invalidCase("missing pthread variant", (value) => { value.variants.pop(); }, "ENGINE_MANIFEST_INVALID", "variants"),
|
||||
invalidCase("duplicate single variant", (value) => { value.variants[1].id = "single"; delete value.variants[1].resources.pthreadWorker; value.variants[1].memory.shared = false; }, "ENGINE_MANIFEST_INVALID", "variants"),
|
||||
invalidCase("single shared memory", (value) => { value.variants[0].memory.shared = true; }, "ENGINE_MANIFEST_INVALID", "variants[0].memory.shared"),
|
||||
invalidCase("single pthread worker", (value) => { value.variants[0].resources.pthreadWorker = structuredClone(value.variants[1].resources.pthreadWorker); }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.pthreadWorker"),
|
||||
invalidCase("pthread unshared memory", (value) => { value.variants[1].memory.shared = false; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.shared"),
|
||||
invalidCase("missing pthread worker", (value) => { delete value.variants[1].resources.pthreadWorker; }, "ENGINE_MANIFEST_INVALID", "variants[1].resources.pthreadWorker"),
|
||||
invalidCase("invalid SHA-256", (value) => { value.variants[1].resources.wasm.sha256 = "ABC"; }, "ENGINE_MANIFEST_INVALID", "variants[1].resources.wasm.sha256"),
|
||||
invalidCase("remote resource URL", (value) => { value.variants[0].resources.js.url = "https://cdn.invalid/web_engine.single.js"; }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.js.url"),
|
||||
invalidCase("file name mismatch", (value) => { value.variants[0].resources.wasm.url = "/vendor/blender/wrong.wasm"; }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.wasm.url"),
|
||||
invalidCase("initial memory below floor", (value) => { value.variants[0].memory.initialPages = 255; }, "ENGINE_MANIFEST_INVALID", "variants[0].memory.initialPages"),
|
||||
invalidCase("maximum memory below initial", (value) => { value.variants[1].memory.maximumPages = 255; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.maximumPages"),
|
||||
invalidCase("maximum memory above ceiling", (value) => { value.variants[1].memory.maximumPages = 32_769; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.maximumPages"),
|
||||
invalidCase("aliased variant WASM", (value) => { value.variants[1].resources.wasm = structuredClone(value.variants[0].resources.wasm); }, "ENGINE_MANIFEST_INVALID", "variants.wasm"),
|
||||
invalidCase("worker aliases single JS", (value) => { value.variants[1].resources.pthreadWorker = structuredClone(value.variants[0].resources.js); }, "ENGINE_MANIFEST_INVALID", "variants.pthreadWorker"),
|
||||
invalidCase("same worker URL with another hash", (value) => { value.variants[1].resources.pthreadWorker.sha256 = "5".repeat(64); }, "ENGINE_MANIFEST_INVALID", "variants.pthreadWorker.sha256"),
|
||||
invalidCase("unknown manifest field", (value) => { value.defaultVariant = "pthread"; }, "ENGINE_MANIFEST_INVALID", "manifest.defaultVariant"),
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
await t.test(item.name, () => {
|
||||
const input = structuredClone(golden);
|
||||
item.mutate(input);
|
||||
assert.throws(
|
||||
() => validateWebEngineManifestV2(input),
|
||||
(error) => error?.name === "WebEngineManifestValidationError" &&
|
||||
error.code === item.code && error.path === item.errorPath,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("M6-04B installs the production manifest with explicit variant paths", () => {
|
||||
const production = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
|
||||
const parsed = validateWebEngineManifestV2(production);
|
||||
assert.equal(parsed.releaseId, `blender-wasm-${packageJson.version}`);
|
||||
assert.equal(parsed.variants[0].resources.wasm.url, "/vendor/blender/single/web_engine.wasm");
|
||||
assert.equal(parsed.variants[1].resources.wasm.url, "/vendor/blender/pthread/web_engine.wasm");
|
||||
assert.notEqual(parsed.variants[0].resources.wasm.sha256, parsed.variants[1].resources.wasm.sha256);
|
||||
});
|
||||
249
web/tests/unit/engine-variant-fallback.test.mjs
Normal file
249
web/tests/unit/engine-variant-fallback.test.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
|
||||
function transpileDataUrl(filePath, replacements = new Map()) {
|
||||
const result = ts.transpileModule(fs.readFileSync(filePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: filePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
let source = result.outputText;
|
||||
for (const [specifier, replacement] of replacements) {
|
||||
source = source.replaceAll(`"${specifier}"`, JSON.stringify(replacement));
|
||||
}
|
||||
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
}
|
||||
|
||||
const gatesUrl = transpileDataUrl(path.join(repoRoot, "web/protocol/capability-gates.ts"));
|
||||
const selectorUrl = transpileDataUrl(
|
||||
path.join(repoRoot, "web/protocol/engine-variant.ts"),
|
||||
new Map([["./capability-gates", gatesUrl]]),
|
||||
);
|
||||
const bootstrapUrl = transpileDataUrl(
|
||||
path.join(repoRoot, "web/app/src/engine-client/engine-variant-bootstrap.ts"),
|
||||
new Map([["../../../protocol/engine-variant", selectorUrl]]),
|
||||
);
|
||||
const {
|
||||
bootstrapWebEngineRelease,
|
||||
engineVariantStatusLabel,
|
||||
EngineVariantLoadError,
|
||||
} = await import(bootstrapUrl);
|
||||
const manifest = JSON.parse(fs.readFileSync(
|
||||
path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json"),
|
||||
"utf8",
|
||||
));
|
||||
const ready = { crossOriginIsolated: true, sharedArrayBuffer: true, worker: true };
|
||||
|
||||
class FakeSession {
|
||||
constructor(variant) {
|
||||
this.variant = variant;
|
||||
this.opened = [];
|
||||
this.disposals = 0;
|
||||
this.state = {
|
||||
handles: 1,
|
||||
workers: variant.id === "pthread" ? 1 : 0,
|
||||
timers: 0,
|
||||
pendingRequests: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async openProject(project) {
|
||||
this.opened.push(project);
|
||||
}
|
||||
|
||||
resourceState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
testOnlySeedTrackedResources() {
|
||||
this.state.timers = 1;
|
||||
this.state.pendingRequests = 1;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.disposals += 1;
|
||||
this.state = { handles: 0, workers: 0, timers: 0, pendingRequests: 0 };
|
||||
return this.resourceState();
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies() {
|
||||
const sessions = [];
|
||||
return {
|
||||
sessions,
|
||||
initialize: async (variant) => {
|
||||
const session = new FakeSession(variant);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("M6-05A/B/C injects one pthread failure, cleans it and falls back once", async () => {
|
||||
const deps = dependencies();
|
||||
const outcome = await bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
ready,
|
||||
deps,
|
||||
undefined,
|
||||
{ failPthreadAfterInitialize: true, seedTrackedPthreadResources: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(outcome.result.attempted, ["pthread", "single"]);
|
||||
assert.equal(deps.sessions.length, 2);
|
||||
assert.equal(deps.sessions[0].disposals, 1);
|
||||
assert.deepEqual(outcome.result.failedAttemptCleanup, {
|
||||
handles: 0,
|
||||
workers: 0,
|
||||
timers: 0,
|
||||
pendingRequests: 0,
|
||||
});
|
||||
assert.equal(outcome.result.selected, "single");
|
||||
assert.deepEqual(outcome.result.fallbackReason, {
|
||||
code: "PTHREAD_INITIALIZATION_FAILED",
|
||||
message: "TEST_PTHREAD_INITIALIZATION_FAILURE",
|
||||
});
|
||||
assert.equal(outcome.result.openCount, 0);
|
||||
assert.equal(deps.sessions[0].opened.length, 0);
|
||||
assert.equal(deps.sessions[1].opened.length, 0);
|
||||
});
|
||||
|
||||
test("M6-05D opens a pending project once only after the fallback settles", async () => {
|
||||
const deps = dependencies();
|
||||
const project = { id: "pending-project" };
|
||||
const outcome = await bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
ready,
|
||||
deps,
|
||||
project,
|
||||
{ failPthreadAfterInitialize: true },
|
||||
);
|
||||
|
||||
assert.equal(outcome.result.openCount, 1);
|
||||
assert.deepEqual(deps.sessions[0].opened, []);
|
||||
assert.deepEqual(deps.sessions[1].opened, [project]);
|
||||
});
|
||||
|
||||
test("M6-05E exposes selected, attempted and fallbackReason without pthread READY UI", async () => {
|
||||
const deps = dependencies();
|
||||
const outcome = await bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
ready,
|
||||
deps,
|
||||
undefined,
|
||||
{ failPthreadAfterInitialize: true },
|
||||
);
|
||||
const label = engineVariantStatusLabel(outcome.result);
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
selected: outcome.result.selected,
|
||||
attempted: outcome.result.attempted,
|
||||
fallbackReason: outcome.result.fallbackReason?.code,
|
||||
},
|
||||
{
|
||||
selected: "single",
|
||||
attempted: ["pthread", "single"],
|
||||
fallbackReason: "PTHREAD_INITIALIZATION_FAILED",
|
||||
},
|
||||
);
|
||||
assert.equal(label, "Runtime: single (pthread fallback: PTHREAD_INITIALIZATION_FAILED)");
|
||||
assert.doesNotMatch(label, /pthread\s+ready/i);
|
||||
});
|
||||
|
||||
test("M6-08E treats a manifest hash mismatch as fatal without fallback or project open", async () => {
|
||||
const initialized = [];
|
||||
const project = { id: "must-stay-unopened" };
|
||||
await assert.rejects(
|
||||
bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
ready,
|
||||
{
|
||||
initialize: async (variant) => {
|
||||
initialized.push(variant.id);
|
||||
if (variant.id === "pthread") {
|
||||
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", variant.resources.wasm.url);
|
||||
}
|
||||
return new FakeSession(variant);
|
||||
},
|
||||
},
|
||||
project,
|
||||
),
|
||||
(error) => error?.code === "ENGINE_VARIANT_INTEGRITY_FAILED" &&
|
||||
error.cause?.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH" &&
|
||||
JSON.stringify(error.attempted) === JSON.stringify(["pthread"]),
|
||||
);
|
||||
assert.deepEqual(initialized, ["pthread"]);
|
||||
});
|
||||
|
||||
test("M6-08D returns refresh-required before initialization or project open", async () => {
|
||||
let initializationCount = 0;
|
||||
const outcome = await bootstrapWebEngineRelease(
|
||||
"blender-wasm-previous",
|
||||
manifest,
|
||||
"AUTO",
|
||||
ready,
|
||||
{
|
||||
initialize: async (variant) => {
|
||||
initializationCount += 1;
|
||||
return new FakeSession(variant);
|
||||
},
|
||||
},
|
||||
{ id: "must-stay-unopened" },
|
||||
);
|
||||
|
||||
assert.deepEqual(outcome, {
|
||||
result: {
|
||||
status: "REFRESH_REQUIRED",
|
||||
expectedReleaseId: "blender-wasm-previous",
|
||||
actualReleaseId: manifest.releaseId,
|
||||
manifest: null,
|
||||
},
|
||||
session: null,
|
||||
});
|
||||
assert.equal(initializationCount, 0);
|
||||
});
|
||||
|
||||
test("M6-08E normalizes a fallback variant hash mismatch and never opens the project", async () => {
|
||||
const initialized = [];
|
||||
const pthreadSession = new FakeSession(manifest.variants[1]);
|
||||
await assert.rejects(
|
||||
bootstrapWebEngineRelease(
|
||||
manifest.releaseId,
|
||||
manifest,
|
||||
"AUTO",
|
||||
ready,
|
||||
{
|
||||
initialize: async (variant) => {
|
||||
initialized.push(variant.id);
|
||||
if (variant.id === "single") {
|
||||
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", variant.resources.wasm.url);
|
||||
}
|
||||
return pthreadSession;
|
||||
},
|
||||
},
|
||||
{ id: "must-stay-unopened" },
|
||||
{ failPthreadAfterInitialize: true },
|
||||
),
|
||||
(error) => error?.code === "ENGINE_VARIANT_INTEGRITY_FAILED" &&
|
||||
error.cause?.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH" &&
|
||||
JSON.stringify(error.attempted) === JSON.stringify(["pthread", "single"]),
|
||||
);
|
||||
assert.deepEqual(initialized, ["pthread", "single"]);
|
||||
assert.equal(pthreadSession.disposals, 1);
|
||||
assert.deepEqual(pthreadSession.opened, []);
|
||||
});
|
||||
125
web/tests/unit/engine-variant-selection.test.mjs
Normal file
125
web/tests/unit/engine-variant-selection.test.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
|
||||
function transpileDataUrl(filePath, replacements = new Map()) {
|
||||
const result = ts.transpileModule(fs.readFileSync(filePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: filePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
let source = result.outputText;
|
||||
for (const [specifier, replacement] of replacements) {
|
||||
source = source.replaceAll(`"${specifier}"`, JSON.stringify(replacement));
|
||||
}
|
||||
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
}
|
||||
|
||||
const gatesUrl = transpileDataUrl(path.join(repoRoot, "web/protocol/capability-gates.ts"));
|
||||
const selectorUrl = transpileDataUrl(
|
||||
path.join(repoRoot, "web/protocol/engine-variant.ts"),
|
||||
new Map([["./capability-gates", gatesUrl]]),
|
||||
);
|
||||
const { bindWebEngineRelease, gateWasmThreadingCapability, selectWebEngineVariant } = await import(selectorUrl);
|
||||
const manifest = JSON.parse(fs.readFileSync(
|
||||
path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json"),
|
||||
"utf8",
|
||||
));
|
||||
const ready = { crossOriginIsolated: true, sharedArrayBuffer: true, worker: true };
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === "object") {
|
||||
Object.freeze(value);
|
||||
for (const item of Object.values(value)) deepFreeze(item);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
test("M6-04C selects from only its manifest, policy and capability inputs", () => {
|
||||
const frozenManifest = deepFreeze(structuredClone(manifest));
|
||||
const frozenCapabilities = deepFreeze({ ...ready });
|
||||
const first = selectWebEngineVariant(frozenManifest, "SINGLE_REQUIRED", frozenCapabilities);
|
||||
const second = selectWebEngineVariant(frozenManifest, "SINGLE_REQUIRED", frozenCapabilities);
|
||||
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.policy, "SINGLE_REQUIRED");
|
||||
assert.equal(first.selectedVariant, frozenManifest.variants[0]);
|
||||
assert.equal(first.selectedVariant.id, "single");
|
||||
assert.equal(first.pthreadGate.status, "READY");
|
||||
assert.throws(
|
||||
() => selectWebEngineVariant(frozenManifest, "INVALID", frozenCapabilities),
|
||||
/ENGINE_VARIANT_POLICY_INVALID/,
|
||||
);
|
||||
});
|
||||
|
||||
test("M6-04D AUTO selects pthread only when the M6-02 gate is READY", () => {
|
||||
const capabilityCases = [
|
||||
[ready, "pthread", []],
|
||||
[{ ...ready, crossOriginIsolated: false }, "single", ["crossOriginIsolated"]],
|
||||
[{ ...ready, sharedArrayBuffer: false }, "single", ["sharedArrayBuffer"]],
|
||||
[{ ...ready, worker: false }, "single", ["worker"]],
|
||||
[
|
||||
{ crossOriginIsolated: false, sharedArrayBuffer: false, worker: false },
|
||||
"single",
|
||||
["crossOriginIsolated", "sharedArrayBuffer", "worker"],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [capabilities, expectedId, expectedPaths] of capabilityCases) {
|
||||
const selection = selectWebEngineVariant(manifest, "AUTO", capabilities);
|
||||
assert.equal(selection.selectedVariant.id, expectedId);
|
||||
assert.equal(selection.pthreadGate.status, expectedId === "pthread" ? "READY" : "BLOCKED");
|
||||
assert.deepEqual(selection.pthreadGate.issues.map((issue) => issue.path), expectedPaths);
|
||||
}
|
||||
});
|
||||
|
||||
test("M6-04E PTHREAD_REQUIRED exposes the M6-02 block without a requestable variant", () => {
|
||||
const capabilities = { crossOriginIsolated: false, sharedArrayBuffer: false, worker: true };
|
||||
const expectedGate = gateWasmThreadingCapability(capabilities);
|
||||
const blocked = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", capabilities);
|
||||
assert.equal(blocked.selectedVariant, null);
|
||||
assert.deepEqual(blocked.pthreadGate, expectedGate);
|
||||
assert.deepEqual(blocked.pthreadGate, {
|
||||
taskId: "M6-02",
|
||||
capability: "WASM_PTHREAD_ENGINE",
|
||||
status: "BLOCKED",
|
||||
issues: [
|
||||
{
|
||||
code: "PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
message: "Cross-origin isolation is required for the pthread WASM engine",
|
||||
path: "crossOriginIsolated",
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
code: "PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
message: "SharedArrayBuffer is required for the pthread WASM engine",
|
||||
path: "sharedArrayBuffer",
|
||||
recoverable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const allowed = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", ready);
|
||||
assert.equal(allowed.selectedVariant.id, "pthread");
|
||||
assert.equal(allowed.pthreadGate.status, "READY");
|
||||
});
|
||||
|
||||
test("M6-08C/D binds both variants to one release and refuses mixed-version startup", () => {
|
||||
const current = bindWebEngineRelease(manifest.releaseId, manifest);
|
||||
assert.equal(current.status, "READY");
|
||||
assert.equal(current.manifest, manifest);
|
||||
assert.deepEqual(current.manifest.variants.map((variant) => variant.id), ["single", "pthread"]);
|
||||
|
||||
const switched = bindWebEngineRelease("blender-wasm-previous", manifest);
|
||||
assert.deepEqual(switched, {
|
||||
status: "REFRESH_REQUIRED",
|
||||
expectedReleaseId: "blender-wasm-previous",
|
||||
actualReleaseId: manifest.releaseId,
|
||||
manifest: null,
|
||||
});
|
||||
});
|
||||
84
web/tests/unit/file-byte-reader.test.mjs
Normal file
84
web/tests/unit/file-byte-reader.test.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/file-byte-reader.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
|
||||
const { FileByteReadError, readFileBytes } = await import(moduleUrl);
|
||||
|
||||
function chunkSource(chunks, declaredSize = chunks.reduce((total, chunk) => total + (chunk.byteLength ?? chunk.length), 0)) {
|
||||
return {
|
||||
size: declaredSize,
|
||||
stream() {
|
||||
let index = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index === chunks.length) controller.close();
|
||||
else controller.enqueue(Uint8Array.from(chunks[index++]));
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("M7-03 reports progress from exact consumed byte counts", async () => {
|
||||
const observations = [];
|
||||
const result = await readFileBytes(chunkSource([[1, 2], [3, 4, 5], [6]]), {
|
||||
signal: new AbortController().signal,
|
||||
onProgress: (item) => observations.push(item),
|
||||
});
|
||||
assert.deepEqual(Array.from(new Uint8Array(result)), [1, 2, 3, 4, 5, 6]);
|
||||
assert.deepEqual(observations.map(({ phase, bytesRead, totalBytes, fraction }) => [phase, bytesRead, totalBytes, fraction]), [
|
||||
["STARTED", 0, 6, 0],
|
||||
["READING", 2, 6, 2 / 6],
|
||||
["READING", 5, 6, 5 / 6],
|
||||
["READING", 6, 6, 1],
|
||||
["COMPLETED", 6, 6, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test("M7-03 cancels between chunks without reporting completion", async () => {
|
||||
const controller = new AbortController();
|
||||
const observations = [];
|
||||
const resources = [];
|
||||
await assert.rejects(
|
||||
readFileBytes(chunkSource([[1, 2], [3, 4], [5, 6]]), {
|
||||
signal: controller.signal,
|
||||
onProgress: (item) => {
|
||||
observations.push(item);
|
||||
if (item.phase === "READING" && item.bytesRead === 2) controller.abort();
|
||||
},
|
||||
onResourceState: (state) => resources.push(state),
|
||||
}),
|
||||
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED",
|
||||
);
|
||||
assert.deepEqual(observations.map((item) => [item.phase, item.bytesRead]), [
|
||||
["STARTED", 0],
|
||||
["READING", 2],
|
||||
["CANCELLED", 2],
|
||||
]);
|
||||
assert.deepEqual(resources, [
|
||||
{ liveReaders: 1, liveInputBytes: 6, liveStagingFiles: 0 },
|
||||
{ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("M7-03 rejects streams shorter or longer than their declared byte size", async () => {
|
||||
await assert.rejects(
|
||||
readFileBytes(chunkSource([[1, 2]], 3), { signal: new AbortController().signal }),
|
||||
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_SIZE_MISMATCH",
|
||||
);
|
||||
await assert.rejects(
|
||||
readFileBytes(chunkSource([[1, 2, 3]], 2), { signal: new AbortController().signal }),
|
||||
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_SIZE_MISMATCH",
|
||||
);
|
||||
});
|
||||
@@ -30,6 +30,10 @@ test("required renderer and engine assets are vendored", () => {
|
||||
"app/src/vendor/blender/web_engine.wasm",
|
||||
"app/public/vendor/blender/web_engine.js",
|
||||
"app/public/vendor/blender/web_engine.wasm",
|
||||
"app/public/vendor/blender/single/web_engine.js",
|
||||
"app/public/vendor/blender/single/web_engine.wasm",
|
||||
"app/public/vendor/blender/pthread/web_engine.js",
|
||||
"app/public/vendor/blender/pthread/web_engine.wasm",
|
||||
];
|
||||
for (const relativePath of requiredFiles) {
|
||||
const filePath = path.join(webRoot, relativePath);
|
||||
@@ -47,6 +51,12 @@ test("required renderer and engine assets are vendored", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("schema v2 engine assets carry a valid release identity", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "app/public/engine-manifest.json"), "utf8"));
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.match(manifest.releaseId, /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/);
|
||||
});
|
||||
|
||||
test("attribute geometry fixture is reproducible input", () => {
|
||||
const fixture = path.join(webRoot, "..", "tests/files/web/attribute_scene.blend");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
|
||||
68
web/tests/unit/project-action-mutex.test.mjs
Normal file
68
web/tests/unit/project-action-mutex.test.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/project-action-mutex.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
|
||||
const {
|
||||
PROJECT_ACTION_CONFLICT_MATRIX,
|
||||
acquireProjectAction,
|
||||
createProjectActionMutexState,
|
||||
releaseProjectAction,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const action = (kind, sequence) => ({ kind, actionId: `${kind}:${sequence}` });
|
||||
|
||||
test("M7-02 keeps the project action conflict matrix symmetric and exclusive", () => {
|
||||
for (const requested of ["OPEN", "SAVE", "CLOSE"]) {
|
||||
for (const owner of ["OPEN", "SAVE", "CLOSE"]) {
|
||||
assert.equal(PROJECT_ACTION_CONFLICT_MATRIX[requested][owner], true);
|
||||
assert.equal(PROJECT_ACTION_CONFLICT_MATRIX[requested][owner], PROJECT_ACTION_CONFLICT_MATRIX[owner][requested]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("M7-02 rejects repeated open with its original owner unchanged", () => {
|
||||
const first = action("OPEN", 1);
|
||||
const second = action("OPEN", 2);
|
||||
const acquired = acquireProjectAction(createProjectActionMutexState(), first);
|
||||
assert.equal(acquired.granted, true);
|
||||
assert.deepEqual(acquireProjectAction(acquired.state, second), {
|
||||
granted: false,
|
||||
state: acquired.state,
|
||||
conflict: { code: "USER_ACTION_CONFLICT", reason: "REPEATED_OPEN", requested: second, owner: first },
|
||||
});
|
||||
});
|
||||
|
||||
test("M7-02 rejects concurrent save and close-during-save with stable reasons", () => {
|
||||
const first = action("SAVE", 1);
|
||||
const acquired = acquireProjectAction(createProjectActionMutexState(), first);
|
||||
assert.equal(acquired.granted, true);
|
||||
assert.equal(acquireProjectAction(acquired.state, action("SAVE", 2)).conflict.reason, "CONCURRENT_SAVE");
|
||||
assert.equal(acquireProjectAction(acquired.state, action("CLOSE", 3)).conflict.reason, "CLOSE_DURING_SAVE");
|
||||
});
|
||||
|
||||
test("M7-02 only lets the exact owner release the project lock", () => {
|
||||
const owner = action("SAVE", 1);
|
||||
const acquired = acquireProjectAction(createProjectActionMutexState(), owner);
|
||||
assert.equal(acquired.granted, true);
|
||||
assert.deepEqual(releaseProjectAction(acquired.state, action("SAVE", 2)), {
|
||||
released: false,
|
||||
state: acquired.state,
|
||||
errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH",
|
||||
});
|
||||
assert.deepEqual(releaseProjectAction(acquired.state, owner), {
|
||||
released: true,
|
||||
state: { owner: null },
|
||||
errorCode: null,
|
||||
});
|
||||
});
|
||||
66
web/tests/unit/save-transaction.test.mjs
Normal file
66
web/tests/unit/save-transaction.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/save-transaction.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
|
||||
const {
|
||||
SAVE_ATTEMPT_STAGES,
|
||||
advanceSaveTransaction,
|
||||
beginSaveTransaction,
|
||||
commitSaveTransaction,
|
||||
createSaveTransactionState,
|
||||
failSaveTransaction,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const oldCommit = { revision: 7, sha256: "a".repeat(64) };
|
||||
const newCommit = { revision: 8, sha256: "b".repeat(64) };
|
||||
|
||||
function stateAt(stage) {
|
||||
let result = beginSaveTransaction(createSaveTransactionState(oldCommit), newCommit.revision);
|
||||
assert.equal(result.ok, true);
|
||||
for (const next of SAVE_ATTEMPT_STAGES.slice(1, SAVE_ATTEMPT_STAGES.indexOf(stage) + 1)) {
|
||||
result = advanceSaveTransaction(result.state, next, next === "OPFS_STAGE" ? newCommit.sha256 : undefined);
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
return result.state;
|
||||
}
|
||||
|
||||
test("M7-05 preserves the old committed identity at every failed save stage", () => {
|
||||
for (const stage of SAVE_ATTEMPT_STAGES) {
|
||||
const failed = failSaveTransaction(stateAt(stage), `SAVE_${stage}_INTERRUPTED`);
|
||||
assert.equal(failed.status, "FAILED");
|
||||
assert.equal(failed.stage, stage);
|
||||
assert.deepEqual(failed.committed, oldCommit);
|
||||
assert.equal(failed.candidate.revision, newCommit.revision);
|
||||
}
|
||||
});
|
||||
|
||||
test("M7-05 advances committed revision and hash only after matching metadata commit", () => {
|
||||
const metadata = stateAt("METADATA_COMMIT");
|
||||
assert.deepEqual(commitSaveTransaction(metadata, { ...newCommit, sha256: "c".repeat(64) }), {
|
||||
ok: false,
|
||||
state: metadata,
|
||||
errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH",
|
||||
});
|
||||
const committed = commitSaveTransaction(metadata, newCommit);
|
||||
assert.equal(committed.ok, true);
|
||||
assert.deepEqual(committed.state.committed, newCommit);
|
||||
assert.equal(committed.state.status, "SUCCEEDED");
|
||||
});
|
||||
|
||||
test("M7-05 rejects reentrant and out-of-order save transitions", () => {
|
||||
const running = beginSaveTransaction(createSaveTransactionState(oldCommit), 8);
|
||||
assert.equal(running.ok, true);
|
||||
assert.equal(beginSaveTransaction(running.state, 9).errorCode, "SAVE_TRANSACTION_INVALID_TRANSITION");
|
||||
assert.equal(advanceSaveTransaction(running.state, "SCENE_COMMIT").errorCode, "SAVE_TRANSACTION_INVALID_TRANSITION");
|
||||
});
|
||||
93
web/tests/unit/user-action-state.test.mjs
Normal file
93
web/tests/unit/user-action-state.test.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/user-action-state.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
|
||||
const {
|
||||
USER_ACTION_KINDS,
|
||||
createInitialUserActionStates,
|
||||
reduceUserActionStates,
|
||||
transitionUserAction,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
function identity(kind, sequence = 1) {
|
||||
return { kind, actionId: `${kind}:${sequence}` };
|
||||
}
|
||||
|
||||
test("M7-01 initializes all five user actions at IDLE", () => {
|
||||
const states = createInitialUserActionStates();
|
||||
assert.deepEqual(Object.keys(states), USER_ACTION_KINDS);
|
||||
for (const kind of USER_ACTION_KINDS) {
|
||||
assert.deepEqual(states[kind], { kind, status: "IDLE", identity: null, errorCode: null });
|
||||
}
|
||||
});
|
||||
|
||||
test("M7-01 records deterministic RUNNING and SUCCEEDED transitions for every action", () => {
|
||||
let states = createInitialUserActionStates();
|
||||
for (const kind of USER_ACTION_KINDS) {
|
||||
const currentIdentity = identity(kind);
|
||||
states = reduceUserActionStates(states, { type: "START", identity: currentIdentity });
|
||||
assert.deepEqual(states[kind], { kind, status: "RUNNING", identity: currentIdentity, errorCode: null });
|
||||
states = reduceUserActionStates(states, { type: "SUCCEED", identity: currentIdentity });
|
||||
assert.deepEqual(states[kind], { kind, status: "SUCCEEDED", identity: currentIdentity, errorCode: null });
|
||||
}
|
||||
});
|
||||
|
||||
test("M7-01 records stable failure and cancellation codes", () => {
|
||||
const failedIdentity = identity("OPEN");
|
||||
let failed = transitionUserAction(createInitialUserActionStates().OPEN, { type: "START", identity: failedIdentity });
|
||||
assert.equal(failed.ok, true);
|
||||
failed = transitionUserAction(failed.state, { type: "FAIL", identity: failedIdentity, errorCode: "OPEN_ENGINE_FAILED" });
|
||||
assert.deepEqual(failed, {
|
||||
ok: true,
|
||||
state: { kind: "OPEN", status: "FAILED", identity: failedIdentity, errorCode: "OPEN_ENGINE_FAILED" },
|
||||
});
|
||||
|
||||
const cancelledIdentity = identity("IMPORT");
|
||||
let cancelled = transitionUserAction(createInitialUserActionStates().IMPORT, { type: "START", identity: cancelledIdentity });
|
||||
assert.equal(cancelled.ok, true);
|
||||
cancelled = transitionUserAction(cancelled.state, { type: "CANCEL", identity: cancelledIdentity });
|
||||
assert.deepEqual(cancelled, {
|
||||
ok: true,
|
||||
state: { kind: "IMPORT", status: "CANCELLED", identity: cancelledIdentity, errorCode: "USER_ACTION_CANCELLED" },
|
||||
});
|
||||
});
|
||||
|
||||
test("M7-01 rejects wrong identities, reused identities and invalid transitions without mutation", () => {
|
||||
const first = identity("SAVE");
|
||||
const other = identity("SAVE", 2);
|
||||
const initial = createInitialUserActionStates().SAVE;
|
||||
const beforeStart = transitionUserAction(initial, { type: "SUCCEED", identity: first });
|
||||
assert.deepEqual(beforeStart, { ok: false, state: initial, errorCode: "USER_ACTION_INVALID_TRANSITION" });
|
||||
|
||||
const running = transitionUserAction(initial, { type: "START", identity: first });
|
||||
assert.equal(running.ok, true);
|
||||
assert.deepEqual(transitionUserAction(running.state, { type: "FAIL", identity: other, errorCode: "SAVE_FAILED" }), {
|
||||
ok: false,
|
||||
state: running.state,
|
||||
errorCode: "USER_ACTION_IDENTITY_MISMATCH",
|
||||
});
|
||||
assert.deepEqual(transitionUserAction(running.state, { type: "FAIL", identity: first, errorCode: "not-stable" }), {
|
||||
ok: false,
|
||||
state: running.state,
|
||||
errorCode: "USER_ACTION_INVALID_ERROR_CODE",
|
||||
});
|
||||
|
||||
const succeeded = transitionUserAction(running.state, { type: "SUCCEED", identity: first });
|
||||
assert.equal(succeeded.ok, true);
|
||||
assert.deepEqual(transitionUserAction(succeeded.state, { type: "START", identity: first }), {
|
||||
ok: false,
|
||||
state: succeeded.state,
|
||||
errorCode: "USER_ACTION_IDENTITY_REUSED",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user