Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
114
web/tests/e2e/asset-catalog-indexeddb-migration.spec.ts
Normal file
114
web/tests/e2e/asset-catalog-indexeddb-migration.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v1.json"),
|
||||
"utf8",
|
||||
)) as unknown;
|
||||
|
||||
test("M12-01G aborts catalog migration without changing the old IndexedDB index", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (source) => {
|
||||
const storage = await import("/src/storage/asset-catalog-indexeddb.ts");
|
||||
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.result.createObjectStore("migration", { 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 seed = async (database: IDBDatabase): Promise<void> => {
|
||||
const transaction = database.transaction(["setting", "migration"], "readwrite");
|
||||
transaction.objectStore("setting").put({ id: storage.ASSET_CATALOG_INDEX_V1_ID, value: source });
|
||||
transaction.objectStore("migration").put({ id: "schema-legacy", version: 1, marker: "unchanged" });
|
||||
await complete(transaction);
|
||||
};
|
||||
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 snapshot = async (database: IDBDatabase) => {
|
||||
const transaction = database.transaction(["setting", "migration"], "readonly");
|
||||
const settings = transaction.objectStore("setting");
|
||||
const migrations = transaction.objectStore("migration");
|
||||
const values = await Promise.all([
|
||||
get(settings, storage.ASSET_CATALOG_INDEX_V1_ID),
|
||||
get(settings, storage.ASSET_CATALOG_INDEX_V2_ID),
|
||||
get(migrations, storage.ASSET_CATALOG_MIGRATION_ID),
|
||||
get(migrations, "schema-legacy"),
|
||||
]);
|
||||
await complete(transaction);
|
||||
return { version: database.version, source: values[0], target: values[1], receipt: values[2], legacy: values[3] };
|
||||
};
|
||||
const faultCode = async (database: IDBDatabase, faultAt: "AFTER_TARGET_PUT" | "AFTER_SOURCE_DELETE"): Promise<string> => {
|
||||
try {
|
||||
await storage.migrateAssetCatalogIndexedDB(database, { faultAt });
|
||||
return "";
|
||||
}
|
||||
catch (error) {
|
||||
return String((error as Error & { code?: string }).code ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const databaseName = `m12-01g-${crypto.randomUUID()}`;
|
||||
let database = await open(databaseName);
|
||||
await seed(database);
|
||||
const baseline = await snapshot(database);
|
||||
const afterTargetCode = await faultCode(database, "AFTER_TARGET_PUT");
|
||||
database.close();
|
||||
database = await open(databaseName);
|
||||
const afterTargetFault = await snapshot(database);
|
||||
const afterDeleteCode = await faultCode(database, "AFTER_SOURCE_DELETE");
|
||||
database.close();
|
||||
database = await open(databaseName);
|
||||
const afterDeleteFault = await snapshot(database);
|
||||
const migrated = await storage.migrateAssetCatalogIndexedDB(database);
|
||||
const committed = await snapshot(database);
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
return {
|
||||
baseline,
|
||||
afterTargetCode,
|
||||
afterTargetFault,
|
||||
afterDeleteCode,
|
||||
afterDeleteFault,
|
||||
migrated: {
|
||||
status: migrated.status,
|
||||
revision: migrated.manifest.revision,
|
||||
sourceHash: migrated.receipt.sourceManifestSha256,
|
||||
targetHash: migrated.receipt.targetManifestSha256,
|
||||
},
|
||||
committed,
|
||||
};
|
||||
}, fixture);
|
||||
|
||||
expect(result.afterTargetCode).toBe("STORAGE_TRANSACTION");
|
||||
expect(result.afterDeleteCode).toBe("STORAGE_TRANSACTION");
|
||||
expect(result.afterTargetFault).toEqual(result.baseline);
|
||||
expect(result.afterDeleteFault).toEqual(result.baseline);
|
||||
expect(result.baseline.version).toBe(1);
|
||||
expect(result.baseline.source).toEqual({ id: "asset-catalog:index:v1", value: fixture });
|
||||
expect(result.baseline.target).toBeUndefined();
|
||||
expect(result.baseline.receipt).toBeUndefined();
|
||||
expect(result.baseline.legacy).toEqual({ id: "schema-legacy", version: 1, marker: "unchanged" });
|
||||
expect(result.migrated).toEqual({
|
||||
status: "MIGRATED",
|
||||
revision: 7,
|
||||
sourceHash: "cbdc8b03dc9ce1f38b44bae1c7459eb273126cbd6c7dd33115d2b1966e975881",
|
||||
targetHash: "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
|
||||
});
|
||||
expect(result.committed.version).toBe(1);
|
||||
expect(result.committed.source).toBeUndefined();
|
||||
expect(result.committed.target).toMatchObject({ id: "asset-catalog:index:v2", value: { schemaVersion: 2, revision: 7 } });
|
||||
expect(result.committed.receipt).toMatchObject({ id: "asset-catalog:migration:v1-to-v2", task: "M12-01G", status: "MIGRATED" });
|
||||
expect(result.committed.legacy).toEqual(result.baseline.legacy);
|
||||
});
|
||||
91
web/tests/e2e/asset-catalog-restart.spec.ts
Normal file
91
web/tests/e2e/asset-catalog-restart.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = JSON.parse(fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../../tests/golden/M12-01D/catalog-v1.json"),
|
||||
"utf8",
|
||||
)) as unknown;
|
||||
|
||||
test("M12-01H preserves catalog order and asset identity across page and Worker restarts", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const databaseName = `m12-01h-${Date.now()}`;
|
||||
const baseline = await page.evaluate(async ({ databaseName, source }) => {
|
||||
const storage = await import("/src/storage/asset-catalog-indexeddb.ts");
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("setting", { keyPath: "id" });
|
||||
request.result.createObjectStore("migration", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: storage.ASSET_CATALOG_INDEX_V1_ID, value: source });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
const snapshot = await storage.loadAssetCatalogIndexedDBSnapshot(database);
|
||||
database.close();
|
||||
return snapshot;
|
||||
}, { databaseName, source: fixture });
|
||||
|
||||
await page.reload();
|
||||
const pageRestart = await page.evaluate(async (name) => {
|
||||
const storage = await import("/src/storage/asset-catalog-indexeddb.ts");
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const snapshot = await storage.loadAssetCatalogIndexedDBSnapshot(database);
|
||||
database.close();
|
||||
return snapshot;
|
||||
}, databaseName);
|
||||
|
||||
const workerSnapshot = async () => page.evaluate((name) => new Promise<unknown>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/asset-catalog-restart-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.ok ? resolve(event.data.snapshot) : reject(new Error(event.data.error));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ databaseName: name });
|
||||
}), databaseName);
|
||||
const firstWorker = await workerSnapshot();
|
||||
const secondWorker = await workerSnapshot();
|
||||
|
||||
expect(pageRestart).toEqual(baseline);
|
||||
expect(firstWorker).toEqual(baseline);
|
||||
expect(secondWorker).toEqual(baseline);
|
||||
expect(baseline).toEqual({
|
||||
schemaVersion: 1,
|
||||
revision: 7,
|
||||
manifestSha256: "0c08ed1af1dd0998c809f76d969fd21d8fdae28ad37cf5571bdc99b16bf3ec90",
|
||||
catalogOrder: [
|
||||
{ catalogId: "44444444-4444-4444-8444-444444444444", path: "Animation" },
|
||||
{ catalogId: "fe3ca14c-95d7-549f-a13b-1bb4c07b6074", path: "Characters" },
|
||||
{ catalogId: "b4bb3608-9267-5aba-bb9e-9e97b006d5ab", path: "Characters/Heroes" },
|
||||
],
|
||||
assetOrder: [
|
||||
"asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
|
||||
"asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
|
||||
],
|
||||
assetIdentities: [
|
||||
{
|
||||
assetId: "asset:ac88c6147ada877adccf4df8ca54d64e1a9cb50a13514212f6d19b1da1660f8d",
|
||||
assetLibraryIdentifier: null,
|
||||
relativeAssetIdentifier: "Action/Legacy Walk",
|
||||
},
|
||||
{
|
||||
assetId: "asset:bb91ef0dddf69bb7d87dd4217a01e7f549b50ad628db0367d3896c6840b3a326",
|
||||
assetLibraryIdentifier: null,
|
||||
relativeAssetIdentifier: "Object/Legacy Hero",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.evaluate((name) => indexedDB.deleteDatabase(name), databaseName);
|
||||
});
|
||||
113
web/tests/e2e/asset-preview-dedup.spec.ts
Normal file
113
web/tests/e2e/asset-preview-dedup.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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-02E deduplicates preview content without merging asset metadata", 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 identities = [];
|
||||
for (const asset of base.assets) {
|
||||
const input = { ...sourceIdentity, assetId: asset.assetId } as Record<string, unknown>;
|
||||
delete input.identitySha256;
|
||||
identities.push(await storage.createAssetPreviewIdentity(input as never));
|
||||
}
|
||||
const databaseName = `m12-02e-${crypto.randomUUID()}`;
|
||||
const projectId = `m12-02e-${crypto.randomUUID()}`;
|
||||
const open = (): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 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);
|
||||
});
|
||||
let database = await open();
|
||||
const seed = database.transaction("setting", "readwrite");
|
||||
seed.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
||||
await complete(seed);
|
||||
const first = await storage.commitAssetPreviewToOPFS(database, projectId, 7, identities[0], encoded.slice(0));
|
||||
const second = await storage.commitAssetPreviewToOPFS(database, projectId, 8, identities[1], encoded.slice(0));
|
||||
database.close();
|
||||
database = await open();
|
||||
const reopened = await storage.loadCommittedAssetPreviewCatalog(database);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let contentDirectory = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", identities[0].content.sha256.slice(0, 2)]) {
|
||||
contentDirectory = await contentDirectory.getDirectoryHandle(segment);
|
||||
}
|
||||
const files: string[] = [];
|
||||
for await (const [name, handle] of (contentDirectory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries()) {
|
||||
if (handle.kind === "file") files.push(name);
|
||||
}
|
||||
files.sort();
|
||||
const stored = await (await contentDirectory.getFileHandle(identities[0].content.sha256)).getFile();
|
||||
const storedBytes = await stored.arrayBuffer();
|
||||
const storedSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", storedBytes)),
|
||||
(byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const withoutPreview = (asset: Record<string, unknown>) => {
|
||||
const copy = { ...asset };
|
||||
delete copy.preview;
|
||||
return copy;
|
||||
};
|
||||
const resultValue = {
|
||||
first: { revision: first.manifest.revision, deduplicated: first.deduplicated, identity: identities[0].identitySha256 },
|
||||
second: { revision: second.manifest.revision, deduplicated: second.deduplicated, identity: identities[1].identitySha256 },
|
||||
contentSha256: identities[0].content.sha256,
|
||||
files,
|
||||
storedBytes: stored.size,
|
||||
storedSha256,
|
||||
baseMetadata: base.assets.map((asset) => withoutPreview(asset as unknown as Record<string, unknown>)),
|
||||
committedMetadata: second.manifest.assets.map((asset) => withoutPreview(asset as unknown as Record<string, unknown>)),
|
||||
previews: second.manifest.assets.map((asset) => asset.preview),
|
||||
reopened: {
|
||||
revision: reopened.manifest.revision,
|
||||
receiptAssetId: reopened.receipt.assetId,
|
||||
preview: reopened.preview,
|
||||
},
|
||||
assetIds: base.assets.map((asset) => asset.assetId),
|
||||
};
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
await projects.removeEntry(projectId, { recursive: true });
|
||||
return resultValue;
|
||||
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
||||
|
||||
expect(result.first).toMatchObject({ revision: 8, deduplicated: false });
|
||||
expect(result.second).toMatchObject({ revision: 9, deduplicated: true });
|
||||
expect(result.first.identity).not.toBe(result.second.identity);
|
||||
expect(result.files).toEqual([result.contentSha256]);
|
||||
expect(result.storedBytes).toBe(513);
|
||||
expect(result.storedSha256).toBe(result.contentSha256);
|
||||
expect(result.committedMetadata).toEqual(result.baseMetadata);
|
||||
expect(result.committedMetadata[0]).not.toEqual(result.committedMetadata[1]);
|
||||
expect(result.previews).toEqual([
|
||||
{ sha256: result.contentSha256, mimeType: "image/png", width: 8, height: 8, byteLength: 513 },
|
||||
{ sha256: result.contentSha256, mimeType: "image/png", width: 8, height: 8, byteLength: 513 },
|
||||
]);
|
||||
expect(result.reopened).toEqual({
|
||||
revision: 9,
|
||||
receiptAssetId: result.assetIds[1],
|
||||
preview: result.previews[1],
|
||||
});
|
||||
});
|
||||
95
web/tests/e2e/asset-preview-display.spec.ts
Normal file
95
web/tests/e2e/asset-preview-display.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
|
||||
const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/desktop-report.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/manifest.json"), "utf8"));
|
||||
const preview = fs.readFileSync(path.join(root, "tests/golden/M12-02B/preview.png"));
|
||||
|
||||
test("M12-02H main-thread and Offscreen preview displays match the Blender RGBA8 metrics", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ identityValue, bytes, desktopValue, thresholds }) => {
|
||||
const { displayAssetPreview } = await import("/src/assets/AssetPreviewDisplay.ts");
|
||||
const { compareRenderImages } = await import("/src/three-adapter/render-image-comparison.ts");
|
||||
document.body.replaceChildren();
|
||||
document.body.style.margin = "0";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "m12-asset-preview";
|
||||
canvas.setAttribute("aria-label", "Asset preview");
|
||||
canvas.style.width = "128px";
|
||||
canvas.style.height = "128px";
|
||||
canvas.style.imageRendering = "pixelated";
|
||||
document.body.append(canvas);
|
||||
const encoded = new Uint8Array(bytes).buffer;
|
||||
const main = await displayAssetPreview(canvas, "MAIN_THREAD_CANVAS_2D", identityValue, encoded.slice(0));
|
||||
const workerResult = await new Promise<{ receipt: unknown; pixels: Uint8Array }>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/asset-preview-display-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.ok ? resolve(event.data) : reject(new Error(event.data.error));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ identity: identityValue, bytes: encoded.slice(0) });
|
||||
});
|
||||
const reference = new Uint8Array(desktopValue.pixelCount * 4);
|
||||
for (let offset = 0; offset < reference.byteLength; offset += 4) reference.set(desktopValue.referencePixel, offset);
|
||||
const mainMetrics = compareRenderImages(reference, main.pixels, desktopValue.width, desktopValue.height, thresholds);
|
||||
const offscreenPixels = new Uint8Array(workerResult.pixels);
|
||||
const offscreenMetrics = compareRenderImages(reference, offscreenPixels, desktopValue.width, desktopValue.height, thresholds);
|
||||
const wrong = new Uint8Array(main.pixels);
|
||||
wrong[0] ^= 0xff;
|
||||
const wrongMetrics = compareRenderImages(reference, wrong, desktopValue.width, desktopValue.height, thresholds);
|
||||
return {
|
||||
main: { receipt: main.receipt, pixels: Array.from(main.pixels) },
|
||||
offscreen: { receipt: workerResult.receipt, pixels: Array.from(offscreenPixels) },
|
||||
mainMetrics,
|
||||
offscreenMetrics,
|
||||
wrongMetrics,
|
||||
};
|
||||
}, { identityValue: identity, bytes: [...preview], desktopValue: desktop, thresholds: manifest.thresholds });
|
||||
|
||||
const canvas = page.locator("#m12-asset-preview");
|
||||
await expect(canvas).toBeVisible();
|
||||
await expect(canvas).toHaveAttribute("width", "8");
|
||||
await expect(canvas).toHaveAttribute("height", "8");
|
||||
await expect(canvas).toHaveAttribute("data-asset-preview-status", "ready");
|
||||
await expect(canvas).toHaveAttribute("data-asset-preview-backend", "MAIN_THREAD_CANVAS_2D");
|
||||
await expect(canvas).toHaveCSS("width", "128px");
|
||||
await expect(canvas).toHaveCSS("height", "128px");
|
||||
expect(result.main.receipt).toMatchObject({
|
||||
status: "READY",
|
||||
backend: "MAIN_THREAD_CANVAS_2D",
|
||||
width: 8,
|
||||
height: 8,
|
||||
pixelByteLength: 256,
|
||||
pixelSha256: desktop.rgbaSha256,
|
||||
nonTransparentPixels: 64,
|
||||
bitmapClosed: true,
|
||||
});
|
||||
expect(result.offscreen.receipt).toMatchObject({
|
||||
status: "READY",
|
||||
backend: "OFFSCREEN_CANVAS_2D",
|
||||
width: 8,
|
||||
height: 8,
|
||||
pixelByteLength: 256,
|
||||
pixelSha256: desktop.rgbaSha256,
|
||||
nonTransparentPixels: 64,
|
||||
bitmapClosed: true,
|
||||
});
|
||||
expect(result.main.pixels).toEqual(result.offscreen.pixels);
|
||||
for (const metrics of [result.mainMetrics, result.offscreenMetrics]) {
|
||||
expect(metrics.status).toBe("READY");
|
||||
expect(metrics.errorCode).toBeNull();
|
||||
expect(metrics.meanAbsoluteError).toBe(0);
|
||||
expect(metrics.rootMeanSquaredError).toBe(0);
|
||||
expect(metrics.p95ChannelError).toBe(0);
|
||||
expect(metrics.maxChannelError).toBe(0);
|
||||
expect(metrics.badPixelRatio).toBe(0);
|
||||
expect(metrics.foregroundIntersectionOverUnion).toBe(1);
|
||||
expect(metrics.alphaCoverageDeltaRatio).toBe(0);
|
||||
}
|
||||
expect(result.wrongMetrics.status).toBe("BLOCKED");
|
||||
expect(result.wrongMetrics.errorCode).toBe("RENDER_REFERENCE_MISMATCH");
|
||||
});
|
||||
171
web/tests/e2e/asset-preview-opfs-commit.spec.ts
Normal file
171
web/tests/e2e/asset-preview-opfs-commit.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
});
|
||||
118
web/tests/e2e/asset-preview-quarantine.spec.ts
Normal file
118
web/tests/e2e/asset-preview-quarantine.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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-02F quarantines corrupt preview bytes while preserving catalog metadata", 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 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<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
||||
});
|
||||
const target = base.assets[0];
|
||||
const identityInput = { ...sourceIdentity, assetId: target.assetId } as Record<string, unknown>;
|
||||
delete identityInput.identitySha256;
|
||||
const boundIdentity = await commit.createAssetPreviewIdentity(identityInput as never);
|
||||
const databaseName = `m12-02f-${crypto.randomUUID()}`;
|
||||
const projectId = `m12-02f-${crypto.randomUUID()}`;
|
||||
const open = (): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 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);
|
||||
});
|
||||
let database = await open();
|
||||
const seed = database.transaction("setting", "readwrite");
|
||||
seed.objectStore("setting").put({ id: "asset-catalog:index:v2", value: base });
|
||||
await complete(seed);
|
||||
const committed = await commit.commitAssetPreviewToOPFS(database, projectId, 7, boundIdentity, encoded.slice(0));
|
||||
const ready = await quarantine.inspectAssetPreviewWithQuarantine(database, projectId, target.assetId);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let contentDirectory = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", boundIdentity.content.sha256.slice(0, 2)]) {
|
||||
contentDirectory = await contentDirectory.getDirectoryHandle(segment);
|
||||
}
|
||||
const fileHandle = await contentDirectory.getFileHandle(boundIdentity.content.sha256);
|
||||
const corrupt = new Uint8Array(await (await fileHandle.getFile()).arrayBuffer());
|
||||
corrupt[corrupt.byteLength - 1] ^= 0xff;
|
||||
const writer = await fileHandle.createWritable();
|
||||
await writer.write(corrupt);
|
||||
await writer.close();
|
||||
|
||||
const quarantined = await quarantine.inspectAssetPreviewWithQuarantine(database, projectId, target.assetId);
|
||||
let contentExists = true;
|
||||
try { await contentDirectory.getFileHandle(boundIdentity.content.sha256); }
|
||||
catch { contentExists = false; }
|
||||
database.close();
|
||||
database = await open();
|
||||
const reopened = await quarantine.inspectAssetPreviewWithQuarantine(database, projectId, target.assetId);
|
||||
const catalogTransaction = database.transaction("setting", "readonly");
|
||||
const catalogRow = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const request = catalogTransaction.objectStore("setting").get("asset-catalog:index:v2");
|
||||
request.onsuccess = () => resolve(request.result as Record<string, unknown>);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await complete(catalogTransaction);
|
||||
const storedCatalog = await commit.parseAssetCatalogManifestV2(catalogRow.value);
|
||||
const quarantinePath = quarantined.status === "QUARANTINED" ? quarantined.receipt.quarantinePath : null;
|
||||
let quarantineFileBytes = 0;
|
||||
if (quarantinePath) {
|
||||
let current = root;
|
||||
const segments = quarantinePath.split("/");
|
||||
for (const segment of segments.slice(0, -1)) current = await current.getDirectoryHandle(segment);
|
||||
quarantineFileBytes = (await (await current.getFileHandle(segments.at(-1)!)).getFile()).size;
|
||||
}
|
||||
const value = {
|
||||
baseMetadata: target,
|
||||
ready: { status: ready.status, bytes: ready.status === "READY" ? ready.data.byteLength : 0 },
|
||||
quarantined,
|
||||
reopened,
|
||||
contentExists,
|
||||
quarantineFileBytes,
|
||||
storedAsset: storedCatalog.assets[0],
|
||||
storedRevision: storedCatalog.revision,
|
||||
committedRevision: committed.manifest.revision,
|
||||
otherAsset: storedCatalog.assets[1],
|
||||
baseOtherAsset: base.assets[1],
|
||||
};
|
||||
database.close();
|
||||
indexedDB.deleteDatabase(databaseName);
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
await projects.removeEntry(projectId, { recursive: true });
|
||||
return value;
|
||||
}, { sourceCatalog: catalog, sourceIdentity: identity, bytes: [...preview] });
|
||||
|
||||
expect(result.ready).toEqual({ status: "READY", bytes: 513 });
|
||||
expect(result.quarantined.status).toBe("QUARANTINED");
|
||||
expect(result.quarantined.preview).toBeNull();
|
||||
expect(result.quarantined.data).toBeNull();
|
||||
expect(result.quarantined.receipt).toMatchObject({
|
||||
task: "M12-02F",
|
||||
status: "QUARANTINED",
|
||||
catalogRevision: 8,
|
||||
actualByteLength: 513,
|
||||
code: "ASSET_SOURCE_HASH_MISMATCH",
|
||||
});
|
||||
expect(result.quarantined.receipt.actualSha256).not.toBe(result.quarantined.receipt.expected.sha256);
|
||||
expect(result.quarantined.receipt.quarantinePath).toContain("/assets/quarantine/");
|
||||
expect(result.reopened).toEqual(result.quarantined);
|
||||
expect(result.contentExists).toBe(false);
|
||||
expect(result.quarantineFileBytes).toBe(513);
|
||||
expect(result.storedRevision).toBe(result.committedRevision);
|
||||
expect(result.storedAsset).toEqual(result.quarantined.asset);
|
||||
expect({ ...result.storedAsset, preview: null }).toEqual(result.baseMetadata);
|
||||
expect(result.otherAsset).toEqual(result.baseOtherAsset);
|
||||
});
|
||||
132
web/tests/e2e/asset-preview-reference-gc.spec.ts
Normal file
132
web/tests/e2e/asset-preview-reference-gc.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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-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<Record<string, unknown>>).map((asset) => ({ ...asset, preview: null })),
|
||||
});
|
||||
const identityFor = async (assetId: string) => {
|
||||
const input = { ...sourceIdentity, assetId } as Record<string, unknown>;
|
||||
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<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 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<boolean> => {
|
||||
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<Record<string, unknown>>((resolve, reject) => {
|
||||
const request = transaction.objectStore("setting").get("asset-catalog:index:v2");
|
||||
request.onsuccess = () => resolve(request.result as Record<string, unknown>);
|
||||
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);
|
||||
});
|
||||
139
web/tests/e2e/library-append-main.spec.ts
Normal file
139
web/tests/e2e/library-append-main.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { computeLibraryAppendClosureSha256, LIBRARY_MAIN_APPEND_SCHEMA } from "../../protocol/library-main-append";
|
||||
import { createLibraryOperationBinding, createLibrarySourceIdentity, LIBRARY_OPERATION_IDENTITY_SCHEMA } from "../../protocol/library-operation-identity";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const source = fs.readFileSync(path.join(root, "tests/files/web/m12_library_append_v1/m12_append_source.blend"));
|
||||
const target = fs.readFileSync(path.join(root, "tests/files/web/empty.blend"));
|
||||
|
||||
test("M12-03D appends one fully-local dependency closure through WASM Main", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const closure = {
|
||||
object: "Object/M12 Append Object",
|
||||
mesh: "Mesh/M12 Append Mesh",
|
||||
material: "Material/M12 Append Material",
|
||||
image: "Image/M12 Append Image",
|
||||
} as const;
|
||||
const sourceSha256 = crypto.createHash("sha256").update(source).digest("hex");
|
||||
const sourceIdentity = await createLibrarySourceIdentity({
|
||||
sourceLocator: "project-assets/libraries/m12_append_source.blend",
|
||||
sourceSha256,
|
||||
});
|
||||
const dependencyClosureSha256 = await computeLibraryAppendClosureSha256(closure);
|
||||
const binding = await createLibraryOperationBinding({
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
operation: "APPEND",
|
||||
source: sourceIdentity,
|
||||
sourceDataBlockId: closure.object,
|
||||
owner: { kind: "LOCAL_MAIN", projectId: "project:m12-03d", localDataBlockId: closure.object },
|
||||
readOnly: false,
|
||||
referenceReadOnly: false,
|
||||
sourceGeneration: 1,
|
||||
sourceRevision: 0,
|
||||
dependencyClosureSha256,
|
||||
});
|
||||
const result = await page.evaluate(async ({ sourceBytes, targetBytes, closure, binding }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const sourceBuffer = Uint8Array.from(sourceBytes).buffer;
|
||||
const targetBuffer = Uint8Array.from(targetBytes).buffer;
|
||||
const digest = async (bytes: ArrayBuffer): Promise<string> => {
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const makeRequest = (baseRevision: number) => ({
|
||||
schemaVersion: 1 as const,
|
||||
baseRevision,
|
||||
binding,
|
||||
expectedClosure: closure,
|
||||
});
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopened = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const ids = {
|
||||
object: "object:M12 Append Object",
|
||||
mesh: "mesh:M12 Append Mesh",
|
||||
material: "material:M12 Append Material",
|
||||
image: "image:M12 Append Image",
|
||||
};
|
||||
const closureState = (snapshot: any) => ({
|
||||
object: snapshot.nodes.find((item: any) => item.id === ids.object),
|
||||
mesh: snapshot.meshes.find((item: any) => item.id === ids.mesh),
|
||||
material: snapshot.materials.find((item: any) => item.id === ids.material),
|
||||
image: snapshot.images.find((item: any) => item.id === ids.image),
|
||||
});
|
||||
try {
|
||||
const opened = await client.openBlend(targetBuffer.slice(0));
|
||||
const baseRevision = opened.snapshot.revision;
|
||||
const request = await makeRequest(baseRevision);
|
||||
const appended = await client.appendLibraryObject(sourceBuffer.slice(0), request);
|
||||
const appendedClosure = closureState(appended.snapshot);
|
||||
const stale = await client.appendLibraryObject(sourceBuffer.slice(0), makeRequest(baseRevision))
|
||||
.then(() => ({ code: "NO_ERROR" }))
|
||||
.catch((error: any) => ({ code: error.code, message: error.message }));
|
||||
const collisionRequest = await makeRequest(appended.snapshot.revision);
|
||||
const collision = await client.appendLibraryObject(sourceBuffer.slice(0), collisionRequest)
|
||||
.then(() => ({ code: "NO_ERROR" }))
|
||||
.catch((error: any) => ({ code: error.code, message: error.message }));
|
||||
const afterCollision = await client.snapshot();
|
||||
const undone = await client.applyCommand({ type: "undo" });
|
||||
const redone = await client.applyCommand({ type: "redo" });
|
||||
const saved = await client.saveBlend();
|
||||
const reopenedResult = await reopened.openBlend(saved);
|
||||
const reopenedClosure = closureState(reopenedResult.snapshot);
|
||||
return {
|
||||
sourceSha256: await digest(sourceBuffer),
|
||||
baseRevision,
|
||||
appendedRevision: appended.snapshot.revision,
|
||||
receipt: appended.receipt,
|
||||
delta: appended.delta,
|
||||
appendedClosure: {
|
||||
object: appendedClosure.object && { type: appendedClosure.object.type, dataId: appendedClosure.object.dataId },
|
||||
mesh: appendedClosure.mesh && { vertexCount: appendedClosure.mesh.vertexCount, materialSlotIds: appendedClosure.mesh.materialSlotIds },
|
||||
material: appendedClosure.material && {
|
||||
imageIds: appendedClosure.material.imageIds ?? appendedClosure.material.nodes?.flatMap((node: any) => node.imageId ? [node.imageId] : []),
|
||||
},
|
||||
image: appendedClosure.image && { libraryLinked: appendedClosure.image.libraryLinked, width: appendedClosure.image.width, height: appendedClosure.image.height },
|
||||
},
|
||||
stale,
|
||||
collision,
|
||||
collisionRevision: afterCollision.snapshot.revision,
|
||||
undoHasObject: Boolean(closureState(undone.snapshot).object),
|
||||
redoHasObject: Boolean(closureState(redone.snapshot).object),
|
||||
reopenedRevision: reopenedResult.snapshot.revision,
|
||||
reopenedHasObject: Boolean(reopenedClosure.object),
|
||||
reopenedHasLocalImage: reopenedClosure.image?.libraryLinked === false,
|
||||
};
|
||||
}
|
||||
catch (error: any) {
|
||||
return { error: { code: error?.code, message: error?.message, detail: error?.detail } };
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
reopened.terminate();
|
||||
}
|
||||
}, { sourceBytes: Array.from(source), targetBytes: Array.from(target), closure, binding });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.sourceSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.appendedRevision).toBe(result.baseRevision + 1);
|
||||
expect(result.receipt).toMatchObject({ operation: "APPEND", transactionCount: 1, baseRevision: result.baseRevision, nextRevision: result.appendedRevision });
|
||||
expect(result.receipt.mapping).toHaveLength(4);
|
||||
expect(result.receipt.mapping.every((item: any) => item.owner === "LOCAL_MAIN" && item.readOnly === false && item.source === item.local)).toBe(true);
|
||||
expect(result.delta).toMatchObject({ schemaVersion: 1, baseRevision: result.baseRevision, nextRevision: result.appendedRevision });
|
||||
expect(result.appendedClosure).toEqual({
|
||||
object: { type: "MESH", dataId: "mesh:M12 Append Mesh" },
|
||||
mesh: { vertexCount: 4, materialSlotIds: ["material:M12 Append Material"] },
|
||||
material: { imageIds: ["image:M12 Append Image"] },
|
||||
image: { libraryLinked: false, width: 2, height: 2 },
|
||||
});
|
||||
expect(result.stale.code).toBe("REVISION_CONFLICT");
|
||||
expect(result.collision.code).toBe("ASSET_MANIFEST_INVALID");
|
||||
expect(result.collisionRevision).toBe(result.appendedRevision);
|
||||
expect(result.undoHasObject).toBe(false);
|
||||
expect(result.redoHasObject).toBe(true);
|
||||
expect(result.reopenedHasObject).toBe(true);
|
||||
expect(result.reopenedHasLocalImage).toBe(true);
|
||||
});
|
||||
72
web/tests/e2e/render-compositor-media-recovery.spec.ts
Normal file
72
web/tests/e2e/render-compositor-media-recovery.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-14/render-compositor-media-recovery.json"), "utf8")) as {
|
||||
domains: Array<{
|
||||
domain: "RENDER" | "COMPOSITOR" | "MEDIA";
|
||||
fixture: string;
|
||||
sha256: string;
|
||||
cancellationCode: string;
|
||||
budgetCode: string;
|
||||
}>;
|
||||
textureFixture: { path: string; sha256: string };
|
||||
};
|
||||
const fixtures = Object.fromEntries(manifest.domains.map((item) => [item.domain, fs.readFileSync(path.join(root, item.fixture))]));
|
||||
const texture = fs.readFileSync(path.join(root, manifest.textureFixture.path));
|
||||
const sha256 = (value: Buffer) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
|
||||
test("M11-14 recovers real render, compositor and media production paths after lifecycle faults", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
for (const item of manifest.domains) expect(sha256(fixtures[item.domain])).toBe(item.sha256);
|
||||
expect(sha256(texture)).toBe(manifest.textureFixture.sha256);
|
||||
await page.goto("/");
|
||||
const reports = await page.evaluate(async ({ renderBlend, compositorBlend, movie, texturePng }) => {
|
||||
const { runRenderCompositorMediaRecoverySuite } = await import("/src/testing/render-compositor-media-recovery.ts");
|
||||
return runRenderCompositorMediaRecoverySuite({
|
||||
renderBlend: Uint8Array.from(renderBlend).buffer,
|
||||
compositorBlend: Uint8Array.from(compositorBlend).buffer,
|
||||
movie: { mimeType: "video/mp4", data: Uint8Array.from(movie).buffer },
|
||||
texturePng: Uint8Array.from(texturePng).buffer,
|
||||
});
|
||||
}, {
|
||||
renderBlend: Array.from(fixtures.RENDER),
|
||||
compositorBlend: Array.from(fixtures.COMPOSITOR),
|
||||
movie: Array.from(fixtures.MEDIA),
|
||||
texturePng: Array.from(texture),
|
||||
});
|
||||
|
||||
expect(reports.map((report) => report.domain)).toEqual(["RENDER", "COMPOSITOR", "MEDIA"]);
|
||||
for (const [index, report] of reports.entries()) {
|
||||
const expected = manifest.domains[index];
|
||||
expect(report.source.sha256).toBe(expected.sha256);
|
||||
expect(report.cancellation).toEqual({
|
||||
status: "CANCELLED",
|
||||
code: expected.cancellationCode,
|
||||
publishedResults: 0,
|
||||
temporaryResourcesAfter: 0,
|
||||
});
|
||||
expect(report.restart).toMatchObject({ status: "RECOVERED", generationBefore: 1, generationAfter: 2 });
|
||||
expect(report.restart.identityAfter).toBe(report.restart.identityBefore);
|
||||
expect(report.restart.outputSha256After).toBe(report.restart.outputSha256Before);
|
||||
expect(report.budget).toEqual({
|
||||
status: "BLOCKED",
|
||||
code: expected.budgetCode,
|
||||
retainedIdentityHash: report.restart.identityAfter,
|
||||
temporaryResourcesAfter: 0,
|
||||
});
|
||||
expect(report.release.status).toBe("RELEASED");
|
||||
expect(report.release.releasedBytes).toBeGreaterThan(0);
|
||||
expect(report.release.releasedResources).toBeGreaterThan(0);
|
||||
expect(report.release.resourcesAfter).toBe(0);
|
||||
expect(report.recovery).toMatchObject({
|
||||
status: "RECOVERED",
|
||||
identityHash: report.restart.identityAfter,
|
||||
outputSha256: report.restart.outputSha256After,
|
||||
});
|
||||
expect(report.recovery.outputBytes).toBeGreaterThan(0);
|
||||
expect(report.recovery.visibleUnits).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user