ci / rust-arm64 (pull_request) Successful in 2m40s
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 11m48s
nix / flake (pull_request) Successful in 16m39s
Found on hardware by the GOG plugin's own parity gate, on a box with exactly one
GOG game installed:
HKLM\SOFTWARE\WOW6432Node\GOG.com\Games -> 1 subkey (IRON NEST ...)
host's built-in scanner: 1 entry
plugin: detect: absent, 0 games
parity FAILED - 1 missing, exit 1
`reg.exe` ALWAYS echoes the full hive name in its output rows, never the
abbreviation it was given: query `HKLM\SOFTWARE\...` and every line comes back
`HKEY_LOCAL_MACHINE\SOFTWARE\...`. regSubKeys built its match prefix from the
`HKLM\...` string it was handed, so no line ever matched and it returned `[]` —
on every machine, for every key, always. Measured verbatim on .173:
reg.exe: [HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GOG.com\Games\2013434102]
regSubKeys: []
Its only consumer is the GOG plugin, so the symptom was "GOG reports no games
installed" rather than an error — the same shape as the SQLite reader in 0.3.1:
a total failure that every layer degrades into an empty library.
The contract was wrong too, and the hive bug hid it. regSubKeys returned whole
key PATHS while the GOG plugin uses each result as a bare NAME
(`const key = \`${GAMES_KEY}\\${id}\``, and the subkey name IS the product id
that becomes `external_id`). Even with the prefix fixed, paths would have
composed nonsense keys. It now returns names, which is what the sole consumer
and its own comment always assumed.
Parsing is split into an exported `parseRegSubKeys(stdout, key)` for the same
reason `parseRegQuery` is exported — this is a text format that breaks quietly,
and it had NO test coverage at all. Six added, using the verbatim .173 output:
names not paths, multiple subkeys, grandchildren ignored, the queried key is not
its own subkey, case-insensitivity, and empty/error input. Four of the six FAIL
against the old behaviour.
0.3.1 -> 0.3.2. Gates: biome clean, tsc clean, 67/67 tests, build clean.
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
// The parser ports, tested against the SAME cases the host's Rust scanners pin.
|
|
//
|
|
// These are not "does TypeScript work" tests. The formats here are undocumented and the host's
|
|
// versions are the reference implementation; a port that drifts produces a library that looks fine
|
|
// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the
|
|
// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but
|
|
// these catch a drift long before that.
|
|
|
|
import { Database } from "bun:sqlite";
|
|
import { describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
confinedJoin,
|
|
crc32,
|
|
fileUrl,
|
|
findGridArtFile,
|
|
findLocalArtFile,
|
|
gridFilenames,
|
|
isSteamTool,
|
|
openReadOnly,
|
|
parseAppManifest,
|
|
parseRegQuery,
|
|
parseRegSubKeys,
|
|
parseShortcuts,
|
|
readTextCapped,
|
|
shortcutAppId,
|
|
shortcutGameId,
|
|
steamCdnUrl,
|
|
vdfPaths,
|
|
vdfValue,
|
|
withReadOnlyDb,
|
|
} from "../src/library/parsers/index.js";
|
|
|
|
const tmp = (name: string): string => {
|
|
const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
};
|
|
|
|
describe("text VDF / ACF", () => {
|
|
test("vdfValue extracts a quoted field", () => {
|
|
expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe(
|
|
"/mnt/games/SteamLibrary",
|
|
);
|
|
expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570");
|
|
expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2");
|
|
// Wrong key → nothing (a prefix match must not leak the neighbouring field).
|
|
expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined();
|
|
});
|
|
|
|
test("vdfPaths pulls every library folder and unescapes Windows separators", () => {
|
|
const vdf = `
|
|
"libraryfolders"
|
|
{
|
|
"0"
|
|
{
|
|
"path" "/home/u/.local/share/Steam"
|
|
"label" ""
|
|
}
|
|
"1"
|
|
{
|
|
"path" "D:\\\\SteamLibrary"
|
|
}
|
|
}`;
|
|
expect(vdfPaths(vdf)).toEqual([
|
|
"/home/u/.local/share/Steam",
|
|
"D:\\SteamLibrary",
|
|
]);
|
|
});
|
|
|
|
test("parseAppManifest reads the flat fields it needs", () => {
|
|
const acf = `"AppState"
|
|
{
|
|
"appid" "570"
|
|
"name" "Dota 2"
|
|
"installdir" "dota 2 beta"
|
|
}`;
|
|
expect(parseAppManifest(acf)).toEqual({
|
|
appid: 570,
|
|
name: "Dota 2",
|
|
installdir: "dota 2 beta",
|
|
});
|
|
// A manifest missing the essentials is not a title.
|
|
expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined();
|
|
});
|
|
|
|
test("isSteamTool keeps runtimes out of a game library", () => {
|
|
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(
|
|
true,
|
|
);
|
|
expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true);
|
|
expect(isSteamTool(999, "Proton 9.0")).toBe(true);
|
|
expect(isSteamTool(999, "SteamVR")).toBe(true);
|
|
expect(isSteamTool(570, "Dota 2")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("binary shortcuts.vdf", () => {
|
|
/** Build a binary shortcuts.vdf the way Steam writes one. */
|
|
const buildShortcuts = (
|
|
entries: ReadonlyArray<{
|
|
appid?: number;
|
|
appname: string;
|
|
exe: string;
|
|
hidden?: boolean;
|
|
}>,
|
|
): Uint8Array => {
|
|
const parts: number[] = [];
|
|
const cstr = (s: string) => {
|
|
for (const b of new TextEncoder().encode(s)) parts.push(b);
|
|
parts.push(0);
|
|
};
|
|
const i32 = (v: number) => {
|
|
parts.push(
|
|
v & 0xff,
|
|
(v >>> 8) & 0xff,
|
|
(v >>> 16) & 0xff,
|
|
(v >>> 24) & 0xff,
|
|
);
|
|
};
|
|
parts.push(0x00);
|
|
cstr("shortcuts");
|
|
entries.forEach((e, i) => {
|
|
parts.push(0x00);
|
|
cstr(String(i));
|
|
if (e.appid !== undefined) {
|
|
parts.push(0x02);
|
|
cstr("appid");
|
|
i32(e.appid);
|
|
}
|
|
parts.push(0x01);
|
|
cstr("AppName");
|
|
cstr(e.appname);
|
|
parts.push(0x01);
|
|
cstr("Exe");
|
|
cstr(e.exe);
|
|
parts.push(0x02);
|
|
cstr("IsHidden");
|
|
i32(e.hidden ? 1 : 0);
|
|
// A nested map the parser must skip wholesale.
|
|
parts.push(0x00);
|
|
cstr("tags");
|
|
parts.push(0x01);
|
|
cstr("0");
|
|
cstr("favourite");
|
|
parts.push(0x08);
|
|
parts.push(0x08); // end of this shortcut
|
|
});
|
|
parts.push(0x08); // end of shortcuts
|
|
parts.push(0x08); // end of document
|
|
return new Uint8Array(parts);
|
|
};
|
|
|
|
test("parses entries, skips nested maps, and reads the hidden flag", () => {
|
|
const buf = buildShortcuts([
|
|
{ appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' },
|
|
{ appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true },
|
|
]);
|
|
const got = parseShortcuts(buf);
|
|
expect(got).toHaveLength(2);
|
|
expect(got[0]).toMatchObject({
|
|
appid: 2456789012,
|
|
name: "My Emulator",
|
|
hidden: false,
|
|
});
|
|
expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true });
|
|
});
|
|
|
|
test("derives the appid when the file omits it", () => {
|
|
const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]);
|
|
const got = parseShortcuts(buf);
|
|
expect(got).toHaveLength(1);
|
|
// Derived ids always carry the high bit — that is how a shortcut is told apart from a real
|
|
// store appid downstream (and why its CDN art fetch is skipped).
|
|
expect(got[0].appid & 0x8000_0000).not.toBe(0);
|
|
expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid"));
|
|
});
|
|
|
|
test("is total on a truncated or garbled file", () => {
|
|
expect(parseShortcuts(new Uint8Array([]))).toEqual([]);
|
|
expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]);
|
|
const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]);
|
|
// Every truncation of a valid file must return, not throw.
|
|
for (let i = 0; i < good.length; i++) {
|
|
expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("crc32 matches the IEEE check value", () => {
|
|
// The canonical CRC-32 check: crc32("123456789") == 0xCBF43926.
|
|
expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926);
|
|
});
|
|
|
|
test("shortcutGameId composes the appid and the shortcut marker", () => {
|
|
// high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does
|
|
// NOT launch a shortcut, which is the entire reason this function exists.
|
|
const id = BigInt(shortcutGameId(0x8000_0000));
|
|
expect(id >> 32n).toBe(0x8000_0000n);
|
|
expect(id & 0xffff_ffffn).toBe(0x0200_0000n);
|
|
// Digits only — it rides the `steam_appid` launch kind, which the host validates as digits.
|
|
expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/);
|
|
});
|
|
});
|
|
|
|
describe("path confinement", () => {
|
|
test("confinedJoin refuses anything that could escape the install dir", () => {
|
|
const base = path.join(path.sep, "games", "W3");
|
|
expect(confinedJoin(base, "bin/game.exe")).toBe(
|
|
path.join(base, "bin", "game.exe"),
|
|
);
|
|
expect(confinedJoin(base, "bin\\game.exe")).toBe(
|
|
path.join(base, "bin", "game.exe"),
|
|
);
|
|
// The three shapes a crafted goggame-*.info would use to point elsewhere.
|
|
expect(
|
|
confinedJoin(base, "../../windows/system32/cmd.exe"),
|
|
).toBeUndefined();
|
|
expect(confinedJoin(base, "/etc/passwd")).toBeUndefined();
|
|
expect(
|
|
confinedJoin(base, "C:\\Windows\\system32\\cmd.exe"),
|
|
).toBeUndefined();
|
|
expect(confinedJoin(base, "")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("capped reads", () => {
|
|
test("readTextCapped refuses an over-cap file and a missing one", () => {
|
|
const dir = tmp("caps");
|
|
const small = path.join(dir, "small.txt");
|
|
fs.writeFileSync(small, "hello");
|
|
expect(readTextCapped(small)).toBe("hello");
|
|
expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap
|
|
expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined();
|
|
expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("art locations", () => {
|
|
test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => {
|
|
expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg");
|
|
expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg");
|
|
// The local cache names the header asset differently from the CDN — pinned because it is
|
|
// the single most common way to get Steam art wrong.
|
|
expect(steamCdnUrl(570, "header")).not.toContain("library_header");
|
|
expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined();
|
|
});
|
|
|
|
test("grid filenames follow Steam's per-kind naming", () => {
|
|
expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]);
|
|
expect(gridFilenames(570, "hero")).toEqual([
|
|
"570_hero.png",
|
|
"570_hero.jpg",
|
|
]);
|
|
expect(gridFilenames(570, "logo")).toEqual([
|
|
"570_logo.png",
|
|
"570_logo.jpg",
|
|
]);
|
|
expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]);
|
|
});
|
|
|
|
test("finds cached and user-override art on disk", () => {
|
|
const dir = tmp("art");
|
|
const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123");
|
|
fs.mkdirSync(hashDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
|
|
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
|
|
path.join(hashDir, "library_600x900.jpg"),
|
|
);
|
|
expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined();
|
|
|
|
const cfg = path.join(dir, "userdata", "1", "config");
|
|
fs.mkdirSync(path.join(cfg, "grid"), { recursive: true });
|
|
fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x");
|
|
expect(findGridArtFile(cfg, 570, "portrait")).toBe(
|
|
path.join(cfg, "grid", "570p.jpg"),
|
|
);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fileUrl produces the host's local-art contract shape", () => {
|
|
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
|
|
expect(u.startsWith("file:///")).toBe(true);
|
|
// Spaces are percent-encoded; the separators survive so the host can rebuild the path.
|
|
expect(u).toContain("My%20Games");
|
|
expect(u).toContain("/c.jpg");
|
|
});
|
|
});
|
|
|
|
describe("reg.exe output", () => {
|
|
test("parses value rows and leaves the key header alone", () => {
|
|
const stdout = [
|
|
"",
|
|
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
|
|
" InstallPath REG_SZ C:\\Program Files (x86)\\Steam",
|
|
" Language REG_SZ english",
|
|
"",
|
|
].join("\r\n");
|
|
expect(parseRegQuery(stdout)).toEqual([
|
|
{
|
|
name: "InstallPath",
|
|
type: "REG_SZ",
|
|
// Data may contain spaces — only the first two columns are split off.
|
|
data: "C:\\Program Files (x86)\\Steam",
|
|
},
|
|
{ name: "Language", type: "REG_SZ", data: "english" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
// The read-only SQLite helper, against a REAL database file.
|
|
//
|
|
// This exists because its absence shipped a total failure. `openReadOnly` built a
|
|
// `file:…?immutable=1` URI but opened it with `{ readonly: true }`, which does not enable SQLite's
|
|
// URI filename parsing — so the name was taken literally, the open threw, and `openReadOnly`
|
|
// returned `undefined`. Every caller reads that as "this launcher isn't installed", and
|
|
// `withReadOnlyDb(...) ?? []` turns it into an empty library. The lutris plugin therefore reported
|
|
// "0 games" on every box, forever, while `detect` still said "present" (it only stats the file) —
|
|
// and the only thing that caught it was a hand-run parity gate against a live host.
|
|
//
|
|
// So: assert the helper can actually READ, not merely that it returns something.
|
|
describe("openReadOnly", () => {
|
|
const withDb = <T>(use: (file: string) => T): T => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-sqlite-"));
|
|
const file = path.join(dir, "pga.db");
|
|
const seed = new Database(file);
|
|
seed.run(
|
|
"CREATE TABLE games (id INTEGER PRIMARY KEY, name TEXT, installed INT)",
|
|
);
|
|
seed.run(
|
|
"INSERT INTO games (id, name, installed) VALUES (1, 'Ubisoft Connect', 1)",
|
|
);
|
|
seed.close();
|
|
try {
|
|
return use(file);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
test("opens a real database and returns its rows", () => {
|
|
withDb((file) => {
|
|
const db = openReadOnly(file);
|
|
expect(db).toBeDefined();
|
|
expect(
|
|
db?.query("SELECT id, name FROM games WHERE installed = 1"),
|
|
).toEqual([{ id: 1, name: "Ubisoft Connect" }]);
|
|
db?.close();
|
|
});
|
|
});
|
|
|
|
// A path with a space is the realistic URI-encoding case (Flatpak roots, "Program Files").
|
|
test("opens a path that needs URI escaping", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf kit sqlite "));
|
|
const file = path.join(dir, "pga.db");
|
|
const seed = new Database(file);
|
|
seed.run("CREATE TABLE games (id INTEGER PRIMARY KEY)");
|
|
seed.run("INSERT INTO games (id) VALUES (7)");
|
|
seed.close();
|
|
try {
|
|
expect(openReadOnly(file)?.query("SELECT id FROM games")).toEqual([
|
|
{ id: 7 },
|
|
]);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("withReadOnlyDb reads, then closes", () => {
|
|
withDb((file) => {
|
|
expect(
|
|
withReadOnlyDb(file, (h) => h.query("SELECT name FROM games")),
|
|
).toEqual([{ name: "Ubisoft Connect" }]);
|
|
});
|
|
});
|
|
|
|
// The "not installed" contract — an absent file is `undefined`, never a throw.
|
|
test("absent file is undefined, not an error", () => {
|
|
expect(
|
|
openReadOnly(path.join(os.tmpdir(), "pf-kit-nope", "pga.db")),
|
|
).toBeUndefined();
|
|
expect(
|
|
withReadOnlyDb(path.join(os.tmpdir(), "pf-kit-nope", "pga.db"), () => 1),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
// Schema drift degrades to no rows rather than taking the plugin down.
|
|
test("a bad query returns [] rather than throwing", () => {
|
|
withDb((file) => {
|
|
const db = openReadOnly(file);
|
|
expect(db?.query("SELECT missing_column FROM games")).toEqual([]);
|
|
db?.close();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Subkey enumeration, against the output reg.exe ACTUALLY prints.
|
|
//
|
|
// This had no coverage and was broken end to end: it matched lines against the abbreviated
|
|
// `HKLM\…` prefix it was handed, but reg.exe echoes `HKEY_LOCAL_MACHINE\…`. Nothing ever matched,
|
|
// so it returned [] on every machine, and the GOG plugin — its only consumer — reported "no games
|
|
// installed" rather than failing. Caught on hardware by the parity gate: the host's built-in
|
|
// scanner found IRON NEST, the plugin found nothing.
|
|
//
|
|
// The fixture is the verbatim output from .173 (a blank line, then one subkey row).
|
|
describe("parseRegSubKeys", () => {
|
|
const KEY = "HKLM\\SOFTWARE\\WOW6432Node\\GOG.com\\Games";
|
|
|
|
test("returns subkey NAMES from real reg.exe output", () => {
|
|
const stdout = [
|
|
"",
|
|
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games\\2013434102",
|
|
"",
|
|
].join("\r\n");
|
|
// The name is the GOG product id, and the consumer composes `${KEY}\\${name}`.
|
|
expect(parseRegSubKeys(stdout, KEY)).toEqual(["2013434102"]);
|
|
});
|
|
|
|
test("several subkeys, in order", () => {
|
|
const base = "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games";
|
|
const stdout = ["", `${base}\\1207658930`, `${base}\\2013434102`].join(
|
|
"\r\n",
|
|
);
|
|
expect(parseRegSubKeys(stdout, KEY)).toEqual(["1207658930", "2013434102"]);
|
|
});
|
|
|
|
// reg.exe /s output nests deeper; only immediate children are subkeys of this key.
|
|
test("ignores grandchildren", () => {
|
|
const base = "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games";
|
|
const stdout = [
|
|
"",
|
|
`${base}\\2013434102`,
|
|
`${base}\\2013434102\\tasks`,
|
|
].join("\r\n");
|
|
expect(parseRegSubKeys(stdout, KEY)).toEqual(["2013434102"]);
|
|
});
|
|
|
|
// The queried key itself is echoed as a header when it has values; it is not its own subkey.
|
|
test("does not return the queried key itself", () => {
|
|
const stdout = [
|
|
"",
|
|
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games",
|
|
"",
|
|
].join("\r\n");
|
|
expect(parseRegSubKeys(stdout, KEY)).toEqual([]);
|
|
});
|
|
|
|
test("case-insensitive on the hive and path", () => {
|
|
const stdout =
|
|
"hkey_local_machine\\software\\wow6432node\\gog.com\\games\\42";
|
|
expect(parseRegSubKeys(stdout, KEY)).toEqual(["42"]);
|
|
});
|
|
|
|
test("no subkeys is empty, not a throw", () => {
|
|
expect(parseRegSubKeys("", KEY)).toEqual([]);
|
|
expect(
|
|
parseRegSubKeys("ERROR: The system was unable to find...", KEY),
|
|
).toEqual([]);
|
|
});
|
|
});
|