// 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), { 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> => { const doc = deriveConfigJsonSchema(ScannerConfig) as { schema: { properties: Record> }; }; 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 ( use: (handler: (req: Request) => Promise, file: string) => Promise, ): Promise => { 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); }); }); });