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.
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// The config contract: defaults live ONLY in the schema, and the Encoded side is the
|
|
// authored file shape. A regression here would let a UI save bake defaults into the
|
|
// operator's config.json (the raw round-trip invariant the kit's ConfigService relies on).
|
|
import { describe, expect, test } from "bun:test";
|
|
import type { RawConfig } from "@playnite/contract";
|
|
import { PlayniteConfigSchema, resolveConfig } from "@playnite/contract";
|
|
import { Schema } from "effect";
|
|
|
|
const encode = Schema.encodeUnknownSync(PlayniteConfigSchema);
|
|
|
|
describe("resolveConfig", () => {
|
|
test("an empty file resolves to the documented defaults", () => {
|
|
expect(resolveConfig({})).toEqual({
|
|
sync: { pollMinutes: 5, watch: true, debounceMs: 1500 },
|
|
filter: {
|
|
installedOnly: true,
|
|
includeHidden: false,
|
|
sources: [],
|
|
excludeSources: [],
|
|
},
|
|
art: { mode: "host", maxBytes: 1_500_000, includeBackground: false },
|
|
gameOverrides: {},
|
|
});
|
|
});
|
|
|
|
test("a partial group keeps the untouched keys at their defaults", () => {
|
|
const config = resolveConfig({ art: { mode: "dataurl" } });
|
|
expect(config.art.mode).toBe("dataurl");
|
|
expect(config.art.maxBytes).toBe(1_500_000);
|
|
expect(config.sync.pollMinutes).toBe(5);
|
|
});
|
|
|
|
test("stale keys from an older config are tolerated and dropped", () => {
|
|
// 0.1.x wrote `ui` and `devEntry`; both are gone in 0.2.0.
|
|
const config = resolveConfig({
|
|
ui: { standalone: true, port: 47994 },
|
|
devEntry: true,
|
|
filter: { installedOnly: false },
|
|
});
|
|
expect(config.filter.installedOnly).toBe(false);
|
|
expect("ui" in config).toBe(false);
|
|
expect("devEntry" in config).toBe(false);
|
|
});
|
|
|
|
test("an invalid art mode is refused", () => {
|
|
expect(() => resolveConfig({ art: { mode: "inline" } })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("raw round-trip", () => {
|
|
test("encoding a resolved config omits every defaulted key", () => {
|
|
// This is what makes a UI save non-destructive: defaults never reach the file.
|
|
expect(encode(resolveConfig({}))).toEqual({});
|
|
});
|
|
|
|
test("encode is LOSSY for defaulted groups — which is why we never re-encode", () => {
|
|
// `encodingStrategy: "omit"` drops a defaulted key unconditionally, so a decode →
|
|
// encode round-trip would silently throw away authored values. The kit's
|
|
// ConfigService therefore persists the RAW payload verbatim and only *validates*
|
|
// by decoding; nothing in the plugin may write `encode(config)` back to disk.
|
|
const raw: RawConfig = {
|
|
playniteDir: "D:\\Playnite",
|
|
filter: { excludeSources: ["Xbox"] },
|
|
gameOverrides: {
|
|
"11111111-1111-1111-1111-111111111111": { exclude: true },
|
|
},
|
|
};
|
|
// Undefaulted keys survive…
|
|
expect(encode(resolveConfig(raw))).toEqual({ playniteDir: "D:\\Playnite" });
|
|
// …but the authored values themselves are only safe in the raw shape.
|
|
expect(resolveConfig(raw).filter.excludeSources).toEqual(["Xbox"]);
|
|
});
|
|
});
|