|
30 | 30 | * |
31 | 31 | * ⚠️ §C is also where the first round's census miss is pinned. That census was |
32 | 32 | * scoped to this package; the risk surface is EVERY package that constructs a |
33 | | - * REST server, and `packages/cli`'s `os serve` ships |
| 33 | + * REST server, and `packages/cli`'s `os serve` shipped |
34 | 34 | * `projectResolution: 'none'` — a value the declared enum does not contain and |
35 | | - * `@objectstack/runtime` declares as a literal type. Five `packages/cli` e2e |
| 35 | + * `@objectstack/runtime` declared as a literal type. Five `packages/cli` e2e |
36 | 36 | * boots went red in CI. The lesson, written where the next author will hit it: |
37 | 37 | * the search radius belongs where the CONSUMERS live, not where the change does. |
| 38 | + * |
| 39 | + * [#12450] That miss is what bought `projectResolution` an `.omit()` from the |
| 40 | + * declared parse — and this file then carried a GREEN §C case defending the |
| 41 | + * exemption: "KEEPS `projectResolution: \"none\"` — the value this platform |
| 42 | + * actually ships". #11999 migrated the producer off that value (PR #12444) and |
| 43 | + * the case did not notice: it called `construct()` with a hand-written literal, |
| 44 | + * so its premise could die while it stayed green. ⛔ THE LESSON, and the reason |
| 45 | + * the retired value is now a REFUSAL in §A rather than a reworded guard here: |
| 46 | + * **a test that cannot fail when its premise dies is not protected by the |
| 47 | + * suite — it is hidden by it. The passing status is what stops anyone looking.** |
| 48 | + * |
| 49 | + * ⚠️ The other half of that premise is NOT measurable from this package, and |
| 50 | + * saying so here is part of the fix. "No boot path emits the retired value any |
| 51 | + * more" is a claim about the PRODUCER, and the producer (`@objectstack/runtime`) |
| 52 | + * depends on this package — so the coupling cannot be imported in this |
| 53 | + * direction without a cycle, and it lives at the producer instead: |
| 54 | + * `packages/runtime/src/standalone-stack.test.ts` drives the REAL emitted `api` |
| 55 | + * block through a REAL `RestServer` construction. THAT is the case that goes red |
| 56 | + * if the platform ever emits the retired value again; the two below only pin |
| 57 | + * what this seam does with a value once it arrives. |
38 | 58 | */ |
39 | 59 |
|
40 | 60 | import { describe, it, expect, vi } from 'vitest'; |
@@ -94,6 +114,31 @@ describe('[#11637] §A RestServer construction runs RestApiConfigSchema', () => |
94 | 114 | expect(() => construct({ enableCrud: 'yes' as never })).toThrow(/api\.enableCrud/); |
95 | 115 | }); |
96 | 116 |
|
| 117 | + it('[#12450] refuses the retired `projectResolution: "none"` — the parse finally reaches this key', () => { |
| 118 | + // `.omit()`ed from the declared parse until #12450, so this seam |
| 119 | + // ACCEPTED a value `RestApiConfigSchema` has never declared. Measured on |
| 120 | + // the pre-change tree: `construct({ projectResolution: 'none' })` |
| 121 | + // returned a server and `getApiBasePath()` answered `/api/v1`. |
| 122 | + // Declared-but-never-executed is the defect this whole file is named |
| 123 | + // after (#11637); #11999 removed the reason for the exemption by |
| 124 | + // migrating `@objectstack/runtime` onto the declared `'auto'`. |
| 125 | + let message = ''; |
| 126 | + try { |
| 127 | + construct({ projectResolution: 'none' as never }); |
| 128 | + } catch (err: any) { |
| 129 | + message = String(err?.message ?? err); |
| 130 | + } |
| 131 | + // POSITIVE CONTROL for the negative assertion below, and not a substring |
| 132 | + // of it: an empty `message` (nothing thrown) would satisfy any |
| 133 | + // `not.toContain` vacuously, so the refusal has to be proven present |
| 134 | + // before its shape can be measured. |
| 135 | + expect(message, 'the retired value must be refused, and the refusal must name the key').toContain('api.projectResolution'); |
| 136 | + expect( |
| 137 | + message, |
| 138 | + 'a projectResolution refusal must not diagnose `version`, a key this config never wrote', |
| 139 | + ).not.toContain('/api//'); |
| 140 | + }); |
| 141 | + |
97 | 142 | it('appends the version rationale ONLY when `version` is what failed', () => { |
98 | 143 | // Caught by this change's own ablation: a `projectResolution` refusal |
99 | 144 | // printed the whole "an empty version mounts the entire API at /api//" |
@@ -205,20 +250,19 @@ describe('[#11637] §C regression guards — the narrowing is exactly the declar |
205 | 250 | expect((rest as any).config.api.enableSearch).toBe(false); |
206 | 251 | }); |
207 | 252 |
|
208 | | - it('KEEPS `projectResolution: "none"` — the value this platform actually ships', () => { |
209 | | - // ⛔ The case CI caught and this file did not. `RestApiConfigSchema` |
210 | | - // declares `z.enum(['required', 'optional', 'auto'])`, but the value |
211 | | - // `os serve` forwards is `'none'`: `@objectstack/runtime`'s |
212 | | - // `StandaloneStackResult.api` DECLARES the literal type |
213 | | - // `{ enableProjectScoping: false; projectResolution: 'none' }`, and |
214 | | - // `serve.ts` passes it through (`?? 'auto'` does not fire — `'none'` is |
215 | | - // not nullish). Parsing this key would turn every `os serve` boot into |
216 | | - // a crash; five packages/cli e2e boots did exactly that before the key |
217 | | - // was `.omit()`ed. Which spelling is right is a contract question about |
218 | | - // project-scoping semantics, filed as #11999 — NOT this seam's to |
219 | | - // settle by refusing the value the platform ships. |
220 | | - expect(() => construct({ enableProjectScoping: false, projectResolution: 'none' })).not.toThrow(); |
221 | | - expect(construct({ projectResolution: 'none' as never }).getApiBasePath()).toBe('/api/v1'); |
| 253 | + it('[#12450] accepts every `projectResolution` the declared enum contains — the narrowing is the retired value ONLY', () => { |
| 254 | + // The BOUND on the change, and the half that makes "exactly one value |
| 255 | + // starts being rejected" a measurement rather than a claim. Census over |
| 256 | + // the tree at the time of #12450: four spellings appear anywhere as a |
| 257 | + // value for this key — `'required'`, `'optional'`, `'auto'` and the |
| 258 | + // retired `'none'` — so these three ARE the population that must keep |
| 259 | + // constructing. Read back off the normalized config rather than off a |
| 260 | + // mount path: a strategy that parsed and was then dropped in |
| 261 | + // normalization would still answer `/api/v1`. |
| 262 | + for (const projectResolution of ['required', 'optional', 'auto'] as const) { |
| 263 | + const rest = construct({ enableProjectScoping: true, projectResolution }); |
| 264 | + expect((rest as any).config.api.projectResolution, projectResolution).toBe(projectResolution); |
| 265 | + } |
222 | 266 | }); |
223 | 267 |
|
224 | 268 | it('KEEPS the retired `api.requireAuth` warn-and-ignore posture (#3963)', () => { |
|
0 commit comments