|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * **Hand-written `FLOW_NODE_CONFIG` ↔ spec-published executor contracts** |
| 5 | + * (framework#4278). |
| 6 | + * |
| 7 | + * Five builtins deliberately publish no descriptor `configSchema` (framework |
| 8 | + * `config-schemas.test.ts`): the schema-driven online form cannot express |
| 9 | + * their editors — decision's virtual Target column, script's |
| 10 | + * actionType-conditional groups, the spec-structured sibling blocks. Their |
| 11 | + * Studio form is therefore this package's hand-written table, and until #4278 |
| 12 | + * nothing reconciled that table against what the executors actually read. |
| 13 | + * `script` had drifted user-visibly: an `outputVariables` field nothing reads, |
| 14 | + * `sms` / `notification` options that fail every run, a no-op `code` default, |
| 15 | + * and no way to author the `function` / `inputs` / `outputVariable` path that |
| 16 | + * works. |
| 17 | + * |
| 18 | + * The machine-readable half now lives in `@objectstack/spec/automation`: |
| 19 | + * executor-derived config Zods for `script` / `subflow` / `decision` |
| 20 | + * (`schemaless-node-config.zod.ts`) plus the `FlowNodeSchema` sibling blocks |
| 21 | + * for `wait` / `connector_action` / `boundary_event`. This file is the |
| 22 | + * objectui half of the ledger — the same bidirectional key-set comparison |
| 23 | + * service-automation's `builtin-node-form-zod-ledger.test.ts` performs for the |
| 24 | + * descriptor-schema'd builtins, carried across the repo seam by the |
| 25 | + * `@objectstack/spec` dependency this package already has. |
| 26 | + * |
| 27 | + * The script/subflow/decision panels feature-detect their spec exports and |
| 28 | + * skip while the installed spec predates them (they arm themselves on the next |
| 29 | + * `@objectstack/spec` bump — see the version-alignment section of AGENTS.md); |
| 30 | + * the sibling-block panels run against every spec version this repo supports. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect } from 'vitest'; |
| 34 | +import * as Automation from '@objectstack/spec/automation'; |
| 35 | +import { fieldsForNodeType, type FlowConfigField } from './flow-node-config'; |
| 36 | + |
| 37 | +// Feature-detected exports — absent on a spec that predates framework#4278. |
| 38 | +// (Truthiness alone never resolves a lazySchema proxy.) |
| 39 | +const spec = Automation as Record<string, unknown>; |
| 40 | +const ScriptConfigSchema = spec.ScriptConfigSchema; |
| 41 | +const SubflowConfigSchema = spec.SubflowConfigSchema; |
| 42 | +const DecisionConfigSchema = spec.DecisionConfigSchema; |
| 43 | +const DecisionConditionSchema = spec.DecisionConditionSchema; |
| 44 | +const SCRIPT_BUILTIN_ACTION_TYPES = spec.SCRIPT_BUILTIN_ACTION_TYPES as readonly string[] | undefined; |
| 45 | +const SCRIPT_INVOKE_FUNCTION_ACTION_TYPE = spec.SCRIPT_INVOKE_FUNCTION_ACTION_TYPE as string | undefined; |
| 46 | + |
| 47 | +/** |
| 48 | + * Keys a Zod object schema accepts, read straight off `.shape`. |
| 49 | + * |
| 50 | + * `retiredKey()` tombstones are excluded: a retired key stays in the shape so |
| 51 | + * its rejection carries the upgrade prescription (spec `shared/retired-key.ts` |
| 52 | + * marks it with a `[REMOVED]` description), but it is NOT part of the |
| 53 | + * authorable contract — a form field writing one produces metadata the loader |
| 54 | + * rejects. Counting tombstones as contract keys would false-pass exactly that |
| 55 | + * drift (e.g. `waitEventConfig.timeoutMs` / `.onTimeout`, retired at spec 18 |
| 56 | + * by framework#4198 — this filter is what makes the `wait` panel fire on that |
| 57 | + * bump until the form drops the two fields). |
| 58 | + */ |
| 59 | +function zodKeys(schema: unknown): string[] { |
| 60 | + const shape = (schema as { shape?: Record<string, unknown> }).shape; |
| 61 | + expect(shape, 'expected a Zod object schema exposing .shape').toBeDefined(); |
| 62 | + return Object.keys(shape ?? {}) |
| 63 | + .filter((k) => { |
| 64 | + const description = (shape![k] as { description?: string } | undefined)?.description; |
| 65 | + return !(typeof description === 'string' && description.startsWith('[REMOVED]')); |
| 66 | + }) |
| 67 | + .sort(); |
| 68 | +} |
| 69 | + |
| 70 | +/** Unwrap `.optional()` / `.default()` wrappers down to the object schema. */ |
| 71 | +function unwrapped(schema: unknown): unknown { |
| 72 | + let cur = schema as { shape?: unknown; unwrap?: () => unknown } | undefined; |
| 73 | + for (let i = 0; cur && !cur.shape && typeof cur.unwrap === 'function' && i < 5; i++) { |
| 74 | + cur = cur.unwrap() as typeof cur; |
| 75 | + } |
| 76 | + return cur; |
| 77 | +} |
| 78 | + |
| 79 | +/** A field render-gated behind the never-matching `__legacy__` controller. */ |
| 80 | +function isLegacyGated(f: FlowConfigField): boolean { |
| 81 | + return f.showWhen?.field === '__legacy__'; |
| 82 | +} |
| 83 | + |
| 84 | +/** Config keys the form OFFERS for new authoring (legacy render-only excluded). */ |
| 85 | +function offeredConfigKeys(type: string): string[] { |
| 86 | + return [...new Set( |
| 87 | + fieldsForNodeType(type) |
| 88 | + .filter((f) => f.path[0] === 'config' && !isLegacyGated(f)) |
| 89 | + .map((f) => f.path[1]!), |
| 90 | + )].sort(); |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Reconcile one node type's config-rooted form keys against its executor |
| 95 | + * contract. `renderOnly` names contract keys the form deliberately does NOT |
| 96 | + * offer for new authoring — each must still be present as a legacy-gated |
| 97 | + * field so stored metadata keeps rendering, and each needs its reason here. |
| 98 | + */ |
| 99 | +function reconcile(type: string, zod: unknown, renderOnly: Record<string, string> = {}) { |
| 100 | + const offered = offeredConfigKeys(type); |
| 101 | + const contract = zodKeys(zod); |
| 102 | + |
| 103 | + // Read by the executor, absent from the form ⇒ authorable only by hand — |
| 104 | + // the exact shape #4278 found for script's function/inputs/outputVariable. |
| 105 | + expect( |
| 106 | + contract.filter((k) => !offered.includes(k) && !(k in renderOnly)), |
| 107 | + `${type}: read by the executor but not offered by the designer form`, |
| 108 | + ).toEqual([]); |
| 109 | + |
| 110 | + // Offered by the form, never read by the executor ⇒ a Studio field that |
| 111 | + // does nothing — the #3528 / outputVariables shape. |
| 112 | + expect( |
| 113 | + offered.filter((k) => !contract.includes(k)), |
| 114 | + `${type}: offered by the designer form but never read by the executor`, |
| 115 | + ).toEqual([]); |
| 116 | + |
| 117 | + // Every render-only exemption must (a) be part of the executor contract and |
| 118 | + // (b) still render for stored metadata via a legacy-gated field. |
| 119 | + for (const key of Object.keys(renderOnly)) { |
| 120 | + expect(contract, `${type}: render-only exemption '${key}' must be a contract key`).toContain(key); |
| 121 | + const field = fieldsForNodeType(type).find((f) => f.path[0] === 'config' && f.path[1] === key); |
| 122 | + expect(field, `${type}: render-only key '${key}' must keep a (legacy-gated) field`).toBeDefined(); |
| 123 | + expect(isLegacyGated(field!), `${type}: '${key}' must be legacy-gated, not offered`).toBe(true); |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +describe.skipIf(!ScriptConfigSchema)('script form ↔ ScriptConfigSchema (framework#4278)', () => { |
| 128 | + it('offers exactly the executor-read keys; inline `script` stays render-only (a recognized no-op)', () => { |
| 129 | + reconcile('script', ScriptConfigSchema, { |
| 130 | + script: 'recognized but NOT executed by the built-in runtime (no server-side JS sandbox) — renders for stored nodes, steers authors to `function`', |
| 131 | + }); |
| 132 | + }); |
| 133 | + |
| 134 | + it('offers exactly the published action types: invoke_function + the built-in set', () => { |
| 135 | + const actionType = fieldsForNodeType('script').find((f) => f.id === 'actionType')!; |
| 136 | + expect(actionType.options!.map((o) => o.value).sort()).toEqual( |
| 137 | + [SCRIPT_INVOKE_FUNCTION_ACTION_TYPE!, ...SCRIPT_BUILTIN_ACTION_TYPES!].sort(), |
| 138 | + ); |
| 139 | + // The default is the path that runs real logic, not the no-op. |
| 140 | + expect(actionType.defaultValue).toBe(SCRIPT_INVOKE_FUNCTION_ACTION_TYPE); |
| 141 | + }); |
| 142 | +}); |
| 143 | + |
| 144 | +describe.skipIf(!SubflowConfigSchema)('subflow form ↔ SubflowConfigSchema (framework#4278)', () => { |
| 145 | + it('offers exactly the executor-read keys', () => { |
| 146 | + reconcile('subflow', SubflowConfigSchema); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe.skipIf(!DecisionConfigSchema)('decision form ↔ DecisionConfigSchema (framework#4278)', () => { |
| 151 | + it('offers exactly the executor-read keys (legacy single `condition` stays render-only)', () => { |
| 152 | + // `condition` (singular) is legacy-gated in the form and deliberately NOT |
| 153 | + // in the contract: the decision executor never reads it — branching lives |
| 154 | + // in `conditions[]` or on edge conditions. Legacy-gated fields are already |
| 155 | + // excluded from `offered`, so plain reconciliation covers it. |
| 156 | + reconcile('decision', DecisionConfigSchema); |
| 157 | + }); |
| 158 | + |
| 159 | + it('branch columns match DecisionConditionSchema one level down (Target is virtual)', () => { |
| 160 | + const conditions = fieldsForNodeType('decision').find((f) => f.id === 'conditions')!; |
| 161 | + const columnKeys = conditions.columns!.map((c) => c.key).sort(); |
| 162 | + const contract = zodKeys(DecisionConditionSchema); |
| 163 | + // `target` is a VIRTUAL column — projected from / applied to the out-edges |
| 164 | + // by flow-decision-edges, never stored on the branch — so it is the one |
| 165 | + // legitimate column the stored-shape contract does not carry. |
| 166 | + expect(columnKeys.filter((k) => k !== 'target')).toEqual(contract); |
| 167 | + expect(columnKeys).toContain('target'); |
| 168 | + }); |
| 169 | +}); |
| 170 | + |
| 171 | +describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet)', () => { |
| 172 | + // These blocks are published by every spec version this repo supports, so |
| 173 | + // no feature detection: the hand-written groups for wait / connector_action |
| 174 | + // / boundary_event must edit exactly the keys the spec block declares — |
| 175 | + // #4161 / #4210 verified them by hand once; this keeps them verified. |
| 176 | + const FlowNodeSchema = spec.FlowNodeSchema as { shape: Record<string, unknown> }; |
| 177 | + |
| 178 | + const BLOCKS: ReadonlyArray<{ type: string; block: string }> = [ |
| 179 | + { type: 'wait', block: 'waitEventConfig' }, |
| 180 | + { type: 'connector_action', block: 'connectorConfig' }, |
| 181 | + { type: 'boundary_event', block: 'boundaryConfig' }, |
| 182 | + ]; |
| 183 | + |
| 184 | + it.each(BLOCKS)('$type: the $block fields match the spec block exactly', ({ type, block }) => { |
| 185 | + const formKeys = [...new Set( |
| 186 | + fieldsForNodeType(type) |
| 187 | + .filter((f) => f.path[0] === block) |
| 188 | + .map((f) => f.path[1]!), |
| 189 | + )].sort(); |
| 190 | + const blockKeys = zodKeys(unwrapped(FlowNodeSchema.shape[block])); |
| 191 | + |
| 192 | + expect( |
| 193 | + blockKeys.filter((k) => !formKeys.includes(k)), |
| 194 | + `${type}: declared by the spec block but absent from the designer form`, |
| 195 | + ).toEqual([]); |
| 196 | + expect( |
| 197 | + formKeys.filter((k) => !blockKeys.includes(k)), |
| 198 | + `${type}: edited by the designer form but not declared by the spec block`, |
| 199 | + ).toEqual([]); |
| 200 | + }); |
| 201 | +}); |
0 commit comments