Files
obsidian-foundry-sync/tests/assets.test.ts
Kaysser Kayyali ce8040c41b feat: file uploads (portrait + body images) + live-relay batch scripts
Portrait upload was broken: it wrote to Data/worlds/<world>/uploads (per-world,
missing the Data/ segment) and did not URL-encode. Fixed to the verified
convention -- copy to the GLOBAL Data/uploads/<world>/ dir (unencoded on disk),
flag stored URL-encoded as uploads/<world>/<encodeURIComponent(file)>. Extracted
a reusable uploadImageAsset helper.

Body ![[image]] embeds were dropped by mdToHtml. Added push-layer pre-processing
(processBodyImages in push.ts): upload co-located files, rewrite the embed to
![](servedPath), drop non-local refs gracefully. mdToHtml renders ![alt](src)
-> <img>. parseBody now returns a preface (preamble content besides the tagline)
so a loose image between the tagline and the first ## section is no longer
dropped before conversion.

scripts/ helpers for the live-relay path (no offline CC export needed):
- seed-by-name: link refined notes to Foundry by name -> uuid, inject foundry block
- import-missing: create Foundry entries with no vault note as new pushable notes
- gen-cc-dir: generate a cc dir from a journal snapshot so the dashboard ui can run
- resync: push every body-changed note and baseline its foundry.contentHash so
  re-runs only catch new edits (idempotent, re-runnable)

Verified end-to-end against the dev Foundry world: portraits + body images
served by Foundry, bidirectional round-trip faithful, push is no-clobber
(diff keys only name + flags.campaign-codex).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 21:01:15 +00:00

141 lines
5.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { rm, mkdir, writeFile, access } from "node:fs/promises";
import { join } from "node:path";
import { uploadPortrait, findPortraitFile, portraitBasename } from "../src/foundry/assets.js";
const TMP = "/tmp/test-assets-out";
const WORLD = "mardonar";
async function mk(p: string): Promise<void> { await mkdir(p, { recursive: true }); }
beforeEach(async () => { await rm(TMP, { recursive: true, force: true }); await mk(TMP); });
afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
describe("portraitBasename", () => {
it("strips [[ ]] wrappers", () => {
expect(portraitBasename("[[Fenris_portrait.png]]")).toBe("Fenris_portrait.png");
expect(portraitBasename("Fenris_portrait.png")).toBe("Fenris_portrait.png");
expect(portraitBasename("")).toBe("");
});
});
describe("findPortraitFile", () => {
it("finds a co-located sibling by exact name", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
await writeFile(join(noteDir, "Fenris_portrait.png"), "png-bytes");
expect(await findPortraitFile(notePath, "[[Fenris_portrait.png]]")).toBe(join(noteDir, "Fenris_portrait.png"));
});
it("tries common extensions when the field has none", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
await writeFile(join(noteDir, "Fenris.webp"), "webp");
expect(await findPortraitFile(notePath, "Fenris")).toBe(join(noteDir, "Fenris.webp"));
});
it("returns null when no sibling matches", async () => {
const notePath = join(TMP, "Fenris.md");
await writeFile(notePath, "x");
expect(await findPortraitFile(notePath, "[[Missing.png]]")).toBeNull();
});
});
describe("uploadPortrait", () => {
it("keeps the existing image when the basename matches (no write)", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
const res = await uploadPortrait({
notePath, portraitField: "[[Fenris_portrait.png]]",
foundryDataDir: join(TMP, "data"), world: WORLD,
existingImage: "uploads/mardonar/Fenris_portrait.png",
});
expect(res.wrote).toBe(false);
expect(res.servedPath).toBe("uploads/mardonar/Fenris_portrait.png");
// Nothing written to the assets dir.
await expect(access(join(TMP, "data"))).rejects.toThrow();
});
it("uploads a new portrait into the global Data/uploads/<world>/ dir (URL-encoded served path)", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
await writeFile(join(noteDir, "NewPortrait.png"), "png-bytes");
const dataDir = join(TMP, "data");
const res = await uploadPortrait({
notePath, portraitField: "[[NewPortrait.png]]",
foundryDataDir: dataDir, world: WORLD,
existingImage: "uploads/mardonar/old.webp",
});
expect(res.wrote).toBe(true);
expect(res.servedPath).toBe("uploads/mardonar/NewPortrait.png");
// File lands in the GLOBAL uploads dir: <dataDir>/Data/uploads/<world>/<file> (unencoded on disk).
const disk = join(dataDir, "Data", "uploads", WORLD, "NewPortrait.png");
await access(disk); // throws if missing
});
it("URL-encodes spaces in the served path (disk file stays unencoded)", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
await writeFile(join(noteDir, "Kara Fairhaven_port.png"), "png-bytes");
const dataDir = join(TMP, "data");
const res = await uploadPortrait({
notePath, portraitField: "[[Kara Fairhaven_port.png]]",
foundryDataDir: dataDir, world: WORLD, existingImage: null,
});
expect(res.wrote).toBe(true);
expect(res.servedPath).toBe("uploads/mardonar/Kara%20Fairhaven_port.png");
// Disk file is unencoded (space, not %20).
await access(join(dataDir, "Data", "uploads", WORLD, "Kara Fairhaven_port.png"));
});
it("defaults to uploads/<world>/ when there is no existing image", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
await writeFile(join(noteDir, "Fenris_portrait.png"), "png-bytes");
const dataDir = join(TMP, "data");
const res = await uploadPortrait({
notePath, portraitField: "[[Fenris_portrait.png]]",
foundryDataDir: dataDir, world: WORLD, existingImage: null,
});
expect(res.wrote).toBe(true);
expect(res.servedPath).toBe(`uploads/${WORLD}/Fenris_portrait.png`);
});
it("returns the existing image when the portrait file is missing", async () => {
const noteDir = join(TMP, "notes");
await mk(noteDir);
const notePath = join(noteDir, "Fenris.md");
await writeFile(notePath, "x");
const res = await uploadPortrait({
notePath, portraitField: "[[Ghost.png]]",
foundryDataDir: join(TMP, "data"), world: WORLD,
existingImage: "uploads/mardonar/old.webp",
});
expect(res.wrote).toBe(false);
expect(res.servedPath).toBe("uploads/mardonar/old.webp");
expect(res.note).toContain("not found");
});
it("no portrait field -> keeps existing, no write", async () => {
const notePath = join(TMP, "Fenris.md");
await writeFile(notePath, "x");
const res = await uploadPortrait({
notePath, portraitField: null,
foundryDataDir: join(TMP, "data"), world: WORLD, existingImage: "uploads/mardonar/old.webp",
});
expect(res.wrote).toBe(false);
expect(res.servedPath).toBe("uploads/mardonar/old.webp");
});
});