From 91fa32fbb62ac93d44fb71c9caadd798500d2441 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 18:52:38 +0200 Subject: [PATCH] feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- plugin-kit/README.md | 35 ++++ plugin-kit/src/library/define.ts | 73 +++++++- plugin-kit/src/library/index.ts | 11 ++ plugin-kit/src/library/parity.ts | 249 +++++++++++++++++++++++++ plugin-kit/test/library-parity.test.ts | 186 ++++++++++++++++++ 5 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 plugin-kit/src/library/parity.ts create mode 100644 plugin-kit/test/library-parity.test.ts diff --git a/plugin-kit/README.md b/plugin-kit/README.md index 0dc97bb0..50fa1f3e 100644 --- a/plugin-kit/README.md +++ b/plugin-kit/README.md @@ -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`) diff --git a/plugin-kit/src/library/define.ts b/plugin-kit/src/library/define.ts index c66d1a32..8978b12c 100644 --- a/plugin-kit/src/library/define.ts +++ b/plugin-kit/src/library/define.ts @@ -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 { readonly commands?: Record>; } +/** `--flag value` from an argv slice, or undefined. */ +const flagValue = ( + argv: ReadonlyArray, + 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 = ( } }), }, + parity: { + summary: + "prove this plugin reproduces the built-in scanner (--snapshot | --compare )", + // `--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 (capture the host's CURRENT library for this store)\n" + + " parity --compare (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: () => diff --git a/plugin-kit/src/library/index.ts b/plugin-kit/src/library/index.ts index ce80c675..a040ec6a 100644 --- a/plugin-kit/src/library/index.ts +++ b/plugin-kit/src/library/index.ts @@ -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"; diff --git a/plugin-kit/src/library/parity.ts b/plugin-kit/src/library/parity.ts new file mode 100644 index 00000000..e0363aaa --- /dev/null +++ b/plugin-kit/src/library/parity.ts @@ -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; + /** `:`, 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>; + /** Flat descriptive metadata (platform, genres, …) — compared verbatim. */ + readonly meta: Readonly>; +} + +/** 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>; + [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> | undefined, +): Record => { + const out = {} as Record; + for (const k of ART_KINDS) out[k] = Boolean(art?.[k]); + return out; +}; + +const pickMeta = (src: Record): Record => { + const out: Record = {}; + 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 `:`. */ +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), +}); + +/** 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; + 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> | 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, + produced: ReadonlyArray, +): 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"); +}; diff --git a/plugin-kit/test/library-parity.test.ts b/plugin-kit/test/library-parity.test.ts new file mode 100644 index 00000000..2abc3f5a --- /dev/null +++ b/plugin-kit/test/library-parity.test.ts @@ -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 => ({ + 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 => + ({ + 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"]); + }); +});