refactor: workspace restructure — contract package + pure domain ported, tests green

- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
  domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
  imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
  hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
  gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:54:38 +02:00
parent e78df91925
commit d6cf5f3d36
30 changed files with 1938 additions and 181 deletions
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import {
type DetectEnv,
detectEmulator,
emulatorById,
} from "../src/domain/emulators.js";
import type { Os } from "../src/domain/quote.js";
const env = (o: {
os?: Os;
which?: Record<string, string>;
files?: string[];
flatpaks?: string[];
dirs?: Record<string, string[]>;
}): DetectEnv => ({
os: o.os ?? "linux",
which: (c) => o.which?.[c] ?? null,
fileExists: (p) => (o.files ?? []).includes(p),
flatpakInstalled: (id) => (o.flatpaks ?? []).includes(id),
listDir: (p) => o.dirs?.[p] ?? [],
expandPath: (p) =>
p
.replace(/^~/, "/home/u")
.replace(/%APPDATA%/, "C:\\Users\\u\\AppData\\Roaming")
.replace(/%LOCALAPPDATA%/, "C:\\Users\\u\\AppData\\Local"),
});
const retroarch = emulatorById("retroarch")!;
const ppsspp = emulatorById("ppsspp")!;
const dolphin = emulatorById("dolphin")!;
describe("detectEmulator", () => {
test("RetroArch on PATH, cores scanned + quoted exe token", () => {
const d = detectEmulator(
retroarch,
env({
which: { retroarch: "/usr/bin/retroarch" },
dirs: {
"/home/u/.config/retroarch/cores": [
"snes9x_libretro.so",
"mgba_libretro.so",
"notes.txt",
],
},
}),
);
expect(d?.via).toBe("path");
expect(d?.exeToken).toBe("'/usr/bin/retroarch'");
expect(d?.coresDir).toBe("/home/u/.config/retroarch/cores");
expect(d?.cores).toEqual(["mgba", "snes9x"]);
});
test("RetroArch via Flatpak when not on PATH", () => {
const d = detectEmulator(
retroarch,
env({
flatpaks: ["org.libretro.RetroArch"],
dirs: {
"/home/u/.var/app/org.libretro.RetroArch/config/retroarch/cores": [
"snes9x_libretro.so",
],
},
}),
);
expect(d?.via).toBe("flatpak");
expect(d?.exeToken).toBe("flatpak run org.libretro.RetroArch");
expect(d?.appId).toBe("org.libretro.RetroArch");
expect(d?.cores).toEqual(["snes9x"]);
});
test("Windows path with spaces is double-quoted", () => {
const d = detectEmulator(
ppsspp,
env({
os: "windows",
files: ["C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe"],
}),
);
expect(d?.via).toBe("file");
expect(d?.exeToken).toBe(
'"C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe"',
);
});
test("returns null when not installed", () => {
expect(detectEmulator(dolphin, env({}))).toBeNull();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import {
buildUrl,
candidateNames,
LibretroProvider,
sanitizeName,
} from "../src/art/libretro.js";
import { parseTitle } from "../src/domain/titles.js";
import { platformById } from "../src/domain/platforms.js";
const snes = platformById("snes")!;
const nswitch = platformById("switch")!;
describe("libretro name handling", () => {
test("sanitizeName replaces the forbidden characters with underscore", () => {
expect(sanitizeName("Ratchet & Clank")).toBe("Ratchet _ Clank");
expect(sanitizeName("Where in the World?")).toBe("Where in the World_");
});
test("candidateNames walks exact → region → base", () => {
const names = candidateNames(parseTitle("Chrono Trigger (USA) (Rev 1)"));
expect(names[0]).toBe("Chrono Trigger (USA) (Rev 1)"); // exact
expect(names).toContain("Chrono Trigger (USA)"); // region-kept
expect(names).toContain("Chrono Trigger (Europe)"); // region-relaxed
expect(names.at(-1)).toBe("Chrono Trigger"); // bare
});
});
describe("LibretroProvider", () => {
test("returns the first probe hit as a portrait", async () => {
const hit = buildUrl(
snes.libretroSystem!,
sanitizeName("Chrono Trigger (USA)"),
);
const provider = new LibretroProvider((url) =>
Promise.resolve(url === hit),
);
const art = await provider.resolve(
snes,
parseTitle("Chrono Trigger (USA)"),
);
expect(art).toEqual({ portrait: hit });
});
test("returns null when nothing matches", async () => {
const provider = new LibretroProvider(() => Promise.resolve(false));
expect(
await provider.resolve(snes, parseTitle("Nonexistent Game")),
).toBeNull();
});
test("returns null for a platform with no libretro system (Switch)", async () => {
const provider = new LibretroProvider(() => Promise.resolve(true));
expect(await provider.resolve(nswitch, parseTitle("Zelda"))).toBeNull();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, test } from "bun:test";
import { quotePosix, quoteWindows, renderCommand } from "../src/domain/quote.js";
describe("quotePosix", () => {
test("wraps a plain path in single quotes", () => {
const r = quotePosix("/roms/snes/Chrono Trigger (USA).sfc");
expect(r).toEqual({
ok: true,
value: "'/roms/snes/Chrono Trigger (USA).sfc'",
});
});
test("escapes embedded single quotes", () => {
const r = quotePosix("it's a rom.sfc");
expect(r.ok && r.value).toBe("'it'\\''s a rom.sfc'");
});
test("neutralises shell metacharacters (no expansion possible)", () => {
for (const evil of [
"$(rm -rf ~).sfc",
"`id`.sfc",
"; rm x.sfc",
"a && b.sfc",
"$HOME.sfc",
]) {
const r = quotePosix(evil);
expect(r.ok).toBe(true);
// Everything stays inside a single-quoted literal.
if (r.ok)
expect(r.value.startsWith("'") && r.value.endsWith("'")).toBe(true);
}
});
test("rejects a NUL byte", () => {
expect(quotePosix("a\0b").ok).toBe(false);
});
});
describe("quoteWindows", () => {
test("double-quotes a normal path", () => {
const r = quoteWindows("C:\\Games\\Chrono Trigger (USA).sfc");
expect(r).toEqual({
ok: true,
value: '"C:\\Games\\Chrono Trigger (USA).sfc"',
});
});
test("refuses each cmd.exe metacharacter", () => {
for (const bad of [
'a"b',
"a%b",
"a!b",
"a^b",
"a&b",
"a|b",
"a<b",
"a>b",
]) {
expect(quoteWindows(bad).ok).toBe(false);
}
});
test("refuses control characters", () => {
expect(quoteWindows("a\nb").ok).toBe(false);
expect(quoteWindows("a\tb").ok).toBe(false);
});
test("doubles a trailing backslash run so the closing quote is not escaped", () => {
const r = quoteWindows("C:\\dir\\");
expect(r.ok && r.value).toBe('"C:\\dir\\\\"');
});
});
describe("renderCommand", () => {
test("POSIX: quotes rom + core, inserts exe verbatim", () => {
const r = renderCommand(
"{exe} -f -L {core} {rom}",
{
exe: "flatpak run org.libretro.RetroArch",
rom: "/roms/Chrono Trigger (USA).sfc",
core: "/cores/snes9x_libretro.so",
},
"linux",
);
expect(r).toEqual({
ok: true,
command:
"flatpak run org.libretro.RetroArch -f -L '/cores/snes9x_libretro.so' '/roms/Chrono Trigger (USA).sfc'",
});
});
test("Windows: a hostile ROM name fails the whole render", () => {
const r = renderCommand(
"{exe} {rom}",
{ exe: '"C:\\ra.exe"', rom: "C:\\roms\\a&b.sfc" },
"windows",
);
expect(r.ok).toBe(false);
});
test("appends trusted extra args verbatim", () => {
const r = renderCommand(
"{exe} {rom}",
{ exe: "mgba", rom: "/x.gba" },
"linux",
"--scale 4",
);
expect(r.ok && r.command).toBe("mgba '/x.gba' --scale 4");
});
test("errors on an unfilled placeholder", () => {
const r = renderCommand(
"{exe} -L {core} {rom}",
{ exe: "ra", rom: "/x.sfc" },
"linux",
);
expect(r.ok).toBe(false);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test";
import { resolveConfig } from "@rom-manager/contract";
import type { DetectedEmulator } from "../src/domain/emulators.js";
import { computeDesired } from "../src/domain/reconcile.js";
import type { RomCandidate } from "../src/domain/scanner.js";
import type { ArtVerdict } from "../src/art/provider.js";
const RA: DetectedEmulator = {
id: "retroarch",
name: "RetroArch",
template: "{exe} -f -L {core} {rom}",
supportsArchives: true,
exeToken: "'/usr/bin/retroarch'",
via: "path",
coresDir: "/cores",
cores: ["snes9x"],
};
const cand = (rel: string, over: Partial<RomCandidate> = {}): RomCandidate => ({
platform: "snes",
rootDir: "/roms/snes",
absPath: `/roms/snes/${rel}`,
relPath: rel,
ext: ".sfc",
stem: rel.replace(/\.[^.]+$/, ""),
isArchive: false,
...over,
});
const base = { roots: [{ dir: "/roms/snes", platform: "snes" }] };
const run = (
raw: Record<string, unknown>,
scan: RomCandidate[],
art: Record<string, ArtVerdict> = {},
detected = [RA],
) =>
computeDesired({
config: resolveConfig({ ...base, ...raw }),
scan,
detected,
art,
os: "linux",
});
describe("computeDesired", () => {
test("produces a quoted launch command with the resolved core path", () => {
const { entries } = run({}, [cand("Chrono Trigger (USA).sfc")]);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
external_id: "snes/Chrono Trigger (USA).sfc",
title: "Chrono Trigger",
launch: {
kind: "command",
value:
"'/usr/bin/retroarch' -f -L '/cores/snes9x_libretro.so' '/roms/snes/Chrono Trigger (USA).sfc'",
},
});
});
test("per-game exclude drops the entry", () => {
const { entries } = run(
{ gameOverrides: { "snes/A.sfc": { exclude: true } } },
[cand("A.sfc"), cand("B.sfc")],
);
expect(entries.map((e) => e.external_id)).toEqual(["snes/B.sfc"]);
});
test("an undetected emulator is skipped with a reason", () => {
const { entries, report } = run({}, [cand("A.sfc")], {}, []);
expect(entries).toHaveLength(0);
expect(report.skipped[0]?.reason).toContain("not detected");
});
test("region dedupe keeps the preferred release", () => {
const { entries, report } = run({ sync: { dedupeRegions: ["snes"] } }, [
cand("Sonic (USA).sfc"),
cand("Sonic (Europe).sfc"),
]);
expect(entries.map((e) => e.external_id)).toEqual(["snes/Sonic (USA).sfc"]);
expect(report.skipped.some((s) => s.reason.includes("deduped"))).toBe(true);
});
test("attaches cached artwork, keyed by external_id", () => {
const art = {
"snes/A.sfc": {
art: { portrait: "http://x/p.png", hero: "http://x/h.png" },
provider: "steamgriddb:ab",
matcher: 1,
},
};
const { entries } = run({}, [cand("A.sfc")], art);
expect(entries[0]?.art).toEqual({
portrait: "http://x/p.png",
hero: "http://x/h.png",
});
});
test("per-game art override wins on portrait but keeps other cached art", () => {
const art = {
"snes/A.sfc": {
art: { portrait: "http://x/p.png", hero: "http://x/h.png" },
provider: "p",
matcher: 1,
},
};
const { entries } = run(
{ gameOverrides: { "snes/A.sfc": { art: "http://o/p.png" } } },
[cand("A.sfc")],
art,
);
expect(entries[0]?.art).toEqual({
portrait: "http://o/p.png",
hero: "http://x/h.png",
});
});
test("archive gating skips a zip when the emulator can't read archives", () => {
const noArc = { ...RA, supportsArchives: false };
const { entries, report } = run(
{},
[cand("A.zip", { ext: ".zip", isArchive: true })],
{},
[noArc],
);
expect(entries).toHaveLength(0);
expect(report.skipped[0]?.reason).toContain("archive");
});
test("scale guard truncates deterministically and reports it", () => {
const { entries, report } = run({ sync: { maxEntries: 1 } }, [
cand("B.sfc"),
cand("A.sfc"),
]);
expect(entries.map((e) => e.external_id)).toEqual(["snes/A.sfc"]); // sorted, first kept
expect(report.truncated).toBe(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, test } from "bun:test";
import {
type DirEnt,
makeExcluder,
type ScanFs,
scanRoot,
} from "../src/domain/scanner.js";
import { platformById } from "../src/domain/platforms.js";
/** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */
const synthFs = (files: Record<string, string>): ScanFs => {
const norm = (p: string) => p.replace(/\/+$/, "");
return {
readDir(dir): DirEnt[] {
const base = norm(dir);
const children = new Map<string, boolean>(); // name → isDir
for (const f of Object.keys(files)) {
if (!f.startsWith(`${base}/`)) continue;
const rest = f.slice(base.length + 1);
const slash = rest.indexOf("/");
if (slash === -1) children.set(rest, false);
else children.set(rest.slice(0, slash), true);
}
return [...children].map(([name, isDir]) => ({
name,
isDir,
isFile: !isDir,
}));
},
readText(file) {
const c = files[norm(file)];
if (c === undefined) throw new Error(`no such file: ${file}`);
return c;
},
};
};
const snes = platformById("snes")!;
const ps1 = platformById("ps1")!;
const rels = (
root: string,
files: Record<string, string>,
platform = snes,
excludes?: string[],
) =>
scanRoot(synthFs(files), root, platform, excludes)
.map((c) => c.relPath)
.sort();
describe("scanRoot — cartridge platform", () => {
test("matches platform extensions, recurses, ignores others", () => {
const got = rels("/roms/snes", {
"/roms/snes/Chrono Trigger (USA).sfc": "",
"/roms/snes/Super Metroid.smc": "",
"/roms/snes/readme.txt": "",
"/roms/snes/sub/Zelda.sfc": "",
});
expect(got).toEqual([
"Chrono Trigger (USA).sfc",
"Super Metroid.smc",
"sub/Zelda.sfc",
]);
});
test("accepts archives regardless of extension list", () => {
const got = rels("/roms/snes", {
"/roms/snes/Game.zip": "",
"/roms/snes/x.foo": "",
});
expect(got).toEqual(["Game.zip"]);
});
test("excludes apply (glob + directory)", () => {
const got = rels(
"/roms/snes",
{
"/roms/snes/A.sfc": "",
"/roms/snes/A.sav": "",
"/roms/snes/bios/x.sfc": "",
},
snes,
["*.sav", "bios/**"],
);
expect(got).toEqual(["A.sfc"]);
});
});
describe("scanRoot — disc folding", () => {
test("m3u playlist folds its cues and their bins into one entry", () => {
const got = rels(
"/roms/ps1",
{
"/roms/ps1/FF7.m3u": "FF7 (Disc 1).cue\nFF7 (Disc 2).cue\n",
"/roms/ps1/FF7 (Disc 1).cue":
'FILE "FF7 (Disc 1).bin" BINARY\n TRACK 01 MODE2/2352',
"/roms/ps1/FF7 (Disc 1).bin": "",
"/roms/ps1/FF7 (Disc 2).cue": 'FILE "FF7 (Disc 2).bin" BINARY',
"/roms/ps1/FF7 (Disc 2).bin": "",
},
ps1,
);
expect(got).toEqual(["FF7.m3u"]);
});
test("a standalone cue hides its bin", () => {
const got = rels(
"/roms/ps1",
{
"/roms/ps1/Tekken.cue": 'FILE "Tekken.bin" BINARY',
"/roms/ps1/Tekken.bin": "",
},
ps1,
);
expect(got).toEqual(["Tekken.cue"]);
});
test("chd stands alone", () => {
const got = rels("/roms/ps1", { "/roms/ps1/Crash.chd": "" }, ps1);
expect(got).toEqual(["Crash.chd"]);
});
});
describe("makeExcluder", () => {
test("bare pattern matches at any depth; negation re-includes", () => {
const ex = makeExcluder(["*.sav", "!keep/**"]);
expect(ex("a/b/x.sav")).toBe(true);
expect(ex("keep/x.sav")).toBe(false);
expect(ex("a/b/x.sfc")).toBe(false);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { SteamGridDbProvider } from "../src/art/steamgriddb.js";
import { parseTitle } from "../src/domain/titles.js";
import { platformById } from "../src/domain/platforms.js";
const snes = platformById("snes")!;
/** A fake SteamGridDB backend keyed by URL path fragments. */
const fakeFetch = (opts: {
status?: number;
games?: unknown[];
grids?: unknown[];
heroes?: unknown[];
logos?: unknown[];
}): typeof fetch =>
((input: string | URL | Request) => {
const url = typeof input === "string" ? input : input.toString();
const path = new URL(url).pathname;
if (opts.status && opts.status !== 200) {
return Promise.resolve(new Response("nope", { status: opts.status }));
}
let data: unknown[] = [];
if (path.includes("/search/autocomplete/")) data = opts.games ?? [];
else if (path.includes("/grids/game/")) data = opts.grids ?? [];
else if (path.includes("/heroes/game/")) data = opts.heroes ?? [];
else if (path.includes("/logos/game/")) data = opts.logos ?? [];
return Promise.resolve(Response.json({ success: true, data }));
}) as typeof fetch;
describe("SteamGridDbProvider", () => {
test("classifies grids into portrait + header and pulls hero + logo", async () => {
const provider = new SteamGridDbProvider(
"key",
fakeFetch({
games: [{ id: 1, name: "Chrono Trigger" }],
grids: [
{ url: "http://p600.png", width: 600, height: 900 },
{ url: "http://h460.png", width: 460, height: 215 },
],
heroes: [{ url: "http://hero.png", width: 1920, height: 620 }],
logos: [{ url: "http://logo.png", width: 400, height: 200 }],
}),
);
const art = await provider.resolve(
snes,
parseTitle("Chrono Trigger (USA)"),
);
expect(art).toEqual({
portrait: "http://p600.png",
header: "http://h460.png",
hero: "http://hero.png",
logo: "http://logo.png",
});
});
test("no game match → null", async () => {
const provider = new SteamGridDbProvider("key", fakeFetch({ games: [] }));
expect(await provider.resolve(snes, parseTitle("Nope"))).toBeNull();
});
test("a bad key (401) disables the provider and returns null", async () => {
const provider = new SteamGridDbProvider("bad", fakeFetch({ status: 401 }));
expect(await provider.resolve(snes, parseTitle("Anything"))).toBeNull();
});
test("the cache id is scoped to the key (so a key change re-resolves)", () => {
const a = new SteamGridDbProvider("k1").id;
const b = new SteamGridDbProvider("k2").id;
expect(a).not.toBe(b);
expect(a.startsWith("steamgriddb:")).toBe(true);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import {
dedupeKey,
parseTitle,
pickBestRelease,
} from "../src/domain/titles.js";
describe("parseTitle", () => {
test("strips No-Intro tags and keeps region + revision metadata", () => {
const p = parseTitle("Chrono Trigger (USA) (Rev 1) [!]");
expect(p.displayTitle).toBe("Chrono Trigger");
expect(p.noIntroName).toBe("Chrono Trigger (USA) (Rev 1) [!]");
expect(p.region).toBe("USA");
expect(p.revision).toBe("Rev 1");
});
test("normalises a trailing article", () => {
expect(parseTitle("Legend of Zelda, The (USA)").displayTitle).toBe(
"The Legend of Zelda",
);
});
test("recognises multi-region tags", () => {
const p = parseTitle("Sonic (USA, Europe)");
expect(p.regions).toEqual(["USA", "Europe"]);
expect(p.region).toBe("USA");
});
test("converts underscores and collapses whitespace", () => {
expect(parseTitle("Super_Mario__World").displayTitle).toBe(
"Super Mario World",
);
});
test("a tagless name is its own title", () => {
const p = parseTitle("Tetris");
expect(p.displayTitle).toBe("Tetris");
expect(p.region).toBeUndefined();
});
});
describe("region dedupe", () => {
test("groups by base title", () => {
expect(dedupeKey(parseTitle("Sonic (USA)"))).toBe(
dedupeKey(parseTitle("Sonic (Europe)")),
);
});
test("pickBestRelease honors region priority, then shortest name", () => {
const items = [
{ parsed: parseTitle("Sonic (Europe)") },
{ parsed: parseTitle("Sonic (USA)") },
{ parsed: parseTitle("Sonic (Japan)") },
];
const best = pickBestRelease(items, ["USA", "World", "Europe", "Japan"]);
expect(best.parsed.region).toBe("USA");
});
test("falls back to shortest name when no region matches priority", () => {
const items = [
{ parsed: parseTitle("Game (Korea) (Beta)") },
{ parsed: parseTitle("Game (Korea)") },
];
const best = pickBestRelease(items, ["USA"]);
expect(best.parsed.noIntroName).toBe("Game (Korea)");
});
});