|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * **A `connector_action` node's `connectorConfig.input` is a value slot** |
| 5 | + * (#27327) — evaluated through the one per-key resolver every other value |
| 6 | + * map uses (`resolveValueSlotMap`), so a flow can send a per-record value to a |
| 7 | + * connector's action. |
| 8 | + * |
| 9 | + * Before this, the executor handed `cfg.input` to the connector's handler as |
| 10 | + * written: an envelope reading a record field reached the provider as the |
| 11 | + * object `{ dialect: 'cel', source: '…' }` it spells, and a declarative |
| 12 | + * connector interpolating it into a request path sent `[object Object]`, with |
| 13 | + * the run reporting success. Pinned through the shipped `AutomationEngine`: |
| 14 | + * |
| 15 | + * 1. **Evaluated.** A top-level envelope is evaluated in the run's scope and |
| 16 | + * reaches the handler as the raw value it computes — a string, a number, |
| 17 | + * a record — next to literal keys handed on as written. |
| 18 | + * 2. **A constant payload is unchanged.** Every literal reaches the handler |
| 19 | + * as it was authored, a nested envelope-shaped object included, and as a |
| 20 | + * copy: a handler that writes into its input never writes into the node. |
| 21 | + * 3. **A fault fails the step.** An envelope that faults on the live |
| 22 | + * variables fails the node with its source, and the handler is never |
| 23 | + * called — nothing is sent upstream. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, expect, it } from 'vitest'; |
| 27 | +import { AutomationEngine } from '../engine.js'; |
| 28 | +import { installBuiltinNodes } from './index.js'; |
| 29 | +import type { Connector } from '@objectstack/spec/integration'; |
| 30 | + |
| 31 | +function silentLogger(): any { |
| 32 | + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; |
| 33 | +} |
| 34 | +function ctx(): any { |
| 35 | + return { logger: silentLogger(), getService() { return undefined; } }; |
| 36 | +} |
| 37 | + |
| 38 | +const CONNECTOR = { |
| 39 | + name: 'vendor', |
| 40 | + label: 'Vendor', |
| 41 | + type: 'api', |
| 42 | + authentication: { type: 'none' }, |
| 43 | + actions: [{ key: 'lookup', label: 'Lookup' }], |
| 44 | +} as Connector; |
| 45 | + |
| 46 | +/** An engine with every builtin node and one connector whose action records each input it is handed. */ |
| 47 | +function makeEngine(): { engine: AutomationEngine; received: Record<string, unknown>[] } { |
| 48 | + const engine = new AutomationEngine(silentLogger()); |
| 49 | + installBuiltinNodes(engine, ctx()); |
| 50 | + const received: Record<string, unknown>[] = []; |
| 51 | + engine.registerConnector(CONNECTOR, { |
| 52 | + async lookup(input) { |
| 53 | + received.push(input); |
| 54 | + return { ok: true }; |
| 55 | + }, |
| 56 | + }); |
| 57 | + return { engine, received }; |
| 58 | +} |
| 59 | + |
| 60 | +/** start → a `connector_action` with `input` → end, the record an input variable. */ |
| 61 | +function flowWith(name: string, input: Record<string, unknown>): any { |
| 62 | + return { |
| 63 | + name, label: name, type: 'autolaunched', |
| 64 | + variables: [{ name: 'rec', type: 'object', isInput: true }], |
| 65 | + nodes: [ |
| 66 | + { id: 'start', type: 'start', label: 'Start' }, |
| 67 | + { id: 'call', type: 'connector_action', label: 'Call', connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input } }, |
| 68 | + { id: 'end', type: 'end', label: 'End' }, |
| 69 | + ], |
| 70 | + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], |
| 71 | + }; |
| 72 | +} |
| 73 | + |
| 74 | +const REC = { id: 'cus_42', name: 'Acme', seats: 7 }; |
| 75 | + |
| 76 | +describe('1. an envelope in `connectorConfig.input` is evaluated in the run\'s scope', () => { |
| 77 | + it('the handler receives the values it computes, raw, beside literals as written', async () => { |
| 78 | + const { engine, received } = makeEngine(); |
| 79 | + engine.registerFlow('per_record', flowWith('per_record', { |
| 80 | + customerId: { dialect: 'cel', source: 'rec.id' }, |
| 81 | + seats: { dialect: 'cel', source: 'rec.seats + 1' }, |
| 82 | + record: { dialect: 'cel', source: 'rec' }, |
| 83 | + greeting: { dialect: 'cel', source: "'Hi ' + rec.name" }, |
| 84 | + region: 'eu', |
| 85 | + })); |
| 86 | + const run = await engine.execute('per_record', { params: { rec: REC } } as any); |
| 87 | + expect(run.success, run.error).toBe(true); |
| 88 | + expect(received).toEqual([{ customerId: 'cus_42', seats: 8, record: REC, greeting: 'Hi Acme', region: 'eu' }]); |
| 89 | + }); |
| 90 | +}); |
| 91 | + |
| 92 | +describe('2. a constant payload reaches the handler exactly as authored', () => { |
| 93 | + it('literals, a nested envelope-shaped object included, are handed on as written, and as a copy', async () => { |
| 94 | + const { engine, received } = makeEngine(); |
| 95 | + const input = { |
| 96 | + path: '/v1/customers', |
| 97 | + limit: 10, |
| 98 | + tags: ['a', 'b'], |
| 99 | + body: { filter: { dialect: 'cel', source: 'rec.id' } }, |
| 100 | + }; |
| 101 | + const flow = flowWith('constant', input); |
| 102 | + engine.registerFlow('constant', flow); |
| 103 | + const run = await engine.execute('constant', { params: { rec: REC } } as any); |
| 104 | + expect(run.success, run.error).toBe(true); |
| 105 | + expect(received).toEqual([input]); |
| 106 | + expect(received[0]).not.toBe(flow.nodes[1].connectorConfig.input); |
| 107 | + expect(received[0].body).not.toBe(input.body); |
| 108 | + }); |
| 109 | + |
| 110 | + it('a node with no `input` hands the handler an empty map, as before', async () => { |
| 111 | + const { engine, received } = makeEngine(); |
| 112 | + const flow = flowWith('no_input', {}); |
| 113 | + delete flow.nodes[1].connectorConfig.input; |
| 114 | + engine.registerFlow('no_input', flow); |
| 115 | + const run = await engine.execute('no_input', { params: { rec: REC } } as any); |
| 116 | + expect(run.success, run.error).toBe(true); |
| 117 | + expect(received).toEqual([{}]); |
| 118 | + }); |
| 119 | +}); |
| 120 | + |
| 121 | +describe('3. an envelope that faults fails the step with its source, and nothing is sent', () => { |
| 122 | + it('a read of an absent key fails the node, naming the slot and the source; the handler is never called', async () => { |
| 123 | + const { engine, received } = makeEngine(); |
| 124 | + engine.registerFlow('faulting', flowWith('faulting', { |
| 125 | + customerId: { dialect: 'cel', source: 'rec.missing.id' }, |
| 126 | + })); |
| 127 | + const run = await engine.execute('faulting', { params: { rec: REC } } as any); |
| 128 | + expect(run.success).toBe(false); |
| 129 | + expect(run.error).toContain('input.customerId'); |
| 130 | + expect(run.error).toContain('rec.missing.id'); |
| 131 | + expect(received).toEqual([]); |
| 132 | + }); |
| 133 | +}); |
0 commit comments