Add Chromium-only Blender WebEngine parity work
This commit is contained in:
73
web/tests/e2e/cross-browser.spec.ts
Normal file
73
web/tests/e2e/cross-browser.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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 the offline engine, renders SceneIR and performs a Main edit", async ({ page, browserName }) => {
|
||||
const externalRequests: string[] = [];
|
||||
let origin = "";
|
||||
page.on("request", (request) => {
|
||||
if (origin && new URL(request.url()).origin !== origin) externalRequests.push(request.url());
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/");
|
||||
origin = new URL(page.url()).origin;
|
||||
await expect(page.locator("[data-testid=engine-status]")).toContainText("Engine: ready", { timeout: 30_000 });
|
||||
await expect(page.locator(".status-bar")).toContainText(/Storage: (IndexedDB \+ OPFS|IndexedDB|unavailable)/, { timeout: 20_000 });
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3 · Vertices 8 · Faces 6");
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 4 · Vertices 16 · Faces 18");
|
||||
const pixels = await page.locator("canvas.viewport-canvas").evaluate((canvas) => {
|
||||
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
if (!gl) return 0;
|
||||
const sample = new Uint8Array(16 * 16 * 4);
|
||||
gl.readPixels(0, 0, 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, sample);
|
||||
return sample.reduce((sum, value) => sum + (value > 0 ? 1 : 0), 0);
|
||||
});
|
||||
expect(pixels, `${browserName} returned a blank viewport`).toBeGreaterThan(0);
|
||||
expect(externalRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps content-addressed asset recovery available in Chromium", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const firstClient = new StorageClient();
|
||||
await firstClient.ensureProject("chromium-assets");
|
||||
const bytes = Uint8Array.from([1, 2, 3, 4]).buffer;
|
||||
const first = await firstClient.putAsset("chromium-assets", bytes, "application/octet-stream", "//cache/check.bin");
|
||||
firstClient.terminate();
|
||||
const secondClient = new StorageClient();
|
||||
const restored = await secondClient.readAsset("chromium-assets", first.sha256);
|
||||
secondClient.terminate();
|
||||
return { sha256: first.sha256, restored: Array.from(new Uint8Array(restored.data)), sourcePath: first.sourcePath };
|
||||
});
|
||||
expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.restored).toEqual([1, 2, 3, 4]);
|
||||
expect(result.sourcePath).toBe("cache/check.bin");
|
||||
});
|
||||
|
||||
test("keeps the committed project after quota failure and Worker restart in Chromium", async ({ page, browserName }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const projectId = `chromium-quota-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const first = new StorageClient();
|
||||
const committed = await first.saveProject(projectId, 1, Uint8Array.from([1, 2, 3, 4]).buffer);
|
||||
let error = "";
|
||||
try { await first.saveProject(projectId, 2, Uint8Array.from([9, 9, 9, 9]).buffer, "quota"); }
|
||||
catch (caught) { error = caught instanceof Error ? caught.message : String(caught); }
|
||||
const retained = await first.readProject(projectId);
|
||||
first.terminate();
|
||||
const restarted = new StorageClient();
|
||||
const reopened = await restarted.readProject(projectId);
|
||||
restarted.terminate();
|
||||
return { backend: committed.backend, error, revision: retained.revision, reopenedRevision: reopened.revision, bytes: Array.from(new Uint8Array(reopened.buffer)) };
|
||||
});
|
||||
expect(result.backend, `${browserName} storage backend`).toMatch(/^(opfs|indexeddb)$/);
|
||||
expect(result.error).toContain("QuotaExceededError");
|
||||
expect(result.revision).toBe(1);
|
||||
expect(result.reopenedRevision).toBe(1);
|
||||
expect(result.bytes).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
2413
web/tests/e2e/smoke.spec.ts
Normal file
2413
web/tests/e2e/smoke.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
125
web/tests/unit/local-deps.test.mjs
Normal file
125
web/tests/unit/local-deps.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";
|
||||
|
||||
const webRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(webRoot, "package.json"), "utf8"),
|
||||
);
|
||||
|
||||
test("browser runtime dependencies stay local", () => {
|
||||
const runtimePackages = Object.keys(packageJson.dependencies ?? {});
|
||||
assert.deepEqual(runtimePackages, ["react", "react-dom"]);
|
||||
assert.equal(packageJson.dependencies?.three, undefined);
|
||||
assert.equal(packageJson.dependencies?.["@sqlite.org/sqlite-wasm"], undefined);
|
||||
for (const name of runtimePackages) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(webRoot, "node_modules", ...name.split("/"))),
|
||||
`${name} must be installed in local node_modules`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("required renderer and engine assets are vendored", () => {
|
||||
const requiredFiles = [
|
||||
"app/src/vendor/three/three.core.js",
|
||||
"app/src/vendor/three/three.module.js",
|
||||
"app/src/vendor/three/addons/controls/OrbitControls.js",
|
||||
"app/src/vendor/blender/web_engine.js",
|
||||
"app/src/vendor/blender/web_engine.wasm",
|
||||
"app/public/vendor/blender/web_engine.js",
|
||||
"app/public/vendor/blender/web_engine.wasm",
|
||||
];
|
||||
for (const relativePath of requiredFiles) {
|
||||
const filePath = path.join(webRoot, relativePath);
|
||||
assert.ok(fs.statSync(filePath).size > 0, `${relativePath} must be non-empty`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(path.join(webRoot, "app/src/vendor/blender/web_engine.wasm")),
|
||||
fs.readFileSync(path.join(webRoot, "app/public/vendor/blender/web_engine.wasm")),
|
||||
);
|
||||
for (const relativePath of [
|
||||
"blender-5.2.0/extern/zlib/CMakeLists.txt",
|
||||
"blender-5.2.0/extern/zstd/build/cmake/CMakeLists.txt",
|
||||
]) {
|
||||
assert.ok(fs.statSync(path.join(webRoot, "..", relativePath)).size > 0, `${relativePath} must be vendored`);
|
||||
}
|
||||
});
|
||||
|
||||
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"));
|
||||
const entry = manifest.fixtures.find((item) => item.id === "attribute_scene");
|
||||
assert.ok(entry, "attribute_scene fixture must be registered");
|
||||
assert.equal(fs.statSync(fixture).size, 90108);
|
||||
});
|
||||
|
||||
test("animation fixture is registered with a bounded frame range", () => {
|
||||
const fixture = path.join(webRoot, "..", "tests/files/web/animation_scene.blend");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
const entry = manifest.fixtures.find((item) => item.id === "animation_scene");
|
||||
assert.deepEqual(entry?.frameRange, [1, 10]);
|
||||
assert.ok(fs.statSync(fixture).size > 0);
|
||||
});
|
||||
|
||||
test("rigged shape fixture covers modifier, skin and shape-key input", () => {
|
||||
const fixture = path.join(webRoot, "..", "tests/files/web/rigged_shape_scene.blend");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
const entry = manifest.fixtures.find((item) => item.id === "rigged_shape_scene");
|
||||
assert.deepEqual(entry?.features, ["modifier-stack", "vertex-weights", "shape-keys"]);
|
||||
assert.equal(entry?.objects, 2);
|
||||
assert.deepEqual(entry?.objectNames, ["RiggedArmatureObject", "RiggedShapeObject"]);
|
||||
assert.ok(fs.statSync(fixture).size > 0);
|
||||
});
|
||||
|
||||
test("packed image fixture is registered as a local binary asset input", () => {
|
||||
const fixture = path.join(webRoot, "..", "tests/files/web/packed_image_scene.blend");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
const entry = manifest.fixtures.find((item) => item.id === "packed_image_scene");
|
||||
assert.deepEqual(entry?.features, ["packed-image"]);
|
||||
assert.equal(entry?.objectNames?.[0], "PackedImageObject");
|
||||
assert.ok(fs.statSync(fixture).size > 0);
|
||||
});
|
||||
|
||||
test("topology fixture covers open and non-manifold Collapse input", () => {
|
||||
const fixture = path.join(webRoot, "..", "tests/files/web/topology_scene.blend");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
const entry = manifest.fixtures.find((item) => item.id === "topology_scene");
|
||||
assert.equal(entry?.objects, 3);
|
||||
assert.equal(entry?.meshes, 3);
|
||||
assert.deepEqual(entry?.features, ["open-boundary-collapse", "open-ngon-collapse", "non-manifold-collapse"]);
|
||||
assert.ok(fs.statSync(fixture).size > 0);
|
||||
});
|
||||
|
||||
test("modifier category fixtures and Blender 5.2 goldens are registered", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
const fixtureIds = [
|
||||
"modifier_generate_scene",
|
||||
"modifier_deform_scene",
|
||||
"modifier_deform_curve_scene",
|
||||
"modifier_physics_scene",
|
||||
"modifier_geometry_nodes_scene",
|
||||
"modifier_grease_pencil_scene",
|
||||
];
|
||||
for (const fixtureId of fixtureIds) {
|
||||
const entry = manifest.fixtures.find((item) => item.id === fixtureId);
|
||||
assert.ok(entry, `${fixtureId} fixture must be registered`);
|
||||
assert.ok(fs.statSync(path.join(webRoot, "..", "tests/files/web", entry.path)).size > 0);
|
||||
const goldenPath = path.join(webRoot, "..", "tests/golden/W-075", entry.path.replace(".blend", ".json"));
|
||||
const golden = JSON.parse(fs.readFileSync(goldenPath, "utf8"));
|
||||
assert.equal(golden.blenderVersion, "5.2.0 LTS");
|
||||
assert.equal(golden.fixture, entry.path);
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-frame pose and constraint fixture is registered with desktop goldens", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));
|
||||
const entry = manifest.fixtures.find((item) => item.id === "pose_constraint_scene");
|
||||
assert.ok(entry);
|
||||
assert.deepEqual(entry.features, ["pose-animation", "pose-constraint", "multi-frame-depsgraph"]);
|
||||
assert.ok(fs.statSync(path.join(webRoot, "..", "tests/files/web", entry.path)).size > 0);
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/golden/W-080/pose-constraint-depsgraph.json"), "utf8"));
|
||||
assert.equal(golden.blenderVersion, "5.2.0 LTS");
|
||||
assert.deepEqual(golden.frames, [1, 5, 10, 15]);
|
||||
});
|
||||
Reference in New Issue
Block a user