Skip to content

Commit 39e43c8

Browse files
os-zhuangclaude
andauthored
test(spec): make the remaining un-runnable flow.test.ts fixture shapes runnable, and pin them (#5500) (#5686)
Follows #4924 / PR #5502 on the same file. Four of the five classes #5500 listed were real and are fixed; the first class's stated mechanism did not survive verification and is reported instead of forced. - assignment (`assign_output`): `{ variable, value }` -> `{ assignments: { … } }`. logic-nodes.ts normalizes three shapes and its last branch makes top-level config keys the variable NAMES, so the old config declared two variables literally named `variable` and `value` while `contactId` (declared `isOutput: true`) was never written. Measured end to end before the fix. - loop (`loop_records`): legacy flat-graph loop -> ADR-0031 structured container. With no `config.body` loop-node.ts reads `config.collection` as a bare VARIABLE NAME, so `'{get_old_records.records}'` matched nothing, bound nothing and fell through; the `loop -> delete -> loop` back-edge was ordinary traversal and `{item.id}` referenced a variable no one set. The delete node moves into `config.body` and `iteratorVariable` binds `item`. - get_record (`get_old_records`): string `filter` -> record form. The contract declares `z.record(z.string(), z.unknown())`, so the string failed safeParse outright, and `DAYS_AGO()` is implemented nowhere. The date window is spelled `{90_days_ago}` — a spec date macro whose slot ownership is declared: a known filter token passes through interpolateFilter verbatim for the query engine's resolveFilterTokens to expand, and date-macros.zod.ts names "flow node filters" as a consumer. `limit` added because it selects the `find`/`records` branch the loop needs. - `object` -> `objectName` at the three remaining sites (ADR-0087 D2 `flow-node-crud-object-alias`). - edge conditions: `{…}` template braces -> bare CEL (ADR-0032 §1a). Verified that registerFlow rejects the braced form outright, so those fixtures could not register at all. Class 1 ("the `{<node id>.<field>}` output dialect is never bound") is NOT implemented, because it is false: engine.ts writes every node's `result.output` under `<nodeId>.<key>` and template.ts resolves that flat key, which crud-nodes.ts documents as the default binding. `{create_contact.id}` resolves and is kept verbatim. The real defect at the other cited site was narrower — get_record only emits `records` on the `limit > 1` branch — and is fixed above. Each fix carries a contract assertion, and each was reverse-verified by restoring the broken shape. That caught a phantom in the first draft of the brace pin: ExpressionInputSchema normalizes a bare-string predicate into a `{ dialect, source }` envelope, so asserting `toContain` on the parsed `condition` object passed regardless of the predicate. The pins now read `condition.source`. Refs #5500 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e472f7 commit 39e43c8

1 file changed

Lines changed: 180 additions & 33 deletions

File tree

packages/spec/src/automation/flow.test.ts

Lines changed: 180 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,16 @@ import {
1818
// builtin's config could run. These are the contracts that do.
1919
import {
2020
GetRecordConfigSchema,
21+
CreateRecordConfigSchema,
2122
UpdateRecordConfigSchema,
2223
DeleteRecordConfigSchema,
2324
} from './builtin-node-config.zod';
25+
// #5500 — the `loop` container contract (ADR-0031). A `loop` with no
26+
// `config.body` is the LEGACY flat-graph shape: `loop-node.ts` reads
27+
// `config.collection` as a bare VARIABLE NAME, binds `$loopItems`/`$loopIndex`
28+
// and falls through without iterating — so no `iteratorVariable` is ever set
29+
// and a `{item.…}` token downstream references nothing.
30+
import { LoopConfigSchema } from './control-flow.zod';
2431

2532
describe('FlowNodeAction', () => {
2633
it('should accept all node action types', () => {
@@ -114,8 +121,12 @@ describe('FlowNodeSchema', () => {
114121
id: 'node_3',
115122
type: 'create_record',
116123
label: 'Create Account',
124+
// #5500 — was `object:`, the retired spelling the ADR-0087 D2 conversion
125+
// `flow-node-crud-object-alias` rewrites at load (live through this major,
126+
// retires at 18). A fixture is a teaching surface, so it spells the
127+
// canonical key the executor actually reads.
117128
config: {
118-
object: 'account',
129+
objectName: 'account',
119130
fields: {
120131
name: '{input.companyName}',
121132
status: 'active',
@@ -124,6 +135,13 @@ describe('FlowNodeSchema', () => {
124135
};
125136

126137
expect(() => FlowNodeSchema.parse(node)).not.toThrow();
138+
139+
// #5500 — `FlowNodeSchema.config` is deliberately open (ADR-0018), so the
140+
// parse above stays green on a config no executor could run. Pin the config
141+
// against the contract `create_record` actually parses: this is a VALUE
142+
// verdict (the executor refuses the node without `objectName`), so full
143+
// safeParse green is the right bar, and the alias spelling turns it red.
144+
expect(CreateRecordConfigSchema.safeParse(node.config).success).toBe(true);
127145
});
128146

129147
it('should accept all node types', () => {
@@ -481,8 +499,11 @@ describe('FlowSchema', () => {
481499
id: 'create_contact',
482500
type: 'create_record',
483501
label: 'Create Contact',
502+
// #5500 — `object:` → `objectName:` (see `should accept node with
503+
// config`). The `{firstName}` &c. tokens are declared INPUT
504+
// variables, which the engine binds by name, so they resolve.
484505
config: {
485-
object: 'contact',
506+
objectName: 'contact',
486507
fields: {
487508
first_name: '{firstName}',
488509
last_name: '{lastName}',
@@ -494,9 +515,21 @@ describe('FlowSchema', () => {
494515
id: 'assign_output',
495516
type: 'assignment',
496517
label: 'Set Output',
518+
// #5500 — was `{ variable: 'contactId', value: '{create_contact.id}' }`,
519+
// which set NEITHER. `logic-nodes.ts` normalizes three assignment
520+
// shapes and its last branch is "no `assignments` wrapper → the
521+
// top-level config keys ARE the variable names", so that config
522+
// declared two variables literally named `variable` and `value`,
523+
// and `contactId` — declared `isOutput: true` right above — was
524+
// never written. Measured: with the old shape the run ends with
525+
// `variable='contactId'`, `value='<the new id>'`, `contactId=undefined`.
526+
//
527+
// The VALUE token was fine and is kept verbatim: the engine binds
528+
// every node's `result.output` under `<nodeId>.<key>` and the
529+
// template resolver reads that flat key, so `{create_contact.id}`
530+
// resolves to the created row's id (`create_record` outputs `id`).
497531
config: {
498-
variable: 'contactId',
499-
value: '{create_contact.id}',
532+
assignments: { contactId: '{create_contact.id}' },
500533
},
501534
},
502535
{ id: 'end', type: 'end', label: 'End' },
@@ -509,6 +542,23 @@ describe('FlowSchema', () => {
509542
};
510543

511544
expect(() => FlowSchema.parse(screenFlow)).not.toThrow();
545+
546+
// #5500 — pin the create node against the contract its executor parses.
547+
const cfgOf = (id: string) => screenFlow.nodes.find(n => n.id === id)?.config;
548+
expect(CreateRecordConfigSchema.safeParse(cfgOf('create_contact')).success).toBe(true);
549+
550+
// #5500 — pin the assignment's SHAPE, which no spec schema governs (the
551+
// executor reads `config` directly, so `FlowSchema.parse` can never catch
552+
// this). The writes must live under `assignments`, and every name written
553+
// must be a variable this flow declares — reverting to the bare
554+
// `{ variable, value }` shape leaves `assignments` undefined and turns
555+
// both assertions red.
556+
const assignCfg = cfgOf('assign_output') as { assignments?: Record<string, unknown> };
557+
expect(Object.keys(assignCfg?.assignments ?? {})).toEqual(['contactId']);
558+
const declared = new Set((screenFlow.variables ?? []).map(v => v.name));
559+
for (const written of Object.keys(assignCfg?.assignments ?? {})) {
560+
expect(declared.has(written)).toBe(true);
561+
}
512562
});
513563

514564
it('should accept scheduled flow', () => {
@@ -523,55 +573,120 @@ describe('FlowSchema', () => {
523573
id: 'get_old_records',
524574
type: 'get_record',
525575
label: 'Find Old Records',
576+
// #5500 — three defects in two keys:
577+
// • `object:` → `objectName:` (the ADR-0087 D2 alias, as above).
578+
// • `filter` was the STRING `'created_at < DAYS_AGO(90)'`. The
579+
// contract declares `z.record(z.string(), z.unknown())`, so a
580+
// string fails safeParse outright ("expected record, received
581+
// string"), and `DAYS_AGO()` is a function no layer implements.
582+
// The date window is spelled in the dialect that OWNS a filter
583+
// value position: `{90_days_ago}` is a spec date macro
584+
// (`DATE_MACRO_PARAM_RE`), and `interpolateFilter` hands a known
585+
// filter token through VERBATIM for the query engine's
586+
// `resolveFilterTokens` to expand (#3810 ownership transfer) —
587+
// `date-macros.zod.ts` names "flow node filters" as a consumer.
588+
// • `limit` was absent. The executor branches on it: `limit > 1`
589+
// runs `find` and outputs a `records` LIST; otherwise `findOne`
590+
// and a single `record`. A cleanup sweep wants the list, and the
591+
// loop below needs an array, so the limit is what makes the
592+
// downstream `collection` an array at all.
526593
config: {
527-
object: 'log_entry',
528-
filter: 'created_at < DAYS_AGO(90)',
594+
objectName: 'log_entry',
595+
filter: { created_at: { $lt: '{90_days_ago}' } },
596+
limit: 200,
597+
outputVariable: 'oldRecords',
529598
},
530599
},
531600
{
532601
id: 'loop_records',
533602
type: 'loop',
534603
label: 'For Each Record',
604+
// #5500 — was a LEGACY flat-graph loop: no `config.body`, so
605+
// `loop-node.ts` took its back-compat branch, which reads
606+
// `config.collection` as a bare VARIABLE NAME (not a template),
607+
// found no variable literally named `{get_old_records.records}`,
608+
// bound nothing and returned success. The `loop → delete → loop`
609+
// back-edge was ordinary graph traversal, and `{item.id}` in the
610+
// delete node below referenced a variable no one ever set.
611+
// Measured on the old shape: `$loopItems` unset, `item` undefined.
612+
//
613+
// This is now the ADR-0031 structured container: the per-item steps
614+
// live in `config.body` (a single-entry/single-exit region run in
615+
// the enclosing scope) and `iteratorVariable` is what binds `item`.
535616
config: {
536-
collection: '{get_old_records.records}',
537-
},
538-
},
539-
{
540-
id: 'delete_record',
541-
type: 'delete_record',
542-
label: 'Delete Record',
543-
// #4924 — this was the worst of the three shapes: `recordId` was the
544-
// node's ONLY key, no executor reads it, and a `delete_record` whose
545-
// single "constraint" is unread is a match-everything delete (#3810)
546-
// wearing a key that reads like a constraint. The executor locates rows
547-
// through `filter` and refuses the node without `objectName`.
548-
// The per-item token is the loop's `iteratorVariable` (default `item`),
549-
// NOT `{<node id>.item}` — node outputs are never bound under a node id.
550-
config: {
551-
objectName: 'log_entry',
552-
filter: { id: '{item.id}' },
617+
collection: '{oldRecords}',
618+
iteratorVariable: 'item',
619+
maxIterations: 200,
620+
body: {
621+
nodes: [
622+
{
623+
id: 'delete_record',
624+
type: 'delete_record',
625+
label: 'Delete Record',
626+
// #4924 — this was the worst of the three shapes: `recordId` was the
627+
// node's ONLY key, no executor reads it, and a `delete_record` whose
628+
// single "constraint" is unread is a match-everything delete (#3810)
629+
// wearing a key that reads like a constraint. The executor locates rows
630+
// through `filter` and refuses the node without `objectName`.
631+
// The per-item token is the loop's `iteratorVariable` (default `item`),
632+
// NOT `{<node id>.item}` — and #5500 moved the node INSIDE the loop
633+
// body, which is what makes `item` actually bound per iteration.
634+
config: {
635+
objectName: 'log_entry',
636+
filter: { id: '{item.id}' },
637+
},
638+
},
639+
],
640+
edges: [],
641+
},
553642
},
554643
},
555644
{ id: 'end', type: 'end', label: 'End' },
556645
],
557646
edges: [
558647
{ id: 'e1', source: 'start', target: 'get_old_records' },
559648
{ id: 'e2', source: 'get_old_records', target: 'loop_records' },
560-
{ id: 'e3', source: 'loop_records', target: 'delete_record' },
561-
{ id: 'e4', source: 'delete_record', target: 'loop_records' },
562-
{ id: 'e5', source: 'loop_records', target: 'end', label: 'Done' },
649+
// #5500 — the `loop → delete → loop` back-edge pair is gone: the
650+
// delete node now lives in `config.body`, so the loop's ordinary
651+
// out-edge is simply the after-loop continuation (ADR-0031).
652+
{ id: 'e3', source: 'loop_records', target: 'end', label: 'Done' },
563653
],
564654
runAs: 'system',
565655
};
566656

567657
expect(() => FlowSchema.parse(scheduledFlow)).not.toThrow();
568658

569659
// #4924 — the delete node addresses rows the only way the executor does.
570-
// (The `get_old_records` / `loop_records` pair upstream still carries
571-
// shapes of its own — a string `filter`, the `object` alias and a
572-
// `{<node id>.…}` output reference — tracked separately, see the PR.)
573-
const deleteConfig = scheduledFlow.nodes.find(n => n.id === 'delete_record')?.config;
660+
// #5500 — it is now reached through the loop's body region.
661+
const loopConfig = scheduledFlow.nodes.find(n => n.id === 'loop_records')?.config;
662+
const parsedLoop = LoopConfigSchema.safeParse(loopConfig);
663+
expect(parsedLoop.success).toBe(true);
664+
const deleteConfig = parsedLoop.success
665+
? parsedLoop.data.body?.nodes.find(n => n.id === 'delete_record')?.config
666+
: undefined;
574667
expect(DeleteRecordConfigSchema.safeParse(deleteConfig).success).toBe(true);
668+
669+
// #5500 — pin the loop as a STRUCTURED container. `body` is what separates
670+
// it from the legacy flat-graph shape that iterated nothing, and
671+
// `iteratorVariable` is the only thing that binds the `{item.…}` token the
672+
// body's filter reads. Dropping `body` puts the fixture back on the
673+
// legacy branch and turns both of these red.
674+
expect(parsedLoop.success && parsedLoop.data.body).toBeDefined();
675+
expect(parsedLoop.success && parsedLoop.data.iteratorVariable).toBe('item');
676+
// …and no main-graph edge targets a body node any more.
677+
const bodyNodeIds = new Set(
678+
parsedLoop.success ? (parsedLoop.data.body?.nodes ?? []).map(n => n.id) : [],
679+
);
680+
expect(scheduledFlow.edges.filter(e => bodyNodeIds.has(e.target))).toHaveLength(0);
681+
682+
// #5500 — the upstream read must produce an ARRAY for the loop to iterate:
683+
// `limit > 1` is what selects the `find`/`records` branch over
684+
// `findOne`/`record`, so it is a contract detail, not a tuning knob.
685+
const getConfig = scheduledFlow.nodes.find(n => n.id === 'get_old_records')?.config;
686+
const parsedGet = GetRecordConfigSchema.safeParse(getConfig);
687+
expect(parsedGet.success).toBe(true);
688+
expect(parsedGet.success && (parsedGet.data.limit ?? 0) > 1).toBe(true);
689+
expect(parsedGet.success && parsedGet.data.outputVariable).toBe('oldRecords');
575690
});
576691

577692
it('should accept API flow with webhook', () => {
@@ -975,11 +1090,29 @@ describe('BPMN — Default Sequence Flow (isDefault)', () => {
9751090
source: 'decision_1',
9761091
target: 'branch_a',
9771092
type: 'conditional',
978-
condition: '{amount} > 1000',
1093+
// #5500 — was `'{amount} > 1000'`. An edge condition is BARE CEL
1094+
// (ADR-0032 §1a); `{…}` template braces parse as a CEL map literal, and
1095+
// `AutomationEngine.registerFlow` parse-validates every predicate at
1096+
// registration, so the braced form is a HARD registration failure:
1097+
// "Flow '…' has 1 invalid expression (ADR-0032 §1a). Predicates … must
1098+
// not wrap references in `{…}` template braces". This is the #1491 trap.
1099+
condition: 'amount > 1000',
9791100
label: 'High Value',
9801101
});
9811102
expect(result.type).toBe('conditional');
9821103
expect(result.isDefault).toBe(false);
1104+
// #5500 — only an explicit assertion keeps this fixture from teaching the
1105+
// braced form back in. Braces are correct in TEMPLATE slots
1106+
// (`loop.collection`) and wrong here — the distinction is the whole point.
1107+
//
1108+
// Read `.source`, NOT the condition itself: `ExpressionInputSchema`
1109+
// normalizes a bare-string predicate into the canonical
1110+
// `{ dialect: 'cel', source }` envelope, so `expect(result.condition)
1111+
// .not.toContain('{')` asserts against an OBJECT and passes no matter what
1112+
// the predicate says. That phantom was written here first and caught by
1113+
// reverse-verification (the braced spelling stayed green) — hence this note.
1114+
expect(result.condition?.dialect).toBe('cel');
1115+
expect(result.condition?.source).not.toContain('{');
9831116
});
9841117

9851118
it('should validate a decision with default and conditional branches', () => {
@@ -997,8 +1130,11 @@ describe('BPMN — Default Sequence Flow (isDefault)', () => {
9971130
],
9981131
edges: [
9991132
{ id: 'e1', source: 'start', target: 'check_priority' },
1000-
{ id: 'e2', source: 'check_priority', target: 'high_path', type: 'conditional', condition: '{priority} == "high"' },
1001-
{ id: 'e3', source: 'check_priority', target: 'medium_path', type: 'conditional', condition: '{priority} == "medium"' },
1133+
// #5500 — bare CEL, not `{…}` template braces (ADR-0032 §1a; see
1134+
// 'should accept conditional edge type'). Registering the braced form
1135+
// threw at `registerFlow`, so this whole fixture was un-runnable.
1136+
{ id: 'e2', source: 'check_priority', target: 'high_path', type: 'conditional', condition: 'priority == "high"' },
1137+
{ id: 'e3', source: 'check_priority', target: 'medium_path', type: 'conditional', condition: 'priority == "medium"' },
10021138
{ id: 'e4', source: 'check_priority', target: 'default_path', isDefault: true, label: 'Default' },
10031139
{ id: 'e5', source: 'high_path', target: 'end' },
10041140
{ id: 'e6', source: 'medium_path', target: 'end' },
@@ -1012,6 +1148,17 @@ describe('BPMN — Default Sequence Flow (isDefault)', () => {
10121148
const defaultEdge = result.data.edges.find(e => e.isDefault);
10131149
expect(defaultEdge).toBeDefined();
10141150
expect(defaultEdge!.target).toBe('default_path');
1151+
// #5500 — every guarded branch is bare CEL. `FlowSchema.parse` accepts any
1152+
// string here, so without this the fixture could teach the #1491 braced
1153+
// form back in while staying green. Assert on the normalized
1154+
// `condition.source` (see 'should accept conditional edge type') — the
1155+
// parsed `condition` is an Expression ENVELOPE, and `toContain` against
1156+
// the envelope object pins nothing.
1157+
const guardedEdges = result.data.edges.filter(e => e.condition);
1158+
expect(guardedEdges).toHaveLength(2);
1159+
for (const guarded of guardedEdges) {
1160+
expect(guarded.condition?.source).not.toContain('{');
1161+
}
10151162
}
10161163
});
10171164
});

0 commit comments

Comments
 (0)