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.
140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
// Spike 2 (plan Phase 0): prove the browser-side client strategy for the console proxy
|
|
// prefix (`/plugin-ui/<id>/...`), headless via AtomRegistry (no React needed).
|
|
//
|
|
// The console serves the plugin SPA under a path prefix; the HttpApi contract uses
|
|
// absolute endpoint paths ("/api/status"). This spike pins down how the derived client
|
|
// must be configured so requests keep the prefix:
|
|
// - transformClient + HttpClientRequest.prependUrl(prefix) → expected to work
|
|
// - baseUrl with a path prefix → documented behavior
|
|
// Also verifies the nested `withDecodingDefaultKey` config-defaults pattern the kit's
|
|
// Config service relies on.
|
|
import { describe, expect, test } from "bun:test";
|
|
import { Effect, Layer, Schema } from "effect";
|
|
import {
|
|
HttpClient,
|
|
HttpClientRequest,
|
|
HttpClientResponse,
|
|
} from "effect/unstable/http";
|
|
import {
|
|
HttpApi,
|
|
HttpApiEndpoint,
|
|
HttpApiGroup,
|
|
} from "effect/unstable/httpapi";
|
|
import { AtomHttpApi, AtomRegistry } from "effect/unstable/reactivity";
|
|
|
|
const Pong = Schema.Struct({ ok: Schema.Boolean });
|
|
|
|
const api = HttpApi.make("spike").add(
|
|
HttpApiGroup.make("spike").add(
|
|
HttpApiEndpoint.get("ping", "/api/ping", { success: Pong }),
|
|
),
|
|
);
|
|
|
|
const captureClient = (captured: Array<string>) =>
|
|
HttpClient.make((request) => {
|
|
captured.push(request.url);
|
|
return Effect.succeed(
|
|
HttpClientResponse.fromWeb(request, Response.json({ ok: true })),
|
|
);
|
|
});
|
|
|
|
const PREFIX = "http://plugin.local/plugin-ui/rom-manager";
|
|
|
|
describe("spike 2: client prefix through the console proxy", () => {
|
|
test("transformClient + prependUrl keeps the path prefix", async () => {
|
|
const captured: Array<string> = [];
|
|
class Api extends AtomHttpApi.Service<Api>()("SpikeApiPrepend", {
|
|
api,
|
|
httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)),
|
|
transformClient: HttpClient.mapRequest(
|
|
HttpClientRequest.prependUrl(PREFIX),
|
|
),
|
|
}) {}
|
|
|
|
const registry = AtomRegistry.make();
|
|
const result = await Effect.runPromise(
|
|
AtomRegistry.getResult(registry, Api.query("spike", "ping", {})),
|
|
);
|
|
expect(result).toEqual({ ok: true });
|
|
expect(captured).toHaveLength(1);
|
|
expect(captured[0]).toBe(`${PREFIX}/api/ping`);
|
|
});
|
|
|
|
test("documents baseUrl behavior with a path-prefix base", async () => {
|
|
const captured: Array<string> = [];
|
|
class Api extends AtomHttpApi.Service<Api>()("SpikeApiBaseUrl", {
|
|
api,
|
|
httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)),
|
|
baseUrl: PREFIX,
|
|
}) {}
|
|
|
|
const registry = AtomRegistry.make();
|
|
await Effect.runPromise(
|
|
AtomRegistry.getResult(registry, Api.query("spike", "ping", {})),
|
|
);
|
|
expect(captured).toHaveLength(1);
|
|
// If this equals `${PREFIX}/api/ping`, baseUrl would also be fine; if the prefix is
|
|
// dropped (URL-resolution semantics for absolute paths), transformClient is the way.
|
|
// Either way the assertion records the actual behavior for the kit docs.
|
|
console.log("baseUrl produced:", captured[0]);
|
|
expect(captured[0]).toContain("/api/ping");
|
|
});
|
|
});
|
|
|
|
describe("spike 2b: nested withDecodingDefaultKey config defaults", () => {
|
|
// withDecodingDefaultKey wraps the schema in optionalKey itself; the default is an
|
|
// Effect producing the ENCODED value. `encodingStrategy: "omit"` makes encode drop
|
|
// defaulted keys again — the raw-round-trip behavior the kit Config service wants.
|
|
const SyncCfg = Schema.Struct({
|
|
pollMinutes: Schema.Number.pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
watch: Schema.Boolean.pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
});
|
|
const Cfg = Schema.Struct({
|
|
roots: Schema.Array(Schema.String).pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed([]), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
sync: SyncCfg.pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed({}), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
),
|
|
});
|
|
|
|
test("empty raw file decodes to full defaults (nested)", () => {
|
|
const decoded = Schema.decodeUnknownSync(Cfg)({});
|
|
expect(decoded).toEqual({
|
|
roots: [],
|
|
sync: { pollMinutes: 15, watch: true },
|
|
});
|
|
});
|
|
|
|
test("partially-authored raw keeps authored values, fills the rest", () => {
|
|
const decoded = Schema.decodeUnknownSync(Cfg)({
|
|
roots: ["/roms"],
|
|
sync: { pollMinutes: 5 },
|
|
});
|
|
expect(decoded).toEqual({
|
|
roots: ["/roms"],
|
|
sync: { pollMinutes: 5, watch: true },
|
|
});
|
|
});
|
|
|
|
test("unknown keys in the raw file are tolerated (legacy ui/devEntry)", () => {
|
|
const decoded = Schema.decodeUnknownSync(Cfg)({
|
|
ui: { port: 5885 },
|
|
devEntry: true,
|
|
} as unknown);
|
|
expect(decoded.roots).toEqual([]);
|
|
});
|
|
});
|