Files
punktfunk/plugin-kit/test/spike-client-prefix.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

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([]);
});
});