tests/export.test.ts
Rama main · réplica de lectura del repositorio original.
// Export (server/export.ts + indexer `exportSelection`): what each scope
// takes, which attachments ride along, the rewrite applied to the copies
// and not the vault, the cap, and the archive that actually streams.
//
// Through a fixture vault, the way tests/tracker.test.ts tests the shelf's
// scope: the selection is answered by the index's own attachment walk, so
// the claim under test is "an export carries exactly what the app would
// show for these notes", and that is only checkable against real records.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { exportSelection, initIndexer } from "../server/indexer.ts";
import { contentDisposition, exportStream, parseExportQuery, planExport, summarize } from "../server/export.ts";
import { initSite } from "../server/site.ts";
import { initVault, VaultError } from "../server/vault.ts";
import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts";
const PNG = "\x89PNG\r\n\x1a\n0123";
const files: Record<string, string> = {
"Ideas.md": note({ tags: "[reading]" }, "# Ideas\n\nSee [[Deep note]] and ![[cover.png]] and [[Nowhere]].\n\n\n"),
"Folder/Deep note.md": note({}, "Back to [[Ideas#Ideas]]. #reading\n\n```\n[[Ideas]] in a fence\n```\n"),
"Folder/Sub/Leaf.md": "Leaf. [[Ideas]]\n",
"ملاحظات/فكرة.md": "# فكرة\n\nانظر [[Ideas]]\n",
"Media/cover.png": PNG,
"Media/inline.png": PNG,
"Media/unused.png": PNG,
"Paper.tex": "\\section{Paper}\n",
};
const data = makeDir();
const root = makeVault(files);
before(async () => {
initSite({ ASTROLABE_DATA: data });
initVault(root);
await initIndexer();
});
after(() => {
removeVault(root);
removeVault(data);
});
async function collect(stream: ReadableStream): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value as Uint8Array);
}
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
let at = 0;
for (const c of chunks) {
out.set(c, at);
at += c.length;
}
return out;
}
/** The central directory's names and each entry's bytes — enough of a
* reader to prove the stream is the plan. */
function entriesOf(bytes: Uint8Array): { name: string; data: string }[] {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const dec = new TextDecoder();
const endAt = bytes.length - 22;
assert.equal(view.getUint32(endAt, true), 0x06054b50);
const count = view.getUint16(endAt + 10, true);
let at = view.getUint32(endAt + 16, true);
const out: { name: string; data: string }[] = [];
for (let i = 0; i < count; i++) {
assert.equal(view.getUint32(at, true), 0x02014b50);
const size = view.getUint32(at + 24, true);
const nameLen = view.getUint16(at + 28, true);
const offset = view.getUint32(at + 42, true);
const name = dec.decode(bytes.subarray(at + 46, at + 46 + nameLen));
const localNameLen = view.getUint16(offset + 26, true);
const dataAt = offset + 30 + localNameLen;
out.push({ name, data: dec.decode(bytes.subarray(dataAt, dataAt + size)) });
at += 46 + nameLen;
}
return out;
}
describe("exportSelection — what a scope takes", () => {
it("one note and the files it points at, by every route the renderer honours", () => {
const sel = exportSelection("note", "Ideas.md", true);
assert.deepEqual(sel.notes, ["Ideas.md"]);
// The wikilink embed AND the standard-markdown image; never the unused one.
assert.deepEqual(sel.attachments, ["Media/cover.png", "Media/inline.png"]);
assert.deepEqual(exportSelection("note", "Ideas.md", false).attachments, []);
});
it("a folder is its subtree, every format", () => {
assert.deepEqual(exportSelection("folder", "Folder", true).notes, ["Folder/Deep note.md", "Folder/Sub/Leaf.md"]);
assert.deepEqual(exportSelection("folder", "", false).notes, exportSelection("vault", "", false).notes);
assert.ok(exportSelection("vault", "", false).notes.includes("Paper.tex"));
assert.ok(exportSelection("vault", "", false).notes.includes("ملاحظات/فكرة.md"));
});
it("a tag is every note carrying it, inline or in frontmatter", () => {
assert.deepEqual(exportSelection("tag", "reading", false).notes, ["Folder/Deep note.md", "Ideas.md"]);
assert.deepEqual(exportSelection("tag", "nothing", false).notes, []);
});
});
describe("parseExportQuery", () => {
it("names each refusal with a stable code", () => {
assert.throws(() => parseExportQuery({}), (e: unknown) => e instanceof VaultError && e.code === "exportBadScope");
assert.throws(() => parseExportQuery({ scope: "note" }), (e: unknown) => e instanceof VaultError && e.code === "exportBadTarget");
assert.throws(() => parseExportQuery({ scope: "tag", target: "#" }), (e: unknown) => e instanceof VaultError && e.code === "exportBadTarget");
assert.throws(() => parseExportQuery({ scope: "vault", links: "absolute" }), (e: unknown) => e instanceof VaultError && e.code === "exportBadLinks");
assert.throws(() => parseExportQuery({ scope: "folder", target: "../etc" }), (e: unknown) => e instanceof VaultError && e.status === 400);
});
it("normalizes what it accepts", () => {
assert.deepEqual(parseExportQuery({ scope: "tag", target: "#Reading" }), { scope: "tag", target: "reading", links: "wiki", attachments: true });
assert.deepEqual(parseExportQuery({ scope: "vault", attachments: "0", links: "relative" }), { scope: "vault", target: "", links: "relative", attachments: false });
assert.equal(parseExportQuery({ scope: "folder", target: "Folder/" }).target, "Folder");
});
});
describe("planExport and the stream", () => {
it("refuses an empty scope as a 404 the dialog can name", async () => {
await assert.rejects(planExport({ scope: "tag", target: "nothing", links: "wiki", attachments: true }), (e: unknown) => e instanceof VaultError && e.status === 404 && e.code === "exportEmpty");
});
it("streams the vault as on disk when links are kept", async () => {
const plan = await planExport({ scope: "vault", target: "", links: "wiki", attachments: true });
const summary = summarize(plan);
assert.equal(summary.notes, 5);
assert.equal(summary.attachments, 2, "unused.png rides no note");
assert.ok(summary.filename.startsWith("vault "));
const entries = entriesOf(await collect(exportStream(plan)));
assert.deepEqual(
entries.map((e) => e.name),
plan.entries.map((e) => e.name),
);
for (const entry of entries) {
assert.equal(entry.data, readFileSync(path.join(root, entry.name), "utf8"), entry.name);
}
});
it("rewrites the COPIES to relative links and leaves the vault alone", async () => {
const plan = await planExport({ scope: "folder", target: "Folder", links: "relative", attachments: true });
const entries = entriesOf(await collect(exportStream(plan)));
const deep = entries.find((e) => e.name === "Folder/Deep note.md");
assert.ok(deep);
// `Ideas.md` is outside the folder, so its link stays a wikilink — and
// the one in the fence stays whatever it was.
assert.equal(deep.data, files["Folder/Deep note.md"]);
const leaf = entries.find((e) => e.name === "Folder/Sub/Leaf.md");
assert.equal(leaf?.data, "Leaf. [[Ideas]]\n");
// Now the whole vault: everything resolves, and the picture is an embed.
const all = entriesOf(await collect(exportStream(await planExport({ scope: "vault", target: "", links: "relative", attachments: true }))));
const ideas = all.find((e) => e.name === "Ideas.md");
assert.equal(
ideas?.data,
note({ tags: "[reading]" }, "# Ideas\n\nSee [Deep note](Folder/Deep%20note.md) and  and [[Nowhere]].\n\n\n"),
);
const deepAll = all.find((e) => e.name === "Folder/Deep note.md");
assert.equal(deepAll?.data, note({}, "Back to [Ideas#Ideas](../Ideas.md#ideas). #reading\n\n```\n[[Ideas]] in a fence\n```\n"));
const arabic = all.find((e) => e.name === "ملاحظات/فكرة.md");
assert.equal(arabic?.data, "# فكرة\n\nانظر [Ideas](../Ideas.md)\n");
// The vault itself is untouched.
assert.equal(readFileSync(path.join(root, "Ideas.md"), "utf8"), files["Ideas.md"]);
});
it("names the download after the scope, Arabic kept, with an ASCII fallback beside it", async () => {
const plan = await planExport({ scope: "note", target: "ملاحظات/فكرة.md", links: "wiki", attachments: false });
assert.match(plan.filename, /^فكرة \d{4}-\d{2}-\d{2}\.zip$/);
const header = contentDisposition(plan.filename);
assert.match(header, /^attachment; filename="export \d{4}-\d{2}-\d{2}\.zip"; filename\*=UTF-8''%D9%81/);
assert.match(contentDisposition("Folder 2026-01-01.zip"), /filename="Folder 2026-01-01\.zip"/);
});
});