11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { afterAll , beforeAll , describe , expect , it } from 'vitest' ;
4+ import { spawn , type ChildProcessByStdio } from 'node:child_process' ;
45import { mkdtempSync , rmSync , writeFileSync } from 'node:fs' ;
56import { tmpdir } from 'node:os' ;
6- import { join } from 'node:path' ;
7- import { randomPort , runServe } from './helpers/serve-process.js' ;
7+ import { join , resolve } from 'node:path' ;
8+ import type { Readable } from 'node:stream' ;
9+ import { fileURLToPath } from 'node:url' ;
10+ import {
11+ childEnv ,
12+ E2E_SECRET_KEY ,
13+ portContentionError ,
14+ requireBuiltCli ,
15+ reservePort ,
16+ RUN_JS_RESOLVES_FROM_DIST ,
17+ } from './helpers/serve-process.js' ;
818
919/**
1020 * #16630 — a REAL `os serve` boot, degraded, read end to end.
@@ -26,106 +36,180 @@ import { randomPort, runServe } from './helpers/serve-process.js';
2636 * ⇒ Testing one package's output is the perspective that produced the defect.
2737 * `src/utils/format.server-ready-degraded-boot.test.ts` closes the seam over a
2838 * real `ObjectKernel`; this file closes it over the real COMMAND — the whole
29- * assembly of plugins, tiers and banner that a user actually runs.
39+ * assembly of plugins, tiers and banner a user actually runs.
3040 *
31- * ## How the degradation is provoked — the shape both incidents had
41+ * ## Why `bin/run.js` with `NODE_ENV` unset, and not `runServe()`
3242 *
33- * Not a fault injected into the kernel: `serve` auto-registers `AuthPlugin`
34- * only when a secret is resolvable, and outside `--dev` there is no fallback
35- * one — so a production boot with no `OS_AUTH_SECRET` skips auth by its own
36- * documented rule, and `auth` (a `core` service with no in-memory fallback)
37- * is genuinely absent. That is the same end state both incidents reached by
38- * other routes: a plugin that did not load and a core service that is not
39- * there.
43+ * The degradation has to be REACHED, not injected, and the only reachable
44+ * spelling is production posture. `serve` auto-registers `AuthPlugin` when a
45+ * secret resolves; outside `--dev` there is no fallback secret, so a production
46+ * boot with no `OS_AUTH_SECRET` skips auth by the command's own documented rule
47+ * (`⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`) and `auth` — a `core` service
48+ * with no in-memory fallback — is genuinely absent. That is the end state both
49+ * incidents reached by other routes: a plugin that did not load, and a core
50+ * service that is not there.
4051 *
41- * ⚠️ This is also the NEGATIVE CONTROL for the acceptance rule that start and
42- * exit behaviour must not change: that boot is a machine DELIBERATELY running
43- * without auth, and it must still reach the banner and still be a live server.
44- * Readiness is not made strict here; the ready line only says what state it is
45- * ready in.
52+ * ⛔ `runServe()` cannot reach it. That helper spawns `bin/run-dev.js`, which
53+ * sets `NODE_ENV = 'development'` before argv is parsed, so `isDev` is true,
54+ * the dev fallback secret applies, auth ALWAYS loads and the boot is never
55+ * degraded. Measured: switching this file to `runServe()` makes both legs
56+ * healthy and the degraded assertions unreachable — a green that measures
57+ * nothing. `bin/run.js` with `NODE_ENV` genuinely unset is the only shape that
58+ * reaches the gate, which is also why {@link requireBuiltCli} guards the file.
59+ *
60+ * ⚠️ The degraded leg is ALSO the negative control for the acceptance rule that
61+ * start and exit behaviour must not change: it is a machine deliberately
62+ * running without auth, and it still reaches the complete banner and is still a
63+ * live server afterwards. Readiness is not made strict here; the ready line
64+ * only says what state it is ready in.
4665 */
4766
67+ /** The complete banner — keyed on its LAST line, so the whole block is on the stream. */
68+ const READY_BANNER_TAIL = / P r e s s C t r l \+ C t o s t o p / ;
69+
70+ const HERE = resolve ( fileURLToPath ( import . meta. url ) , '..' ) ;
71+ /** `bin/run.js` — the SHIPPED entrypoint. See the header for why this one. */
72+ const CLI = resolve ( HERE , '../bin/run.js' ) ;
73+
4874const BARE_CONFIG = 'export default {};\n' ;
4975
50- let degradedDir : string ;
51- let healthyDir : string ;
76+ /** What `spawn(…, { stdio: ['ignore', 'pipe', 'pipe'] })` returns — no `stdin`. */
77+ type BootChild = ChildProcessByStdio < null , Readable , Readable > ;
5278
53- beforeAll ( ( ) => {
54- degradedDir = mkdtempSync ( join ( tmpdir ( ) , 'os-ready-degraded-' ) ) ;
55- writeFileSync ( join ( degradedDir , 'objectstack.config.ts' ) , BARE_CONFIG , 'utf8' ) ;
79+ const children : BootChild [ ] = [ ] ;
80+ let fixtureDir : string ;
81+
82+ interface Boot {
83+ out : string ;
84+ err : string ;
85+ child : BootChild ;
86+ }
87+
88+ /**
89+ * Spawn `os serve` in production posture and resolve once the COMPLETE banner
90+ * is on the stream. Extra `env` entries override the base; `undefined` unsets.
91+ */
92+ async function boot ( env : Record < string , string | undefined > = { } ) : Promise < Boot > {
93+ const port = reservePort ( ) ;
94+ const child = spawn ( process . execPath , [ CLI , 'serve' , 'objectstack.config.ts' , '--port' , String ( port ) ] , {
95+ cwd : fixtureDir ,
96+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
97+ // `childEnv`, never a bare `...process.env` — see its header (#11267).
98+ env : childEnv ( {
99+ NO_COLOR : '1' ,
100+ OS_DATABASE_URL : ':memory:' ,
101+ OS_LOG_LEVEL : '' ,
102+ OS_DISABLE_CONSOLE : '1' ,
103+ // Production posture refuses to mint a crypto key; supplying a stable one
104+ // keeps the refusal out of the way of the property under test.
105+ OS_SECRET_KEY : E2E_SECRET_KEY ,
106+ // ⭐ Truly unset — the value that leaves the boot in production posture
107+ // (and leaves oclif resolving the command from `dist/`). Node omits an
108+ // `undefined` entry rather than inheriting the vitest worker's own.
109+ NODE_ENV : undefined ,
110+ ...env ,
111+ } ) ,
112+ } ) as BootChild ;
113+ children . push ( child ) ;
56114
57- healthyDir = mkdtempSync ( join ( tmpdir ( ) , 'os-ready-healthy-' ) ) ;
58- writeFileSync ( join ( healthyDir , 'objectstack.config.ts' ) , BARE_CONFIG , 'utf8' ) ;
115+ let out = '' ;
116+ let err = '' ;
117+
118+ await new Promise < void > ( ( ready , fail ) => {
119+ const timer = setTimeout ( ( ) => {
120+ fail ( new Error (
121+ 'serve never printed its COMPLETE ready banner (last line: "Press Ctrl+C to stop")'
122+ + `\n--- stdout ---\n${ out } \n--- stderr ---\n${ err } ` ,
123+ ) ) ;
124+ } , 150_000 ) ;
125+ const onData = ( ) => {
126+ if ( READY_BANNER_TAIL . test ( out + err ) ) {
127+ clearTimeout ( timer ) ;
128+ ready ( ) ;
129+ }
130+ } ;
131+ child . stdout . on ( 'data' , ( d ) => { out += String ( d ) ; onData ( ) ; } ) ;
132+ child . stderr . on ( 'data' , ( d ) => { err += String ( d ) ; onData ( ) ; } ) ;
133+ child . on ( 'exit' , ( code ) => {
134+ clearTimeout ( timer ) ;
135+ // A lost port race gets its own failure before the generic one (#12441):
136+ // `serve exited 1 before the banner` is what it produces otherwise, and
137+ // that reads as a verdict about this file's subject when it is not.
138+ fail (
139+ portContentionError ( out + err , 'os serve (bin/run.js, NODE_ENV unset ⇒ production)' , port )
140+ ?? new Error ( `serve exited ${ code } before the ready banner\n--- stdout ---\n${ out } \n--- stderr ---\n${ err } ` ) ,
141+ ) ;
142+ } ) ;
143+ } ) ;
144+
145+ return { out, err, child } ;
146+ }
147+
148+ beforeAll ( ( ) => {
149+ requireBuiltCli ( RUN_JS_RESOLVES_FROM_DIST ) ;
150+ fixtureDir = mkdtempSync ( join ( tmpdir ( ) , 'os-ready-degraded-' ) ) ;
151+ writeFileSync ( join ( fixtureDir , 'objectstack.config.ts' ) , BARE_CONFIG , 'utf8' ) ;
59152} ) ;
60153
61154afterAll ( ( ) => {
62- for ( const dir of [ degradedDir , healthyDir ] ) {
63- if ( dir ) rmSync ( dir , { recursive : true , force : true } ) ;
155+ for ( const child of children ) {
156+ try { child . kill ( 'SIGTERM' ) ; } catch { /* already gone */ }
64157 }
158+ if ( fixtureDir ) rmSync ( fixtureDir , { recursive : true , force : true } ) ;
65159} ) ;
66160
67161describe ( 'os serve — the ready line reports a degraded boot (#16630)' , ( ) => {
68162 it (
69163 'prints ready AND the missing core service in the same output, with no unconditional tick' ,
70164 async ( ) => {
71- const { stdout, stderr } = await runServe ( degradedDir , [ '--port' , randomPort ( ) ] , {
72- waitFor : / P r e s s C t r l \+ C t o s t o p / ,
73- timeoutMs : 240_000 ,
74- } ) ;
75- const seen = `\n--- stdout ---\n${ stdout } \n--- stderr ---\n${ stderr } ` ;
76- const output = stdout + stderr ;
165+ const { out, err, child } = await boot ( ) ;
166+ const seen = `\n--- stdout ---\n${ out } \n--- stderr ---\n${ err } ` ;
167+ const output = out + err ;
77168
78169 // The premise: this boot really is degraded, and the kernel really said
79- // so. Asserted first — without it every assertion below is vacuous.
170+ // so. Asserted FIRST — without it every assertion below is vacuous.
80171 expect ( output , `this boot was not degraded${ seen } ` ) . toContain (
81172 'System started with degraded capabilities. Missing core services: auth' ,
82173 ) ;
83174
84175 // ⭐ BOTH sides, in ONE output — the property the two packages could not
85176 // hold between them before the data path existed.
86- expect ( stderr , `serve never reported ready${ seen } ` ) . toContain ( 'Server is ready' ) ;
87- expect ( stderr , `the ready line withheld the degradation${ seen } ` ) . toContain (
177+ expect ( err , `serve never reported ready${ seen } ` ) . toContain ( 'Server is ready' ) ;
178+ expect ( err , `the ready line withheld the degradation${ seen } ` ) . toContain (
88179 '⚠ Server is ready — DEGRADED: missing core services: auth' ,
89180 ) ;
90181
91182 // ⭐ The defect itself, gone: the green tick may not appear on a boot the
92183 // kernel has recorded as degraded.
93- expect ( stderr , `the unconditional green tick survived${ seen } ` ) . not . toContain (
94- '✓ Server is ready' ,
95- ) ;
184+ expect ( err , `the unconditional green tick survived${ seen } ` ) . not . toContain ( '✓ Server is ready' ) ;
96185
97- // ⛔ Start behaviour unchanged: a machine deliberately without auth still
98- // boots all the way through the banner. `runServe` only resolves once
99- // the banner's LAST line is on the stream, so reaching here IS that.
100- expect ( stderr , `boot stopped short of the banner tail${ seen } ` ) . toContain (
101- 'Press Ctrl+C to stop' ,
102- ) ;
186+ // ⛔ Start behaviour unchanged. Reaching here already means the COMPLETE
187+ // banner printed; the process being alive is the other half.
103188 expect ( output ) . not . toContain ( 'rollback complete' ) ;
189+ expect ( child . exitCode , `serve exited during a boot it called ready${ seen } ` ) . toBeNull ( ) ;
104190 } ,
105191 240_000 ,
106192 ) ;
107193
108194 it (
109195 'leaves a healthy boot printing exactly the tick it always printed' ,
110196 async ( ) => {
111- // Same fixture, same command — the ONLY difference is that auth can now
112- // register, so nothing is missing. An "always append a status line"
113- // implementation passes the degraded case above and fails here.
114- const { stdout, stderr } = await runServe ( healthyDir , [ '--port' , randomPort ( ) ] , {
115- waitFor : / P r e s s C t r l \+ C t o s t o p / ,
116- timeoutMs : 240_000 ,
117- env : { OS_AUTH_SECRET : 'os-16630-healthy-leg-secret-not-a-real-credential' } ,
197+ // Same fixture, same entrypoint, same posture — the ONLY difference is
198+ // that auth can now register, so nothing is missing. An "always append a
199+ // status line" implementation passes the degraded leg and fails here.
200+ const { out, err } = await boot ( {
201+ OS_AUTH_SECRET : 'os-16630-healthy-leg-secret-not-a-real-credential' ,
118202 } ) ;
119- const seen = `\n--- stdout ---\n${ stdout } \n--- stderr ---\n${ stderr } ` ;
120- const output = stdout + stderr ;
203+ const seen = `\n--- stdout ---\n${ out } \n--- stderr ---\n${ err } ` ;
204+ const output = out + err ;
121205
122206 // The premise for THIS leg: nothing was missing.
123207 expect ( output , `the healthy leg booted degraded${ seen } ` ) . not . toContain (
124208 'System started with degraded capabilities' ,
125209 ) ;
126210
127- expect ( stderr , `healthy boot lost its ready tick${ seen } ` ) . toContain ( '✓ Server is ready' ) ;
128- expect ( stderr , `healthy boot grew a degraded notice${ seen } ` ) . not . toContain ( 'DEGRADED' ) ;
211+ expect ( err , `healthy boot lost its ready tick${ seen } ` ) . toContain ( '✓ Server is ready' ) ;
212+ expect ( err , `healthy boot grew a degraded notice${ seen } ` ) . not . toContain ( 'DEGRADED' ) ;
129213 } ,
130214 240_000 ,
131215 ) ;
0 commit comments