refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces
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.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExportedGame, LibraryExport } from "@playnite/contract";
|
||||
import { resolveConfig } from "@playnite/contract";
|
||||
import {
|
||||
computeEntries,
|
||||
isGuid,
|
||||
launchCommand,
|
||||
} from "../src/domain/reconcile.js";
|
||||
|
||||
const G1 = "11111111-1111-1111-1111-111111111111";
|
||||
const G2 = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
const game = (over: Partial<ExportedGame>): ExportedGame => ({
|
||||
id: G1,
|
||||
name: "Game",
|
||||
installed: true,
|
||||
hidden: false,
|
||||
source: "Steam",
|
||||
platforms: ["PC (Windows)"],
|
||||
cover: null,
|
||||
background: null,
|
||||
icon: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const exp = (games: ExportedGame[]): LibraryExport => ({
|
||||
schema: 1,
|
||||
generatedAt: "2026-01-01T00:00:00Z",
|
||||
playnite: { version: "10", mode: "Desktop" },
|
||||
games,
|
||||
});
|
||||
|
||||
const noArt = () => undefined;
|
||||
|
||||
describe("isGuid", () => {
|
||||
test("accepts a real Playnite guid", () => {
|
||||
expect(isGuid(G1)).toBe(true);
|
||||
});
|
||||
test("rejects junk", () => {
|
||||
expect(isGuid("not-a-guid")).toBe(false);
|
||||
expect(isGuid('570" & calc')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchCommand", () => {
|
||||
test("Windows uses start + playnite:// uri", () => {
|
||||
expect(launchCommand(G1, "win32")).toEqual({
|
||||
kind: "command",
|
||||
value: `start "" "playnite://playnite/start/${G1}"`,
|
||||
});
|
||||
});
|
||||
test("non-Windows falls back to xdg-open", () => {
|
||||
expect(launchCommand(G1, "linux")?.value).toContain("xdg-open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEntries filtering", () => {
|
||||
test("installedOnly drops uninstalled games", () => {
|
||||
const { entries, report } = computeEntries({
|
||||
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(entries.map((e) => e.external_id)).toEqual([G1]);
|
||||
expect(report.skipped[0]?.reason).toBe("not installed");
|
||||
});
|
||||
|
||||
test("includeHidden lets hidden games through", () => {
|
||||
const hidden = exp([game({ id: G1, hidden: true })]);
|
||||
expect(
|
||||
computeEntries({
|
||||
exp: hidden,
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
}).entries.length,
|
||||
).toBe(0);
|
||||
expect(
|
||||
computeEntries({
|
||||
exp: hidden,
|
||||
config: resolveConfig({ filter: { includeHidden: true } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
}).entries.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("source include/exclude is case-insensitive", () => {
|
||||
const games = exp([
|
||||
game({ id: G1, source: "Steam" }),
|
||||
game({ id: G2, source: "GOG" }),
|
||||
]);
|
||||
const only = computeEntries({
|
||||
exp: games,
|
||||
config: resolveConfig({ filter: { sources: ["steam"] } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(only.entries.map((e) => e.external_id)).toEqual([G1]);
|
||||
|
||||
const without = computeEntries({
|
||||
exp: games,
|
||||
config: resolveConfig({ filter: { excludeSources: ["GOG"] } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(without.entries.map((e) => e.external_id)).toEqual([G1]);
|
||||
});
|
||||
|
||||
test("invalid ids and empty titles are skipped", () => {
|
||||
const { entries, report } = computeEntries({
|
||||
exp: exp([game({ id: "bogus" }), game({ id: G2, name: " " })]),
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(entries.length).toBe(0);
|
||||
expect(report.skipped.map((s) => s.reason).sort()).toEqual([
|
||||
"invalid game id",
|
||||
"no title",
|
||||
]);
|
||||
});
|
||||
|
||||
test("entries are title-sorted and per-source counted", () => {
|
||||
const { entries, report } = computeEntries({
|
||||
exp: exp([
|
||||
game({ id: G1, name: "Zelda", source: "Nintendo" }),
|
||||
game({ id: G2, name: "Antichamber", source: "Steam" }),
|
||||
]),
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(entries.map((e) => e.title)).toEqual(["Antichamber", "Zelda"]);
|
||||
expect(report.perSource).toEqual({ Nintendo: 1, Steam: 1 });
|
||||
});
|
||||
|
||||
test("artFor result is attached", () => {
|
||||
const { entries } = computeEntries({
|
||||
exp: exp([game({ id: G1 })]),
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: () => ({ portrait: "data:image/png;base64,AAAA" }),
|
||||
});
|
||||
expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
test("the report carries the export's stamp", () => {
|
||||
const { report } = computeEntries({
|
||||
exp: exp([game({ id: G1 })]),
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(report.generatedAt).toBe("2026-01-01T00:00:00Z");
|
||||
expect(report.schemaVersion).toBe(1);
|
||||
expect(report.considered).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-game overrides", () => {
|
||||
test("an excluded game lands in `excluded`, not `skipped`", () => {
|
||||
const { entries, report } = computeEntries({
|
||||
exp: exp([
|
||||
game({ id: G1, name: "Kept" }),
|
||||
game({ id: G2, name: "Dropped" }),
|
||||
]),
|
||||
config: resolveConfig({ gameOverrides: { [G2]: { exclude: true } } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(entries.map((e) => e.title)).toEqual(["Kept"]);
|
||||
expect(report.excluded).toEqual([{ id: G2, title: "Dropped" }]);
|
||||
expect(report.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
test("`exclude: false` is a no-op (the UI omits the key instead)", () => {
|
||||
const { entries } = computeEntries({
|
||||
exp: exp([game({ id: G1 })]),
|
||||
config: resolveConfig({ gameOverrides: { [G1]: { exclude: false } } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(entries.length).toBe(1);
|
||||
});
|
||||
|
||||
test("a game already filtered out is not double-reported as excluded", () => {
|
||||
const { report } = computeEntries({
|
||||
exp: exp([game({ id: G1, installed: false })]),
|
||||
config: resolveConfig({ gameOverrides: { [G1]: { exclude: true } } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(report.excluded).toEqual([]);
|
||||
expect(report.skipped[0]?.reason).toBe("not installed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dataurl guard rail", () => {
|
||||
test("warns once the inlined-cover payload would be too large", () => {
|
||||
const many = Array.from({ length: 3001 }, (_, i) =>
|
||||
game({
|
||||
id: `${i.toString(16).padStart(8, "0")}-1111-1111-1111-111111111111`,
|
||||
name: `Game ${i}`,
|
||||
}),
|
||||
);
|
||||
const { report } = computeEntries({
|
||||
exp: exp(many),
|
||||
config: resolveConfig({ art: { mode: "dataurl" } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(report.warnings[0]).toContain('art.mode "dataurl"');
|
||||
});
|
||||
|
||||
test("host mode never warns", () => {
|
||||
const { report } = computeEntries({
|
||||
exp: exp([game({ id: G1 })]),
|
||||
config: resolveConfig({ art: { mode: "host" } }),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
expect(report.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user