|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 4 | +import { ObjectKernel, type Plugin } from '@objectstack/core'; |
| 5 | +import { printServerReady, type ServerReadyOptions } from './format.js'; |
| 6 | +import { readMissingCoreServices } from './degraded-capabilities.js'; |
| 7 | + |
| 8 | +/** |
| 9 | + * #16630 — the ready signal must report the degraded boot it is standing on. |
| 10 | + * |
| 11 | + * ## Why this file boots a REAL kernel instead of unit-testing the formatter |
| 12 | + * |
| 13 | + * The defect was never a wording problem. `✓ Server is ready` is printed by |
| 14 | + * `printServerReady` in `@objectstack/cli`; `System started with degraded |
| 15 | + * capabilities. Missing core services: …` is concluded by |
| 16 | + * `ObjectKernel.validateSystemRequirements()` in `@objectstack/core`; and there |
| 17 | + * was **no data path between them**, so the banner could not report the |
| 18 | + * degradation — it never learned of it. Two statements about one boot, produced |
| 19 | + * independently, and the louder one was the wrong one. |
| 20 | + * |
| 21 | + * ⇒ A test that hands a formatter a hand-written list asserts nothing about |
| 22 | + * that. It exercises exactly one of the two packages that each spoke alone, |
| 23 | + * which is the very perspective that produced the defect. So every degraded |
| 24 | + * assertion below starts from a REAL `ObjectKernel` bootstrap whose `auth` |
| 25 | + * service is genuinely absent, reads the conclusion the way `serve` reads it, |
| 26 | + * and prints the REAL banner from it. |
| 27 | + * |
| 28 | + * ## One output, both sides |
| 29 | + * |
| 30 | + * The two statements do not even share a stream: `ObjectLogger` writes `warn` |
| 31 | + * to `process.stdout`, the banner writes to `process.stderr` via |
| 32 | + * `console.error`. A terminal — and a CI log — interleaves them into ONE |
| 33 | + * transcript, which is where a reader met the contradiction: on this repo's own |
| 34 | + * registry canary (run `34084559243`, job `101626009369`, the published |
| 35 | + * `npx create-objectstack@latest` on-ramp) `✓ Server is ready` printed directly |
| 36 | + * ABOVE the four boot warnings that said the opposite. `beforeEach` below |
| 37 | + * reassembles that single transcript on purpose, so the assertions can hold |
| 38 | + * both sentences against ONE output rather than two. |
| 39 | + * |
| 40 | + * ## The three properties, and the one that is easy to lose |
| 41 | + * |
| 42 | + * 1. degraded ⇒ the ready line names what the kernel found missing, and the |
| 43 | + * unconditional `✓` is gone; |
| 44 | + * 2. healthy ⇒ the ready block is byte-for-byte what it has always been — |
| 45 | + * the property an "always append a status line" implementation quietly |
| 46 | + * spends to buy (1); |
| 47 | + * 3. `Server is ready` still appears on a degraded boot. Readiness is NOT |
| 48 | + * made strict (a machine deliberately booted without auth still boots and |
| 49 | + * still exits 0); the line only says what state it is ready in. |
| 50 | + */ |
| 51 | + |
| 52 | +/** A plugin that registers exactly the named kernel services and nothing else. */ |
| 53 | +function servicesPlugin(names: string[]): Plugin { |
| 54 | + return { |
| 55 | + name: 'com.objectstack.test.degraded-boot-fixture', |
| 56 | + version: '1.0.0', |
| 57 | + init: async (ctx) => { |
| 58 | + for (const name of names) ctx.registerService(name, { fixture: name }); |
| 59 | + }, |
| 60 | + }; |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Boot a real kernel providing exactly `names`, and hand back the kernel. |
| 65 | + * |
| 66 | + * `data` is `required` (the kernel throws without it) and `auth`/`job` are the |
| 67 | + * two `core` services with no in-memory fallback — see `CORE_FALLBACK_FACTORIES` |
| 68 | + * — so they are the only ones that can ever reach the degraded list. Omitting |
| 69 | + * `auth` reproduces both filed incidents; the rest are pre-injected. |
| 70 | + */ |
| 71 | +async function bootKernel(names: string[]): Promise<ObjectKernel> { |
| 72 | + const kernel = new ObjectKernel({ |
| 73 | + logger: { level: 'warn' }, |
| 74 | + // ⛔ Not `skipSystemValidation` — that is the branch under test. |
| 75 | + gracefulShutdown: false, |
| 76 | + }); |
| 77 | + await kernel.use(servicesPlugin(names)); |
| 78 | + await kernel.bootstrap(); |
| 79 | + return kernel; |
| 80 | +} |
| 81 | + |
| 82 | +/** Banner options held fixed across the legs, so only the boot differs. */ |
| 83 | +const BASE: ServerReadyOptions = { |
| 84 | + externalBaseOrigin: 'http://localhost:3000', |
| 85 | + isDev: true, |
| 86 | + pluginCount: 3, |
| 87 | +}; |
| 88 | + |
| 89 | +/** |
| 90 | + * The healthy ready block, verbatim, as `printServerReady` has always emitted |
| 91 | + * it for {@link BASE} under NO_COLOR. |
| 92 | + * |
| 93 | + * ⛔ This literal is the point of the byte-identity leg — do not regenerate it |
| 94 | + * from the implementation. It is transcribed from the block the same options |
| 95 | + * produced before #16630 touched this function, and its ready line is |
| 96 | + * character-identical to the one in `bannerFor()` in |
| 97 | + * `test/serve-port-readback.e2e.test.ts`, which was transcribed independently |
| 98 | + * from a real boot. |
| 99 | + */ |
| 100 | +const HEALTHY_READY_BLOCK = [ |
| 101 | + '', |
| 102 | + ' ✓ Server is ready', |
| 103 | + '', |
| 104 | + ' ➜ API: http://localhost:3000/', |
| 105 | + '', |
| 106 | + ' Mode: development', |
| 107 | + ' Plugins: 3 loaded', |
| 108 | + '', |
| 109 | + ' Press Ctrl+C to stop', |
| 110 | + '', |
| 111 | +]; |
| 112 | + |
| 113 | +let transcript: string[]; |
| 114 | +let errSpy: ReturnType<typeof vi.spyOn>; |
| 115 | +let outSpy: ReturnType<typeof vi.spyOn>; |
| 116 | + |
| 117 | +/** Strip SGR so assertions hold whether or not chalk colors this run. */ |
| 118 | +const plain = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, ''); |
| 119 | + |
| 120 | +beforeEach(() => { |
| 121 | + transcript = []; |
| 122 | + // The banner (stderr, #7915) … |
| 123 | + errSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { |
| 124 | + transcript.push(plain(args.join(' '))); |
| 125 | + }); |
| 126 | + // … and the kernel's own `warn` (stdout — see `ObjectLogger.emit`), into the |
| 127 | + // SAME buffer and in emission order, which is what a terminal shows. |
| 128 | + outSpy = vi |
| 129 | + .spyOn(process.stdout, 'write') |
| 130 | + .mockImplementation(((chunk: unknown) => { |
| 131 | + for (const line of plain(String(chunk)).split('\n')) { |
| 132 | + if (line !== '') transcript.push(line); |
| 133 | + } |
| 134 | + return true; |
| 135 | + }) as never); |
| 136 | +}); |
| 137 | + |
| 138 | +afterEach(() => { |
| 139 | + errSpy.mockRestore(); |
| 140 | + outSpy.mockRestore(); |
| 141 | +}); |
| 142 | + |
| 143 | +describe('the ready signal on a degraded boot (#16630)', () => { |
| 144 | + it("carries the kernel's own missing-core-service list from core to the banner", async () => { |
| 145 | + const kernel = await bootKernel(['data', 'job']); // auth deliberately absent |
| 146 | + |
| 147 | + // ── the data path, read exactly as `serve` reads it ────────────── |
| 148 | + const missingCoreServices = readMissingCoreServices(kernel); |
| 149 | + expect(missingCoreServices, 'the kernel published no degraded readout').toEqual(['auth']); |
| 150 | + |
| 151 | + printServerReady({ ...BASE, missingCoreServices }); |
| 152 | + const output = transcript.join('\n'); |
| 153 | + |
| 154 | + // ── BOTH sides, in ONE output ──────────────────────────────────── |
| 155 | + // The producer: the kernel's own sentence, unchanged. |
| 156 | + expect(output).toContain('System started with degraded capabilities. Missing core services: auth'); |
| 157 | + // The consumer: the ready line, now reporting the same fact. |
| 158 | + expect(output).toContain('Server is ready'); |
| 159 | + expect(output).toContain('⚠ Server is ready — DEGRADED: missing core services: auth'); |
| 160 | + |
| 161 | + // ⭐ The defect itself: an unconditional green tick over a boot the kernel |
| 162 | + // had just called degraded. This is the assertion that was impossible to |
| 163 | + // write before the data path existed. |
| 164 | + expect(output).not.toContain('✓ Server is ready'); |
| 165 | + |
| 166 | + await kernel.shutdown(); |
| 167 | + }); |
| 168 | + |
| 169 | + it('names the SAME list the kernel printed, never a separately computed one', async () => { |
| 170 | + // Both fallback-less core services absent at once: the banner must render |
| 171 | + // the kernel's list as the kernel ordered it, not a set of its own. |
| 172 | + const kernel = await bootKernel(['data']); |
| 173 | + |
| 174 | + const missingCoreServices = readMissingCoreServices(kernel); |
| 175 | + printServerReady({ ...BASE, missingCoreServices }); |
| 176 | + |
| 177 | + const kernelLine = transcript.find((l) => l.includes('Missing core services:')) ?? ''; |
| 178 | + const bannerLine = transcript.find((l) => l.includes('Server is ready')) ?? ''; |
| 179 | + const namesFrom = (line: string) => line.slice(line.lastIndexOf(':') + 1).trim(); |
| 180 | + |
| 181 | + expect(kernelLine, 'the kernel said nothing about degraded capabilities').not.toBe(''); |
| 182 | + expect(namesFrom(bannerLine)).toBe(namesFrom(kernelLine)); |
| 183 | + expect(namesFrom(bannerLine)).toBe('auth, job'); |
| 184 | + |
| 185 | + await kernel.shutdown(); |
| 186 | + }); |
| 187 | + |
| 188 | + it('leaves a healthy boot byte-identical — no readout, no extra line', async () => { |
| 189 | + const kernel = await bootKernel(['data', 'auth', 'job']); |
| 190 | + |
| 191 | + // Nothing published ⇒ nothing to say. `getService` throws on a healthy |
| 192 | + // boot and the reader reports that as "no degradation", not as unknown. |
| 193 | + expect(readMissingCoreServices(kernel)).toBeUndefined(); |
| 194 | + expect(transcript, 'a healthy boot logged a degraded-capabilities warning').toEqual([]); |
| 195 | + |
| 196 | + printServerReady({ ...BASE, missingCoreServices: readMissingCoreServices(kernel) }); |
| 197 | + |
| 198 | + expect(transcript).toEqual(HEALTHY_READY_BLOCK); |
| 199 | + |
| 200 | + await kernel.shutdown(); |
| 201 | + }); |
| 202 | + |
| 203 | + it('treats an empty list as healthy, so no caller can append an empty warning', () => { |
| 204 | + printServerReady({ ...BASE, missingCoreServices: [] }); |
| 205 | + expect(transcript).toEqual(HEALTHY_READY_BLOCK); |
| 206 | + }); |
| 207 | + |
| 208 | + it('reports nothing rather than throwing when there is no kernel to ask', () => { |
| 209 | + // The readout is a diagnostic. It must never be able to fail a boot the |
| 210 | + // kernel has already decided is good enough to run. |
| 211 | + expect(readMissingCoreServices(undefined)).toBeUndefined(); |
| 212 | + expect(readMissingCoreServices({})).toBeUndefined(); |
| 213 | + expect(readMissingCoreServices({ getService: () => { throw new Error('nope'); } })).toBeUndefined(); |
| 214 | + expect(readMissingCoreServices({ getService: () => ({ missingCoreServices: 'auth' }) })).toBeUndefined(); |
| 215 | + }); |
| 216 | +}); |
0 commit comments