Files
punktfunk/plugin-kit/test/errors.test.ts
T
enricobuehler 5872dfc649
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 3m46s
ci / bun-nix (pull_request) Successful in 54s
ci / rust-arm64 (pull_request) Successful in 5m54s
android / android (pull_request) Successful in 7m29s
ci / rust (pull_request) Successful in 21m2s
feat(library): a plugin launch kind, so a scanner can publish tiles the host cannot name
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.

So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).

The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.

- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
  characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
  `launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
  its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
  own UI as nothing but "Decode error"
2026-08-08 23:46:05 +02:00

98 lines
3.8 KiB
TypeScript

// What a kit error says when something interpolates it — which is the whole diagnosis surface a
// plugin operator gets, because `sync-engine`'s failure path logs `${e.cause}` and nothing else.
import { describe, expect, test } from "bun:test";
import { HostRequestError, SyncError } from "../src/errors.js";
describe("HostRequestError", () => {
// Regression for 2026-08-08: this printed the bare tag, so `plugin:lutris sync (startup)
// failed: HostRequestError` was the ENTIRE record of a host that had answered with a precise
// 400. Interpolation is the assertion because interpolation is what the sync engine does.
test("names the call and carries the host's explanation", () => {
const err = new HostRequestError({
method: "PUT",
path: "/library/provider/lutris?store=lutris",
cause: new Error("art.portrait: local art must be an image file"),
});
expect(`${err}`).toContain("PUT");
expect(`${err}`).toContain("/library/provider/lutris?store=lutris");
expect(`${err}`).toContain("art.portrait");
expect(`${err}`).not.toBe("HostRequestError");
});
// The host's rejection arrives as a parsed `{error: "…"}` body, not an Error. Left to default
// stringification that is `[object Object]` — the useful half lost a second way.
test("renders an object cause instead of [object Object]", () => {
const err = new HostRequestError({
method: "PUT",
path: "/library/provider/steam",
cause: { error: "art.header: local art must be an image file" },
});
expect(`${err}`).toContain("art.header");
expect(`${err}`).not.toContain("[object Object]");
});
// Error formatting must never itself throw: a cycle (or a BigInt) would make JSON.stringify
// blow up INSIDE the catch that is trying to report the original failure.
test("survives a cause that cannot be serialized", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const err = new HostRequestError({
method: "GET",
path: "/library",
cause: cyclic,
});
expect(() => `${err}`).not.toThrow();
expect(`${err}`).toContain("/library");
});
// The tag stays matchable — `Effect.catchTag`/`_tag` narrowing must not be traded away for a
// readable message.
test("keeps its tag and its fields", () => {
const err = new HostRequestError({
method: "DELETE",
path: "/library/provider/heroic",
cause: "boom",
});
expect(err._tag).toBe("HostRequestError");
expect(err.method).toBe("DELETE");
expect(err.path).toBe("/library/provider/heroic");
});
});
describe("SyncError", () => {
// Regression for 2026-08-08 (rom-manager): the host refused every ROM reconcile with a 403 that
// named the offending field AND the fix, `HostRequestError` carried that sentence faithfully —
// and then this class dropped it, because the default string form is the bare tag. The plugin
// rendered `String(e)` into its API error, so the operator's entire diagnosis was the word
// "SyncError" (and, after the undecodable 500, "Decode error"). The chain must survive.
test("carries the nested host explanation, not the bare tag", () => {
const err = new SyncError({
reason: "manual",
cause: new HostRequestError({
method: "PUT",
path: "/library/provider/rom-manager",
cause: {
error:
'`launch.kind = "command"` is executed as the host user and may only be set with the operator\'s admin token',
},
}),
});
expect(`${err}`).toContain("manual");
expect(`${err}`).toContain("/library/provider/rom-manager");
expect(`${err}`).toContain("launch.kind");
expect(`${err}`).not.toBe("SyncError");
expect(`${err}`).not.toContain("[object Object]");
});
test("keeps its tag and fields for catchTag narrowing", () => {
const err = new SyncError({ reason: "startup", cause: "boom" });
expect(err._tag).toBe("SyncError");
expect(err.reason).toBe("startup");
});
});