Skip to content

Commit 3fc129b

Browse files
committed
test(verify): pin the trigger route's terminal messages end-to-end at the documented wire path (#9414)
`content/docs/automation/flows.mdx` promises that a 400 from `POST /api/v1/automation/:name/trigger` carries the flow author's own `errorMessage` at `error.details.errorMessage`. That sentence was false before the engine repair — the field was always absent at the source on this route — so the docs described the declaration while the implementation never honoured it. The engine pins assert `AutomationResult`; the route pins drive a scripted result. Neither proves the documented sentence. `@objectstack/verify` is the one package depending on both `@objectstack/runtime` and `@objectstack/service-automation`, so a real engine is driven through a real `HttpDispatcher` here and the response body is read at the doc's own path and key — on both exits, plus the "when the flow declares one" half. One case drives a deliberately hostile string (braces, padding, `&`, quotes) and asserts byte identity: `flows.mdx` documents both fields as plain strings with `{var}` explicitly NOT interpolated, so templating, trimming or escaping on the way out would contradict the shipped contract. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
1 parent d2d10a1 commit 3fc129b

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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

Comments
 (0)