|
| 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