penpot/library/test/export.test.js
Andrey Antukh 7ad12f8fcc 🐛 Address review findings on storage-bucket references
Apply the review findings on the reference-decided bucket change:
memoize export aliases per [source-id bucket], raise on unreadable
references instead of emitting null, keep the valid predicate in the
include-deleted read, cache the import pre-scan, resolve import rows
through one helper, and add the missing round-trip and v1 parity
tests plus hash and key guards.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-18 18:22:51 +02:00

123 lines
4.1 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import * as fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Uint8ArrayReader, ZipReader, TextWriter } from "@zip.js/zip.js";
import * as penpot from "#self";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Buckets that can appear inside a `.penpot` file. Oracle kept in sync
// by hand with the backend `app.storage/valid-buckets` and
// `lib.export/valid-buckets` (`organization`, `tempfile`,
// `upload-session` and `job-resource` are runtime-only and never travel
// inside a file).
const ALLOWED_BUCKETS = new Set([
"file-media-object",
"team-font-variant",
"file-object-thumbnail",
"file-thumbnail",
"profile",
"file-data",
"file-data-fragment",
"file-change",
]);
async function readEntries(bytes) {
const reader = new ZipReader(new Uint8ArrayReader(bytes));
const entries = await reader.getEntries();
return new Map(entries.map((entry) => [entry.filename, entry]));
}
async function readJson(entry) {
return JSON.parse(await entry.getData(new TextWriter()));
}
test("export a file with media writes one coherent object per media", async () => {
const context = penpot.createBuildContext();
const fileId = context.addFile({ name: "file with media" });
context.addPage({ name: "page" });
const jpeg = fs.readFileSync(
path.join(__dirname, "..", "playground", "sample.jpg")
);
context.addFileMedia(
{ name: "photo.jpg", width: 32, height: 16 },
new Blob([jpeg], { type: "image/jpeg" })
);
context.addFileMedia(
{ name: "icon.png", width: 8, height: 8 },
new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" })
);
context.closeFile();
const bytes = await penpot.exportAsBytes(context);
const entries = await readEntries(bytes);
const manifest = await readJson(entries.get("manifest.json"));
assert.equal(manifest.type, "penpot/export-files");
assert.ok(
manifest.files.some((file) => file.id === fileId),
"the manifest should list the exported file"
);
const fileMedia = [];
for (const [name, entry] of entries) {
const match = /^files\/([^/]+)\/media\/([^/]+)\.json$/.exec(name);
if (match) {
fileMedia.push(await readJson(entry));
}
}
assert.equal(fileMedia.length, 2, "one metadata entry per added media");
const objectJsonEntries = [...entries.entries()].filter(([name]) =>
/^objects\/[^/]+\.json$/.test(name)
);
// (a) one `objects/<id>.json` entry per media referenced by the file.
const referencedMediaIds = new Set(fileMedia.map((item) => item.mediaId));
const objectIds = new Set(
objectJsonEntries.map(([name]) => name.slice("objects/".length, -".json".length))
);
assert.equal(objectIds.size, 2);
assert.deepEqual(objectIds, referencedMediaIds);
for (const [name, entry] of objectJsonEntries) {
const object = await readJson(entry);
const id = name.slice("objects/".length, -".json".length);
assert.equal(object.id, id, "the object id should match its entry name");
// (b) media objects always live in the `file-media-object` bucket.
assert.equal(object.bucket, "file-media-object");
// (c) no object may use a bucket outside the allowed file buckets.
assert.ok(ALLOWED_BUCKETS.has(object.bucket), `unexpected bucket: ${object.bucket}`);
// The builder does not compute a hash, and the importer accepts its absence.
assert.equal(object.hash, undefined);
assert.equal(typeof object.size, "number");
assert.equal(typeof object.contentType, "string");
// No other keys may leak into the entry: the malli encoder only
// transforms the declared ones.
assert.deepEqual(Object.keys(object).sort(), [
"bucket",
"contentType",
"id",
"size",
]);
}
// The object blob (not only its metadata) is part of the bundle.
const objectBlobs = new Set(
[...entries.keys()]
.filter((name) => /^objects\/[^/]+\.[a-z0-9]+$/.test(name) && !name.endsWith(".json"))
.map((name) => name.slice("objects/".length).replace(/\.[^.]+$/, ""))
);
assert.deepEqual(objectBlobs, referencedMediaIds);
});