Skip to content

Commit 381abe2

Browse files
committed
test(plugin-hono-server): serve through the mount, not just register it
Contract review found the landed pins blind past the route string: because A, B and D replace `rawApp.get` with a recorder, no handler is ever installed and nothing is served, so four mutations that change real route behaviour left every pin green -- `root` swapped to `process.cwd()`, `rewrite` flipped to false, the type guard widened to `!== 'driver'`, and the default redirect disabled. A pin file whose title promises more than it measures is the same defect this card exists to close, one turn later: a new artifact a reader over-trusts. So: E (new) -- leaves `rawApp.get` alone, installs the real handlers on the real Hono app, and drives `rawApp.request(...)`. Two requests: the base route must answer with the fixture's own index bytes, and an asset request must answer with the asset -- the second is what pins the prefix strip, since without it the SPA fallback returns index.html with a 200 and only the BODY tells the two apart. D (widened) -- now every declared plugin type except `ui`, derived from `CORE_PLUGIN_TYPES` rather than listed, so the guard's specificity is pinned and a type added tomorrow is covered without anyone remembering. Header -- says what each group actually observes, registration vs response, and names the `default`/`isDefault` redirect as still unpinned. The `default` redirect is deliberately NOT pinned: the fixture does not set it and that is a wider change than this card carries. Pin C is untouched -- the #15638 deferral stands exactly as it was. Test file only. No product code changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent 8ee5fcb commit 381abe2

1 file changed

Lines changed: 129 additions & 15 deletions

File tree

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

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,25 @@
2121
*
2222
* WHAT MAKES IT END-TO-END. The fixture plugin is registered through the real
2323
* `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.
24+
* against the context the kernel hands its plugins. Nothing here stubs the
25+
* kernel, the plugin, or the branch under test.
26+
*
27+
* 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
29+
* `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 —
31+
* and it is blind to everything downstream of the route string. E closes that:
32+
* it leaves `rawApp.get` alone, so the real handlers install on the real Hono
33+
* app, and drives `rawApp.request(...)` to pin what actually comes BACK. E is
34+
* what makes `staticPath` load-bearing (the bytes served come from that
35+
* directory) and `rewrite: true` load-bearing (the prefix really is stripped
36+
* before the file is looked up) — two properties every registration-only
37+
* assertion in this file passes through untouched.
38+
*
39+
* STILL UNPINNED, deliberately, and named here so the next reader does not
40+
* over-trust this file: the `default`/`isDefault` redirect that mounts `/` at
41+
* the plugin's base route. The fixture does not set it, and pinning it is a
42+
* wider change than this card carries.
2843
*
2944
* WHY THE NEGATIVE CONTROL IS NOT OPTIONAL. A harness that mounts everything
3045
* would produce pin B's four route registrations whether or not the branch
@@ -37,6 +52,7 @@ import fs from 'node:fs';
3752
import os from 'node:os';
3853
import path from 'node:path';
3954
import { ObjectKernel } from '@objectstack/core';
55+
import { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel';
4056
import type { Plugin, PluginContext } from '@objectstack/core';
4157
import { HonoServerPlugin } from './hono-plugin';
4258

@@ -49,9 +65,15 @@ import { HonoServerPlugin } from './hono-plugin';
4965
*/
5066
let STATIC_ROOT: string;
5167

68+
/** Served verbatim by pin E, so its exact bytes are part of the assertion. */
69+
const INDEX_HTML = '<!doctype html>';
70+
const ASSET_CSS = '.os-pin{color:#123456}';
71+
5272
beforeAll(() => {
5373
STATIC_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'os-hono-ui-pin-'));
54-
fs.writeFileSync(path.join(STATIC_ROOT, 'index.html'), '<!doctype html>');
74+
fs.writeFileSync(path.join(STATIC_ROOT, 'index.html'), INDEX_HTML);
75+
fs.mkdirSync(path.join(STATIC_ROOT, 'assets'));
76+
fs.writeFileSync(path.join(STATIC_ROOT, 'assets', 'app.css'), ASSET_CSS);
5577
});
5678

5779
afterAll(() => {
@@ -93,7 +115,25 @@ interface Observation {
93115
* Register `fixture` on a real kernel, run the real Hono plugin's `init()` and
94116
* `start()`, and report what the auto-discovery block did.
95117
*/
96-
async function observe(fixture: UiPluginFixture): Promise<Observation> {
118+
interface Booted {
119+
kernel: ObjectKernel;
120+
honoPlugin: HonoServerPlugin;
121+
ctx: PluginContext;
122+
rawApp: RawApp;
123+
}
124+
125+
/** The subset of the raw Hono app these pins touch. */
126+
interface RawApp {
127+
get: (...args: unknown[]) => unknown;
128+
request: (input: string) => Promise<Response>;
129+
}
130+
131+
/**
132+
* Register `fixture` on a real kernel and run the real plugin's `init()`, stopping
133+
* short of `start()` so each pin can decide whether to watch registration or let
134+
* it happen for real.
135+
*/
136+
async function boot(fixture: UiPluginFixture): Promise<Booted> {
97137
const kernel = new ObjectKernel({
98138
logger: { level: 'silent' },
99139
// No process signal handlers: this kernel is never bootstrapped or shut
@@ -112,14 +152,25 @@ async function observe(fixture: UiPluginFixture): Promise<Observation> {
112152

113153
await honoPlugin.init(ctx);
114154

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.
155+
// `getRawApp()` returns the adapter's single stable Hono instance, so what is
156+
// taken here is the object `start()` will register on.
119157
const rawApp = (
120-
honoPlugin as unknown as { server: { getRawApp(): { get: (...args: unknown[]) => unknown } } }
158+
honoPlugin as unknown as { server: { getRawApp(): RawApp } }
121159
).server.getRawApp();
122160

161+
return { kernel, honoPlugin, ctx, rawApp };
162+
}
163+
164+
/**
165+
* Run `start()` with `rawApp.get` replaced by a recorder, and report the route
166+
* strings the auto-discovery block handed it.
167+
*
168+
* ⚠️ Nothing is installed and nothing is served under this helper — that is the
169+
* point of pin E, which does not use it.
170+
*/
171+
async function observe(fixture: UiPluginFixture): Promise<Observation> {
172+
const { kernel, honoPlugin, ctx, rawApp } = await boot(fixture);
173+
123174
const routes: string[] = [];
124175
const spy = vi.spyOn(rawApp, 'get').mockImplementation(((route: string) => {
125176
routes.push(route);
@@ -138,6 +189,16 @@ async function observe(fixture: UiPluginFixture): Promise<Observation> {
138189
return { routes, stored };
139190
}
140191

192+
/**
193+
* Run `start()` for real — `rawApp.get` untouched, so the static and SPA handlers
194+
* actually install — and hand back the app to issue requests against.
195+
*/
196+
async function serve(fixture: UiPluginFixture): Promise<RawApp> {
197+
const { honoPlugin, ctx, rawApp } = await boot(fixture);
198+
await honoPlugin.start(ctx);
199+
return rawApp;
200+
}
201+
141202
describe('UI plugin auto-discovery (#16050)', () => {
142203
describe('A — the kernel carries the keys the block reads', () => {
143204
it('kernel.use() accepts a `ui` plugin and stores `type`, `staticPath` and `slug` verbatim', async () => {
@@ -208,12 +269,19 @@ describe('UI plugin auto-discovery (#16050)', () => {
208269
it.todo('C — the legacy `ui-plugin` arm behaves as #15638 rules that it should');
209270

210271
describe('D — the negative control: the harness can produce an empty result', () => {
211-
it('a non-UI type mounts nothing', async () => {
272+
// Every declared plugin type EXCEPT `ui`, read off the spec's own closed set
273+
// rather than listed here — so a type added to `CORE_PLUGIN_TYPES` tomorrow
274+
// is covered without anyone remembering to come back. Enumerating the whole
275+
// complement is what makes the guard's SPECIFICITY pinned: a single `driver`
276+
// control would sit green while the guard was widened to `type !== 'driver'`.
277+
const NON_UI_TYPES = ['standard', ...CORE_PLUGIN_TYPES].filter((t) => t !== 'ui');
278+
279+
it.each(NON_UI_TYPES)('a `%s` plugin mounts nothing', async (type) => {
212280
const { routes, stored } = await observe(
213281
makeFixture({
214-
name: '@os-fixture/driver',
215-
type: 'driver',
216-
slug: 'driver-fixture',
282+
name: `@os-fixture/${type}`,
283+
type: type as UiPluginFixture['type'],
284+
slug: 'not-ui-fixture',
217285
}),
218286
);
219287

@@ -241,4 +309,50 @@ describe('UI plugin auto-discovery (#16050)', () => {
241309
expect(routes).toEqual([]);
242310
});
243311
});
312+
313+
/**
314+
* E — served, not merely registered.
315+
*
316+
* A, B and D replace `rawApp.get`, so they observe route STRINGS and nothing
317+
* downstream of them: which directory is mounted, and whether the route prefix
318+
* is stripped before the file is looked up, are both invisible to every one of
319+
* them. Measured, not assumed — with `root` swapped to `process.cwd()` and with
320+
* `rewrite` flipped to `false`, all of A, B and D stay green.
321+
*
322+
* So this group installs the real handlers and asks the real Hono app for a
323+
* response. It is what makes the file's `staticPath` and `rewrite` claims
324+
* load-bearing rather than decorative.
325+
*/
326+
describe('E — the mounted routes actually serve from staticPath', () => {
327+
it('serves the SPA index for the base route', async () => {
328+
const rawApp = await serve(
329+
makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' }),
330+
);
331+
332+
const res = await rawApp.request('/console-fixture/');
333+
334+
// The bytes come out of the fixture's own temp directory, so a mount
335+
// pointed anywhere else cannot answer this.
336+
expect(res.status).toBe(200);
337+
expect(await res.text()).toContain(INDEX_HTML);
338+
});
339+
340+
it('strips the route prefix before looking the asset up', async () => {
341+
const rawApp = await serve(
342+
makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' }),
343+
);
344+
345+
const res = await rawApp.request('/console-fixture/assets/app.css');
346+
const body = await res.text();
347+
348+
// `rewrite: true` turns /console-fixture/assets/app.css into
349+
// /assets/app.css before the lookup. Without the strip the file is
350+
// missed and the SPA fallback answers with index.html INSTEAD — a 200
351+
// either way, which is exactly why the assertion is on the body and
352+
// names the wrong answer explicitly rather than checking the status.
353+
expect(res.status).toBe(200);
354+
expect(body.trim()).toBe(ASSET_CSS);
355+
expect(body).not.toContain(INDEX_HTML);
356+
});
357+
});
244358
});

0 commit comments

Comments
 (0)