feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.
Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.
The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.
What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.
punktfunk-plugin-steam parity --snapshot before.json # host on its built-in
punktfunk-plugin-steam parity --compare before.json # offline; exits non-zero
--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.
Three judgement calls in the diff, each pinned by a test:
* art is compared by PRESENCE, not value. The representation legitimately
changes on extraction (a host-relative proxy path or inlined data: URL
becomes a file:// path or a CDN URL), so comparing values would fail every
run for no reason. Losing an art kind fails; gaining one does not.
* launcher entries (role: "launcher") are reported separately instead of as
unexpected extras — the built-in scanner had no concept of them, so they can
never be in a baseline. An ORDINARY title the scanner never had still fails,
which is what catches a bad tool filter.
* absent and empty are the same thing in metadata: the host omits empty lists
and nulls, so a plugin sending genres: [] has not changed anything.
plugin-kit: tsc clean, 56 tests pass (10 new).
This commit is contained in:
@@ -53,6 +53,41 @@ export default definePluginKit({
|
||||
| `loggingLayer` | runner-journal line format |
|
||||
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
|
||||
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
|
||||
| `@punktfunk/plugin-kit/library` | everything a **game-library scanner** plugin needs — see below |
|
||||
|
||||
## Library-scanner plugins (`@punktfunk/plugin-kit/library`)
|
||||
|
||||
The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in **their own
|
||||
repo**, like every other punktfunk plugin. Nothing is lost by that split because everything they
|
||||
share is published here rather than sitting adjacent to them:
|
||||
|
||||
| Export | What it saves you writing |
|
||||
| --- | --- |
|
||||
| `defineLibraryPlugin` | the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, `__config`, `category: "library"` registration, and the `detect` / `scan` / `parity` / `uninstall` CLI verbs |
|
||||
| `parsers/*` | text VDF + `.acf`, binary `shortcuts.vdf` (with the CRC-32 appid and the 64-bit `rungameid` composition), read-only SQLite, `reg.exe`, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch |
|
||||
| `diffParity` + the `parity` verb | the acceptance gate below |
|
||||
|
||||
A first-party scanner is therefore **its parsers and a `scan` function** — a few hundred lines.
|
||||
|
||||
### The parity gate
|
||||
|
||||
Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces.
|
||||
A plugin that parses perfectly and emits `steam:440.0` instead of `steam:440` breaks every Moonlight
|
||||
pin on the host, and no parser test notices. So, on a box with that launcher installed:
|
||||
|
||||
```sh
|
||||
# 1. while the host is still using its BUILT-IN scanner:
|
||||
punktfunk-plugin-steam parity --snapshot before.json
|
||||
# 2. offline — runs this plugin's own scan and diffs:
|
||||
punktfunk-plugin-steam parity --compare before.json
|
||||
```
|
||||
|
||||
`--compare` exits non-zero on any difference, so it works as a release gate. It compares ids,
|
||||
titles, launch recipes, roles and metadata exactly; **art by presence, not value** (the
|
||||
representation legitimately changes — a host-relative proxy path or inlined `data:` URL becomes a
|
||||
`file://` path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin
|
||||
adds are reported separately rather than failing the run; an ordinary title the scanner never had
|
||||
still fails.
|
||||
|
||||
## Telling the host how to recognize a running title (`detect`)
|
||||
|
||||
|
||||
@@ -7,15 +7,23 @@
|
||||
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
|
||||
// standard CLI verbs.
|
||||
import type { PluginDef } from "@punktfunk/host";
|
||||
import * as fs from "node:fs";
|
||||
import { Duration, Effect, Layer, Schema, Stream } from "effect";
|
||||
import { type CliCommand, runPluginCli } from "../cli.js";
|
||||
import { type ConfigService, makeConfigService } from "../config.js";
|
||||
import { type HostClient, PluginInfo } from "../host-client.js";
|
||||
import { HostClient, PluginInfo } from "../host-client.js";
|
||||
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
|
||||
import { definePluginKit, type PluginKitDef } from "../runtime.js";
|
||||
import { makeSyncEngine } from "../sync-engine.js";
|
||||
import { serveUi } from "../ui-server.js";
|
||||
import type { ProviderEntry } from "../wire.js";
|
||||
import {
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
} from "./parity.js";
|
||||
|
||||
/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */
|
||||
export interface ScanReport {
|
||||
@@ -70,6 +78,15 @@ export interface LibraryPluginDef<S extends Schema.Top> {
|
||||
readonly commands?: Record<string, CliCommand<never>>;
|
||||
}
|
||||
|
||||
/** `--flag value` from an argv slice, or undefined. */
|
||||
const flagValue = (
|
||||
argv: ReadonlyArray<string>,
|
||||
flag: string,
|
||||
): string | undefined => {
|
||||
const i = argv.indexOf(flag);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
||||
};
|
||||
|
||||
/** The pieces a library plugin package wires into its entry points. */
|
||||
export interface LibraryPlugin {
|
||||
/** The runner-discovered default export (`export default plugin.def`). */
|
||||
@@ -235,6 +252,60 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
|
||||
}
|
||||
}),
|
||||
},
|
||||
parity: {
|
||||
summary:
|
||||
"prove this plugin reproduces the built-in scanner (--snapshot <f> | --compare <f>)",
|
||||
// `--compare` is offline (it runs THIS plugin's scan); `--snapshot` needs the host. The
|
||||
// dispatcher decides per invocation below, so the verb is registered as online and the
|
||||
// snapshot path is the one that actually uses the client.
|
||||
run: (argv) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = flagValue(argv, "--snapshot");
|
||||
const compare = flagValue(argv, "--compare");
|
||||
if (!snapshot && !compare) {
|
||||
console.error(
|
||||
"usage: parity --snapshot <file> (capture the host's CURRENT library for this store)\n" +
|
||||
" parity --compare <file> (diff this plugin's scan against that capture)",
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (snapshot) {
|
||||
// The baseline: what the host reports for THIS store while its built-in scanner
|
||||
// is still the thing producing it. Capture before installing the plugin.
|
||||
const host = yield* HostClient;
|
||||
const body = yield* host.request("GET", "/library");
|
||||
const mine = (Array.isArray(body) ? (body as HostGameEntry[]) : [])
|
||||
.filter((e) => e.store === (store ?? def.name))
|
||||
.map(fromHostEntry)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
yield* Effect.sync(() =>
|
||||
fs.writeFileSync(snapshot, `${JSON.stringify(mine, null, 2)}\n`),
|
||||
);
|
||||
console.log(
|
||||
`captured ${mine.length} "${store ?? def.name}" entries to ${snapshot}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const baseline = yield* Effect.try({
|
||||
try: () =>
|
||||
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
|
||||
typeof fromHostEntry
|
||||
>[],
|
||||
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
|
||||
});
|
||||
const cfg = yield* (yield* config).load;
|
||||
const { entries } = yield* computeEntries(cfg);
|
||||
const produced = entries.map((e) =>
|
||||
fromProviderEntry(store ?? def.name, e),
|
||||
);
|
||||
const report = diffParity(baseline, produced);
|
||||
console.log(formatParityReport(report));
|
||||
// A non-zero exit is what makes this usable as a release gate rather than a report
|
||||
// somebody skims.
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
}),
|
||||
},
|
||||
uninstall: {
|
||||
summary: "remove this source's games from the host and release its store claim",
|
||||
run: () =>
|
||||
|
||||
@@ -9,4 +9,15 @@ export {
|
||||
type LibraryPluginDef,
|
||||
type ScanReport,
|
||||
} from "./define.js";
|
||||
export {
|
||||
claimedLibraryId,
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
type ParityChange,
|
||||
type ParityEntry,
|
||||
type ParityReport,
|
||||
} from "./parity.js";
|
||||
export * from "./parsers/index.js";
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// The parity harness: proof that a library plugin reproduces the in-host scanner it replaces.
|
||||
//
|
||||
// This is the acceptance gate for every extracted scanner (design M5). Ported unit tests are
|
||||
// necessary but nowhere near sufficient — they pin the PARSERS, while what actually has to hold is
|
||||
// that the whole pipeline lands the same entries, with the same ids, launch recipes and detect
|
||||
// signals, on a real box with a real launcher installed. A plugin that parses perfectly and emits
|
||||
// `steam:440` as `steam:440.0` breaks every Moonlight pin on the host and no parser test notices.
|
||||
//
|
||||
// It lives in the KIT, not in a plugin, because it is identical for all six: capture what the host
|
||||
// reports while its built-in scanner is doing the work, then check the plugin produces the same set.
|
||||
// (One plugin per repo is the house pattern, so anything shared has to be published, not adjacent.)
|
||||
//
|
||||
// Usage, per plugin, on a box with that launcher installed:
|
||||
//
|
||||
// punktfunk-plugin-steam parity --snapshot before.json # host still on its built-in scanner
|
||||
// punktfunk-plugin-steam parity --compare before.json # offline: runs THIS plugin's scan
|
||||
//
|
||||
// `--compare` runs the plugin's own scan directly rather than installing it first, so a mismatch is
|
||||
// visible before anything is published — and the run is repeatable while you fix it.
|
||||
import type { ProviderEntry } from "../wire.js";
|
||||
|
||||
/** The four art slots, in the order the host's box-art ladder tries them. */
|
||||
const ART_KINDS = ["portrait", "hero", "logo", "header"] as const;
|
||||
type ArtKind = (typeof ART_KINDS)[number];
|
||||
|
||||
/** One entry, reduced to the facts parity is about. */
|
||||
export interface ParityEntry {
|
||||
/** The store-qualified library id — the field everything downstream is keyed on. */
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
/** `<kind>:<value>`, or null when the entry has no launch recipe. */
|
||||
readonly launch: string | null;
|
||||
/** `"game"` or `"launcher"`. */
|
||||
readonly role: string;
|
||||
/**
|
||||
* Which art kinds are PRESENT, not their values. The representation legitimately changes on
|
||||
* extraction (a scanner's `data:` URL or host-relative proxy path becomes a `file://` path or a
|
||||
* CDN URL), so comparing values would fail every time for no reason. Presence is the invariant
|
||||
* that matters: a title that had a poster must still have one.
|
||||
*/
|
||||
readonly art: Readonly<Record<ArtKind, boolean>>;
|
||||
/** Flat descriptive metadata (platform, genres, …) — compared verbatim. */
|
||||
readonly meta: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** What the host reports for one entry in `GET /library`. */
|
||||
export interface HostGameEntry {
|
||||
id: string;
|
||||
store: string;
|
||||
title: string;
|
||||
role?: string;
|
||||
launch?: { kind: string; value: string } | null;
|
||||
art?: Partial<Record<ArtKind, string | null>>;
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** Keys on a host entry that are structure, not descriptive metadata. */
|
||||
const NON_META = new Set([
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"role",
|
||||
"launch",
|
||||
"art",
|
||||
"provider",
|
||||
"external_id",
|
||||
"prep",
|
||||
"detect",
|
||||
]);
|
||||
|
||||
const artPresence = (
|
||||
art: Partial<Record<ArtKind, string | null>> | undefined,
|
||||
): Record<ArtKind, boolean> => {
|
||||
const out = {} as Record<ArtKind, boolean>;
|
||||
for (const k of ART_KINDS) out[k] = Boolean(art?.[k]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const pickMeta = (src: Record<string, unknown>): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
// Absent and empty are the same thing here: the host omits empty lists and null fields, and a
|
||||
// plugin that sends `genres: []` has not changed anything.
|
||||
if (NON_META.has(k) || v == null) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** The library id the host assigns a claimed entry — the deterministic `<store>:<external_id>`. */
|
||||
export const claimedLibraryId = (store: string, externalId: string): string =>
|
||||
`${store}:${externalId}`;
|
||||
|
||||
/** Reduce what the host reported (the BEFORE side) to a comparable entry. */
|
||||
export const fromHostEntry = (e: HostGameEntry): ParityEntry => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
|
||||
role: e.role ?? "game",
|
||||
art: artPresence(e.art),
|
||||
meta: pickMeta(e as Record<string, unknown>),
|
||||
});
|
||||
|
||||
/** Reduce what this plugin produced (the AFTER side) to a comparable entry. */
|
||||
export const fromProviderEntry = (
|
||||
store: string,
|
||||
e: ProviderEntry,
|
||||
): ParityEntry => {
|
||||
const rec = e as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: claimedLibraryId(store, e.external_id),
|
||||
title: e.title,
|
||||
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
|
||||
role: (e as { role?: string }).role ?? "game",
|
||||
art: artPresence(
|
||||
e.art as Partial<Record<ArtKind, string | null>> | undefined,
|
||||
),
|
||||
meta: pickMeta(rec),
|
||||
};
|
||||
};
|
||||
|
||||
/** One field that differs between the two sides. */
|
||||
export interface ParityChange {
|
||||
readonly id: string;
|
||||
readonly field: string;
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
}
|
||||
|
||||
export interface ParityReport {
|
||||
/** In the baseline, absent from what the plugin produced — the plugin LOST a title. */
|
||||
readonly missing: ParityEntry[];
|
||||
/** Produced by the plugin, absent from the baseline — the plugin invented a title. */
|
||||
readonly extra: ParityEntry[];
|
||||
/** Same id, different facts. */
|
||||
readonly changed: ParityChange[];
|
||||
/** Entries present on both sides and identical. */
|
||||
readonly matched: number;
|
||||
/**
|
||||
* Launcher entries the plugin adds (design D4). Never a failure: the built-in scanner had no
|
||||
* concept of them, so they are expected to be `extra` and are reported separately so a real
|
||||
* regression isn't buried under them.
|
||||
*/
|
||||
readonly launchersAdded: ParityEntry[];
|
||||
readonly ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff a baseline (what the host reported while its built-in scanner ran) against what this plugin
|
||||
* produced. `ok` is true only when nothing is missing, nothing unexpected is extra, and no compared
|
||||
* field changed.
|
||||
*/
|
||||
export const diffParity = (
|
||||
baseline: ReadonlyArray<ParityEntry>,
|
||||
produced: ReadonlyArray<ParityEntry>,
|
||||
): ParityReport => {
|
||||
const byId = new Map(baseline.map((e) => [e.id, e]));
|
||||
const producedIds = new Set(produced.map((e) => e.id));
|
||||
const changed: ParityChange[] = [];
|
||||
const extra: ParityEntry[] = [];
|
||||
const launchersAdded: ParityEntry[] = [];
|
||||
let matched = 0;
|
||||
|
||||
for (const after of produced) {
|
||||
const before = byId.get(after.id);
|
||||
if (!before) {
|
||||
// A launcher entry has no counterpart by construction — the scanner never emitted one.
|
||||
(after.role === "launcher" ? launchersAdded : extra).push(after);
|
||||
continue;
|
||||
}
|
||||
const diffs = compareEntry(before, after);
|
||||
if (diffs.length === 0) matched++;
|
||||
else changed.push(...diffs);
|
||||
}
|
||||
|
||||
const missing = baseline.filter((e) => !producedIds.has(e.id));
|
||||
return {
|
||||
missing,
|
||||
extra,
|
||||
changed,
|
||||
matched,
|
||||
launchersAdded,
|
||||
ok: missing.length === 0 && extra.length === 0 && changed.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
const compareEntry = (
|
||||
before: ParityEntry,
|
||||
after: ParityEntry,
|
||||
): ParityChange[] => {
|
||||
const out: ParityChange[] = [];
|
||||
const note = (field: string, b: unknown, a: unknown) =>
|
||||
out.push({ id: before.id, field, before: b, after: a });
|
||||
|
||||
if (before.title !== after.title) note("title", before.title, after.title);
|
||||
if (before.launch !== after.launch)
|
||||
note("launch", before.launch, after.launch);
|
||||
if (before.role !== after.role) note("role", before.role, after.role);
|
||||
for (const k of ART_KINDS) {
|
||||
// Only a LOST art kind is a regression. Gaining one is an improvement (the plugin can reach
|
||||
// art the host never resolved), and failing a run over it would just train people to ignore
|
||||
// the harness.
|
||||
if (before.art[k] && !after.art[k]) note(`art.${k}`, true, false);
|
||||
}
|
||||
const keys = new Set([
|
||||
...Object.keys(before.meta),
|
||||
...Object.keys(after.meta),
|
||||
]);
|
||||
for (const k of keys) {
|
||||
const b = before.meta[k];
|
||||
const a = after.meta[k];
|
||||
if (JSON.stringify(b) !== JSON.stringify(a)) note(`meta.${k}`, b, a);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Render a report for a terminal. Empty-ish when everything matched. */
|
||||
export const formatParityReport = (r: ParityReport): string => {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
r.ok
|
||||
? `parity OK — ${r.matched} entries identical`
|
||||
: `parity FAILED — ${r.matched} identical, ${r.missing.length} missing, ${r.extra.length} unexpected, ${r.changed.length} changed`,
|
||||
);
|
||||
for (const e of r.missing) lines.push(` missing: ${e.id} ${e.title}`);
|
||||
for (const e of r.extra) lines.push(` extra: ${e.id} ${e.title}`);
|
||||
for (const c of r.changed) {
|
||||
lines.push(
|
||||
` changed: ${c.id} ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`,
|
||||
);
|
||||
}
|
||||
if (r.launchersAdded.length > 0) {
|
||||
lines.push(
|
||||
` (+${r.launchersAdded.length} launcher ${r.launchersAdded.length === 1 ? "entry" : "entries"}, expected: ${r.launchersAdded
|
||||
.map((e) => e.id)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
// Art REPRESENTATION always changes on extraction (a host-relative proxy path or an inlined
|
||||
// `data:` URL becomes a `file://` path or a CDN URL). Presence is what this harness checks, so
|
||||
// say plainly that the bytes still want a human's eyes once.
|
||||
if (r.ok) {
|
||||
lines.push(
|
||||
" note: art is compared by presence, not value — spot-check a few covers render.",
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
// The parity harness is the release gate for every extracted scanner, so the thing that decides
|
||||
// pass/fail needs its own tests. The cases below are the ones that actually happen during a port:
|
||||
// a lost title, a wrong id, a dropped launch recipe, art whose representation changed but whose
|
||||
// presence didn't, and the launcher entries the plugin legitimately adds.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
claimedLibraryId,
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
} from "../src/library/parity.js";
|
||||
import type { ProviderEntry } from "../src/wire.js";
|
||||
|
||||
/** What the host reports while its BUILT-IN steam scanner is producing the library. */
|
||||
const hostEntry = (over: Partial<HostGameEntry> = {}): HostGameEntry => ({
|
||||
id: "steam:440",
|
||||
store: "steam",
|
||||
title: "Team Fortress 2",
|
||||
launch: { kind: "steam_appid", value: "440" },
|
||||
// The scanner emits host-relative proxy paths the CLIENT resolves.
|
||||
art: {
|
||||
portrait: "/api/v1/library/art/steam:440/portrait",
|
||||
hero: "/api/v1/library/art/steam:440/hero",
|
||||
logo: null,
|
||||
header: "/api/v1/library/art/steam:440/header",
|
||||
},
|
||||
platform: "PC",
|
||||
...over,
|
||||
});
|
||||
|
||||
/** What the extracted plugin produces for the same title. */
|
||||
const pluginEntry = (over: Partial<ProviderEntry> = {}): ProviderEntry =>
|
||||
({
|
||||
external_id: "440",
|
||||
title: "Team Fortress 2",
|
||||
launch: { kind: "steam_appid", value: "440" },
|
||||
// The plugin emits file:// paths and CDN URLs — a DIFFERENT representation of the same art.
|
||||
art: {
|
||||
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
|
||||
hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.jpg",
|
||||
header: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg",
|
||||
},
|
||||
platform: "PC",
|
||||
...over,
|
||||
}) as ProviderEntry;
|
||||
|
||||
describe("id mapping", () => {
|
||||
test("a claimed entry's id is the scanner's id", () => {
|
||||
// The whole migration rests on this one line: Moonlight pins, GameStream app ids and client
|
||||
// art caches are all derived from it.
|
||||
expect(claimedLibraryId("steam", "440")).toBe("steam:440");
|
||||
expect(claimedLibraryId("heroic", "legendary:Quail")).toBe(
|
||||
"heroic:legendary:Quail",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffParity", () => {
|
||||
const base = [fromHostEntry(hostEntry())];
|
||||
|
||||
test("a faithful port passes, even though the art VALUES all changed", () => {
|
||||
const r = diffParity(base, [fromProviderEntry("steam", pluginEntry())]);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.matched).toBe(1);
|
||||
expect(r.changed).toEqual([]);
|
||||
expect(formatParityReport(r)).toContain("parity OK");
|
||||
});
|
||||
|
||||
test("a lost title is reported as missing", () => {
|
||||
const r = diffParity(base, []);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
|
||||
expect(formatParityReport(r)).toContain("missing: steam:440");
|
||||
});
|
||||
|
||||
test("a wrong id shows up as BOTH missing and extra — the loudest failure", () => {
|
||||
// The exact shape of the bug this harness exists to catch: the plugin found the title, but
|
||||
// under an id nothing downstream recognizes.
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ external_id: "440.0" })),
|
||||
]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
|
||||
expect(r.extra.map((e) => e.id)).toEqual(["steam:440.0"]);
|
||||
});
|
||||
|
||||
test("a changed launch recipe is caught", () => {
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({ launch: { kind: "command", value: "steam" } }),
|
||||
),
|
||||
]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.changed).toEqual([
|
||||
{
|
||||
id: "steam:440",
|
||||
field: "launch",
|
||||
before: "steam_appid:440",
|
||||
after: "command:steam",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a dropped launch recipe is caught (an unlaunchable tile)", () => {
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ launch: null })),
|
||||
]);
|
||||
expect(r.changed.map((c) => c.field)).toEqual(["launch"]);
|
||||
});
|
||||
|
||||
test("LOSING an art kind fails; gaining one does not", () => {
|
||||
const lost = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ art: { portrait: null } })),
|
||||
]);
|
||||
expect(lost.ok).toBe(false);
|
||||
expect(lost.changed.map((c) => c.field)).toContain("art.portrait");
|
||||
|
||||
// The baseline had no logo; the plugin resolves one. That is an improvement, and failing the
|
||||
// run over it would only train people to ignore the harness.
|
||||
const gained = diffParity(base, [
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({
|
||||
art: { ...pluginEntry().art, logo: "file:///l.png" },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
expect(gained.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("metadata drift is caught, but absent-vs-empty is not drift", () => {
|
||||
const changed = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ platform: "Linux" })),
|
||||
]);
|
||||
expect(changed.changed).toEqual([
|
||||
{ id: "steam:440", field: "meta.platform", before: "PC", after: "Linux" },
|
||||
]);
|
||||
// The host omits empty lists and nulls; a plugin sending them has changed nothing.
|
||||
const noise = diffParity(base, [
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({ genres: [], tags: [], region: null } as never),
|
||||
),
|
||||
]);
|
||||
expect(noise.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("launcher entries are expected extras, not failures", () => {
|
||||
// The built-in scanner had no concept of a launcher entry, so it can never be in the
|
||||
// baseline — reporting it as `extra` would fail every steam run forever.
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry()),
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({
|
||||
external_id: "ui:bigpicture",
|
||||
title: "Steam Big Picture",
|
||||
role: "launcher",
|
||||
launch: { kind: "steam_ui", value: "bigpicture" },
|
||||
art: {},
|
||||
} as never),
|
||||
),
|
||||
]);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.extra).toEqual([]);
|
||||
expect(r.launchersAdded.map((e) => e.id)).toEqual(["steam:ui:bigpicture"]);
|
||||
expect(formatParityReport(r)).toContain("+1 launcher entry");
|
||||
});
|
||||
|
||||
test("an ordinary title the scanner never had IS a failure", () => {
|
||||
// The mirror of the case above: only `role: "launcher"` gets the exemption, so a plugin that
|
||||
// invents games (a bad filter, a tool listed as a game) still fails.
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry()),
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({ external_id: "228980", title: "Steamworks Common" }),
|
||||
),
|
||||
]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.extra.map((e) => e.id)).toEqual(["steam:228980"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user