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.
257 lines
9.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|