|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #9414 — the flow author's terminal messages reach THE WIRE on a triggered run. |
| 5 | + * |
| 6 | + * This is the end-to-end half of the repair, and it lives here because |
| 7 | + * `@objectstack/verify` is the one package that already depends on BOTH |
| 8 | + * `@objectstack/runtime` (the trigger route) and `@objectstack/service-automation` |
| 9 | + * (the engine that produces the result). The engine-side pins live in |
| 10 | + * `packages/services/service-automation/src/flow-terminal-messages.test.ts`; the |
| 11 | + * route-side pins, driven with a scripted `AutomationResult`, live in |
| 12 | + * `packages/runtime/src/domains/automation-trigger-route-status.test.ts`. Neither |
| 13 | + * of those, alone or together, asserts the sentence the documentation actually |
| 14 | + * makes — so this file drives a REAL engine through the REAL dispatcher and reads |
| 15 | + * the response body. |
| 16 | + * |
| 17 | + * **The documented promise is the acceptance criterion.** |
| 18 | + * `content/docs/automation/flows.mdx` says, in prose, of the trigger route: |
| 19 | + * |
| 20 | + * > A `400` additionally carries the flow author's own `errorMessage` (when the |
| 21 | + * > flow declares one) at `error.details.errorMessage` |
| 22 | + * |
| 23 | + * That sentence was FALSE before this change, and not by a little: the field was |
| 24 | + * *always* absent at the source on the trigger path, because only `resumeInternal` |
| 25 | + * ever produced it. The docs described the declaration; the implementation never |
| 26 | + * honoured it, and three published pages plus two consumers (the trigger route |
| 27 | + * itself, and objectui's `flowResponse.ts`) were written against the description. |
| 28 | + * The repair does not invalidate the documentation — it makes the documentation |
| 29 | + * true. So the assertions below are written against the doc's exact PATH and KEY |
| 30 | + * (`error.details.errorMessage` on a 400, `data.successMessage` on a 200), never |
| 31 | + * against the engine's internal result object: a pin that stops at |
| 32 | + * `AutomationResult` cannot fail when the wire mapping is the half that breaks. |
| 33 | + * |
| 34 | + * **Verbatim is contract too.** `flows.mdx` documents both fields as plain |
| 35 | + * author-declared strings with `{var}` explicitly NOT interpolated. Anything this |
| 36 | + * path did to them on the way out — templating, trimming, HTML-escaping — would |
| 37 | + * contradict the shipped contract, so one case drives a deliberately |
| 38 | + * hostile string and asserts byte identity rather than a substring match. |
| 39 | + * |
| 40 | + * ⚠️ This suite resolves both packages through their BUILT `dist/`, as every |
| 41 | + * dependent of theirs in this workspace does. Rebuild `@objectstack/service-automation` |
| 42 | + * before trusting a run of this file — and especially before trusting an ABLATED |
| 43 | + * one, where a stale `dist` would run the pre-mutation code and report green over |
| 44 | + * a mutation that never reached the artifact. |
| 45 | + */ |
| 46 | + |
| 47 | +import { describe, it, expect } from 'vitest'; |
| 48 | + |
| 49 | +import { HttpDispatcher } from '@objectstack/runtime'; |
| 50 | +import { AutomationEngine } from '@objectstack/service-automation'; |
| 51 | + |
| 52 | +const SUCCESS_TEXT = 'Opportunity created — the owner has been notified.'; |
| 53 | +const ERROR_TEXT = 'We could not create the opportunity — check the amount and try again.'; |
| 54 | + |
| 55 | +/** The raw node failure. Deliberately unlike ERROR_TEXT: the two must not be confusable. */ |
| 56 | +const RAW_FAILURE = 'downstream 503'; |
| 57 | + |
| 58 | +/** |
| 59 | + * Braces (a templating probe), padding (a trimming probe), and `&`/quotes/`>` |
| 60 | + * (an escaping probe) in one string. `{amount}` is supplied as a real trigger |
| 61 | + * param below, so an interpolating producer would visibly substitute it. |
| 62 | + */ |
| 63 | +const HOSTILE = ' {amount} items — "R&D" & 5 > 3, kept verbatim '; |
| 64 | + |
| 65 | +const CTX = { request: {}, executionContext: { userId: 'user_1' } } as never; |
| 66 | + |
| 67 | +function createTestLogger(): never { |
| 68 | + const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => logger }; |
| 69 | + return logger as never; |
| 70 | +} |
| 71 | + |
| 72 | +/** A one-node flow whose single `script` node passes or fails as asked. */ |
| 73 | +function bootFlow(opts: { fails: boolean; successMessage?: string; errorMessage?: string }): HttpDispatcher { |
| 74 | + const engine = new AutomationEngine(createTestLogger()); |
| 75 | + engine.registerNodeExecutor({ |
| 76 | + type: 'script', |
| 77 | + async execute() { |
| 78 | + return opts.fails ? { success: false, error: RAW_FAILURE } : { success: true, output: { ok: true } }; |
| 79 | + }, |
| 80 | + } as never); |
| 81 | + engine.registerFlow('notify_owner', { |
| 82 | + name: 'notify_owner', |
| 83 | + label: 'notify_owner', |
| 84 | + type: 'autolaunched', |
| 85 | + ...(opts.successMessage !== undefined ? { successMessage: opts.successMessage } : {}), |
| 86 | + ...(opts.errorMessage !== undefined ? { errorMessage: opts.errorMessage } : {}), |
| 87 | + nodes: [ |
| 88 | + { id: 'start', type: 'start', label: 'Start' }, |
| 89 | + { id: 'work', type: 'script', label: 'Work' }, |
| 90 | + { id: 'end', type: 'end', label: 'End' }, |
| 91 | + ], |
| 92 | + edges: [ |
| 93 | + { id: 'e0', source: 'start', target: 'work' }, |
| 94 | + { id: 'e1', source: 'work', target: 'end' }, |
| 95 | + ], |
| 96 | + }); |
| 97 | + |
| 98 | + const services: Record<string, unknown> = { automation: engine }; |
| 99 | + const resolve = (name: string): unknown => services[name]; |
| 100 | + const kernel = { |
| 101 | + getService: resolve, |
| 102 | + getServiceAsync: async (name: string): Promise<unknown> => resolve(name), |
| 103 | + context: { getService: resolve }, |
| 104 | + }; |
| 105 | + return new HttpDispatcher(kernel as never); |
| 106 | +} |
| 107 | + |
| 108 | +/** `POST /api/v1/automation/notify_owner/trigger`, as the SDK and console reach it. */ |
| 109 | +function trigger(dispatcher: HttpDispatcher, body: Record<string, unknown> = {}) { |
| 110 | + return dispatcher.handleAutomation('/notify_owner/trigger', 'POST', body, CTX); |
| 111 | +} |
| 112 | + |
| 113 | +describe('#9414 — a triggered run reaches the wire with the author\'s terminal messages', () => { |
| 114 | + it('400: the author\'s errorMessage arrives at error.details.errorMessage — the documented path', async () => { |
| 115 | + const dispatcher = bootFlow({ fails: true, successMessage: SUCCESS_TEXT, errorMessage: ERROR_TEXT }); |
| 116 | + |
| 117 | + const result = await trigger(dispatcher); |
| 118 | + |
| 119 | + expect(result.response?.status).toBe(400); |
| 120 | + expect(result.response?.body?.error?.code).toBe('FLOW_FAILED'); |
| 121 | + // THE documented sentence, asserted at the documented key. Before the |
| 122 | + // repair this was `undefined` for every non-screen flow ever triggered. |
| 123 | + expect(result.response?.body?.error?.details?.errorMessage).toBe(ERROR_TEXT); |
| 124 | + // Beside, not instead of: the raw node failure stays the envelope's |
| 125 | + // human-readable message, so diagnostics do not lose the real cause… |
| 126 | + expect(result.response?.body?.error?.message).toContain(RAW_FAILURE); |
| 127 | + // …and the author's text is NOT folded into it (objectui reads the two |
| 128 | + // from different places and shows them differently). |
| 129 | + expect(result.response?.body?.error?.message).not.toContain(ERROR_TEXT); |
| 130 | + // ADR-0112: no inner envelope for a status-blind caller to misread. |
| 131 | + expect(result.response?.body?.data).toBeUndefined(); |
| 132 | + }); |
| 133 | + |
| 134 | + it('200: the author\'s successMessage arrives on the response data', async () => { |
| 135 | + const dispatcher = bootFlow({ fails: false, successMessage: SUCCESS_TEXT, errorMessage: ERROR_TEXT }); |
| 136 | + |
| 137 | + const result = await trigger(dispatcher); |
| 138 | + |
| 139 | + expect(result.response?.status).toBe(200); |
| 140 | + expect(result.response?.body?.success).toBe(true); |
| 141 | + expect(result.response?.body?.data?.successMessage).toBe(SUCCESS_TEXT); |
| 142 | + }); |
| 143 | + |
| 144 | + it('carries both VERBATIM — no templating, no trimming, no escaping', async () => { |
| 145 | + // `flows.mdx` documents these as plain strings with `{var}` explicitly |
| 146 | + // NOT interpolated. `amount` is a real trigger param here, so an |
| 147 | + // interpolating producer would substitute it and this would fail loudly. |
| 148 | + const failing = bootFlow({ fails: true, errorMessage: HOSTILE }); |
| 149 | + const passing = bootFlow({ fails: false, successMessage: HOSTILE }); |
| 150 | + |
| 151 | + const failed = await trigger(failing, { amount: 42 }); |
| 152 | + const passed = await trigger(passing, { amount: 42 }); |
| 153 | + |
| 154 | + expect(failed.response?.body?.error?.details?.errorMessage).toBe(HOSTILE); |
| 155 | + expect(passed.response?.body?.data?.successMessage).toBe(HOSTILE); |
| 156 | + // Spelled out, because `toBe` on a constant can be read as tautological: |
| 157 | + // the padding survives, and the brace was never a template. |
| 158 | + expect(failed.response?.body?.error?.details?.errorMessage).not.toContain('42'); |
| 159 | + expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/^ {2}\{amount\}/); |
| 160 | + expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/verbatim {2}$/); |
| 161 | + }); |
| 162 | + |
| 163 | + it('a flow declaring NO messages gets no invented key — the doc\'s "when the flow declares one" half', async () => { |
| 164 | + // ⚠️ Green before and after the repair, deliberately: it fences the fix |
| 165 | + // rather than demonstrating it. The route omits the key entirely when |
| 166 | + // the engine has nothing to give, so a consumer can still tell "the |
| 167 | + // author wrote one" from "the author did not". |
| 168 | + const dispatcher = bootFlow({ fails: true }); |
| 169 | + |
| 170 | + const result = await trigger(dispatcher); |
| 171 | + |
| 172 | + expect(result.response?.status).toBe(400); |
| 173 | + expect(result.response?.body?.error?.details?.errorMessage).toBeUndefined(); |
| 174 | + expect(result.response?.body?.error?.message).toContain(RAW_FAILURE); |
| 175 | + }); |
| 176 | +}); |
0 commit comments