|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The startup banner names a contested flow name, and says WHICH body is armed |
| 5 | + * (#12028). |
| 6 | + * |
| 7 | + * ── The hole this closes ───────────────────────────────────────────────── |
| 8 | + * |
| 9 | + * The engine's flow map is keyed by BARE name. When a packaged flow and a |
| 10 | + * runtime-authored `sys_metadata` overlay both claim one name, ADR-0005 |
| 11 | + * precedence arms one of them and the loser is not in the map — so it is not in |
| 12 | + * `listFlows()`, not in `getFlowRuntimeStates()`'s row set, and therefore not in |
| 13 | + * ANY count this banner prints. `3 flow(s), 3 bound to triggers` is a true |
| 14 | + * sentence about a set that does not contain the definition the operator just |
| 15 | + * edited, and nothing on the banner contradicts it. |
| 16 | + * |
| 17 | + * #11997 gave the engine the receipt (`getShadowedFlows()`, plus `armedFrom` / |
| 18 | + * `shadowed` on each runtime-state row). The automation plugin warns from it at |
| 19 | + * `kernel:bootstrapped` — but that is a `logger.warn`, and the whole reason this |
| 20 | + * banner reads engine STATE rather than scraping output is that the boot-quiet |
| 21 | + * stdout window swallows exactly those lines. The banner was the reliable |
| 22 | + * channel and it was silent. |
| 23 | + * |
| 24 | + * ── Why these pins read the RENDERED line ──────────────────────────────── |
| 25 | + * |
| 26 | + * Asserting that `collectAutomationSummary` "read the field" would pass with a |
| 27 | + * banner that prints nothing, which is the defect. So every pin below drives a |
| 28 | + * shadowing receipt through the real `collectAutomationSummary` and the real |
| 29 | + * `printServerReady`, and reads the stderr line an operator sees — the shape |
| 30 | + * `format.seed-summary.test.ts` and `serve-organizations-message-spelling.test.ts` |
| 31 | + * already use on this surface. |
| 32 | + * |
| 33 | + * ── The instrument must be able to say no ──────────────────────────────── |
| 34 | + * |
| 35 | + * `prints no shadowing line …` is not filler. The banner is read on every |
| 36 | + * `os dev` / `os start`; a warning that also appears when nothing is wrong is a |
| 37 | + * warning readers learn to skip, and the next real one goes with it. An |
| 38 | + * always-firing implementation passes every positive pin in this file, so the |
| 39 | + * absence legs are what actually constrain it. |
| 40 | + * |
| 41 | + * ── The fakes ──────────────────────────────────────────────────────────── |
| 42 | + * |
| 43 | + * `getShadowedFlows()` returns `FlowShadowingRecord[]` — `{ name, armed, |
| 44 | + * shadowed }`, where a contender is `{ source: 'package' | 'runtime'; |
| 45 | + * packageId?: string }` (`packages/services/service-automation/src/engine.ts`, |
| 46 | + * `FlowContender` / `FlowShadowingRecord`). Hand-rolled here rather than |
| 47 | + * imported, matching the sibling `serve-automation-summary.test.ts`: the probe |
| 48 | + * under test is feature-detected against an OLDER automation package, so typing |
| 49 | + * these fakes against the current one would defeat the tolerance legs below. |
| 50 | + */ |
| 51 | + |
| 52 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 53 | +import { collectAutomationSummary } from './serve.js'; |
| 54 | +import { printServerReady, type ServerReadyOptions } from '../utils/format.js'; |
| 55 | + |
| 56 | +type Contender = { source: 'package' | 'runtime'; packageId?: string }; |
| 57 | +type ShadowRecord = { name: string; armed: Contender; shadowed: Contender[] }; |
| 58 | + |
| 59 | +type FlowState = { |
| 60 | + name: string; |
| 61 | + enabled: boolean; |
| 62 | + bound: boolean; |
| 63 | + status?: string; |
| 64 | + triggerType?: string; |
| 65 | + object?: string; |
| 66 | +}; |
| 67 | + |
| 68 | +function fakeKernel(services: Record<string, unknown>) { |
| 69 | + return { |
| 70 | + getService(name: string) { |
| 71 | + if (!(name in services)) throw new Error(`Service '${name}' not found`); |
| 72 | + return services[name]; |
| 73 | + }, |
| 74 | + }; |
| 75 | +} |
| 76 | + |
| 77 | +/** An engine that knows about shadowing — i.e. anything from #11997 onwards. */ |
| 78 | +function fakeAutomation(states: FlowState[], shadowing: ShadowRecord[] = []) { |
| 79 | + return { |
| 80 | + getFlowRuntimeStates: () => |
| 81 | + states.map((s) => { |
| 82 | + const record = shadowing.find((r) => r.name === s.name); |
| 83 | + // The engine attaches the receipt to the row too, for a name still in |
| 84 | + // the flow map. Mirrored here so the fake is not quietly narrower than |
| 85 | + // the thing it stands in for. |
| 86 | + return record ? { ...s, armedFrom: record.armed, shadowed: record.shadowed } : s; |
| 87 | + }), |
| 88 | + getRegisteredTriggerTypes: () => ['record_change'], |
| 89 | + getTriggerBindingAudit: () => [], |
| 90 | + getShadowedFlows: () => shadowing, |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +const armed = (states: FlowState[], shadowing: ShadowRecord[] = []) => |
| 95 | + collectAutomationSummary(fakeKernel({ automation: fakeAutomation(states, shadowing) }), states.length); |
| 96 | + |
| 97 | +const flow = (name: string): FlowState => ({ |
| 98 | + name, |
| 99 | + enabled: true, |
| 100 | + bound: true, |
| 101 | + status: 'active', |
| 102 | + triggerType: 'record_change', |
| 103 | + object: 'lead', |
| 104 | +}); |
| 105 | + |
| 106 | +/** |
| 107 | + * The realistic pair, in the direction ADR-0005 actually resolves: a runtime |
| 108 | + * overlay row OUTRANKS the packaged body, so the definition shipped in the |
| 109 | + * package is the one that stopped running. |
| 110 | + */ |
| 111 | +const CONTESTED: ShadowRecord = { |
| 112 | + name: 'send-welcome', |
| 113 | + armed: { source: 'runtime' }, |
| 114 | + shadowed: [{ source: 'package', packageId: 'crm' }], |
| 115 | +}; |
| 116 | + |
| 117 | +describe('collectAutomationSummary — flow-name shadowing (#12028)', () => { |
| 118 | + it('carries the contested name, the armed body and the displaced count', () => { |
| 119 | + const summary = armed([flow('send-welcome'), flow('score-lead')], [CONTESTED])!; |
| 120 | + expect(summary.shadowed).toEqual([ |
| 121 | + { flowName: 'send-welcome', armed: { source: 'runtime' }, shadowedCount: 1 }, |
| 122 | + ]); |
| 123 | + }); |
| 124 | + |
| 125 | + it('is empty when no name is contested', () => { |
| 126 | + expect(armed([flow('send-welcome')])!.shadowed).toEqual([]); |
| 127 | + }); |
| 128 | + |
| 129 | + it('drops a receipt that displaced nothing — that is not a contested name', () => { |
| 130 | + const summary = armed( |
| 131 | + [flow('send-welcome')], |
| 132 | + [{ name: 'send-welcome', armed: { source: 'runtime' }, shadowed: [] }], |
| 133 | + )!; |
| 134 | + expect(summary.shadowed).toEqual([]); |
| 135 | + }); |
| 136 | + |
| 137 | + // The probe is feature-detected exactly like the `unbound` one beside it, and |
| 138 | + // with nothing more. These two legs are what "exactly" means: an automation |
| 139 | + // package predating #11997 has no `getShadowedFlows` at all, and the banner |
| 140 | + // must degrade to its plain counts rather than take the whole boot down. |
| 141 | + it('degrades on an engine that predates the receipt, without losing the banner', () => { |
| 142 | + const older = { |
| 143 | + getFlowRuntimeStates: () => [flow('send-welcome')], |
| 144 | + getRegisteredTriggerTypes: () => ['record_change'], |
| 145 | + getTriggerBindingAudit: () => [], |
| 146 | + }; |
| 147 | + const summary = collectAutomationSummary(fakeKernel({ automation: older }), 1)!; |
| 148 | + expect(summary.shadowed).toEqual([]); |
| 149 | + expect(summary.flowCount).toBe(1); |
| 150 | + }); |
| 151 | + |
| 152 | + it('degrades when the receipt probe throws', () => { |
| 153 | + const hostile = { |
| 154 | + getFlowRuntimeStates: () => [flow('send-welcome')], |
| 155 | + getRegisteredTriggerTypes: () => ['record_change'], |
| 156 | + getTriggerBindingAudit: () => [], |
| 157 | + getShadowedFlows: () => { |
| 158 | + throw new Error('older engine'); |
| 159 | + }, |
| 160 | + }; |
| 161 | + const summary = collectAutomationSummary(fakeKernel({ automation: hostile }), 1)!; |
| 162 | + expect(summary.shadowed).toEqual([]); |
| 163 | + expect(summary.flowCount).toBe(1); |
| 164 | + }); |
| 165 | +}); |
| 166 | + |
| 167 | +describe('startup banner — what an operator reads when a flow name is contested (#12028)', () => { |
| 168 | + const base: ServerReadyOptions = { |
| 169 | + externalBaseOrigin: 'http://localhost:3000', |
| 170 | + configFile: 'objectstack.config.ts', |
| 171 | + isDev: true, |
| 172 | + pluginCount: 1, |
| 173 | + }; |
| 174 | + let lines: string[]; |
| 175 | + let spy: ReturnType<typeof vi.spyOn>; |
| 176 | + |
| 177 | + beforeEach(() => { |
| 178 | + lines = []; |
| 179 | + // stderr, not stdout (#7915) — the whole banner is a diagnostic. |
| 180 | + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { |
| 181 | + lines.push(args.join(' ')); |
| 182 | + }); |
| 183 | + }); |
| 184 | + afterEach(() => spy.mockRestore()); |
| 185 | + |
| 186 | + const render = (states: FlowState[], shadowing: ShadowRecord[] = []) => { |
| 187 | + printServerReady({ ...base, automation: armed(states, shadowing) }); |
| 188 | + return lines.filter((l) => l.includes('is claimed by')); |
| 189 | + }; |
| 190 | + |
| 191 | + it('names the contested flow, WHICH definition is armed, and how many were shadowed', () => { |
| 192 | + const shown = render([flow('send-welcome'), flow('score-lead')], [CONTESTED]); |
| 193 | + expect(shown).toHaveLength(1); |
| 194 | + // All three facts, in the one line the operator gets. The middle one is the |
| 195 | + // point of the card: a line that reports the count and stops has told an |
| 196 | + // admin something is wrong and withheld which body is running. |
| 197 | + expect(shown[0]).toContain("flow 'send-welcome'"); // which name |
| 198 | + expect(shown[0]).toContain('a runtime-authored row (sys_metadata) is ARMED'); // which body |
| 199 | + expect(shown[0]).toContain('1 shadowed'); // how many lost |
| 200 | + expect(shown[0]).toContain('is claimed by 2 definitions'); |
| 201 | + expect(shown[0]).toContain('only the armed definition dispatches'); |
| 202 | + }); |
| 203 | + |
| 204 | + it('names the package when the packaged body is the one that armed', () => { |
| 205 | + const shown = render( |
| 206 | + [flow('send-welcome')], |
| 207 | + [{ name: 'send-welcome', armed: { source: 'package', packageId: 'crm' }, shadowed: [{ source: 'runtime' }] }], |
| 208 | + ); |
| 209 | + expect(shown[0]).toContain("package 'crm' is ARMED"); |
| 210 | + }); |
| 211 | + |
| 212 | + it('never interpolates an absent package id into the sentence', () => { |
| 213 | + const shown = render( |
| 214 | + [flow('send-welcome')], |
| 215 | + [{ name: 'send-welcome', armed: { source: 'package' }, shadowed: [{ source: 'runtime' }] }], |
| 216 | + ); |
| 217 | + expect(shown[0]).toContain('a code-shipped package (id unknown) is ARMED'); |
| 218 | + expect(shown[0]).not.toContain('undefined'); |
| 219 | + }); |
| 220 | + |
| 221 | + it('counts every displaced definition, not just the first', () => { |
| 222 | + const shown = render( |
| 223 | + [flow('send-welcome')], |
| 224 | + [{ |
| 225 | + name: 'send-welcome', |
| 226 | + armed: { source: 'runtime' }, |
| 227 | + shadowed: [{ source: 'package', packageId: 'crm' }, { source: 'package', packageId: 'marketing' }], |
| 228 | + }], |
| 229 | + ); |
| 230 | + expect(shown[0]).toContain('is claimed by 3 definitions'); |
| 231 | + expect(shown[0]).toContain('2 shadowed'); |
| 232 | + }); |
| 233 | + |
| 234 | + it('reports one line per contested name', () => { |
| 235 | + const shown = render( |
| 236 | + [flow('send-welcome'), flow('score-lead')], |
| 237 | + [ |
| 238 | + CONTESTED, |
| 239 | + { name: 'score-lead', armed: { source: 'runtime' }, shadowed: [{ source: 'package', packageId: 'crm' }] }, |
| 240 | + ], |
| 241 | + ); |
| 242 | + expect(shown).toHaveLength(2); |
| 243 | + expect(shown.join('\n')).toContain("flow 'score-lead'"); |
| 244 | + }); |
| 245 | + |
| 246 | + // ── The instrument can say no ────────────────────────────────────────── |
| 247 | + it('prints no shadowing line on a healthy boot, while still printing the counts', () => { |
| 248 | + expect(render([flow('send-welcome'), flow('score-lead')])).toEqual([]); |
| 249 | + // Not silent about everything — the ordinary Flows: row is still there, so |
| 250 | + // the absence above is the line being withheld, not the banner being off. |
| 251 | + expect(lines.some((l) => l.includes('Flows:') && l.includes('2 flow(s)'))).toBe(true); |
| 252 | + }); |
| 253 | + |
| 254 | + it('prints no shadowing line for a receipt that displaced nothing', () => { |
| 255 | + expect( |
| 256 | + render([flow('send-welcome')], [{ name: 'send-welcome', armed: { source: 'runtime' }, shadowed: [] }]), |
| 257 | + ).toEqual([]); |
| 258 | + }); |
| 259 | + |
| 260 | + it('prints no shadowing line when the automation engine is not enabled at all', () => { |
| 261 | + printServerReady({ ...base, automation: collectAutomationSummary(fakeKernel({}), 2) }); |
| 262 | + expect(lines.filter((l) => l.includes('is claimed by'))).toEqual([]); |
| 263 | + }); |
| 264 | +}); |
0 commit comments