148 lines
7.3 KiB
TypeScript
148 lines
7.3 KiB
TypeScript
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([]);
|
|
});
|
|
|