|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #27828 — a flow node's config-value finding carries the slot's key path. |
| 5 | + * |
| 6 | + * `expression-invalid` is what publish refuses a node's config value with |
| 7 | + * (measured on a blank expression in an Update Records value), and its finding |
| 8 | + * was located by its `where` prose alone (`flow 'f' · node 'set' (update_record) |
| 9 | + * update_record field value at config.fields.status`). A draft's |
| 10 | + * `_diagnostics` path was therefore that prose, so Studio's Problems row listed |
| 11 | + * the refusal but could not select the node. The finding now carries the key |
| 12 | + * path in the spelling the edge findings use since #26858 |
| 13 | + * (`flows[0].nodes[1].config.fields.status`) beside the unchanged `where` and |
| 14 | + * message. |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, expect, it } from 'vitest'; |
| 18 | + |
| 19 | +import { runRuntimeAuthoringRules } from './runtime-gate.js'; |
| 20 | +import { validateStackExpressions } from './validate-expressions.js'; |
| 21 | +import type { ExprIssue } from './validate-expressions.js'; |
| 22 | + |
| 23 | +type AnyRec = Record<string, unknown>; |
| 24 | + |
| 25 | +const BLANK = { dialect: 'cel', source: ' ' }; |
| 26 | + |
| 27 | +const START = { id: 'start', type: 'start', label: 'Start' }; |
| 28 | +const END = { id: 'end', type: 'end', label: 'End' }; |
| 29 | + |
| 30 | +/** An Update Records node whose `status` value is a blank CEL envelope. */ |
| 31 | +const SET = { |
| 32 | + id: 'set', |
| 33 | + type: 'update_record', |
| 34 | + label: 'Set', |
| 35 | + config: { objectName: 'task', filter: { id: 'x' }, fields: { title: 'kept', status: BLANK } }, |
| 36 | +}; |
| 37 | + |
| 38 | +function flow(nodes: unknown[], extra: AnyRec = {}): AnyRec { |
| 39 | + return { |
| 40 | + name: 'node_cfg', |
| 41 | + label: 'Node config', |
| 42 | + type: 'autolaunched', |
| 43 | + nodes, |
| 44 | + edges: [ |
| 45 | + { id: 'e1', source: 'start', target: 'set' }, |
| 46 | + { id: 'e2', source: 'set', target: 'end' }, |
| 47 | + ], |
| 48 | + ...extra, |
| 49 | + }; |
| 50 | +} |
| 51 | + |
| 52 | +const onNode = (issues: ExprIssue[], id: string) => issues.filter((i) => i.where.includes(`node '${id}'`)); |
| 53 | + |
| 54 | +describe('a flow node config-value finding carries a key path (#27828)', () => { |
| 55 | + it('names the node by its index in the authored list, where and message unchanged', () => { |
| 56 | + const issues = onNode(validateStackExpressions({ flows: [flow([START, SET, END])] }), 'set'); |
| 57 | + expect(issues.length).toBeGreaterThan(0); |
| 58 | + for (const i of issues) { |
| 59 | + expect(i.path).toBe('flows[0].nodes[1].config.fields.status'); |
| 60 | + expect(i.where).toBe("flow 'node_cfg' · node 'set' (update_record) update_record field value at config.fields.status"); |
| 61 | + } |
| 62 | + }); |
| 63 | + |
| 64 | + it('indexes the raw list: a null member before the node does not renumber it', () => { |
| 65 | + const issues = onNode(validateStackExpressions({ flows: [flow([START, null, SET, END])] }), 'set'); |
| 66 | + expect(issues.length).toBeGreaterThan(0); |
| 67 | + for (const i of issues) expect(i.path).toBe('flows[0].nodes[2].config.fields.status'); |
| 68 | + }); |
| 69 | + |
| 70 | + it('indexes the flow in the stack', () => { |
| 71 | + const clean = { ...flow([START, END]), name: 'clean', edges: [{ id: 'e1', source: 'start', target: 'end' }] }; |
| 72 | + const issues = onNode(validateStackExpressions({ flows: [clean, flow([START, SET, END])] }), 'set'); |
| 73 | + expect(issues.length).toBeGreaterThan(0); |
| 74 | + for (const i of issues) expect(i.path).toBe('flows[1].nodes[1].config.fields.status'); |
| 75 | + }); |
| 76 | + |
| 77 | + it('every node-config arm locates its finding at the slot it judged', () => { |
| 78 | + const nodes = [ |
| 79 | + { ...START, config: { condition: 'record.name ==' } }, |
| 80 | + // A value slot's retired single-brace token (the template-refusal arm). |
| 81 | + { id: 'tok', type: 'update_record', label: 'Tok', config: { objectName: 'task', filter: { id: 'x' }, fields: { title: '{record.name}' } } }, |
| 82 | + // A text slot's single-brace token (the text-slot arm). |
| 83 | + { id: 'say', type: 'notify', label: 'Say', config: { recipients: ['a@b.c'], title: 'Hi {record.name}', message: 'ok' } }, |
| 84 | + // A key the node's contract does not declare (the config-refusal arm). |
| 85 | + { id: 'odd', type: 'notify', label: 'Odd', config: { bogusKey: 1, recipients: ['a@b.c'], title: 'ok', message: 'ok' } }, |
| 86 | + // A script with no callable (the script arm). |
| 87 | + { id: 'run', type: 'script', label: 'Run', config: {} }, |
| 88 | + // A sibling-block value slot (#27968): the path names its block. |
| 89 | + { |
| 90 | + id: 'call', |
| 91 | + type: 'connector_action', |
| 92 | + label: 'Call', |
| 93 | + config: {}, |
| 94 | + connectorConfig: { connectorId: 'crm', actionId: 'push', input: { customerId: '{record.id}' } }, |
| 95 | + }, |
| 96 | + ]; |
| 97 | + const issues = validateStackExpressions({ flows: [flow(nodes, { edges: [] })] }); |
| 98 | + const pathsOf = (id: string) => [...new Set(onNode(issues, id).map((i) => i.path))]; |
| 99 | + expect(pathsOf('start')).toEqual(['flows[0].nodes[0].config.condition']); |
| 100 | + expect(pathsOf('tok')).toEqual(['flows[0].nodes[1].config.fields.title']); |
| 101 | + expect(pathsOf('say')).toEqual(['flows[0].nodes[2].config.title']); |
| 102 | + expect(pathsOf('odd')).toEqual(['flows[0].nodes[3].config.bogusKey']); |
| 103 | + expect(pathsOf('run')).toEqual(['flows[0].nodes[4].config.function']); |
| 104 | + expect(pathsOf('call')).toEqual(['flows[0].nodes[5].connectorConfig.input.customerId']); |
| 105 | + }); |
| 106 | + |
| 107 | + it('a map-form flows collection has no index to spell, so the finding keeps where alone', () => { |
| 108 | + const { name: _name, ...body } = flow([START, SET, END]); |
| 109 | + const issues = onNode(validateStackExpressions({ flows: { node_cfg: body } }), 'set'); |
| 110 | + expect(issues.length).toBeGreaterThan(0); |
| 111 | + for (const i of issues) expect(i.path).toBeUndefined(); |
| 112 | + }); |
| 113 | + |
| 114 | + it('a node inside a nested region keeps where alone, as a nested edge does', () => { |
| 115 | + const loop = { |
| 116 | + id: 'each', |
| 117 | + type: 'loop', |
| 118 | + label: 'Each', |
| 119 | + config: { |
| 120 | + collection: { dialect: 'cel', source: '[1, 2]' }, |
| 121 | + itemVariable: 'item', |
| 122 | + body: { nodes: [{ ...SET, id: 'inner' }], edges: [] }, |
| 123 | + }, |
| 124 | + }; |
| 125 | + const issues = onNode( |
| 126 | + validateStackExpressions({ flows: [flow([START, loop, END], { edges: [{ id: 'e1', source: 'start', target: 'each' }, { id: 'e2', source: 'each', target: 'end' }] })] }), |
| 127 | + 'inner', |
| 128 | + ); |
| 129 | + expect(issues.length).toBeGreaterThan(0); |
| 130 | + for (const i of issues) expect(i.path).toBeUndefined(); |
| 131 | + }); |
| 132 | + |
| 133 | + it('reaches the publish gate: the refusal is located at the node config value, message unchanged', () => { |
| 134 | + const result = runRuntimeAuthoringRules({ type: 'flow', item: flow([START, SET, END]) }); |
| 135 | + const refusals = result.errors.filter((f) => f.rule === 'expression-invalid'); |
| 136 | + expect(refusals.length).toBeGreaterThan(0); |
| 137 | + for (const f of refusals) { |
| 138 | + expect(f.path).toMatch(/^flows(\[0\]|\.node_cfg)\.nodes\[1\]\.config\.fields\.status$/); |
| 139 | + expect(f.where).toBe("flow 'node_cfg' · node 'set' (update_record) update_record field value at config.fields.status"); |
| 140 | + } |
| 141 | + }); |
| 142 | +}); |
0 commit comments