Skip to content

Commit 8ee5fcb

Browse files
committed
test(plugin-hono-server): pin the UI auto-discovery block end to end
The block in `HonoServerPlugin.start()` reads `type`, `staticPath` and `slug` off every loaded plugin and mounts `/slug` plus `/slug/*` for the ones that answer. Grepping `staticPath` across every `.ts` outside `node_modules`/`dist` returned four lines -- the `PluginSchema` declaration and the three reads inside the block itself -- and `slug` had the same shape. Zero producers, no test: the block served only externally authored plugins, and nothing in the tree would have noticed if it stopped working. A block in that state is indistinguishable from dead code to anyone reading the repository, which is what it already cost: a careful reader concluded the legacy arm was unreachable and the premise had to be falsified by a purpose-built probe driving the real kernel. This is that probe, made permanent. Four pins, three of them landed: A -- `ObjectKernel.use()` accepts a `ui` plugin and stores `type`, `staticPath` and `slug` verbatim into `kernel.plugins`. B -- the modern `ui` arm mounts `/slug` and `/slug/*`, for an explicit slug and for one derived from the plugin name. C -- the legacy `ui-plugin` arm: a documented placeholder, because the two candidate shapes are incompatible and the ruling picks between them. D -- the negative control: same fixture, same existing static root, one key different, mounts nothing. Without D, B proves nothing. The fixture is registered through the real kernel and the real `init()`/`start()` run against the context the kernel hands its plugins. No product behaviour is changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent 212eaba commit 8ee5fcb

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The UI auto-discovery block in `HonoServerPlugin.start()`, pinned end to end.
5+
*
6+
* WHY THIS FILE EXISTS (#16050). That block reads three keys off every plugin
7+
* the kernel has loaded — `type`, `staticPath`, `slug` — and mounts `/slug`
8+
* plus `/slug/*` for the ones that answer. Before this file, grepping
9+
* `staticPath` across every `.ts` outside `node_modules`/`dist` returned four
10+
* lines: the `PluginSchema` declaration and the three reads inside the block
11+
* itself. `slug` had the same shape. Zero producers, and no test — so the block
12+
* was exercised by no in-repo plugin, served only externally authored ones, and
13+
* nothing in the tree would have noticed if it stopped working.
14+
*
15+
* A block in that state is not merely untested, it is INDISTINGUISHABLE FROM
16+
* DEAD CODE to anyone reading this repository, and #15638 is what that costs:
17+
* a careful reader concluded the legacy arm was unreachable and the premise had
18+
* to be falsified by a purpose-built probe driving the real kernel. This file is
19+
* that probe, made permanent — the artifact in the tree that says the block is
20+
* live.
21+
*
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, so the pin measures the
25+
* whole path a real UI plugin takes: loader validation, the verbatim store into
26+
* `kernel.plugins`, the read back out, and the routes handed to `rawApp.get`.
27+
* Nothing here stubs the kernel, the plugin, or the branch under test.
28+
*
29+
* WHY THE NEGATIVE CONTROL IS NOT OPTIONAL. A harness that mounts everything
30+
* would produce pin B's four route registrations whether or not the branch
31+
* works. Pin D is the calibration: the SAME fixture, the SAME on-disk static
32+
* root, one key different, must produce `[]`. Without D, B proves nothing.
33+
*/
34+
35+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
36+
import fs from 'node:fs';
37+
import os from 'node:os';
38+
import path from 'node:path';
39+
import { ObjectKernel } from '@objectstack/core';
40+
import type { Plugin, PluginContext } from '@objectstack/core';
41+
import { HonoServerPlugin } from './hono-plugin';
42+
43+
/**
44+
* The auto-discovery block skips a mount whose root does not exist on disk
45+
* (`fs.existsSync(mountRoot)`), so the pin needs a real directory. It lives in
46+
* the OS temp dir rather than in the tree: an in-repo fixture root would need a
47+
* tracked ignore rule to keep `Lint & Repo Gates` green, and this needs no
48+
* repository state at all.
49+
*/
50+
let STATIC_ROOT: string;
51+
52+
beforeAll(() => {
53+
STATIC_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'os-hono-ui-pin-'));
54+
fs.writeFileSync(path.join(STATIC_ROOT, 'index.html'), '<!doctype html>');
55+
});
56+
57+
afterAll(() => {
58+
fs.rmSync(STATIC_ROOT, { recursive: true, force: true });
59+
});
60+
61+
/**
62+
* The two keys the block reads are declared on `PluginSchema`
63+
* (`packages/spec/src/kernel/plugin.zod.ts`) but NOT on the `Plugin` interface
64+
* the kernel's `use()` accepts (`packages/core/src/types.ts`) — which is one
65+
* reason the repo contained no producer of either. The fixture states the extra
66+
* surface explicitly instead of casting it away, so a future change to `Plugin`
67+
* that adopts these keys does not silently pass this file by.
68+
*/
69+
type UiPluginFixture = Plugin & {
70+
staticPath?: string;
71+
slug?: string;
72+
default?: boolean;
73+
};
74+
75+
function makeFixture(overrides: Partial<UiPluginFixture> & { name: string }): UiPluginFixture {
76+
return {
77+
version: '1.0.0',
78+
type: 'ui',
79+
staticPath: STATIC_ROOT,
80+
init: () => { /* a UI plugin contributes assets, not services */ },
81+
...overrides,
82+
};
83+
}
84+
85+
interface Observation {
86+
/** Every route argument handed to `rawApp.get`, in registration order. */
87+
routes: string[];
88+
/** The entry `kernel.use()` left in the kernel's own plugin map. */
89+
stored: Record<string, unknown> | undefined;
90+
}
91+
92+
/**
93+
* Register `fixture` on a real kernel, run the real Hono plugin's `init()` and
94+
* `start()`, and report what the auto-discovery block did.
95+
*/
96+
async function observe(fixture: UiPluginFixture): Promise<Observation> {
97+
const kernel = new ObjectKernel({
98+
logger: { level: 'silent' },
99+
// No process signal handlers: this kernel is never bootstrapped or shut
100+
// down, and a listener per test case would leak across the file.
101+
gracefulShutdown: false,
102+
});
103+
104+
await kernel.use(fixture as Plugin);
105+
106+
const honoPlugin = new HonoServerPlugin({ port: 0 });
107+
108+
// The very object `bootstrap()` passes to every plugin: `initPluginWithTimeout`
109+
// calls `plugin.init(this.context)` with this context, and its `getKernel()`
110+
// returns this kernel — which is what the block under test reaches through.
111+
const ctx = (kernel as unknown as { context: PluginContext }).context;
112+
113+
await honoPlugin.init(ctx);
114+
115+
// Observe AFTER init(): init() installs middleware and registers hooks, and
116+
// this pin is about the routes `start()` mounts. `getRawApp()` returns the
117+
// adapter's single stable Hono instance, so the spy set here is the one
118+
// `start()` will call.
119+
const rawApp = (
120+
honoPlugin as unknown as { server: { getRawApp(): { get: (...args: unknown[]) => unknown } } }
121+
).server.getRawApp();
122+
123+
const routes: string[] = [];
124+
const spy = vi.spyOn(rawApp, 'get').mockImplementation(((route: string) => {
125+
routes.push(route);
126+
return rawApp;
127+
}) as never);
128+
129+
try {
130+
await honoPlugin.start(ctx);
131+
} finally {
132+
spy.mockRestore();
133+
}
134+
135+
const stored = (kernel as unknown as { plugins: Map<string, Record<string, unknown>> })
136+
.plugins.get(fixture.name);
137+
138+
return { routes, stored };
139+
}
140+
141+
describe('UI plugin auto-discovery (#16050)', () => {
142+
describe('A — the kernel carries the keys the block reads', () => {
143+
it('kernel.use() accepts a `ui` plugin and stores `type`, `staticPath` and `slug` verbatim', async () => {
144+
const { stored } = await observe(
145+
makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' }),
146+
);
147+
148+
// `PluginLoader.toPluginMetadata` is a CAST, not a copy, so keys the
149+
// `Plugin` interface never declares survive into `kernel.plugins`.
150+
// That is precisely what makes the auto-discovery block reachable,
151+
// and it is a property of the loader, not an accident of this test.
152+
expect(stored).toBeDefined();
153+
expect(stored?.type).toBe('ui');
154+
expect(stored?.staticPath).toBe(STATIC_ROOT);
155+
expect(stored?.slug).toBe('console-fixture');
156+
});
157+
});
158+
159+
describe('B — the modern `ui` arm mounts', () => {
160+
it('mounts `/slug` and `/slug/*` for an explicit slug', async () => {
161+
const { routes } = await observe(
162+
makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' }),
163+
);
164+
165+
// Two registrations per route, in this order: the static handler,
166+
// then the scoped SPA fallback (`spa: true` is hard-coded for an
167+
// auto-discovered UI plugin). Pinned as the exact sequence rather
168+
// than a de-duplicated set, because losing the SPA fallback is a
169+
// real regression that a set comparison would hide.
170+
expect(routes).toEqual([
171+
'/console-fixture',
172+
'/console-fixture',
173+
'/console-fixture/*',
174+
'/console-fixture/*',
175+
]);
176+
});
177+
178+
it('derives the slug from the last path segment of the plugin name when none is declared', async () => {
179+
const { routes } = await observe(makeFixture({ name: '@os-fixture/console' }));
180+
181+
// `plugin.slug || plugin.name.split('/').pop()` — the documented
182+
// `@org/console -> console` derivation.
183+
expect(routes).toEqual(['/console', '/console', '/console/*', '/console/*']);
184+
});
185+
});
186+
187+
/**
188+
* C — the legacy `ui-plugin` arm. DELIBERATELY NOT WRITTEN YET.
189+
*
190+
* `hono-plugin.ts` matches `plugin.type === 'ui' || plugin.type === 'ui-plugin'`,
191+
* and the second disjunct is the subject of #15638: `ui-plugin` is not a
192+
* member of `CORE_PLUGIN_TYPES`, so `PluginSchema` refuses the value while
193+
* the boot path — which never calls `PluginSchema` — accepts it and mounts.
194+
* That arm is live, and #15638 decides what it should be. The ruling picks
195+
* between two INCOMPATIBLE pins, so writing either one now would pin a guess:
196+
*
197+
* - if #15638 rules REMOVE, C inverts: a `ui-plugin` fixture must mount
198+
* NOTHING, i.e. `routes` equal to `[]`, exactly like pin D;
199+
* - if #15638 rules DECLARE/CONVERT (an ADR-0087 conversion entry), C
200+
* becomes: a `ui-plugin` fixture is normalised to `ui`, mounts `/slug`
201+
* and `/slug/*` exactly like pin B, and emits one deprecation warning.
202+
*
203+
* Whoever lands #15638 writes this case in that PR — the harness above takes
204+
* it unchanged; only the fixture's `type` and the expectation differ. Until
205+
* then the placeholder is the honest state: measured as live on #15638,
206+
* unpinned here on purpose.
207+
*/
208+
it.todo('C — the legacy `ui-plugin` arm behaves as #15638 rules that it should');
209+
210+
describe('D — the negative control: the harness can produce an empty result', () => {
211+
it('a non-UI type mounts nothing', async () => {
212+
const { routes, stored } = await observe(
213+
makeFixture({
214+
name: '@os-fixture/driver',
215+
type: 'driver',
216+
slug: 'driver-fixture',
217+
}),
218+
);
219+
220+
// Same fixture builder, same EXISTING static root, same slug shape as
221+
// pin B — `type` is the only difference. So `[]` here is caused by the
222+
// type guard and not by a harness that never mounts, and pin B's four
223+
// registrations are caused by the branch and not by a harness that
224+
// mounts everything.
225+
expect(stored?.staticPath).toBe(STATIC_ROOT);
226+
expect(routes).toEqual([]);
227+
});
228+
229+
it('a `ui` type with no staticPath mounts nothing', async () => {
230+
const { routes } = await observe(
231+
makeFixture({
232+
name: '@os-fixture/console-no-assets',
233+
staticPath: undefined,
234+
slug: 'console-fixture',
235+
}),
236+
);
237+
238+
// The other conjunct of the same guard (`&& plugin.staticPath`), so a
239+
// change that keeps the type check but drops the assets check cannot
240+
// sit green.
241+
expect(routes).toEqual([]);
242+
});
243+
});
244+
});

0 commit comments

Comments
 (0)