`defineLibraryPlugin` has taken a `launchers()` hook since kit 0.3.0 — entries that open the LAUNCHER rather than a title (design D4) — and nothing implemented it. The host has resolved `launcher_ui` valued `"heroic"` since the same release. So the whole path existed end to end with no producer at either end. One entry, config-toggled and on by default: installing, updating or logging in are exactly the things you cannot do from a game tile. `launcher_ui` is valued by STORE ID, never a command: the host resolves "heroic" to the native binary or the Flatpak, minus the `--no-gui` and the URI that game entries carry, so the window itself opens (D1). That is also the only shape available to a plugin at all — the 2026-08-05 review made `launch.kind = "command"` operator-only, so a plugin publishing one has its entire reconcile refused. CAVEAT carried in the code: Heroic is a single-instance Electron app, so if a window is ALREADY open the spawned process forwards to it and exits. The host documents the same caveat for game launches; keeping the session alive across it is a host-side question (the launcher-tile lease), not this plugin's. Deliberately art-less — see the lutris plugin: a square app icon cover-cropped into a 2:3 tile looks broken, and every client renders an art-less launcher entry as "opens Heroic" on purpose. Gates: tsc --noEmit clean, 7 tests pass (2 new), biome clean. Needs a host carrying the M2 wire (`role` + `launcher_ui`), which is on main.
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
// The Heroic-specific half: which cached titles become entries, and what art survives. Everything
|
|
// else this plugin does is the kit's. The real end-to-end proof is
|
|
// `punktfunk-plugin-heroic parity --compare`, run on a box with Heroic actually installed.
|
|
import { afterAll, beforeAll, 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 { launcherEntries, runnerGames } from "../src/plugin.js";
|
|
|
|
// A throwaway Heroic config root with a real store_cache and real install dirs — the scan requires
|
|
// the install dir to EXIST, so a fixture of pure JSON would report nothing and pass vacuously.
|
|
let root: string;
|
|
let installed: string;
|
|
|
|
beforeAll(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-heroic-"));
|
|
installed = path.join(root, "games", "Quail");
|
|
fs.mkdirSync(installed, { recursive: true });
|
|
fs.mkdirSync(path.join(root, "store_cache"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, "store_cache", "legendary_library.json"),
|
|
JSON.stringify({
|
|
library: [
|
|
{
|
|
app_name: "Quail",
|
|
title: "Quail",
|
|
is_installed: true,
|
|
install: { install_path: installed },
|
|
art_square: "https://cdn/quail_tall.jpg",
|
|
art_cover: "https://cdn/quail_wide.jpg",
|
|
// Sideloaded titles carry local paths here — the client cannot fetch them.
|
|
art_logo: "file:///home/u/logo.png",
|
|
},
|
|
{ app_name: "Owned", title: "Owned Only", is_installed: false },
|
|
{
|
|
app_name: "Ghost",
|
|
title: "Uninstalled Underneath Us",
|
|
is_installed: true,
|
|
// Heroic's gog `is_installed` bug (#2691): flagged installed, directory long gone.
|
|
install: { install_path: path.join(root, "games", "gone") },
|
|
},
|
|
{
|
|
title: "No App Name",
|
|
is_installed: true,
|
|
install: { install_path: installed },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
|
|
const scan = () =>
|
|
runnerGames(root, "legendary_library.json", "legendary", "library");
|
|
|
|
/** The single surviving entry. Throws rather than returning undefined, so a regression that
|
|
* empties the scan fails loudly here instead of skipping every assertion below. */
|
|
const only = () => {
|
|
const got = scan();
|
|
if (got.length !== 1) throw new Error(`expected 1 entry, got ${got.length}`);
|
|
return got[0]!;
|
|
};
|
|
|
|
describe("heroic store_cache", () => {
|
|
test("keeps only installed titles whose install dir still exists", () => {
|
|
const got = scan();
|
|
expect(got.map((e) => e.external_id)).toEqual(["legendary:Quail"]);
|
|
// Each exclusion is a distinct real case, so spell out why the other three are gone:
|
|
// not installed, installed-but-directory-gone (the #2691 workaround), and no app_name.
|
|
expect(got).toHaveLength(1);
|
|
});
|
|
|
|
test("the external id is <runner>:<appName>, which the host prefixes with the store", () => {
|
|
// This is the migration invariant: the host composes `heroic:legendary:Quail`, byte-identical
|
|
// to what the built-in scanner produced, so GameStream app ids and Moonlight pins survive.
|
|
const e = only();
|
|
expect(e.external_id).toBe("legendary:Quail");
|
|
expect(e.launch).toEqual({ kind: "heroic", value: "legendary:Quail" });
|
|
});
|
|
|
|
test("only http(s) art survives; a sideloaded file:// path is dropped", () => {
|
|
const e = only();
|
|
expect(e.art?.portrait).toBe("https://cdn/quail_tall.jpg");
|
|
expect(e.art?.header).toBe("https://cdn/quail_wide.jpg");
|
|
// No art_background in the fixture → hero falls back to the cover, as in-host.
|
|
expect(e.art?.hero).toBe("https://cdn/quail_wide.jpg");
|
|
expect(e.art?.logo).toBeNull();
|
|
});
|
|
|
|
test("carries both detect signals — the dir AND the env marker", () => {
|
|
// Heroic hands off to legendary/gogdl/nile, so the host never sees the game's own process
|
|
// any other way. The env marker is load-bearing under Proton specifically.
|
|
const e = only();
|
|
expect(e.detect?.install_dir).toBe(installed);
|
|
expect(e.detect?.env_marker).toEqual({
|
|
key: "HEROIC_APP_NAME",
|
|
value: "Quail",
|
|
});
|
|
});
|
|
|
|
test("a missing or malformed cache is empty, not an error", () => {
|
|
// The normal "this backend is unused" case — two of the three files are usually absent.
|
|
expect(runnerGames(root, "gog_library.json", "gog", "games")).toEqual([]);
|
|
expect(
|
|
runnerGames(root, "legendary_library.json", "legendary", "nope"),
|
|
).toEqual([]);
|
|
expect(runnerGames("/nope/not/here", "x.json", "gog", "games")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("the launcher tile", () => {
|
|
test("is published by default, valued by store id and never by a command", () => {
|
|
// Design D4 + D1: the plugin names a launcher, the host resolves it to the native binary
|
|
// or the Flatpak. A `command` kind would be refused (operator-only since the 2026-08-05
|
|
// review), so `launcher_ui` valued "heroic" is the only shape that can work.
|
|
const tiles = launcherEntries({});
|
|
expect(tiles).toHaveLength(1);
|
|
expect(tiles[0]).toMatchObject({
|
|
role: "launcher",
|
|
launch: { kind: "launcher_ui", value: "heroic" },
|
|
});
|
|
// `heroic:launcher` — game ids are `<runner>:<appName>`, so this cannot collide.
|
|
expect(tiles[0]?.external_id).toBe("launcher");
|
|
expect(tiles[0]?.title).toBe("Heroic");
|
|
// Deliberately art-less — see the tile's own comment.
|
|
expect(tiles[0]?.art).toBeUndefined();
|
|
});
|
|
|
|
test("an operator can turn it off", () => {
|
|
expect(launcherEntries({ launcher: false })).toEqual([]);
|
|
expect(launcherEntries({ launcher: true })).toHaveLength(1);
|
|
});
|
|
});
|