import { expect, test } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; const catalog = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v2.json"), "utf8")) as Record; const identity = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/identity.json"), "utf8")) as Record; const preview = fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/preview.png")); test("M12-02G reclaims only the final project-scoped preview payload reference", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async ({ sourceCatalog, sourceIdentity, bytes }) => { const commit = await import("/src/storage/asset-preview-opfs-commit.ts"); const gc = await import("/src/storage/asset-preview-reference-gc.ts"); const quarantine = await import("/src/storage/asset-preview-quarantine.ts"); const encoded = new Uint8Array(bytes).buffer; const base = await commit.parseAssetCatalogManifestV2({ ...sourceCatalog, assets: (sourceCatalog.assets as Array>).map((asset) => ({ ...asset, preview: null })), }); const identityFor = async (assetId: string) => { const input = { ...sourceIdentity, assetId } as Record; delete input.identitySha256; return commit.createAssetPreviewIdentity(input as never); }; const identities = await Promise.all(base.assets.map((asset) => identityFor(asset.assetId))); const open = (name: string): Promise => new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => request.result.createObjectStore("setting", { keyPath: "id" }); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const complete = (transaction: IDBTransaction): Promise => new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); const seed = async (database: IDBDatabase) => { const transaction = database.transaction("setting", "readwrite"); transaction.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base }); await complete(transaction); }; const exists = async (projectId: string, sha256: string): Promise => { try { const root = await navigator.storage.getDirectory(); let current = root; for (const segment of ["projects", projectId, "assets", "sha256", sha256.slice(0, 2)]) current = await current.getDirectoryHandle(segment); await current.getFileHandle(sha256); return true; } catch { return false; } }; const databaseAName = `m12-02g-a-${crypto.randomUUID()}`; const databaseBName = `m12-02g-b-${crypto.randomUUID()}`; const projectA = `m12-02g-a-${crypto.randomUUID()}`; const projectB = `m12-02g-b-${crypto.randomUUID()}`; let databaseA = await open(databaseAName); let databaseB = await open(databaseBName); await seed(databaseA); await seed(databaseB); await commit.commitAssetPreviewToOPFS(databaseA, projectA, 7, identities[0], encoded.slice(0)); await commit.commitAssetPreviewToOPFS(databaseA, projectA, 8, identities[1], encoded.slice(0)); await commit.commitAssetPreviewToOPFS(databaseB, projectB, 7, identities[0], encoded.slice(0)); const firstRemoval = await gc.removeAssetPreviewReference(databaseA, projectA, 9, base.assets[0].assetId); const afterFirstA = await exists(projectA, identities[0].content.sha256); const afterFirstB = await exists(projectB, identities[0].content.sha256); const finalRemoval = await gc.removeAssetPreviewReference(databaseA, projectA, 10, base.assets[1].assetId); const afterFinalA = await exists(projectA, identities[0].content.sha256); const afterFinalB = await exists(projectB, identities[0].content.sha256); const projectBReady = await quarantine.inspectAssetPreviewWithQuarantine(databaseB, projectB, base.assets[0].assetId); databaseA.close(); databaseA = await open(databaseAName); const transaction = databaseA.transaction("setting", "readonly"); const row = await new Promise>((resolve, reject) => { const request = transaction.objectStore("setting").get("asset-catalog:index:v2"); request.onsuccess = () => resolve(request.result as Record); request.onerror = () => reject(request.error); }); await complete(transaction); const reopenedA = await commit.parseAssetCatalogManifestV2(row.value); const value = { firstReceipt: firstRemoval.receipt, finalReceipt: finalRemoval.receipt, afterFirstA, afterFirstB, afterFinalA, afterFinalB, projectBReady: { status: projectBReady.status, bytes: projectBReady.status === "READY" ? projectBReady.data.byteLength : 0 }, reopenedRevision: reopenedA.revision, reopenedPreviews: reopenedA.assets.map((asset) => asset.preview), baseMetadata: base.assets, reopenedMetadata: reopenedA.assets.map((asset) => ({ ...asset, preview: null })), }; databaseA.close(); databaseB.close(); indexedDB.deleteDatabase(databaseAName); indexedDB.deleteDatabase(databaseBName); const root = await navigator.storage.getDirectory(); const projects = await root.getDirectoryHandle("projects"); await projects.removeEntry(projectA, { recursive: true }); await projects.removeEntry(projectB, { recursive: true }); return value; }, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] }); expect(result.firstReceipt).toMatchObject({ task: "M12-02G", status: "RETAINED", baseRevision: 9, committedRevision: 10, remainingProjectReferences: 1, payloadReclaimed: false, }); expect(result.finalReceipt).toMatchObject({ task: "M12-02G", status: "COLLECTED", baseRevision: 10, committedRevision: 11, remainingProjectReferences: 0, payloadReclaimed: true, }); expect({ afterFirstA: result.afterFirstA, afterFirstB: result.afterFirstB, afterFinalA: result.afterFinalA, afterFinalB: result.afterFinalB, }).toEqual({ afterFirstA: true, afterFirstB: true, afterFinalA: false, afterFinalB: true }); expect(result.projectBReady).toEqual({ status: "READY", bytes: 513 }); expect(result.reopenedRevision).toBe(11); expect(result.reopenedPreviews).toEqual([null, null]); expect(result.reopenedMetadata).toEqual(result.baseMetadata); });