b81e03685a
The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.
contract/ Effect Schemas: the cross-process export contract (the C#
exporter's other half, with the schema-version guard as a decode
check), the config schema (one schema, Encoded = authored file
shape, defaults only via withDecodingDefaultKey), domain DTOs, the
HttpApi contract, API errors.
plugin/ src/domain = the pure core (locate/read, art, reconcile), ported
tests green before any Effect wiring; src/services = the kit's
ConfigService/CacheStore/SyncEngine/ProviderClient; definePluginKit
entry, kit CLI, auth-free dev API.
Preserved, because they are what makes playnite work at all: the ingest inbox
is still read FIRST (the de-privileged LocalService runner cannot reach the
interactive user's %APPDATA%), the host/dataurl/off art modes, and the
playnite:// launch hand-back. The C# exporter is untouched.
New: per-game include/exclude overrides, and an allow-listed /api/art proxy so
the SPA can render local Playnite covers — it serves only paths the currently
ingested export references.
Dropped: the standalone password UI server (console surface + CLI only, as in
rom-manager 0.3.0) and the devEntry flag.
Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import { afterAll, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { EXPORT_FILE, resolveConfig } from "@playnite/contract";
|
|
import { ExportError, locateExport, readExport } from "../src/domain/locate.js";
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
|
|
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
|
|
/** Lay out a fake Playnite data dir with an export under ExtensionsData/<guid>/. */
|
|
const layout = (name: string, doc: unknown): string => {
|
|
const dir = path.join(root, name);
|
|
const ext = path.join(dir, "ExtensionsData", "abc-guid");
|
|
fs.mkdirSync(ext, { recursive: true });
|
|
if (doc !== undefined) {
|
|
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
|
|
}
|
|
return dir;
|
|
};
|
|
|
|
const validDoc = {
|
|
schema: 1,
|
|
generatedAt: "2026-01-01T00:00:00Z",
|
|
playnite: { version: "10", mode: "Desktop" },
|
|
games: [],
|
|
};
|
|
|
|
describe("locateExport", () => {
|
|
test("finds the export under an overridden Playnite dir", () => {
|
|
const dir = layout("found", validDoc);
|
|
const loc = locateExport(resolveConfig({ playniteDir: dir }));
|
|
expect(loc.dir).toBe(dir);
|
|
expect(loc.file).toBe(
|
|
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
|
|
);
|
|
expect(loc.mtime).toBeGreaterThan(0);
|
|
expect(loc.viaIngest).toBe(false);
|
|
});
|
|
|
|
test("reports the dir but no file when the exporter hasn't written yet", () => {
|
|
const dir = path.join(root, "empty");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const loc = locateExport(resolveConfig({ playniteDir: dir }));
|
|
expect(loc.dir).toBe(dir);
|
|
expect(loc.file).toBeNull();
|
|
});
|
|
|
|
test("reads the ingest inbox first — the de-privileged runner's source", () => {
|
|
const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "pf-cfg-"));
|
|
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
|
|
process.env.PUNKTFUNK_CONFIG_DIR = cfg;
|
|
try {
|
|
const inbox = path.join(cfg, "ingest", "playnite");
|
|
fs.mkdirSync(inbox, { recursive: true });
|
|
fs.writeFileSync(path.join(inbox, EXPORT_FILE), JSON.stringify(validDoc));
|
|
// Even with a Playnite ExtensionsData dir present, the ingest drop wins.
|
|
const pd = layout("with-ingest", validDoc);
|
|
const loc = locateExport(resolveConfig({ playniteDir: pd }));
|
|
expect(loc.dir).toBe(inbox);
|
|
expect(loc.file).toBe(path.join(inbox, EXPORT_FILE));
|
|
expect(loc.viaIngest).toBe(true);
|
|
// The inbox always leads the probe list, so the UI can show it as the target.
|
|
expect(loc.candidates[0]).toBe(inbox);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
|
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
|
|
fs.rmSync(cfg, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("readExport", () => {
|
|
test("reads a valid export", () => {
|
|
const dir = layout("valid", validDoc);
|
|
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
|
expect(readExport(file).schema).toBe(1);
|
|
});
|
|
|
|
test("fills defaults for fields an older exporter omits", () => {
|
|
const dir = layout("sparse", { schema: 1, games: [{ id: "abc" }] });
|
|
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
|
const exp = readExport(file);
|
|
expect(exp.generatedAt).toBe("");
|
|
expect(exp.playnite).toEqual({ version: null, mode: null });
|
|
expect(exp.games[0]).toEqual({
|
|
id: "abc",
|
|
name: "",
|
|
installed: false,
|
|
hidden: false,
|
|
source: null,
|
|
platforms: [],
|
|
cover: null,
|
|
background: null,
|
|
icon: null,
|
|
});
|
|
});
|
|
|
|
test("rejects a future schema", () => {
|
|
const dir = layout("future", { ...validDoc, schema: 99 });
|
|
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
|
expect(() => readExport(file)).toThrow(ExportError);
|
|
});
|
|
|
|
test("rejects non-JSON and foreign shapes", () => {
|
|
const bad = path.join(root, "bad.json");
|
|
fs.writeFileSync(bad, "{not json");
|
|
expect(() => readExport(bad)).toThrow(ExportError);
|
|
|
|
const foreign = path.join(root, "foreign.json");
|
|
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
|
|
expect(() => readExport(foreign)).toThrow(ExportError);
|
|
});
|
|
|
|
test("rejects a missing file", () => {
|
|
expect(() => readExport(path.join(root, "nope.json"))).toThrow(ExportError);
|
|
});
|
|
});
|