Advance M7 workflows and release operations
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-15 17:43:53 -04:00
parent 17ab961485
commit 7c16b279ae
103 changed files with 8064 additions and 429 deletions

View 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([]);
});

View 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 ?? "");
});

View 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);
});

View 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;
});

View 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();
});

View 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);
});

View 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);
});

View 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);
});

View 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");
});

View File

@@ -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 }) => {

View 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");
});

View File

@@ -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",