128 lines
7.6 KiB
TypeScript
128 lines
7.6 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
const root = path.resolve(import.meta.dirname, "../../..");
|
|
const glbBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/pbr.glb")));
|
|
const blendBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_main_v1/pbr.blend")));
|
|
|
|
test("M12-06G cancels GLB import/export without publishing temporary output", async ({ page }) => {
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async (fixtures) => {
|
|
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
|
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
|
const opened = await client.openBlend(Uint8Array.from(fixtures.blend).buffer);
|
|
const assets = [];
|
|
for (const image of opened.snapshot.images) {
|
|
const asset = await client.requestAsset(image.assetId);
|
|
if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, data: asset.data, mimeType: asset.mimeType });
|
|
}
|
|
const cancel = async (operation: "IMPORT" | "EXPORT") => new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" });
|
|
const requestId = `glb-${operation.toLowerCase()}-cancel-1`;
|
|
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => {
|
|
worker.terminate();
|
|
if (!event.data.ok) reject(new Error(String(event.data.error)));
|
|
else resolve(event.data);
|
|
};
|
|
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
|
const bytes = Uint8Array.from(fixtures.glb).buffer;
|
|
worker.postMessage({
|
|
type: "run",
|
|
requestId,
|
|
operation,
|
|
bytes,
|
|
snapshot: operation === "EXPORT" ? opened.snapshot : undefined,
|
|
geometryBuffers: operation === "EXPORT" ? opened.geometryBuffers : undefined,
|
|
assetBuffers: operation === "EXPORT" ? assets : undefined,
|
|
nonMeshGeometryBuffers: operation === "EXPORT" ? opened.nonMeshGeometryBuffers ?? [] : undefined,
|
|
baseRevision: opened.snapshot.revision,
|
|
workerGeneration: 1,
|
|
});
|
|
setTimeout(() => worker.postMessage({ type: "cancel", targetRequestId: requestId }), 3);
|
|
});
|
|
const imported = await cancel("IMPORT");
|
|
const exported = await cancel("EXPORT");
|
|
client.terminate();
|
|
return { imported, exported };
|
|
}, { glb: glbBytes, blend: blendBytes });
|
|
for (const operation of [result.imported, result.exported]) {
|
|
expect(operation.ok).toBe(true);
|
|
expect((operation.receipt as { status: string }).status).toBe("CANCELLED");
|
|
expect((operation.receipt as { errorCode: string }).errorCode).toBe("GLB_OPERATION_CANCELLED");
|
|
expect((operation.receipt as { temporaryBytes: number; liveRequests: number; committed: boolean })).toMatchObject({ temporaryBytes: 0, liveRequests: 0, committed: false });
|
|
}
|
|
});
|
|
|
|
test("M12-06G recovers GLB import after Worker restart and retains old OPFS asset after quota", async ({ page }) => {
|
|
await page.goto("/");
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 64 * 1024 });
|
|
const result = await page.evaluate(async (fixture) => {
|
|
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
|
const { beginGLBRecoveryOperation, blockGLBRecoveryForQuota, recoverGLBRecoveryOperation } = await import("/src/testing/glb-recovery.ts");
|
|
const runWorker = (generation: number) => new Promise<{ receipt: any; result: { outputSha256: string } }>((resolve, reject) => {
|
|
const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" });
|
|
worker.onmessage = (event: MessageEvent<{ ok: boolean; receipt?: any; result?: { outputSha256: string }; error?: string }>) => {
|
|
worker.terminate();
|
|
if (!event.data.ok || !event.data.receipt || !event.data.result) reject(new Error(event.data.error ?? "GLB worker failed"));
|
|
else resolve({ receipt: event.data.receipt, result: event.data.result });
|
|
};
|
|
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
|
worker.postMessage({ type: "run", requestId: `glb-import-generation-${generation}`, operation: "IMPORT", bytes: Uint8Array.from(fixture).buffer, baseRevision: 4, workerGeneration: generation });
|
|
});
|
|
const firstRun = await runWorker(1);
|
|
const restartedRun = await runWorker(2);
|
|
const recovered = recoverGLBRecoveryOperation(firstRun.receipt, 2);
|
|
const projectId = `glb-recovery-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const storage = new StorageClient();
|
|
await storage.ensureProject(projectId);
|
|
const asset = await storage.putAsset(projectId, Uint8Array.from(fixture).buffer, "model/gltf-binary", "imports/model.glb");
|
|
let quotaError = "";
|
|
let quotaReceipt;
|
|
const quotaPayload = Uint8Array.from({ length: 128 * 1024 }, (_, index) => (index * 7) & 0xff).buffer;
|
|
const quotaRunning = beginGLBRecoveryOperation({ operationId: "export-quota-1", operation: "EXPORT", workerGeneration: 2, baseRevision: 4, inputBytes: quotaPayload.byteLength, inputSha256: await crypto.subtle.digest("SHA-256", quotaPayload).then((digest) => Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")) });
|
|
try { await storage.putAsset(projectId, quotaPayload, "model/gltf-binary", "imports/rejected.glb"); }
|
|
catch (error) { quotaError = error instanceof Error ? error.message : String(error); quotaReceipt = blockGLBRecoveryForQuota(quotaRunning); }
|
|
storage.terminate();
|
|
const restartedStorage = new StorageClient();
|
|
const restored = await restartedStorage.readAsset(projectId, asset.sha256);
|
|
const assets = await restartedStorage.listAssets(projectId);
|
|
restartedStorage.terminate();
|
|
return {
|
|
firstHash: firstRun.result.outputSha256,
|
|
restartedHash: restartedRun.result.outputSha256,
|
|
recoveredStatus: recovered.status,
|
|
recoveredGeneration: recovered.workerGeneration,
|
|
quotaError,
|
|
quotaCode: quotaReceipt?.errorCode,
|
|
assetSha256: asset.sha256,
|
|
restoredSha256: restored.asset.sha256,
|
|
restoredBytes: restored.data.byteLength,
|
|
assetCount: assets.assets.length,
|
|
projectId,
|
|
backendPath: asset.path,
|
|
};
|
|
}, glbBytes);
|
|
expect(result.firstHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(result.restartedHash).toBe(result.firstHash);
|
|
expect(result.recoveredStatus).toBe("RECOVERED");
|
|
expect(result.recoveredGeneration).toBe(2);
|
|
expect(result.quotaError).toMatch(/QuotaExceededError|storage quota|exceed its storage quota/i);
|
|
expect(result.quotaCode).toBe("GLB_OPFS_QUOTA");
|
|
expect(result.restoredSha256).toBe(result.assetSha256);
|
|
expect(result.restoredBytes).toBe(glbBytes.length);
|
|
expect(result.assetCount).toBe(1);
|
|
expect(result.backendPath).toMatch(/^projects\//);
|
|
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 1024 * 1024 * 1024 });
|
|
const recoveredStorage = await page.evaluate(async (projectId) => {
|
|
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
|
const storage = new StorageClient();
|
|
const small = await storage.putAsset(projectId, Uint8Array.from([5, 6, 7]).buffer, "model/gltf-binary", "imports/recovery.glb");
|
|
const assets = await storage.listAssets(projectId);
|
|
storage.terminate();
|
|
return { persisted: small.persisted, assetCount: assets.assets.length };
|
|
}, result.projectId);
|
|
expect(recoveredStorage).toEqual({ persisted: true, assetCount: 2 });
|
|
});
|