212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
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");
|
|
});
|