forked from unom/punktfunk
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).
250 lines
9.0 KiB
TypeScript
250 lines
9.0 KiB
TypeScript
// 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");
|
|
};
|