Skip to content

Commit a8f3faa

Browse files
os-trumpclaude
andauthored
test(plugin-hono-server): pin the LiteKernel half of the UI auto-discovery guards (#16599) (#16725)
The pin file asserted, in prose, that the slug-from-name fallback and the `&& plugin.staticPath` conjunct were dead code once `PluginSchema` began requiring both keys. That was a claim about every entry point argued from one. Measured per kernel, it is false for the second published kernel: - `ObjectKernel.use()` runs `PluginLoader.validatePluginContract` -> `PluginSchema.safeParse` and refuses both inputs (#16334, #16363). The two existing refusal pins are correct and keep their expectations. - `LiteKernel.use()` calls `registerPluginByName` directly and never reaches `PluginLoader`, so both objects are stored verbatim, the block iterates them, and both branches execute. Ablation, from this commit: dropping the `||` moves the mounted route from `/console` to `/undefined`; dropping the `&& plugin.staticPath` conjunct turns an assetless `ui` plugin's clean boot into a `TypeError` naming `paths[1]`, out of `path.resolve(process.cwd(), mount.root)`. Both branches are load-bearing. New group F carries those two readings as permanent pins, with F0 as the firing control that the `LiteKernel` harness can mount at all — so F2's `[]` is caused by the conjunct and not by a harness that never mounts. The three passages that said otherwise now state the fact per kernel, including the `it.todo` case-C narration, whose "the boot path — which never calls `PluginSchema`" is still true for `LiteKernel` and stopped being true for `ObjectKernel` at #16363. No runtime change: one test file, comments and cases only. Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d73d45 commit a8f3faa

1 file changed

Lines changed: 236 additions & 30 deletions

File tree

packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts

Lines changed: 236 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,42 @@
1919
* that probe, made permanent — the artifact in the tree that says the block is
2020
* live.
2121
*
22-
* WHAT MAKES IT END-TO-END. The fixture plugin is registered through the real
23-
* `ObjectKernel.use()` and the real `HonoServerPlugin.init()`/`start()` run
24-
* against the context the kernel hands its plugins. Nothing here stubs the
25-
* kernel, the plugin, or the branch under test.
22+
* WHAT MAKES IT END-TO-END. The fixture plugin is registered through a real
23+
* kernel's `use()` and the real `HonoServerPlugin.init()`/`start()` run against
24+
* the context that kernel hands its plugins. Nothing here stubs the kernel, the
25+
* plugin, or the branch under test.
26+
*
27+
* ⭐ WHICH KERNEL, AND WHY THAT IS NOW HALF THE FILE (#16599). This repository
28+
* publishes TWO kernels and `@objectstack/core` exports both. They do not agree
29+
* about this block's inputs, and the disagreement is the reason groups B, D and
30+
* F exist in the shape they do:
31+
*
32+
* - `ObjectKernel.use()` runs `PluginLoader.loadPlugin` ->
33+
* `validatePluginContract` -> `PluginSchema.safeParse` on every plugin
34+
* object (#16049, landed as #16363), and since #16334 that schema requires
35+
* `staticPath` AND `slug` for `type: 'ui'`. A `ui` plugin missing either is
36+
* a boot REFUSAL and never reaches `kernel.plugins` at all.
37+
* - `LiteKernel.use()` calls `registerPluginByName` directly and never
38+
* touches `PluginSchema`, `PluginLoader` or any part of that path — #16363
39+
* changed `PluginLoader` only, and `PluginLoader` is reached from
40+
* `ObjectKernel.use()` alone. The same object is stored verbatim, and
41+
* `ObjectKernelBase.createContext()` hands plugins a context whose
42+
* `getKernel()` returns that kernel, whose `plugins` map is exactly what
43+
* this block iterates.
44+
*
45+
* `AGENTS.md`'s Kernel table names `LiteKernel` for "Tests (vitest), serverless,
46+
* edge (Workers)", so this is not a curiosity — it is the second supported way
47+
* to run a UI plugin, and with zero in-repo `type: 'ui'` producers, externally
48+
* authored plugins are the block's only real callers on EITHER kernel.
49+
*
50+
* ⇒ "Reachable" is therefore not a property of a branch here, it is a property
51+
* of a branch PER KERNEL, and this file states both halves rather than one.
2652
*
2753
* WHAT EACH GROUP ACTUALLY OBSERVES — stated because the difference is the whole
28-
* point of this file. A, B and D observe ROUTE REGISTRATION: they replace
54+
* point of this file. A, B, D and F observe ROUTE REGISTRATION: they replace
2955
* `rawApp.get` with a recorder, so no handler is ever installed and nothing is
30-
* served. That is enough to pin WHICH routes exist and, for D, that none does —
56+
* served. That is enough to pin WHICH routes exist and, for D and F2, that none
57+
* does —
3158
* and it is blind to everything downstream of the route string. E closes that:
3259
* it leaves `rawApp.get` alone, so the real handlers install on the real Hono
3360
* app, and drives `rawApp.request(...)` to pin what actually comes BACK. E is
@@ -47,13 +74,19 @@
4774
* would produce pin B's four route registrations whether or not the branch
4875
* works. Pin D is the calibration: the SAME fixture, the SAME on-disk static
4976
* root, one key different, must produce `[]`. Without D, B proves nothing.
77+
*
78+
* Group F carries its own copy of that discipline rather than borrowing D's,
79+
* because it runs on a different kernel: F0 is the firing control showing the
80+
* `LiteKernel` harness CAN mount, so F2's `[]` is caused by the
81+
* `&& plugin.staticPath` conjunct and not by a harness that never mounts under
82+
* that kernel. ⛔ A group that can only ever produce `[]` measures nothing.
5083
*/
5184

5285
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
5386
import fs from 'node:fs';
5487
import os from 'node:os';
5588
import path from 'node:path';
56-
import { ObjectKernel } from '@objectstack/core';
89+
import { LiteKernel, ObjectKernel } from '@objectstack/core';
5790
import { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel';
5891
import type { Plugin, PluginContext } from '@objectstack/core';
5992
import { HonoServerPlugin } from './hono-plugin';
@@ -127,12 +160,22 @@ interface Observation {
127160
* `start()`, and report what the auto-discovery block did.
128161
*/
129162
interface Booted {
130-
kernel: ObjectKernel;
163+
kernel: KernelUnderTest;
131164
honoPlugin: HonoServerPlugin;
132165
ctx: PluginContext;
133166
rawApp: RawApp;
134167
}
135168

169+
/**
170+
* Either published kernel. Both are exported from `@objectstack/core`, both give
171+
* their plugins a context whose `getKernel()` returns the kernel itself, and both
172+
* keep the loaded plugins in a `plugins` map — the three properties the block
173+
* under test depends on. What they do NOT share is whether `use()` validates:
174+
* see the header. Groups A, B, D and E run on `ObjectKernel`; group F runs on
175+
* `LiteKernel`.
176+
*/
177+
type KernelUnderTest = ObjectKernel | LiteKernel;
178+
136179
/** The subset of the raw Hono app these pins touch. */
137180
interface RawApp {
138181
get: (...args: unknown[]) => unknown;
@@ -154,11 +197,44 @@ async function boot(fixture: UiPluginFixture): Promise<Booted> {
154197

155198
await kernel.use(fixture as Plugin);
156199

200+
return attachHono(kernel);
201+
}
202+
203+
/**
204+
* The `LiteKernel` counterpart of {@link boot} — the kernel `AGENTS.md` names for
205+
* tests, serverless and edge. `LiteKernel.use()` is synchronous and stores the
206+
* object through `registerPluginByName` with no schema in the path, so a fixture
207+
* that {@link boot} REFUSES arrives here intact and the block sees it.
208+
*
209+
* ⚠️ Not merely "less strict": nothing on this path calls `PluginSchema` at all,
210+
* so #16334's `type: 'ui'` requirements and #16363's enforcement are both absent
211+
* here — which is what makes group F a measurement of the branches rather than a
212+
* second copy of group B.
213+
*
214+
* No `gracefulShutdown` option exists on this kernel and it registers no signal
215+
* handlers of its own, so there is nothing to opt out of.
216+
*/
217+
async function bootLite(fixture: UiPluginFixture): Promise<Booted> {
218+
const kernel = new LiteKernel({ logger: { level: 'silent' } });
219+
220+
kernel.use(fixture as Plugin);
221+
222+
return attachHono(kernel);
223+
}
224+
225+
/**
226+
* The half both kernels share: construct the real Hono plugin, take the very
227+
* context object the kernel hands its plugins, and run the real `init()` —
228+
* stopping short of `start()` so each pin can decide whether to watch
229+
* registration or let it happen for real.
230+
*/
231+
async function attachHono(kernel: KernelUnderTest): Promise<Booted> {
157232
const honoPlugin = new HonoServerPlugin({ port: 0 });
158233

159234
// The very object `bootstrap()` passes to every plugin: `initPluginWithTimeout`
160235
// calls `plugin.init(this.context)` with this context, and its `getKernel()`
161236
// returns this kernel — which is what the block under test reaches through.
237+
// `LiteKernel` builds the same object, in `ObjectKernelBase.createContext()`.
162238
const ctx = (kernel as unknown as { context: PluginContext }).context;
163239

164240
await honoPlugin.init(ctx);
@@ -179,8 +255,11 @@ async function boot(fixture: UiPluginFixture): Promise<Booted> {
179255
* ⚠️ Nothing is installed and nothing is served under this helper — that is the
180256
* point of pin E, which does not use it.
181257
*/
182-
async function observe(fixture: UiPluginFixture): Promise<Observation> {
183-
const { kernel, honoPlugin, ctx, rawApp } = await boot(fixture);
258+
async function observe(
259+
fixture: UiPluginFixture,
260+
bootOn: (f: UiPluginFixture) => Promise<Booted> = boot,
261+
): Promise<Observation> {
262+
const { kernel, honoPlugin, ctx, rawApp } = await bootOn(fixture);
184263

185264
const routes: string[] = [];
186265
const spy = vi.spyOn(rawApp, 'get').mockImplementation(((route: string) => {
@@ -247,14 +326,19 @@ describe('UI plugin auto-discovery (#16050)', () => {
247326
]);
248327
});
249328

250-
it('a `ui` plugin declaring no `slug` is refused at kernel.use() before the block can derive one (#16334)', async () => {
329+
it('a `ui` plugin declaring no `slug` is refused at ObjectKernel.use() before the block can derive one (#16334)', async () => {
251330
// `plugin.slug || plugin.name.split('/').pop()` — the block's documented
252-
// `@org/console -> console` derivation — is UNREACHABLE through the
253-
// kernel since #16334: `PluginSchema` requires `slug` for `type: 'ui'`
254-
// and `kernel.use()` runs the schema (#16049), so the object never
255-
// reaches `kernel.plugins`. Pinned as the refusal, with the spec's
256-
// stable code surfacing inside the loader's envelope. The fallback
257-
// expression itself is dead code now, awaiting its own card.
331+
// `@org/console -> console` derivation — is unreachable ON THIS KERNEL
332+
// since #16334: `PluginSchema` requires `slug` for `type: 'ui'` and
333+
// `ObjectKernel.use()` runs the schema (#16049, landed as #16363), so
334+
// the object never reaches `kernel.plugins`. Pinned as the refusal,
335+
// with the spec's stable code surfacing inside the loader's envelope.
336+
//
337+
// ⛔ NOT dead code, and this comment used to say it was (#16599). The
338+
// expression is LIVE AND LOAD-BEARING on `LiteKernel`, which never
339+
// calls `PluginSchema` — pin F1 is the measurement, and ablating the
340+
// `||` there moves the mounted route from `/console` to `/undefined`.
341+
// ⇒ The two halves are one fact stated per kernel; read them together.
258342
const err = await refusal(boot(makeFixture({ name: '@os-fixture/console' })));
259343
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
260344
expect(err.message).toContain("at 'slug'");
@@ -267,21 +351,35 @@ describe('UI plugin auto-discovery (#16050)', () => {
267351
*
268352
* `hono-plugin.ts` matches `plugin.type === 'ui' || plugin.type === 'ui-plugin'`,
269353
* and the second disjunct is the subject of #15638: `ui-plugin` is not a
270-
* member of `CORE_PLUGIN_TYPES`, so `PluginSchema` refuses the value while
271-
* the boot path — which never calls `PluginSchema` — accepts it and mounts.
272-
* That arm is live, and #15638 decides what it should be. The ruling picks
273-
* between two INCOMPATIBLE pins, so writing either one now would pin a guess:
354+
* member of `CORE_PLUGIN_TYPES`, so `PluginSchema` refuses the value.
355+
*
356+
* ⚠️ WHICH KERNEL (#16599). This narration used to say "the boot path — which
357+
* never calls `PluginSchema` — accepts it and mounts", naming no kernel. That
358+
* is true of exactly one of the two, and both were measured:
359+
*
360+
* - `ObjectKernel.use()` REFUSES it since #16363, with
361+
* `PLUGIN_CONTRACT_VIOLATION … at 'type'` naming the closed set.
362+
* - `LiteKernel.use()` still accepts it and the block still mounts `/slug`
363+
* and `/slug/*`, because #16363 changed `PluginLoader` and this kernel
364+
* never reaches `PluginLoader`.
365+
*
366+
* ⇒ #15638's arm is HALF dead — the same shape as the two arms #16599
367+
* measured — and whoever lands it owes both halves rather than one. The
368+
* ruling picks between two INCOMPATIBLE pins, so writing either one now would
369+
* pin a guess:
274370
*
275371
* - if #15638 rules REMOVE, C inverts: a `ui-plugin` fixture must mount
276-
* NOTHING, i.e. `routes` equal to `[]`, exactly like pin D;
372+
* NOTHING on `LiteKernel` too, i.e. `routes` equal to `[]`, exactly like
373+
* pin D;
277374
* - if #15638 rules DECLARE/CONVERT (an ADR-0087 conversion entry), C
278375
* becomes: a `ui-plugin` fixture is normalised to `ui`, mounts `/slug`
279376
* and `/slug/*` exactly like pin B, and emits one deprecation warning.
280377
*
281378
* Whoever lands #15638 writes this case in that PR — the harness above takes
282-
* it unchanged; only the fixture's `type` and the expectation differ. Until
283-
* then the placeholder is the honest state: measured as live on #15638,
284-
* unpinned here on purpose.
379+
* it unchanged; only the fixture's `type`, the kernel it boots on
380+
* (`bootLite`, per group F) and the expectation differ. Until then the
381+
* placeholder is the honest state: measured as live on `LiteKernel` and
382+
* refused on `ObjectKernel`, unpinned here on purpose.
285383
*/
286384
it.todo('C — the legacy `ui-plugin` arm behaves as #15638 rules that it should');
287385

@@ -311,12 +409,19 @@ describe('UI plugin auto-discovery (#16050)', () => {
311409
expect(routes).toEqual([]);
312410
});
313411

314-
it('a `ui` plugin declaring no `staticPath` is refused at kernel.use() before the block runs (#16334)', async () => {
412+
it('a `ui` plugin declaring no `staticPath` is refused at ObjectKernel.use() before the block runs (#16334)', async () => {
315413
// The other conjunct of the same guard (`&& plugin.staticPath`) is
316-
// likewise unreachable through the kernel: `staticPath` is required
317-
// for `type: 'ui'` since #16334, so a `ui` plugin without assets is a
318-
// boot refusal, not a silent non-mount. The `NON_UI_TYPES` cases
319-
// above remain the proof that this harness CAN produce `[]`.
414+
// likewise unreachable ON THIS KERNEL: `staticPath` is required for
415+
// `type: 'ui'` since #16334, so a `ui` plugin without assets is a boot
416+
// refusal here, not a silent non-mount. The `NON_UI_TYPES` cases above
417+
// remain the proof that this harness CAN produce `[]`.
418+
//
419+
// ⛔ Again NOT dead, and this comment used to imply it (#16599): on
420+
// `LiteKernel` the same object reaches the block and the conjunct is
421+
// what skips it — pin F2. Deleting the conjunct there does not
422+
// "remove dead code", it turns a clean boot into a `TypeError` naming
423+
// `paths[1]`, thrown by `path.resolve(process.cwd(), mount.root)`
424+
// further down `start()` once `undefined` is pushed as a mount root.
320425
const err = await refusal(boot(makeFixture({
321426
name: '@os-fixture/console-no-assets',
322427
staticPath: undefined,
@@ -408,4 +513,105 @@ describe('UI plugin auto-discovery (#16050)', () => {
408513
expect(body).toBe(INDEX_HTML);
409514
});
410515
});
516+
517+
/**
518+
* F — the SAME two inputs, on `LiteKernel`, where they are not refused.
519+
*
520+
* WHY THIS GROUP EXISTS (#16599). B and D pin that `ObjectKernel.use()`
521+
* REFUSES a `ui` plugin missing `slug` or `staticPath`, and until this group
522+
* existed the file went on to assert — in prose, with no case behind it —
523+
* that the two branches those keys feed were therefore dead. ⛔ That is a
524+
* claim about every entry point, argued from one. It was wrong.
525+
*
526+
* `LiteKernel.use()` never calls `PluginSchema` (see the header), so both
527+
* inputs reach `kernel.plugins` intact and the block runs against them. Both
528+
* are also ordinary type-legal `Plugin` values — nothing here needs a cast to
529+
* construct them, so this is not a torture fixture, it is what an external
530+
* `ui` plugin looks like when its author left an optional key out.
531+
*
532+
* ⭐ WHAT EACH CASE REPLACES. These three pins carry readings that were
533+
* previously produced by ABLATING `hono-plugin.ts` in a throwaway probe —
534+
* deleting the `||` moved F1's route to `/undefined`, and deleting the
535+
* `&& plugin.staticPath` conjunct turned F2's clean boot into a `TypeError`.
536+
* An ablation proves a branch load-bearing ONCE, in a session nobody can
537+
* re-read. These cases are the same two readings, made permanent, so the next
538+
* reader who concludes "dead code" is contradicted by a red test rather than
539+
* by an argument.
540+
*
541+
* ⛔ F is NOT a claim about which kernel is right. Whether `LiteKernel` should
542+
* validate at all is a contract question, carried on its own card and
543+
* deliberately not pre-empted here. This group pins only what the tree does
544+
* today.
545+
*/
546+
describe('F — the same inputs on `LiteKernel`, which never calls `PluginSchema` (#16599)', () => {
547+
it('F0 — the firing control: a fully declared `ui` plugin mounts on this kernel too', async () => {
548+
const { routes } = await observe(
549+
makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' }),
550+
bootLite,
551+
);
552+
553+
// The calibration F2 depends on, and the reason F2's `[]` is a
554+
// reading rather than a harness that never mounts under this kernel.
555+
// Identical to pin B's expectation, which is the point: the block
556+
// behaves the same on both kernels once the object gets through.
557+
expect(routes).toEqual([
558+
'/console-fixture',
559+
'/console-fixture',
560+
'/console-fixture/*',
561+
'/console-fixture/*',
562+
]);
563+
});
564+
565+
it('F1 — with no `slug`, the fallback derives one from the last path segment of the name', async () => {
566+
const { stored, routes } = await observe(
567+
makeFixture({ name: '@os-fixture/console' }),
568+
bootLite,
569+
);
570+
571+
// The object B could not get past `ObjectKernel.use()` is stored here
572+
// verbatim, `slug` genuinely absent — so the fallback is reached with
573+
// nothing to short-circuit on.
574+
expect(stored).toBeDefined();
575+
expect(stored?.slug).toBeUndefined();
576+
expect(stored?.staticPath).toBe(STATIC_ROOT);
577+
578+
// `plugin.slug || plugin.name.split('/').pop()` — `@os-fixture/console`
579+
// becomes `console`, exactly the `@org/console -> console` derivation
580+
// the block documents. ⭐ THE NAME IS THE ASSERTION: `console` appears
581+
// in no fixture field, only in the tail of `name`, so this expectation
582+
// cannot be satisfied by anything except the fallback running. Drop the
583+
// `||` and every route below reads `/undefined`.
584+
expect(routes).toEqual([
585+
'/console',
586+
'/console',
587+
'/console/*',
588+
'/console/*',
589+
]);
590+
});
591+
592+
it('F2 — with no `staticPath`, the guard skips the plugin and the boot stays clean', async () => {
593+
const fixture = makeFixture({
594+
name: '@os-fixture/console-no-assets',
595+
slug: 'console-fixture',
596+
staticPath: undefined,
597+
});
598+
599+
// ⛔ The assertion is NOT merely `[]`. `start()` resolving is half of
600+
// it: the `&& plugin.staticPath` conjunct is what keeps an assetless
601+
// `ui` plugin from being pushed onto `mounts` with `root: undefined`,
602+
// which `path.resolve(process.cwd(), mount.root)` further down
603+
// `start()` rejects with a `TypeError` naming `paths[1]`. A guard that
604+
// merely "avoided a pointless mount" would be dead weight; this one is
605+
// the difference between a clean boot and a crashed one.
606+
const observation = await observe(fixture, bootLite);
607+
608+
expect(observation.stored).toBeDefined();
609+
expect(observation.stored?.staticPath).toBeUndefined();
610+
611+
// Same kernel, same harness, same fixture builder as F0 — `staticPath`
612+
// is the only difference, so `[]` is caused by the conjunct and not by
613+
// a harness that never mounts here.
614+
expect(observation.routes).toEqual([]);
615+
});
616+
});
411617
});

0 commit comments

Comments
 (0)