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.
141 lines
4.5 KiB
TypeScript
141 lines
4.5 KiB
TypeScript
// ConfigService semantics (the canonical suite — ported from rom-manager's state.test.ts
|
|
// obligations): raw round-trip, schema defaults at decode only, atomic writes, missing
|
|
// file == defaults, world-writable refusal, changes stream.
|
|
import { afterEach, beforeEach, 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, Fiber, Schema, Stream } from "effect";
|
|
import {
|
|
type ConfigService,
|
|
makeConfigService,
|
|
pluginInfoLayer,
|
|
pluginStateDir,
|
|
} from "../src/index.js";
|
|
|
|
const TestSchema = Schema.Struct({
|
|
roots: Schema.Array(Schema.String).pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed([]), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
sync: Schema.Struct({
|
|
pollMinutes: Schema.Number.pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
watch: Schema.Boolean.pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
}).pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed({}), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
});
|
|
|
|
const PLUGIN = "kit-config-test";
|
|
let tmp: string;
|
|
|
|
const withService = <A>(
|
|
f: (svc: ConfigService<typeof TestSchema>) => Effect.Effect<A, unknown>,
|
|
): Promise<A> =>
|
|
Effect.runPromise(
|
|
makeConfigService({ schema: TestSchema }).pipe(
|
|
Effect.flatMap(f),
|
|
Effect.provide(pluginInfoLayer({ name: PLUGIN })),
|
|
) as Effect.Effect<A, never>,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-config-"));
|
|
process.env.PUNKTFUNK_CONFIG_DIR = tmp;
|
|
});
|
|
afterEach(() => {
|
|
delete process.env.PUNKTFUNK_CONFIG_DIR;
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("ConfigService", () => {
|
|
test("missing file decodes to full defaults", async () => {
|
|
const loaded = await withService((svc) => svc.load);
|
|
expect(loaded).toEqual({
|
|
roots: [],
|
|
sync: { pollMinutes: 15, watch: true },
|
|
});
|
|
});
|
|
|
|
test("saveRaw persists the RAW shape verbatim (no defaults baked in)", async () => {
|
|
await withService((svc) => svc.saveRaw({ roots: ["/roms"] }));
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(pluginStateDir(PLUGIN), "config.json"), "utf8"),
|
|
);
|
|
expect(onDisk).toEqual({ roots: ["/roms"] }); // no sync block materialized
|
|
const loaded = await withService((svc) => svc.load);
|
|
expect(loaded.sync.pollMinutes).toBe(15);
|
|
});
|
|
|
|
test("saveRaw rejects an invalid raw config and leaves the file untouched", async () => {
|
|
await withService((svc) => svc.saveRaw({ roots: ["/a"] }));
|
|
const before = fs.readFileSync(
|
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
|
"utf8",
|
|
);
|
|
const exit = await withService((svc) =>
|
|
Effect.exit(svc.saveRaw({ roots: "not-an-array" })),
|
|
);
|
|
expect(exit._tag).toBe("Failure");
|
|
const after = fs.readFileSync(
|
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
|
"utf8",
|
|
);
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
test("unknown keys in the file are tolerated and survive loadRaw", async () => {
|
|
fs.mkdirSync(pluginStateDir(PLUGIN), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(
|
|
path.join(pluginStateDir(PLUGIN), "config.json"),
|
|
JSON.stringify({ roots: [], ui: { port: 5885 }, devEntry: true }),
|
|
{ mode: 0o600 },
|
|
);
|
|
const raw = (await withService((svc) => svc.loadRaw)) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(raw.ui).toEqual({ port: 5885 }); // verbatim — a save decides what survives
|
|
});
|
|
|
|
test("refuses a group/world-writable config file (POSIX)", async () => {
|
|
if (process.platform === "win32") return;
|
|
fs.mkdirSync(pluginStateDir(PLUGIN), { recursive: true, mode: 0o700 });
|
|
const file = path.join(pluginStateDir(PLUGIN), "config.json");
|
|
fs.writeFileSync(file, "{}");
|
|
fs.chmodSync(file, 0o666); // bypass umask — writeFileSync's mode is masked
|
|
const exit = await withService((svc) => Effect.exit(svc.load));
|
|
expect(exit._tag).toBe("Failure");
|
|
if (exit._tag === "Failure") {
|
|
expect(String(exit.cause)).toContain("ConfigPermissionError");
|
|
}
|
|
});
|
|
|
|
test("changes stream emits the decoded config after saveRaw", async () => {
|
|
const decoded = await withService((svc) =>
|
|
Effect.gen(function* () {
|
|
const fiber = yield* Effect.forkChild(
|
|
svc.changes.pipe(Stream.take(1), Stream.runCollect),
|
|
);
|
|
yield* Effect.sleep("20 millis"); // let the subscription attach
|
|
yield* svc.saveRaw({ roots: ["/x"], sync: { pollMinutes: 5 } });
|
|
return yield* Fiber.join(fiber);
|
|
}),
|
|
);
|
|
expect(decoded).toEqual([
|
|
{ roots: ["/x"], sync: { pollMinutes: 5, watch: true } },
|
|
]);
|
|
});
|
|
});
|