Skip to content

Commit 46e86ba

Browse files
authored
test(automation): reconcile the control-flow designer forms against their Zod (#4045) (#4078)
`loop` / `parallel` / `try_catch` each carry two descriptions of the same config — a hand-written configSchema on the descriptor (drives the Studio form) and a Zod schema in control-flow.zod.ts (validates the value) — and nothing compared them. #4064 pinned the shape of the first; this pins the relationship, so neither side can gain or lose a key without the other noticing. Both directions are asserted. A key the Zod accepts but the form omits is #3528's failure inverted: a real config key with no way to author it in Studio, discoverable only by reading the executor — exactly how notify.source sat unnoticed until #4050. A key the form offers but the Zod rejects means the author fills in a field whose value is then dropped. The two sources are deliberately NOT merged, now measured rather than assumed: generating the form from the Zod emits 9-17x more schema at +9 levels of depth (loop 597 -> 5,537 chars / 25 -> 201 keys; parallel 294 -> 5,112; try_catch 737 -> 10,739) with NO $defs and NO $ref, because FlowNodeSchema is inlined in full at every region key. A loop body would reach the designer as the entire node/edge definition instead of the opaque array a canvas-edited sub-graph needs. A projection pruning ~90% back off leaves three things to maintain instead of two. This corrects the premise #4045 opened with. Since the divergence is legitimate, what is enforced is that it stays DECLARED: a DELIBERATELY_SHALLOW ledger names each shallow key with a reason, and every entry must name a key both sides still declare, so it cannot rot. Compares key sets rather than generated shapes on purpose: generating needs z.toJSONSchema and service-automation does not depend on zod. `schema.shape` gives the key set without that dependency, and the key set is where the drift that matters lives. Verified to bite in all three directions — a Zod key the form omits, a form key the Zod rejects, and a ledger entry pointing at a renamed key each turn the suite red with the offending name in the message.
1 parent 43ff598 commit 46e86ba

2 files changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
---
3+
4+
Tests only — no package changes, nothing to release.
5+
6+
Reconciles the designer form against the Zod for the region-bearing flow nodes
7+
(#4045). `loop` / `parallel` / `try_catch` each carry two descriptions of the same
8+
config — a hand-written `configSchema` on the descriptor and a Zod schema in
9+
`control-flow.zod.ts` — and nothing compared them. #4064 pinned the shape of the
10+
first; this pins the relationship, so neither side can gain or lose a key without
11+
the other noticing.
12+
13+
The two are **not** merged into one source, because measurement says that does not
14+
work here: generating from the Zod emits 9–17× more schema at +9 levels of depth
15+
(`loop` 597 → 5,537 chars, `parallel` 294 → 5,112, `try_catch` 737 → 10,739), with
16+
no `$defs`/`$ref``FlowNodeSchema` is inlined at every region key, so a loop body
17+
would arrive as the whole node/edge definition instead of the opaque array the
18+
designer needs for a canvas-edited sub-graph. A projection pruning ~90% back off
19+
would leave three things to maintain instead of two.
20+
21+
So the divergence is legitimate, and what is enforced is that it stays declared: a
22+
`DELIBERATELY_SHALLOW` ledger names each shallow key with a reason, and every entry
23+
must name a key both sides still declare, so it cannot rot.
24+
25+
Compares key sets rather than generated shapes on purpose — generating needs
26+
`z.toJSONSchema`, and `service-automation` does not depend on `zod`. The Zod's key
27+
set is reachable via `schema.shape` without that dependency, and the key set is
28+
where the drift that matters shows up.
29+
30+
Verified to bite in all three directions: a Zod key the form does not offer, a form
31+
key the Zod rejects, and a ledger entry pointing at a renamed key each turn the
32+
suite red with the offending name in the message.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* **Designer form ↔ Zod reconciliation for the region-bearing nodes** (#4045).
5+
*
6+
* `loop` / `parallel` / `try_catch` each carry TWO descriptions of the same config:
7+
* a hand-written `configSchema` on the descriptor (drives the Studio form) and a Zod
8+
* schema in `control-flow.zod.ts` (validates the value). #4064 pinned the *shape* of
9+
* the first; this pins the *relationship* between the two, so neither can gain or
10+
* lose a key without the other noticing.
11+
*
12+
* ## Why they are not merged into one source
13+
*
14+
* The obvious de-duplication — publish `z.toJSONSchema(LoopConfigSchema)` and delete
15+
* the literal — was measured on main `53fbf49` and is not viable for these three:
16+
*
17+
* | node | published form | generated from Zod |
18+
* |---|---|---|
19+
* | `loop` | 597 chars, depth 5, 25 keys | 5,537 chars, depth 14, 201 keys |
20+
* | `parallel` | 294 chars, depth 6, 16 keys | 5,112 chars, depth 15, 189 keys |
21+
* | `try_catch` | 737 chars, depth 5, 40 keys | 10,739 chars, depth 14, 397 keys |
22+
*
23+
* Generation succeeds — no recursion error — but emits **no `$defs` and no `$ref`**:
24+
* `FlowNodeSchema` is inlined in full at every region key, so a loop body would
25+
* arrive as the entire node/edge definition instead of the opaque `{ type: 'array' }`
26+
* the designer needs for a sub-graph that is edited on the canvas. Making the
27+
* generated output usable would mean a projection pruning ~90% of it back off, at
28+
* which point "one source" is a fiction: you would maintain the Zod, the projection
29+
* rules, and a check that the projection still does the right thing.
30+
*
31+
* So the two artifacts have different jobs and legitimately differ. What is worth
32+
* enforcing is not that the difference disappears but that it stays **declared** —
33+
* the #3569 / #4040 ledger pattern.
34+
*
35+
* ## Why this file compares KEY SETS and not generated shapes
36+
*
37+
* Deliberate, not an omission. Generating requires `z.toJSONSchema`, and
38+
* `service-automation` does not depend on `zod` — only `@objectstack/spec` does.
39+
* Adding that dependency to run a test would put a real edge in the package graph for
40+
* no runtime need. The Zod's own key set is reachable without it (`schema.shape`),
41+
* and the key set is where the drift that matters shows up: a key on one side and not
42+
* the other. The depth divergence is measured once in the table above and its form
43+
* half is pinned by `config-schemas.test.ts`.
44+
*/
45+
46+
import { describe, it, expect } from 'vitest';
47+
import { LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema } from '@objectstack/spec/automation';
48+
import { AutomationEngine } from '../engine.js';
49+
import { registerLoopNode } from './loop-node.js';
50+
import { registerParallelNode } from './parallel-node.js';
51+
import { registerTryCatchNode } from './try-catch-node.js';
52+
53+
/**
54+
* Config keys whose form shape is deliberately shallower than the Zod, with the
55+
* reason. Checked both ways below — an entry must name a key both sides really
56+
* declare, so it cannot rot into a reference to something that no longer exists.
57+
*/
58+
const DELIBERATELY_SHALLOW: ReadonlyArray<{ nodeType: string; key: string; why: string }> = [
59+
{
60+
nodeType: 'loop', key: 'body',
61+
why: 'FlowRegionSchema — the body sub-graph is edited on the canvas, so the form publishes an opaque array',
62+
},
63+
{
64+
nodeType: 'parallel', key: 'branches',
65+
why: 'each branch carries a region; the form publishes name + opaque node/edge arrays',
66+
},
67+
{
68+
nodeType: 'try_catch', key: 'try',
69+
why: 'protected region — opaque for the same reason as loop.body',
70+
},
71+
{
72+
nodeType: 'try_catch', key: 'catch',
73+
why: 'handler region — opaque for the same reason as loop.body',
74+
},
75+
];
76+
77+
const NODES: ReadonlyArray<{ nodeType: string; zod: unknown }> = [
78+
{ nodeType: 'loop', zod: LoopConfigSchema },
79+
{ nodeType: 'parallel', zod: ParallelConfigSchema },
80+
{ nodeType: 'try_catch', zod: TryCatchConfigSchema },
81+
];
82+
83+
function engineWithControlFlow() {
84+
const logger: any = {
85+
info() {}, warn() {}, error() {}, debug() {},
86+
child() { return logger; },
87+
};
88+
const ctx: any = { logger, getService() { throw new Error('none'); } };
89+
const engine = new AutomationEngine(logger);
90+
registerLoopNode(engine, ctx);
91+
registerParallelNode(engine, ctx);
92+
registerTryCatchNode(engine, ctx);
93+
return engine;
94+
}
95+
96+
const engine = engineWithControlFlow();
97+
98+
/** Top-level keys the designer form offers for a node type. */
99+
function formKeys(nodeType: string): string[] {
100+
const schema = engine.getActionDescriptor(nodeType)?.configSchema as
101+
| { properties?: Record<string, unknown> }
102+
| undefined;
103+
expect(schema, `${nodeType} should publish a configSchema`).toBeDefined();
104+
return Object.keys(schema!.properties ?? {}).sort();
105+
}
106+
107+
/**
108+
* Top-level keys the Zod object accepts, read straight off `.shape` — no `zod`
109+
* import, so this stays inside the package's existing dependency graph.
110+
*/
111+
function zodKeys(schema: unknown): string[] {
112+
const shape = (schema as { shape?: Record<string, unknown> }).shape;
113+
expect(shape, 'the Zod schema should expose a .shape').toBeDefined();
114+
return Object.keys(shape ?? {}).sort();
115+
}
116+
117+
describe('control-flow form ↔ Zod reconciliation (#4045)', () => {
118+
it.each(NODES)('$nodeType: the form offers exactly the keys the Zod accepts', ({ nodeType, zod }) => {
119+
const form = formKeys(nodeType);
120+
const zodded = zodKeys(zod);
121+
122+
// Accepted by Zod, absent from the form ⇒ an author cannot set it in Studio.
123+
// This is #3528's failure in the other direction: a real config key with no way
124+
// to author it, discoverable only by reading the executor.
125+
expect(
126+
zodded.filter((k) => !form.includes(k)),
127+
`${nodeType}: accepted by the Zod but absent from the designer form`,
128+
).toEqual([]);
129+
130+
// Offered by the form, not accepted by the Zod ⇒ the author fills in a field
131+
// whose value the parse then rejects or drops.
132+
expect(
133+
form.filter((k) => !zodded.includes(k)),
134+
`${nodeType}: offered by the designer form but not accepted by the Zod`,
135+
).toEqual([]);
136+
});
137+
138+
it('every ledger entry names a key both sides actually declare', () => {
139+
// Stops the ledger rotting into references to keys that were renamed or removed —
140+
// the same bidirectionality the #4040 expression ledger has.
141+
for (const { nodeType, key } of DELIBERATELY_SHALLOW) {
142+
const node = NODES.find((n) => n.nodeType === nodeType);
143+
expect(node, `ledger references unknown node type '${nodeType}'`).toBeDefined();
144+
expect(formKeys(nodeType), `${nodeType}.${key}: not on the form any more`).toContain(key);
145+
expect(zodKeys(node!.zod), `${nodeType}.${key}: not in the Zod any more`).toContain(key);
146+
}
147+
});
148+
149+
it('the ledger is not vacuous and every entry carries a reason', () => {
150+
// The failure mode a ledger test must not have: if the entries stopped resolving,
151+
// the assertions above would pass over an empty set.
152+
expect(DELIBERATELY_SHALLOW.length).toBeGreaterThan(0);
153+
for (const entry of DELIBERATELY_SHALLOW) {
154+
expect(
155+
entry.why.length,
156+
`${entry.nodeType}.${entry.key} needs a reason a reader can act on`,
157+
).toBeGreaterThan(20);
158+
}
159+
});
160+
});

0 commit comments

Comments
 (0)