172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
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<string, unknown>;
|
|
const identity = JSON.parse(fs.readFileSync(
|
|
path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/identity.json"),
|
|
"utf8",
|
|
)) as Record<string, unknown>;
|
|
const preview = fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M12-02B/preview.png"));
|
|
|
|
test("M12-02D publishes a catalog preview only after verified OPFS persistence", async ({ page }) => {
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async ({ sourceCatalog, sourceIdentity, bytes }) => {
|
|
const storage = await import("/src/storage/asset-preview-opfs-commit.ts");
|
|
const encoded = new Uint8Array(bytes).buffer;
|
|
const base = await storage.parseAssetCatalogManifestV2({
|
|
...sourceCatalog,
|
|
assets: (sourceCatalog.assets as Array<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
|
});
|
|
const targetAssetId = base.assets[0].assetId;
|
|
const identityInput = { ...sourceIdentity, assetId: targetAssetId } as Record<string, unknown>;
|
|
delete identityInput.identitySha256;
|
|
const boundIdentity = await storage.createAssetPreviewIdentity(identityInput as never);
|
|
|
|
const open = (name: string): Promise<IDBDatabase> => 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<void> => new Promise((resolve, reject) => {
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
transaction.onabort = () => reject(transaction.error);
|
|
});
|
|
const get = (store: IDBObjectStore, id: string): Promise<unknown> => new Promise((resolve, reject) => {
|
|
const request = store.get(id);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
const seed = async (database: IDBDatabase): Promise<void> => {
|
|
const transaction = database.transaction("setting", "readwrite");
|
|
transaction.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
|
await complete(transaction);
|
|
};
|
|
const snapshot = async (database: IDBDatabase) => {
|
|
const transaction = database.transaction("setting", "readonly");
|
|
const store = transaction.objectStore("setting");
|
|
const [index, receipt] = await Promise.all([
|
|
get(store, "asset-catalog:index:v2"),
|
|
get(store, storage.ASSET_PREVIEW_CATALOG_HEAD_ID),
|
|
]);
|
|
await complete(transaction);
|
|
return { index, receipt };
|
|
};
|
|
const payloadExists = async (projectId: string): Promise<boolean> => {
|
|
try {
|
|
const root = await navigator.storage.getDirectory();
|
|
let current = root;
|
|
for (const segment of ["projects", projectId, "assets", "sha256", boundIdentity.content.sha256.slice(0, 2)]) {
|
|
current = await current.getDirectoryHandle(segment);
|
|
}
|
|
const file = await (await current.getFileHandle(boundIdentity.content.sha256)).getFile();
|
|
return file.size === encoded.byteLength;
|
|
}
|
|
catch { return false; }
|
|
};
|
|
const cleanupProject = async (projectId: string): Promise<void> => {
|
|
const root = await navigator.storage.getDirectory();
|
|
try {
|
|
const projects = await root.getDirectoryHandle("projects");
|
|
await projects.removeEntry(projectId, { recursive: true });
|
|
}
|
|
catch { /* The before-write fault creates no project directory. */ }
|
|
};
|
|
const fault = async (faultAt: "BEFORE_OPFS_WRITE" | "AFTER_OPFS_WRITE" | "AFTER_CATALOG_PUT") => {
|
|
const databaseName = `m12-02d-${faultAt.toLowerCase()}-${crypto.randomUUID()}`;
|
|
const projectId = `m12-02d-${crypto.randomUUID()}`;
|
|
let database = await open(databaseName);
|
|
await seed(database);
|
|
let code = "";
|
|
try {
|
|
await storage.commitAssetPreviewToOPFS(database, projectId, base.revision, boundIdentity, encoded.slice(0), { faultAt });
|
|
}
|
|
catch (error) { code = String((error as Error & { code?: string }).code ?? ""); }
|
|
database.close();
|
|
database = await open(databaseName);
|
|
const state = await snapshot(database);
|
|
const persisted = await payloadExists(projectId);
|
|
database.close();
|
|
indexedDB.deleteDatabase(databaseName);
|
|
await cleanupProject(projectId);
|
|
return { faultAt, code, state, persisted };
|
|
};
|
|
|
|
const faults = [];
|
|
for (const faultAt of ["BEFORE_OPFS_WRITE", "AFTER_OPFS_WRITE", "AFTER_CATALOG_PUT"] as const) {
|
|
faults.push(await fault(faultAt));
|
|
}
|
|
|
|
const databaseName = `m12-02d-success-${crypto.randomUUID()}`;
|
|
const projectId = `m12-02d-${crypto.randomUUID()}`;
|
|
let database = await open(databaseName);
|
|
await seed(database);
|
|
const committed = await storage.commitAssetPreviewToOPFS(database, projectId, base.revision, boundIdentity, encoded.slice(0));
|
|
database.close();
|
|
database = await open(databaseName);
|
|
const reopened = await storage.loadCommittedAssetPreviewCatalog(database);
|
|
const successState = await snapshot(database);
|
|
const persisted = await payloadExists(projectId);
|
|
database.close();
|
|
indexedDB.deleteDatabase(databaseName);
|
|
await cleanupProject(projectId);
|
|
return {
|
|
base,
|
|
targetAssetId,
|
|
identitySha256: boundIdentity.identitySha256,
|
|
contentSha256: boundIdentity.content.sha256,
|
|
faults,
|
|
committed: {
|
|
revision: committed.manifest.revision,
|
|
preview: committed.preview,
|
|
receipt: committed.receipt,
|
|
deduplicated: committed.deduplicated,
|
|
},
|
|
reopened: {
|
|
revision: reopened.manifest.revision,
|
|
preview: reopened.preview,
|
|
receipt: reopened.receipt,
|
|
},
|
|
successState,
|
|
persisted,
|
|
};
|
|
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
|
|
|
for (const failure of result.faults) {
|
|
expect(failure.code).toBe("STORAGE_TRANSACTION");
|
|
expect(failure.state.index).toEqual({ id: "asset-catalog:index:v2", value: result.base });
|
|
expect(failure.state.receipt).toBeUndefined();
|
|
}
|
|
expect(result.faults.map((failure) => failure.persisted)).toEqual([false, true, true]);
|
|
expect(result.persisted).toBe(true);
|
|
expect(result.committed.revision).toBe(8);
|
|
expect(result.committed.deduplicated).toBe(false);
|
|
expect(result.committed.preview).toEqual({
|
|
sha256: result.contentSha256,
|
|
mimeType: "image/png",
|
|
width: 8,
|
|
height: 8,
|
|
byteLength: 513,
|
|
});
|
|
expect(result.committed.receipt).toMatchObject({
|
|
task: "M12-02D",
|
|
status: "COMMITTED",
|
|
assetId: result.targetAssetId,
|
|
baseRevision: 7,
|
|
committedRevision: 8,
|
|
previewIdentitySha256: result.identitySha256,
|
|
contentSha256: result.contentSha256,
|
|
});
|
|
expect(result.reopened).toEqual({
|
|
revision: result.committed.revision,
|
|
preview: result.committed.preview,
|
|
receipt: result.committed.receipt,
|
|
});
|
|
expect(result.successState.receipt).toEqual(result.committed.receipt);
|
|
});
|