Files
punktfunk/plugin-kit/test/library-config.test.ts
T
enricobuehler 10a0ef3283 style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.

Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.

Two deliberate differences from the plugin repos' copy:

  * no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
    the config; plugin-kit is a directory inside this one, and biome errors with
    "couldn't find an ignore file". The `files.includes` exclusions cover it.
  * `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.

`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.

Everything here is formatter/import churn except two real findings, both fixed:

  * `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
    never used;
  * test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
    `expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
    `reg` been undefined the `.ui` access would throw a TypeError instead of
    failing the test readably. Now asserted to the type system too.

Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.

Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
2026-08-08 02:19:06 +02:00

257 lines
9.1 KiB
TypeScript

// The `__config` contract — the wire shape the console's generic settings drawer codes against,
// plus the JSON-Schema derivation's committed fixture (design M0/S2).
//
// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic
// form can render. If an effect upgrade changes any of it, this test fails and the console's form
// needs re-checking before the change ships — far cheaper than discovering it on a user's box.
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 { Effect, Layer, Schema } from "effect";
import { makeConfigService } from "../src/config.js";
import { pluginInfoLayer } from "../src/host-client.js";
import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js";
/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */
const ScannerConfig = Schema.Struct({
enabled: Schema.Boolean.annotate({
title: "Enable scanning",
description: "Whether this source contributes titles.",
default: true,
}).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(true), {
encodingStrategy: "omit",
}),
),
root: Schema.optionalKey(
Schema.String.annotate({
title: "Launcher root",
description: "Absolute path.",
}),
),
extraRoots: Schema.Array(Schema.String)
.annotate({ title: "Extra roots" })
.pipe(
Schema.withDecodingDefaultKey(
Effect.succeed([] as ReadonlyArray<string>),
{ encodingStrategy: "omit" },
),
),
launchers: Schema.Struct({
bigpicture: Schema.Boolean.annotate({
title: "Big Picture",
default: true,
}),
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
}).pipe(
Schema.withDecodingDefaultKey(
Effect.succeed({ bigpicture: true, desktop: false }),
{ encodingStrategy: "omit" },
),
),
pollMinutes: Schema.Int.annotate({
title: "Poll interval (minutes)",
default: 15,
}).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(15), {
encodingStrategy: "omit",
}),
),
artSource: Schema.Literals(["local", "cdn", "both"])
.annotate({ title: "Art source", default: "both" })
.pipe(
Schema.withDecodingDefaultKey(Effect.succeed("both" as const), {
encodingStrategy: "omit",
}),
),
});
const props = (): Record<string, Record<string, unknown>> => {
const doc = deriveConfigJsonSchema(ScannerConfig) as {
schema: { properties: Record<string, Record<string, unknown>> };
};
return doc.schema.properties;
};
describe("S2 — JSON Schema derivation for __config", () => {
test("derives a renderable form for every shape a scanner config uses", () => {
const p = props();
expect(p.enabled).toMatchObject({ type: "boolean" });
expect(p.root).toMatchObject({ type: "string" });
expect(p.extraRoots).toMatchObject({
type: "array",
items: { type: "string" },
});
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
expect(p.launchers).toMatchObject({
type: "object",
properties: {
bigpicture: { type: "boolean" },
desktop: { type: "boolean" },
},
});
// A literal union derives a clean enum — prefer it over a union of strings.
expect(p.artSource).toMatchObject({
type: "string",
enum: ["local", "cdn", "both"],
});
});
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
const p = props();
expect(p.enabled.title).toBe("Enable scanning");
expect(p.enabled.description).toBe(
"Whether this source contributes titles.",
);
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
// field shows the form no placeholder at all. Annotate every field.
expect(p.enabled.default).toBe(true);
expect(p.artSource.default).toBe("both");
// A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a
// form reading `default` must merge allOf branches rather than only looking at the top level.
expect(p.pollMinutes.allOf).toEqual([
{ default: 15, title: "Poll interval (minutes)" },
]);
});
test("a decoding default is an Effect, not a thunk — and it actually applies", () => {
// The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk
// (`() => true`) type-checks against the derivation path but blows up at DECODE time with
// "Not a valid effect". Deriving a schema is therefore NOT evidence that it works.
expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({
enabled: true,
pollMinutes: 15,
artSource: "both",
launchers: { bigpicture: true, desktop: false },
});
});
test("Schema.Int derives a plain integer — Schema.Number does NOT", () => {
expect(props().pollMinutes).toMatchObject({ type: "integer" });
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
// derives a four-way anyOf that no number input can render. Use Finite or Int.
const bad = deriveConfigJsonSchema(Schema.Struct({ n: Schema.Number })) as {
schema: { properties: { n: { anyOf?: unknown[] } } };
};
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
const ok = deriveConfigJsonSchema(Schema.Struct({ n: Schema.Finite })) as {
schema: { properties: { n: { type?: string } } };
};
expect(ok.schema.properties.n.type).toBe("number");
});
test("defaulted fields drop out of `required` — the raw file stays default-free", () => {
const doc = deriveConfigJsonSchema(ScannerConfig) as {
schema: { required?: string[] };
};
// Every field here either has a decoding default or is optionalKey, so nothing is required.
expect(doc.schema.required ?? []).toEqual([]);
});
});
describe("__config wire contract", () => {
const withService = async <A>(
use: (
handler: (req: Request) => Promise<Response>,
file: string,
) => Promise<A>,
): Promise<A> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
process.env.PUNKTFUNK_CONFIG_DIR = dir;
try {
const service = await Effect.runPromise(
makeConfigService({ schema: ScannerConfig }).pipe(
Effect.provide(
Layer.mergeAll(
pluginInfoLayer({ name: "steam", version: "0.1.0" }),
),
),
),
);
return await use(
makeConfigHandler({ schema: ScannerConfig, service }),
service.path,
);
} finally {
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
fs.rmSync(dir, { recursive: true, force: true });
}
};
test("GET answers {schema, value} with an absent file reading as empty", async () => {
await withService(async (handler) => {
const res = await handler(new Request("http://x/__config"));
expect(res.status).toBe(200);
const body = (await res.json()) as { schema: unknown; value: unknown };
// Both keys are ALWAYS present and never `undefined` — the console decodes this shape,
// and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release.
expect(body).toHaveProperty("schema");
expect(body).toHaveProperty("value");
expect(body.schema).not.toBeNull();
// A missing config file is an EMPTY config, not an error.
expect(body.value).toEqual({});
});
});
test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => {
await withService(async (handler, file) => {
const res = await handler(
new Request("http://x/__config", {
method: "PUT",
body: JSON.stringify({ enabled: false }),
}),
);
expect(res.status).toBe(200);
// The file holds exactly what was authored — the five defaulted fields are NOT written,
// which is what keeps a future change to a default from being silently pinned.
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
enabled: false,
});
const get = (await (
await handler(new Request("http://x/__config"))
).json()) as { value: unknown };
expect(get.value).toEqual({ enabled: false });
});
});
test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => {
await withService(async (handler, file) => {
const res = await handler(
new Request("http://x/__config", {
method: "PUT",
body: JSON.stringify({ enabled: "yes please" }),
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; issue: string };
expect(body.error).toBe("config rejected");
expect(body.issue.length).toBeGreaterThan(0);
expect(fs.existsSync(file)).toBe(false);
});
});
test("PUT rejects a non-JSON body", async () => {
await withService(async (handler) => {
const res = await handler(
new Request("http://x/__config", { method: "PUT", body: "not json" }),
);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBe(
"body must be JSON",
);
});
});
test("other methods are refused", async () => {
await withService(async (handler) => {
const res = await handler(
new Request("http://x/__config", { method: "DELETE" }),
);
expect(res.status).toBe(405);
});
});
});