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';
3752import os from 'node:os' ;
3853import path from 'node:path' ;
3954import { ObjectKernel } from '@objectstack/core' ;
55+ import { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel' ;
4056import type { Plugin , PluginContext } from '@objectstack/core' ;
4157import { HonoServerPlugin } from './hono-plugin' ;
4258
@@ -49,9 +65,15 @@ import { HonoServerPlugin } from './hono-plugin';
4965 */
5066let 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+
5272beforeAll ( ( ) => {
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
5779afterAll ( ( ) => {
@@ -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+
141202describe ( '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