Advance M8-M11 parity workflows
This commit is contained in:
211
web/tests/e2e/simulation-cache-lifecycle.spec.ts
Normal file
211
web/tests/e2e/simulation-cache-lifecycle.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M10-06 gates playback on verification and enforces cancel, LRU, restart and corruption quarantine", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
|
||||
const { upgradeStorageSchema } = await import("/src/storage/migrations.ts");
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const errorCode = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) {
|
||||
const code = (error as Error & { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : (error as Error).name;
|
||||
}
|
||||
};
|
||||
const projectId = `m10-06-${Date.now()}`;
|
||||
const migrationDatabase = `m10-06-migration-${Date.now()}`;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(migrationDatabase, 6);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("migration", { keyPath: "id" });
|
||||
request.result.createObjectStore("simulation_manifest", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => { request.result.close(); resolve(); };
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const migration = await new Promise<{ version: number; stores: string[]; record?: { version: number } }>((resolve, reject) => {
|
||||
const request = indexedDB.open(migrationDatabase, 7);
|
||||
request.onupgradeneeded = (event) => upgradeStorageSchema(request.result, request.transaction!, (event as IDBVersionChangeEvent).oldVersion);
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
const transaction = database.transaction("migration", "readonly");
|
||||
const record = transaction.objectStore("migration").get("schema-7");
|
||||
record.onsuccess = () => resolve({ version: database.version, stores: [...database.objectStoreNames], record: record.result as { version: number } | undefined });
|
||||
record.onerror = () => reject(record.error);
|
||||
transaction.oncomplete = () => database.close();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
indexedDB.deleteDatabase(migrationDatabase);
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 6]).buffer;
|
||||
const sourceBlendSha256 = await digest(sourceBlend);
|
||||
const makeManifest = async (name: string, payload: ArrayBuffer, frameCount = 1) => {
|
||||
const frameBytes = payload.byteLength / frameCount;
|
||||
const binding = {
|
||||
graphId: `node-group:${name}`,
|
||||
graphHash: await digest(new TextEncoder().encode(`graph:${name}`).buffer),
|
||||
sourceBlendSha256,
|
||||
sourceRevision: 6,
|
||||
inputHash: await digest(new TextEncoder().encode(`input:${name}`).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({
|
||||
frame: index + 1,
|
||||
byteOffset: index * frameBytes,
|
||||
byteLength: frameBytes,
|
||||
sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)),
|
||||
})));
|
||||
return {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
};
|
||||
};
|
||||
|
||||
const first = new StorageClient();
|
||||
await first.saveProject(projectId, 6, sourceBlend.slice(0));
|
||||
const payloadA = Uint8Array.from([1, 2, 3, 4]).buffer;
|
||||
const manifestA = await makeManifest("lru-a", payloadA);
|
||||
const storedA = await first.putSimulationCache(projectId, manifestA, payloadA.slice(0));
|
||||
first.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
const notReadyAfterRestart = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1));
|
||||
const prepared = await restarted.prepareSimulationCachePlayback(projectId, storedA.cacheKey);
|
||||
const recovered = await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1);
|
||||
let cancelledPublishedFrames = 0;
|
||||
const playback = new BrowserTransformCachePlaybackSession({
|
||||
schemaVersion: 1 as const,
|
||||
revision: 6,
|
||||
sceneId: "scene:M10-06",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
activeObjectId: null,
|
||||
frame: { current: 1, start: 1, end: 1 },
|
||||
nodes: [], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
|
||||
}, {
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, frame, signal)).data,
|
||||
}, () => { cancelledPublishedFrames += 1; });
|
||||
const cancellingPlayback = playback.play();
|
||||
playback.cancel();
|
||||
const playbackCancellation = await cancellingPlayback;
|
||||
const pendingAfterPlaybackCancel = restarted.getPendingRequestCount();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const payloadB = Uint8Array.from([5, 6, 7, 8]).buffer;
|
||||
const manifestB = await makeManifest("lru-b", payloadB);
|
||||
const storedB = await restarted.putSimulationCache(projectId, manifestB, payloadB.slice(0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const payloadC = Uint8Array.from([9, 10, 11, 12]).buffer;
|
||||
const manifestC = await makeManifest("lru-c", payloadC);
|
||||
const storedC = await restarted.putSimulationCache(projectId, manifestC, payloadC.slice(0));
|
||||
const activePrune = await restarted.pruneSimulationCaches(projectId, 4);
|
||||
const afterActivePrune = await restarted.listSimulationCaches(projectId);
|
||||
await restarted.releaseSimulationCachePlayback(projectId, storedA.cacheKey);
|
||||
const releasedCode = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1));
|
||||
const finalPrune = await restarted.pruneSimulationCaches(projectId, 0);
|
||||
|
||||
const cancelledPayload = new ArrayBuffer(256 * 32);
|
||||
const cancelledBytes = new Uint8Array(cancelledPayload);
|
||||
for (let index = 0; index < cancelledBytes.length; index += 1) cancelledBytes[index] = index % 251;
|
||||
const cancelledManifest = await makeManifest("cancelled", cancelledPayload, 256);
|
||||
const controller = new AbortController();
|
||||
const cancelledWrite = restarted.putSimulationCache(projectId, cancelledManifest, cancelledPayload, controller.signal);
|
||||
controller.abort();
|
||||
const cancelledCode = await errorCode(() => cancelledWrite);
|
||||
const afterCancel = await restarted.listSimulationCaches(projectId);
|
||||
const assetsAfterCancel = await restarted.listAssets(projectId);
|
||||
|
||||
const corruptPayload = Uint8Array.from([31, 32, 33, 34]).buffer;
|
||||
const corruptManifest = await makeManifest("corrupt", corruptPayload);
|
||||
const corruptStored = await restarted.putSimulationCache(projectId, corruptManifest, corruptPayload.slice(0));
|
||||
restarted.terminate();
|
||||
const pathSegments = corruptStored.path.split("/");
|
||||
const fileName = pathSegments.pop()!;
|
||||
let directory = await navigator.storage.getDirectory();
|
||||
for (const segment of pathSegments) directory = await directory.getDirectoryHandle(segment);
|
||||
const handle = await directory.getFileHandle(fileName);
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(Uint8Array.from([99, 98, 97, 96]));
|
||||
await writable.close();
|
||||
|
||||
const quarantineReader = new StorageClient();
|
||||
const corruptNotReady = await errorCode(() => quarantineReader.readSimulationCacheFrame(projectId, corruptStored.cacheKey, 1));
|
||||
const corruptionCode = await errorCode(() => quarantineReader.prepareSimulationCachePlayback(projectId, corruptStored.cacheKey));
|
||||
const afterCorruption = await quarantineReader.listSimulationCaches(projectId);
|
||||
const info = await quarantineReader.info();
|
||||
const pending = quarantineReader.getPendingRequestCount();
|
||||
quarantineReader.terminate();
|
||||
|
||||
return {
|
||||
notReadyAfterRestart,
|
||||
verifiedAt: prepared.verifiedAt,
|
||||
recovered: Array.from(new Uint8Array(recovered.data)),
|
||||
playbackCancellation,
|
||||
cancelledPublishedFrames,
|
||||
pendingAfterPlaybackCancel,
|
||||
activePrune,
|
||||
activeKeys: afterActivePrune.caches.map((cache) => cache.cacheKey),
|
||||
expectedActiveKey: storedA.cacheKey,
|
||||
evictedKeys: [storedB.cacheKey, storedC.cacheKey].sort(),
|
||||
releasedCode,
|
||||
finalPrune,
|
||||
cancelledCode,
|
||||
cancelledPublished: afterCancel.caches.some((cache) => cache.cacheKey === `sim2-${cancelledManifest.revisionHash}`),
|
||||
cancelledAssetPresent: assetsAfterCancel.assets.some((asset) => asset.sha256 === cancelledManifest.cacheSha256),
|
||||
corruptNotReady,
|
||||
corruptionCode,
|
||||
cachesAfterCorruption: afterCorruption.caches.length,
|
||||
quarantined: afterCorruption.quarantined,
|
||||
issues: afterCorruption.issues,
|
||||
schemaVersion: info.schemaVersion,
|
||||
stores: info.stores,
|
||||
pending,
|
||||
migration,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.notReadyAfterRestart).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result.recovered).toEqual([1, 2, 3, 4]);
|
||||
expect(result.playbackCancellation).toEqual({ status: "CANCELLED", appliedFrames: 0, lastFrame: null });
|
||||
expect(result.cancelledPublishedFrames).toBe(0);
|
||||
expect(result.pendingAfterPlaybackCancel).toBe(0);
|
||||
expect(result.activePrune).toMatchObject({ beforeBytes: 12, remainingBytes: 4, removedBytes: 8, removed: 2, budgetSatisfied: true });
|
||||
expect(result.activePrune.cacheKeys.sort()).toEqual(result.evictedKeys);
|
||||
expect(result.activePrune.protectedCacheKeys).toContain(result.expectedActiveKey);
|
||||
expect(result.activeKeys).toEqual([result.expectedActiveKey]);
|
||||
expect(result.releasedCode).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.finalPrune).toMatchObject({ beforeBytes: 4, remainingBytes: 0, removedBytes: 4, removed: 1, budgetSatisfied: true });
|
||||
expect(result.cancelledCode).toBe("AbortError");
|
||||
expect(result.cancelledPublished).toBe(false);
|
||||
expect(result.cancelledAssetPresent).toBe(false);
|
||||
expect(result.corruptNotReady).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.corruptionCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
expect(result.cachesAfterCorruption).toBe(0);
|
||||
expect(result.quarantined).toBe(1);
|
||||
expect(result.issues).toEqual([expect.objectContaining({ code: "SIMULATION_CACHE_HASH_MISMATCH" })]);
|
||||
expect(result.schemaVersion).toBe(7);
|
||||
expect(result.stores).toContain("simulation_quarantine");
|
||||
expect(result.pending).toBe(0);
|
||||
expect(result.migration).toMatchObject({ version: 7, record: { version: 7 } });
|
||||
expect(result.migration.stores).toContain("simulation_quarantine");
|
||||
});
|
||||
Reference in New Issue
Block a user