Files
punktfunk/plugin-kit/test/spike-client-prefix.test.ts
T
enricobuehler bb58fde369 feat(plugin-kit): scaffold @punktfunk/plugin-kit + phase-0 spikes
Two spikes validating the riskiest seams of the plugin-kit design before
implementation:

- HttpApi (effect/unstable/httpapi) served on Bun behind the SDK's
  servePluginUi using ONLY effect-core layers (Etag.layerWeak, Path.layer,
  HttpPlatform.layer + FileSystem.layerNoop) — no platform package. Auth,
  schema validation, and static fallthrough all verified end-to-end.
- Browser client prefix strategy for the console proxy: both
  transformClient+prependUrl AND baseUrl preserve the /plugin-ui/<id>
  path prefix. Nested Schema.withDecodingDefaultKey defaults confirmed
  (Effect-valued defaults; encodingStrategy "omit" restores raw shape
  on encode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:41:11 +02:00

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