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//. */ 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); }); });