import { DateTime, Duration, Interval } from 'luxon'; import * as Helpers from './helpers'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { ExpressionError } from '../src/errors/expression.error'; import { NodeConnectionTypes, type NodeConnectionType, type IDataObject, type IExecuteData, type INode, type INodeExecutionData, type IPairedItemData, type IPinData, type IRun, type IWorkflowBase, type IWorkflowDataProxyAdditionalKeys, type WorkflowExecuteMode, type WorkflowSettingsBinaryMode, type INodeType, type INodeTypes, } from '../src/interfaces'; import { BINARY_MODE_COMBINED } from '../src/constants'; import { createEmptyRunExecutionData, createRunExecutionData, } from '../src/run-execution-data-factory'; import { Workflow } from '../src/workflow'; import { WorkflowDataProxy } from '../src/workflow-data-proxy'; const loadFixture = (fixture: string) => { const workflow = Helpers.readJsonFileSync( `test/fixtures/WorkflowDataProxy/${fixture}_workflow.json`, ); const run = Helpers.readJsonFileSync(`test/fixtures/WorkflowDataProxy/${fixture}_run.json`); return { workflow, run }; }; const getProxyFromFixture = ( workflow: IWorkflowBase, run: IRun | null, activeNode: string, mode?: WorkflowExecuteMode, opts?: { throwOnMissingExecutionData: boolean; connectionType?: NodeConnectionType; runIndex?: number; additionalKeys?: IWorkflowDataProxyAdditionalKeys; }, ) => { const taskData = run?.data.resultData.runData[activeNode]?.[opts?.runIndex ?? 0]; const lastNodeConnectionInputData = taskData?.data?.[opts?.connectionType ?? NodeConnectionTypes.Main]?.[0]; let executeData: IExecuteData | undefined; if (taskData) { executeData = { data: taskData.data!, node: workflow.nodes.find((node) => node.name === activeNode) as INode, source: { [opts?.connectionType ?? NodeConnectionTypes.Main]: taskData.source, }, }; } let pinData: IPinData = {}; if (workflow.pinData) { // json key is stored as part of workflow // but dropped when copy/pasting // so adding here to keep updating tests simple for (let nodeName in workflow.pinData) { pinData[nodeName] = workflow.pinData[nodeName].map((item) => ({ json: item })); } } const dataProxy = new WorkflowDataProxy( new Workflow({ id: '123', name: 'test workflow', nodes: workflow.nodes, connections: workflow.connections, active: false, nodeTypes: Helpers.NodeTypes(), pinData, settings: workflow.settings, }), run?.data ?? null, opts?.runIndex ?? 0, 0, activeNode, lastNodeConnectionInputData ?? [], {}, mode ?? 'integrated', opts?.additionalKeys ?? {}, executeData, ); return dataProxy.getDataProxy(opts); }; describe('WorkflowDataProxy', () => { describe('$(If))', () => { const fixture = loadFixture('multiple_outputs'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Edit Fields'); test('last() should use the output the node is connected to by default', () => { expect(proxy.$('If').last().json.code).toEqual(2); }); test('last(0) should use the first output', () => { expect(proxy.$('If').last(0)).toBeUndefined(); }); test('last(1) should use the second output', () => { expect(proxy.$('If').last(1).json.code).toEqual(2); }); test('first() should use the output the node is connected to by default', () => { expect(proxy.$('If').first().json.code).toEqual(1); }); test('first(0) should use the output the node is connected to by default', () => { expect(proxy.$('If').first(0)).toBeUndefined(); }); test('first(1) should use the output the node is connected to by default', () => { expect(proxy.$('If').first(1).json.code).toEqual(1); }); test('all() should use the output the node is connected to by default', () => { expect(proxy.$('If').all()[0].json.code).toEqual(1); expect(proxy.$('If').all()[1].json.code).toEqual(2); }); test('all(0) should use the output the node is connected to by default', () => { expect(proxy.$('If').all(0)[0]).toBeUndefined(); expect(proxy.$('If').all(0)[1]).toBeUndefined(); }); test('all(1) should use the output the node is connected to by default', () => { expect(proxy.$('If').all(1)[0].json.code).toEqual(1); expect(proxy.$('If').all(1)[1].json.code).toEqual(2); }); }); describe('Binary Mode: Combined', () => { const createWorkflowWithBinaryData = ( binaryMode?: WorkflowSettingsBinaryMode, ): IWorkflowBase => ({ id: '123', name: 'test workflow with binary', nodes: [ { id: 'node1', name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {}, }, { id: 'node2', name: 'ProcessData', type: 'n8n-nodes-base.code', typeVersion: 1, position: [300, 0], parameters: {}, }, ], connections: { Start: { main: [[{ node: 'ProcessData', type: NodeConnectionTypes.Main, index: 0 }]], }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), settings: binaryMode ? { binaryMode } : undefined, }); const createRunWithBinaryData = (): IRun => ({ data: createRunExecutionData({ resultData: { runData: { Start: [ { startTime: 123, executionTime: 10, executionIndex: 0, source: [null], data: { main: [ [ { json: { id: 1, name: 'Item 1' }, binary: { file: { data: Buffer.from('test file content').toString('base64'), mimeType: 'text/plain', fileName: 'test.txt', fileSize: '18B', }, }, pairedItem: { item: 0 }, }, { json: { id: 2, name: 'Item 2' }, binary: { file: { data: Buffer.from('second file').toString('base64'), mimeType: 'text/plain', fileName: 'test2.txt', fileSize: '11B', }, }, pairedItem: { item: 1 }, }, ], ], }, }, ], ProcessData: [ { startTime: 150, executionTime: 10, executionIndex: 0, source: [{ previousNode: 'Start' }], data: { main: [ [ { json: { id: 1, name: 'Item 1' }, binary: { file: { data: Buffer.from('test file content').toString('base64'), mimeType: 'text/plain', fileName: 'test.txt', fileSize: '18B', }, }, pairedItem: { item: 0 }, }, { json: { id: 2, name: 'Item 2' }, binary: { file: { data: Buffer.from('second file').toString('base64'), mimeType: 'text/plain', fileName: 'test2.txt', fileSize: '11B', }, }, pairedItem: { item: 1 }, }, ], ], }, }, ], }, }, }), mode: 'manual', startedAt: new Date(), status: 'success', storedAt: 'db', }); describe('Default behavior (no binaryMode or separate mode)', () => { const workflow = createWorkflowWithBinaryData(); const run = createRunWithBinaryData(); const proxyAtProcessData = getProxyFromFixture(workflow, run, 'ProcessData'); test('$input.item should return full item with json and binary', () => { const item = proxyAtProcessData.$input.item; expect(item).toBeDefined(); expect(item?.json).toEqual({ id: 1, name: 'Item 1' }); expect(item?.binary).toBeDefined(); expect(item?.binary?.file).toBeDefined(); }); test('$input.all() should return full items with json and binary', () => { const items = proxyAtProcessData.$input.all(); expect(items.length).toBe(2); expect(items[0].json).toEqual({ id: 1, name: 'Item 1' }); expect(items[0].binary).toBeDefined(); expect(items[1].json).toEqual({ id: 2, name: 'Item 2' }); expect(items[1].binary).toBeDefined(); }); test('$("Start").first() should return full item with json and binary', () => { const item = proxyAtProcessData.$('Start').first(); expect(item.json).toEqual({ id: 1, name: 'Item 1' }); expect(item.binary).toBeDefined(); }); test('$("Start").last() should return full item with json and binary', () => { const item = proxyAtProcessData.$('Start').last(); expect(item.json).toEqual({ id: 2, name: 'Item 2' }); expect(item.binary).toBeDefined(); }); test('$("Start").all() should return full items with json and binary', () => { const items = proxyAtProcessData.$('Start').all(); expect(items.length).toBe(2); expect(items[0].binary).toBeDefined(); expect(items[1].binary).toBeDefined(); }); test('$json should return json data', () => { expect(proxyAtProcessData.$json).toEqual({ id: 1, name: 'Item 1' }); }); test('$binary should return binary data', () => { expect(proxyAtProcessData.$binary).toBeDefined(); expect(proxyAtProcessData.$binary?.file).toBeDefined(); expect(proxyAtProcessData.$binary?.file?.fileName).toBe('test.txt'); }); }); describe('Combined mode (binaryMode: "combined")', () => { const workflow = createWorkflowWithBinaryData(BINARY_MODE_COMBINED); const run = createRunWithBinaryData(); const proxyAtProcessData = getProxyFromFixture(workflow, run, 'ProcessData'); test('$input.item should return only json data (no binary)', () => { const item = proxyAtProcessData.$input.item; expect(item).toEqual({ id: 1, name: 'Item 1' }); expect(item).not.toHaveProperty('binary'); expect(item).not.toHaveProperty('pairedItem'); }); test('$input.all() should return only json data for all items', () => { const items = proxyAtProcessData.$input.all(); expect(items.length).toBe(2); expect(items[0]).toEqual({ id: 1, name: 'Item 1' }); expect(items[1]).toEqual({ id: 2, name: 'Item 2' }); expect(items[0]).not.toHaveProperty('binary'); expect(items[1]).not.toHaveProperty('binary'); }); test('$input.first() should return only json data', () => { const item = proxyAtProcessData.$input.first(); expect(item).toEqual({ id: 1, name: 'Item 1' }); expect(item).not.toHaveProperty('binary'); }); test('$input.last() should return only json data', () => { const item = proxyAtProcessData.$input.last(); expect(item).toEqual({ id: 2, name: 'Item 2' }); expect(item).not.toHaveProperty('binary'); }); test('$("Start").first() should return only json data', () => { const item = proxyAtProcessData.$('Start').first(); expect(item).toEqual({ id: 1, name: 'Item 1' }); expect(item).not.toHaveProperty('binary'); }); test('$("Start").last() should return only json data', () => { const item = proxyAtProcessData.$('Start').last(); expect(item).toEqual({ id: 2, name: 'Item 2' }); expect(item).not.toHaveProperty('binary'); }); test('$("Start").all() should return only json data for all items', () => { const items = proxyAtProcessData.$('Start').all(); expect(items.length).toBe(2); expect(items[0]).toEqual({ id: 1, name: 'Item 1' }); expect(items[1]).toEqual({ id: 2, name: 'Item 2' }); expect(items[0]).not.toHaveProperty('binary'); expect(items[1]).not.toHaveProperty('binary'); }); test('$item should return json data', () => { expect(proxyAtProcessData.$item).toEqual({ id: 1, name: 'Item 1' }); }); }); describe('Combined mode with $() resolveFullItem parameter', () => { const workflow = createWorkflowWithBinaryData(BINARY_MODE_COMBINED); const run = createRunWithBinaryData(); const proxyAtProcessData = getProxyFromFixture(workflow, run, 'ProcessData'); test('$("Start", true).first() should return full item with binary', () => { const item = proxyAtProcessData.$('Start', true).first(); expect(item.json).toEqual({ id: 1, name: 'Item 1' }); expect(item.binary).toBeDefined(); }); test('$("Start", true).last() should return full item with binary', () => { const item = proxyAtProcessData.$('Start', true).last(); expect(item.json).toEqual({ id: 2, name: 'Item 2' }); expect(item.binary).toBeDefined(); }); test('$("Start", true).all() should return full items with binary', () => { const items = proxyAtProcessData.$('Start', true).all(); expect(items.length).toBe(2); expect(items[0].json).toEqual({ id: 1, name: 'Item 1' }); expect(items[0].binary).toBeDefined(); expect(items[1].json).toEqual({ id: 2, name: 'Item 2' }); expect(items[1].binary).toBeDefined(); }); }); describe('Combined mode with $input accessors', () => { const workflow = createWorkflowWithBinaryData(BINARY_MODE_COMBINED); const run = createRunWithBinaryData(); const proxy = getProxyFromFixture(workflow, run, 'ProcessData'); test('$input.item should return only json in combined mode', () => { const item = proxy.$input.item; expect(item).toEqual({ id: 1, name: 'Item 1' }); expect(item).not.toHaveProperty('binary'); }); test('$input.all() should return only json in combined mode', () => { const items = proxy.$input.all(); expect(items.length).toBe(2); expect(items[0]).toEqual({ id: 1, name: 'Item 1' }); expect(items[1]).toEqual({ id: 2, name: 'Item 2' }); }); test('$input.first() should return only json in combined mode', () => { const item = proxy.$input.first(); expect(item).toEqual({ id: 1, name: 'Item 1' }); }); test('$input.last() should return only json in combined mode', () => { const item = proxy.$input.last(); expect(item).toEqual({ id: 2, name: 'Item 2' }); }); }); describe('Backward compatibility: separate mode behaves like default', () => { const workflow = createWorkflowWithBinaryData('separate'); const run = createRunWithBinaryData(); const proxyAtProcessData = getProxyFromFixture(workflow, run, 'ProcessData'); test('$input.item should return full item with binary in separate mode', () => { const item = proxyAtProcessData.$input.item; expect(item).toBeDefined(); expect(item?.json).toEqual({ id: 1, name: 'Item 1' }); expect(item?.binary).toBeDefined(); }); test('$("Start").first() should return full item with binary in separate mode', () => { const item = proxyAtProcessData.$('Start').first(); expect(item.json).toEqual({ id: 1, name: 'Item 1' }); expect(item.binary).toBeDefined(); }); test('$("Start").all() should return full items with binary in separate mode', () => { const items = proxyAtProcessData.$('Start').all(); expect(items.length).toBe(2); expect(items[0].binary).toBeDefined(); expect(items[1].binary).toBeDefined(); }); }); }); describe('Base', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); test('$("NodeName").all()', () => { expect(proxy.$('Rename').all()[1].json.data).toEqual(160); }); test('$("NodeName").all() length', () => { expect(proxy.$('Rename').all().length).toEqual(5); }); test('$("NodeName").item', () => { expect(proxy.$('Rename').item).toEqual({ json: { data: 105 }, pairedItem: { item: 0 } }); }); test('$("NodeNameEarlier").item', () => { expect(proxy.$('Function').item).toEqual({ json: { initialName: 105 }, pairedItem: { item: 0 }, }); }); test('$("NodeName").itemMatching(2)', () => { expect(proxy.$('Rename').itemMatching(2).json.data).toEqual(121); }); test('$("NodeName").first()', () => { expect(proxy.$('Rename').first().json.data).toEqual(105); }); test('$("NodeName").last()', () => { expect(proxy.$('Rename').last().json.data).toEqual(950); }); test('$("NodeName").params', () => { expect(proxy.$('Rename').params).toEqual({ value1: 'data', value2: 'initialName' }); }); test('$("NodeName").context', () => { expect(proxy.$('Rename').context).toBeDefined(); }); test('$("NodeName") not in workflow should throw', () => { expect(() => proxy.$('doNotExist')).toThrowError(ExpressionError); }); test('$("NodeName").item on Node that has not executed', () => { expect(() => proxy.$('Set').item).toThrowError(ExpressionError); }); test('$("NodeName").isExecuted', () => { expect(proxy.$('Function').isExecuted).toEqual(true); expect(proxy.$('Set').isExecuted).toEqual(false); }); test('$input.all()', () => { expect(proxy.$input.all()[1].json.data).toEqual(160); }); test('$input.all() length', () => { expect(proxy.$input.all().length).toEqual(5); }); test('$input.first()', () => { expect(proxy.$input.first()?.json?.data).toEqual(105); }); test('$input.last()', () => { expect(proxy.$input.last()?.json?.data).toEqual(950); }); test('$input.item', () => { expect(proxy.$input.item?.json?.data).toEqual(105); }); test('$input.context', () => { expect(proxy.$input.context).toBeDefined(); }); test('$input.params', () => { expect(proxy.$input.params).toBeDefined(); }); test('$thisItem', () => { expect(proxy.$thisItem.json.data).toEqual(105); }); test('$binary', () => { expect(proxy.$binary).toEqual({}); }); test('$json', () => { expect(proxy.$json).toEqual({ data: 105 }); }); test('$itemIndex', () => { expect(proxy.$itemIndex).toEqual(0); }); test('$prevNode', () => { expect(proxy.$prevNode).toEqual({ name: 'Rename', outputIndex: 0, runIndex: 0 }); }); test('$runIndex', () => { expect(proxy.$runIndex).toEqual(0); }); test('$workflow', () => { expect(proxy.$workflow).toEqual({ active: false, id: '123', name: 'test workflow', }); }); }); describe('Errors', () => { const fixture = loadFixture('errors'); test('$("NodeName").item, Node does not exist', () => { const proxy = getProxyFromFixture( fixture.workflow, fixture.run, 'Reference non-existent node', ); try { proxy.$('does not exist').item; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual("Referenced node doesn't exist"); } }); test('$("NodeName").item, node has no connection to referenced node', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'NoPathBack'); try { proxy.$('Customer Datastore (n8n training)').item; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual('Invalid expression'); expect(exprError.context.type).toEqual('paired_item_no_connection'); } }); test('$("NodeName").first(), node has no connection to referenced node', () => { const proxy = getProxyFromFixture( fixture.workflow, fixture.run, 'Reference impossible with .first()', ); try { proxy.$('Impossible').first().json.name; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual("Node 'Impossible' hasn't been executed"); expect(exprError.context.type).toEqual('no_execution_data'); } }); test('$json, Node has no connections', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'NoInputConnection'); try { proxy.$json.email; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual("Node 'NoInputConnection' hasn't been executed"); expect(exprError.context.type).toEqual('no_execution_data'); } }); test('$("NodeName").item, Node has not run', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Impossible'); try { proxy.$('Impossible if').item; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual("Node 'Impossible if' hasn't been executed"); expect(exprError.context.type).toEqual('no_execution_data'); } }); test('$json, Node has not run', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Impossible'); try { proxy.$json.email; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual("Node 'Impossible' hasn't been executed"); expect(exprError.context.type).toEqual('no_execution_data'); } }); test('$("NodeName").item, paired item error: more than 1 matching item', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'PairedItemMultipleMatches'); try { proxy.$('Edit Fields').item; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual('Multiple matches found'); expect(exprError.context.type).toEqual('paired_item_multiple_matches'); } }); test('$("NodeName").item, paired item error: missing paired item', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'PairedItemInfoMissing'); try { proxy.$('Edit Fields').item; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toEqual( "Paired item data for item from node 'Break pairedItem chain' is unavailable. Ensure 'Break pairedItem chain' is providing the required output.", ); expect(exprError.context.type).toEqual('paired_item_no_info'); } }); test('$("NodeName").item, paired item error: invalid paired item', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'IncorrectPairedItem'); try { proxy.$('Edit Fields').item; } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; expect(exprError.message).toContain('Paired item data for item from node'); expect(exprError.message).toContain('Edit Fields'); expect(exprError.context.type).toEqual('paired_item_no_info'); } }); }); describe('Pinned data with manual execution', () => { const fixture = loadFixture('pindata'); const proxy = getProxyFromFixture(fixture.workflow, null, 'NotPinnedSet1', 'manual'); test('$(PinnedSet).item.json', () => { expect(proxy.$('PinnedSet').item.json).toEqual({ firstName: 'Joe', lastName: 'Smith' }); }); test('$(PinnedSet).item.json.firstName', () => { expect(proxy.$('PinnedSet').item.json.firstName).toBe('Joe'); }); test('$(PinnedSet).pairedItem().json.firstName', () => { expect(proxy.$('PinnedSet').pairedItem().json.firstName).toBe('Joe'); }); test('$(PinnedSet).first().json.firstName', () => { expect(proxy.$('PinnedSet').first().json.firstName).toBe('Joe'); }); test('$(PinnedSet).first().json.firstName', () => { expect(proxy.$('PinnedSet').first().json.firstName).toBe('Joe'); }); test('$(PinnedSet).last().json.firstName', () => { expect(proxy.$('PinnedSet').last().json.firstName).toBe('Joan'); }); test('$(PinnedSet).all()[0].json.firstName', () => { expect(proxy.$('PinnedSet').all()[0].json.firstName).toBe('Joe'); }); test('$(PinnedSet).all()[1].json.firstName', () => { expect(proxy.$('PinnedSet').all()[1].json.firstName).toBe('Joan'); }); test('$(PinnedSet).all()[2]', () => { expect(proxy.$('PinnedSet').all()[2]).toBeUndefined(); }); test('$(PinnedSet).itemMatching(0).json.firstName', () => { expect(proxy.$('PinnedSet').itemMatching(0).json.firstName).toBe('Joe'); }); test('$(PinnedSet).itemMatching(1).json.firstName', () => { expect(proxy.$('PinnedSet').itemMatching(1).json.firstName).toBe('Joan'); }); test('$(PinnedSet).itemMatching(2)', () => { expect(proxy.$('PinnedSet').itemMatching(2)).toBeUndefined(); }); test('$node[PinnedSet].json.firstName', () => { expect(proxy.$node.PinnedSet.json.firstName).toBe('Joe'); }); }); describe('Pinned data with paired items', () => { const fixture = loadFixture('pindata_paireditem'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Set', 'manual', { runIndex: 0, throwOnMissingExecutionData: false, }); test.each([{ methodName: 'itemMatching' }, { methodName: 'pairedItem' }])( '$methodName should throw when it cannot find a paired item', ({ methodName }) => { try { proxy.$('DebugHelper')[methodName](0); throw new Error('should throw'); } catch (e) { const error = ensureError(e); expect(error.message).toEqual( `Using the ${methodName} method doesn't work with pinned data in this scenario. Please unpin 'Edit Fields' and try again.`, ); expect(error).toMatchObject({ functionality: 'pairedItem', context: { runIndex: 0, itemIndex: 0, type: 'paired_item_no_info', descriptionKey: 'pairedItemNoInfo', nodeCause: 'Edit Fields', causeDetailed: "Missing pairedItem data (node 'Edit Fields' probably didn't supply it)", }, }); } }, ); test('item should throw when it cannot find a paired item', () => { try { proxy.$('DebugHelper').item; throw new Error('should throw'); } catch (e) { const error = ensureError(e); expect(error.message).toEqual( "Using the item method doesn't work with pinned data in this scenario. Please unpin 'Edit Fields' and try again.", ); expect(error).toMatchObject({ functionality: 'pairedItem', context: { runIndex: 0, itemIndex: 0, type: 'paired_item_no_info', descriptionKey: 'pairedItemNoInfo', nodeCause: 'Edit Fields', causeDetailed: "Missing pairedItem data (node 'Edit Fields' probably didn't supply it)", }, }); } }); }); describe('Partial data', () => { const fixture = loadFixture('partial_data'); describe('Default behaviour (throw on missing execution data)', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); test('$binary', () => { expect(() => proxy.$binary).toThrowError(ExpressionError); }); test('$json', () => { expect(() => proxy.$json).toThrowError(ExpressionError); }); test('$data', () => { expect(() => proxy.$data).toThrowError(ExpressionError); }); }); describe("Don't throw on missing execution data)", () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End', undefined, { throwOnMissingExecutionData: false, }); test('$binary', () => { expect(proxy.$binary).toBeUndefined(); }); test('$json', () => { expect(proxy.$json).toBeUndefined(); }); test('$data', () => { expect(proxy.$data).toBeUndefined(); }); }); }); describe('$fromAI', () => { const fixture = loadFixture('from_ai_multiple_items'); const getFromAIProxy = (runIndex = 0) => getProxyFromFixture(fixture.workflow, fixture.run, 'Google Sheets1', 'manual', { connectionType: NodeConnectionTypes.AiTool, throwOnMissingExecutionData: false, runIndex, }); test('Retrieves values for first item', () => { expect(getFromAIProxy().$fromAI('full_name')).toEqual('Mr. Input 1'); expect(getFromAIProxy().$fromAI('email')).toEqual('input1@n8n.io'); }); test('Retrieves values for second item', () => { expect(getFromAIProxy(1).$fromAI('full_name')).toEqual('Mr. Input 2'); expect(getFromAIProxy(1).$fromAI('email')).toEqual('input2@n8n.io'); }); test('Case variants: $fromAi and $fromai', () => { expect(getFromAIProxy().$fromAi('full_name')).toEqual('Mr. Input 1'); expect(getFromAIProxy().$fromai('email')).toEqual('input1@n8n.io'); }); test('Returns default value when key not found', () => { expect( getFromAIProxy().$fromAI('non_existent_key', 'description', 'string', 'default_value'), ).toEqual('default_value'); }); test('Throws an error when a key is invalid (e.g. empty string)', () => { expect(() => getFromAIProxy().$fromAI('')).toThrow(ExpressionError); expect(() => getFromAIProxy().$fromAI('invalid key')).toThrow(ExpressionError); expect(() => getFromAIProxy().$fromAI('invalid!')).toThrow(ExpressionError); }); test('Falls back to connectionInputData when no resultData exists', () => { // Create a workflow with connectionInputData but no resultData const workflowWithoutResultData: IWorkflowBase = { id: '123', name: 'test workflow', nodes: [ { id: 'aiNode', name: 'AI Node', type: 'n8n-nodes-base.aiAgent', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; // Create connection input data with AI query data const connectionInputData = [ { json: { full_name: 'Test User', email: 'test@example.com', }, pairedItem: { item: 0 }, }, ]; const dataProxy = new WorkflowDataProxy( new Workflow({ id: '123', name: 'test workflow', nodes: workflowWithoutResultData.nodes, connections: workflowWithoutResultData.connections, active: false, nodeTypes: Helpers.NodeTypes(), }), null, // No run execution data 0, 0, 'AI Node', connectionInputData, {}, 'manual', {}, undefined, ); const proxy = dataProxy.getDataProxy(); expect(proxy.$fromAI('full_name')).toEqual('Test User'); expect(proxy.$fromAI('email')).toEqual('test@example.com'); expect(proxy.$fromAI('non_existent_key', 'description', 'string', 'default_value')).toEqual( 'default_value', ); }); test('Returns default value when connection input data lacks expected keys', () => { // Create a workflow with connection input data that doesn't have the expected AI keys const workflowWithLimitedData: IWorkflowBase = { id: '123', name: 'test workflow', nodes: [ { id: 'aiNode', name: 'AI Node', type: 'n8n-nodes-base.aiAgent', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; // Connection input data without the expected AI keys const connectionInputData = [ { json: { some_other_field: 'other data', regular_field: 'regular_value', }, pairedItem: { item: 0 }, }, ]; const dataProxy = new WorkflowDataProxy( new Workflow({ id: '123', name: 'test workflow', nodes: workflowWithLimitedData.nodes, connections: workflowWithLimitedData.connections, active: false, nodeTypes: Helpers.NodeTypes(), }), null, // No run execution data 0, 0, 'AI Node', connectionInputData, {}, 'manual', {}, undefined, ); const proxy = dataProxy.getDataProxy(); // Should return undefined for missing keys and default value when provided expect(proxy.$fromAI('missing_key')).toBeUndefined(); expect(proxy.$fromAI('missing_key', 'description', 'string', 'default_value')).toEqual( 'default_value', ); // Should return existing values for keys that are present expect(proxy.$fromAI('regular_field')).toEqual('regular_value'); }); test('Throws ExpressionError when there is no execution data', () => { // Create a workflow with no data at all const workflowWithNoData: IWorkflowBase = { id: '123', name: 'test workflow', nodes: [ { id: 'aiNode', name: 'AI Node', type: 'n8n-nodes-base.aiAgent', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; const dataProxy = new WorkflowDataProxy( new Workflow({ id: '123', name: 'test workflow', nodes: workflowWithNoData.nodes, connections: workflowWithNoData.connections, active: false, nodeTypes: Helpers.NodeTypes(), }), null, // No run execution data 0, 0, 'AI Node', [], // Empty connectionInputData - this triggers the bug {}, 'manual', {}, undefined, ); const proxy = dataProxy.getDataProxy(); expect(() => proxy.$fromAI('some_key')).toThrow(ExpressionError); }); describe('key resolution is limited to own placeholder keys', () => { const buildProxy = (json: unknown) => { const workflow = new Workflow({ id: '123', name: 'test workflow', nodes: [ { id: 'aiNode', name: 'AI Node', type: 'n8n-nodes-base.aiAgent', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, nodeTypes: Helpers.NodeTypes(), }); const connectionInputData = [{ json: json as IDataObject, pairedItem: { item: 0 } }]; const dataProxy = new WorkflowDataProxy( workflow, null, 0, 0, 'AI Node', connectionInputData, {}, 'manual', {}, undefined, ); return dataProxy.getDataProxy(); }; test('rejects reserved object keys (__proto__, constructor, prototype)', () => { const proxy = buildProxy({ safe: 'ok' }); expect(proxy.$fromAI('safe')).toBe('ok'); expect(() => proxy.$fromAI('__proto__')).toThrow(ExpressionError); expect(() => proxy.$fromAI('constructor')).toThrow(ExpressionError); expect(() => proxy.$fromAI('prototype')).toThrow(ExpressionError); }); test('ignores inherited (non-own) keys and returns the default', () => { const proxy = buildProxy({ safe: 'ok' }); expect(proxy.$fromAI('toString', '', 'string', 'fallback')).toBe('fallback'); expect(proxy.$fromAI('hasOwnProperty', '', 'string', 'fallback')).toBe('fallback'); expect(proxy.$fromAI('valueOf', '', 'string', 'fallback')).toBe('fallback'); }); test('rejects a reserved key even when present as an own key from parsed JSON', () => { const json = JSON.parse('{"__proto__": {"polluted": true}, "safe": "ok"}'); const proxy = buildProxy(json); expect(() => proxy.$fromAI('__proto__')).toThrow(ExpressionError); expect(proxy.$fromAI('safe')).toBe('ok'); }); test('returns the default when input json is a bare primitive', () => { const proxy = buildProxy(1); expect(() => proxy.$fromAI('constructor')).toThrow(ExpressionError); expect(() => proxy.$fromAI('__proto__')).toThrow(ExpressionError); expect(proxy.$fromAI('any_key', '', 'string', 'fallback')).toBe('fallback'); }); test('resolves a placeholder from an own query container', () => { const proxy = buildProxy({ query: { full_name: 'Alice' } }); expect(proxy.$fromAI('full_name')).toBe('Alice'); }); test('does not read the query container through the prototype chain', () => { const json = Object.create({ query: { evil: 'from-prototype' } }); json.safe = 'ok'; const proxy = buildProxy(json); expect(proxy.$fromAI('safe')).toBe('ok'); expect(proxy.$fromAI('evil', '', 'string', 'fallback')).toBe('fallback'); }); }); }); describe('$tool', () => { const fixture = loadFixture('from_ai_tool_alias'); const getToolProxy = (runIndex = 0, additionalKeys?: IWorkflowDataProxyAdditionalKeys) => getProxyFromFixture(fixture.workflow, fixture.run, 'Google Sheets1', 'manual', { connectionType: NodeConnectionTypes.AiTool, throwOnMissingExecutionData: false, runIndex, additionalKeys, }); test('Returns object with name and parameters when execution data exists', () => { const proxy = getToolProxy(); const toolValue = proxy.$tool; // $tool should now return an object with name and parameters expect(toolValue).toBeDefined(); expect(typeof toolValue).toBe('object'); expect(toolValue).toHaveProperty('name'); expect(toolValue).toHaveProperty('parameters'); }); test('Works consistently across different items', () => { const proxy0 = getToolProxy(0); const proxy1 = getToolProxy(1); // Both should return objects expect(typeof proxy0.$tool).toBe('object'); expect(typeof proxy1.$tool).toBe('object'); }); test('Falls back to additionalKeys when no execution data exists', () => { // Create a workflow with no execution data at all (empty connectionInputData) const workflowWithoutResultData: IWorkflowBase = { id: '123', name: 'test workflow', nodes: [ { id: 'aiNode', name: 'AI Node', type: 'n8n-nodes-base.aiAgent', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; const dataProxy = new WorkflowDataProxy( new Workflow({ id: '123', name: 'test workflow', nodes: workflowWithoutResultData.nodes, connections: workflowWithoutResultData.connections, active: false, nodeTypes: Helpers.NodeTypes(), }), null, // No run execution data 0, 0, 'AI Node', [], // Empty connectionInputData - this triggers no_execution_data error {}, 'manual', { $tool: { name: 'fallback-tool', parameters: 'fallback-params' } }, // Fallback value undefined, ); const proxy = dataProxy.getDataProxy(); // Should return the fallback value since there's no execution data expect(proxy.$tool).toEqual({ name: 'fallback-tool', parameters: 'fallback-params' }); }); test('Uses execution data when available, ignoring fallback', () => { // When execution data exists, it should use that instead of fallback const proxy = getToolProxy(0, { $tool: { name: 'fallback-tool', parameters: 'fallback-params' }, }); // Should use the actual value from execution data // The fallback should be ignored when execution data is available const toolValue = proxy.$tool; // Should return an object with name and parameters from execution data expect(toolValue).toBeDefined(); expect(typeof toolValue).toBe('object'); expect(toolValue).toHaveProperty('name'); expect(toolValue).toHaveProperty('parameters'); }); }); describe('$rawParameter', () => { const fixture = loadFixture('rawParameter'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Execute Workflow', 'manual', { connectionType: NodeConnectionTypes.Main, throwOnMissingExecutionData: false, runIndex: 0, }); test('returns simple raw parameter value', () => { expect(proxy.$rawParameter.options).toEqual({ waitForSubWorkflow: '={{ true }}', }); }); test('returns raw parameter value for resource locator values', () => { expect(proxy.$rawParameter.workflowId).toEqual('={{ $json.foo }}'); }); test('extracts resource locator parameter values with regex metadata', () => { const workflow = structuredClone(fixture.workflow); const node = workflow.nodes.find((workflowNode) => workflowNode.name === 'Execute Workflow'); if (!node) throw new Error('Missing Execute Workflow node'); node.parameters.workflowId = { __rl: true, value: 'workflow-id:123', mode: 'url', __regex: 'workflow-id:(\\d+)', }; const regexProxy = getProxyFromFixture(workflow, fixture.run, 'Execute Workflow', 'manual', { connectionType: NodeConnectionTypes.Main, throwOnMissingExecutionData: false, runIndex: 0, }); expect(regexProxy.$parameter.workflowId).toBe('123'); }); test('returns raw parameter value when there is no run data', () => { const noRunDataProxy = getProxyFromFixture( fixture.workflow, { data: createEmptyRunExecutionData(), mode: 'manual', startedAt: new Date(), status: 'success', storedAt: 'db', }, 'Execute Workflow', 'manual', { connectionType: NodeConnectionTypes.Main, throwOnMissingExecutionData: false, runIndex: 0, }, ); expect(noRunDataProxy.$rawParameter.options).toEqual({ waitForSubWorkflow: '={{ true }}', }); }); }); describe('DateTime and Time-related functions', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); test('$now should return current datetime', () => { expect(proxy.$now).toBeInstanceOf(DateTime); }); test('$today should return datetime at start of day', () => { const today = proxy.$today; expect(today).toBeInstanceOf(DateTime); expect(today.hour).toBe(0); expect(today.minute).toBe(0); expect(today.second).toBe(0); expect(today.millisecond).toBe(0); }); test('should expose DateTime, Interval, and Duration', () => { expect(proxy.DateTime).toBe(DateTime); expect(proxy.Interval).toBe(Interval); expect(proxy.Duration).toBe(Duration); }); test('$now should be configurable with timezone', () => { const timezoneProxy = getProxyFromFixture( { ...fixture.workflow, settings: { timezone: 'America/New_York' } }, fixture.run, 'End', ); expect(timezoneProxy.$now.zoneName).toBe('America/New_York'); }); }); describe('Node version and ID', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); test('$nodeVersion should return node type version', () => { expect(proxy.$nodeVersion).toBe(1); }); test('$nodeId should return node ID', () => { expect(proxy.$nodeId).toBe('uuid-5'); }); test('$webhookId should be optional', () => { expect(proxy.$webhookId).toBeUndefined(); }); }); describe('$jmesPath', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); test('should query simple object', () => { const data = { name: 'John', age: 30 }; expect(proxy.$jmesPath(data, 'name')).toBe('John'); }); test('should query nested object', () => { const data = { user: { name: 'John', details: { age: 30 }, }, }; expect(proxy.$jmesPath(data, 'user.details.age')).toBe(30); }); test('should query array', () => { const data = [ { name: 'John', age: 30 }, { name: 'Jane', age: 25 }, ]; expect(proxy.$jmesPath(data, '[*].name')).toEqual(['John', 'Jane']); }); test('should throw error for invalid arguments', () => { expect(() => proxy.$jmesPath('not an object', 'test')).toThrow(ExpressionError); expect(() => proxy.$jmesPath({}, 123 as unknown as string)).toThrow(ExpressionError); }); test('$jmespath should alias $jmesPath', () => { const data = { name: 'John' }; expect(proxy.$jmespath(data, 'name')).toBe(proxy.$jmesPath(data, 'name')); }); test.each([ ['constructor'], ['__proto__'], ['prototype'], ['getPrototypeOf'], ['user.constructor'], ['"constructor"'], ['a.b."__proto__"'], ])('should reject query "%s"', (query) => { expect(() => proxy.$jmesPath({ a: 1 }, query)).toThrow(ExpressionError); expect(() => proxy.$jmesPath({ a: 1 }, query)).toThrow(/due to security concerns/); }); test('should reject query containing restricted identifier on aliased function', () => { expect(() => proxy.$jmespath({ a: 1 }, 'constructor')).toThrow(ExpressionError); }); test('should accept queries with restricted identifiers as substrings', () => { const data = { constructorName: 'Widget', prototypeId: 42 }; expect(proxy.$jmesPath(data, 'constructorName')).toBe('Widget'); expect(proxy.$jmesPath(data, 'prototypeId')).toBe(42); }); test.each([ ['"\\u005f\\u005fproto\\u005f\\u005f"'], ['"\\u0063onstructor"'], ['"\\u0070rototype"'], ['"\\u005f\\u005fproto\\u005f\\u005f"."\\u0063onstructor"'], ['"foo\\bar"'], ])('should reject query "%s" containing backslash escapes', (query) => { expect(() => proxy.$jmesPath({ a: 1 }, query)).toThrow(ExpressionError); expect(() => proxy.$jmesPath({ a: 1 }, query)).toThrow(/due to security concerns/); }); }); describe('$mode', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End', 'manual'); test('should return execution mode', () => { expect(proxy.$mode).toBe('manual'); }); }); describe('$item', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); test('should return data proxy for specific item', () => { const itemProxy = proxy.$item(1); expect(itemProxy.$json.data).toBe(160); }); test('should allow specifying run index', () => { const itemProxy = proxy.$item(1, 0); expect(itemProxy.$json.data).toBe(160); }); }); describe('$items', () => { const fixture = loadFixture('base'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); describe('Default behavior (no arguments)', () => { test('should return input items from previous node', () => { const items = proxy.$items(); expect(items.length).toBe(5); expect(items[0].json.data).toBe(105); expect(items[1].json.data).toBe(160); }); test('should limit items for nodes with executeOnce=true', () => { // Mock a node with executeOnce=true const mockWorkflow = { ...fixture.workflow, nodes: fixture.workflow.nodes.map((node) => node.name === 'Rename' ? { ...node, executeOnce: true } : node, ), }; const mockProxy = getProxyFromFixture(mockWorkflow, fixture.run, 'End'); const items = mockProxy.$items(); expect(items.length).toBe(1); expect(items[0].json.data).toBe(105); }); }); describe('With node name argument', () => { test('should return items for specified node', () => { const items = proxy.$items('Rename'); expect(items.length).toBe(5); expect(items[0].json.data).toBe(105); expect(items[1].json.data).toBe(160); }); test('should throw error for non-existent node', () => { expect(() => proxy.$items('NonExistentNode')).toThrowError(ExpressionError); }); }); describe('With node name and output index', () => { const switchWorkflow = loadFixture('multiple_outputs'); const switchProxy = getProxyFromFixture( switchWorkflow.workflow, switchWorkflow.run, 'Edit Fields', ); test('should return items from specific output', () => { const items = switchProxy.$items('If', 1); expect(items[0].json.code).toBe(1); }); }); describe('With node name, output index, and run index', () => { test('should handle negative run index', () => { const items = proxy.$items('Rename', 0, -1); expect(items.length).toBe(5); expect(items[0].json.data).toBe(105); }); }); describe('Error handling', () => { test('should throw error for invalid run index', () => { expect(() => proxy.$items('Rename', 0, 999)).toThrowError(ExpressionError); }); test('should handle nodes with no execution data', () => { const noDataWorkflow = { ...fixture.workflow, nodes: fixture.workflow.nodes.filter((node) => node.name !== 'Rename'), }; const noDataProxy = getProxyFromFixture(noDataWorkflow, null, 'End'); expect(() => noDataProxy.$items('Rename')).toThrowError(ExpressionError); }); }); }); describe('$agentInfo', () => { const fixture = loadFixture('agentInfo'); const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'AI Agent'); test('$agentInfo should return undefined for non-agent nodes', () => { const nonAgentProxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Calculator'); expect(nonAgentProxy.$agentInfo).toBeUndefined(); }); test('$agentInfo should return memoryConnectedToAgent as true if memory is connected', () => { expect(proxy.$agentInfo.memoryConnectedToAgent).toBe(true); }); test('$agentInfo should return memoryConnectedToAgent as false if no memory is connected', () => { const noMemoryProxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Another Agent'); expect(noMemoryProxy.$agentInfo.memoryConnectedToAgent).toBe(false); }); test('$agentInfo.tools should include connected tools with correct details', () => { const tools = proxy.$agentInfo.tools; // don't show tool connected to other agent expect(tools.length).toEqual(2); expect(tools[0]).toMatchObject({ connected: true, name: 'Google Calendar', type: 'Google Calendar', resource: 'Event', operation: 'Create', hasCredentials: false, }); expect(tools[1]).toMatchObject({ connected: false, name: 'Calculator', type: 'Calculator', resource: null, operation: null, hasCredentials: false, }); }); test('$agentInfo.tools should correctly identify AI-defined fields', () => { const tools = proxy.$agentInfo.tools; expect(tools[0].name).toBe('Google Calendar'); expect(tools[0].aiDefinedFields.length).toBe(1); expect(tools[0].aiDefinedFields).toEqual(['Start']); }); }); describe('memory node with disconnected tool', () => { const fixture = loadFixture('memory_with_disconnected_tool'); test('should resolve expressions referencing nodes connected via non-main connections', () => { // This tests the fix for AI-1396: Memory node connected to both a parent agent // (via AiMemory) and an agent tool (via AiTool) where the tool is NOT connected // to the parent agent. The memory node should still be able to resolve expressions // that reference the Chat Trigger node. const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Simple Memory'); // This should not throw "No path back to node" error expect(() => proxy.$('Edit Fields1').first().json.sessionId).not.toThrow(); expect(proxy.$('Edit Fields1').first().json.sessionId).toEqual('test-session-123'); }); }); describe('multiple inputs', () => { const fixture = loadFixture('multiple_inputs'); it('should correctly resolve expressions with multiple inputs (using paired item)', () => { const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Output'); expect(proxy.$('Set variable_3').item.json.variable_3).toEqual('3456'); expect(proxy.$('Set main variable').item.json.main_variable).toEqual(2); }); }); describe('Improved error messages for missing execution data', () => { test('should show helpful error message when accessing node without execution data', () => { // Create a simple workflow with two connected nodes const workflow: IWorkflowBase = { id: '1', name: 'test-workflow', nodes: [ { id: '1', name: 'Telegram Trigger', type: 'n8n-nodes-base.telegramTrigger', typeVersion: 1.2, position: [0, 0], parameters: {}, }, { id: '2', name: 'Send a text message', type: 'n8n-nodes-base.telegram', typeVersion: 1.2, position: [576, 0], parameters: { chatId: "={{ $('Telegram Trigger').item.json.message.chat.id }}", text: 'Test message', }, }, ], connections: { 'Telegram Trigger': { main: [[{ node: 'Send a text message', type: NodeConnectionTypes.Main, index: 0 }]], }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; // Create run data without execution data for Telegram Trigger const run = { data: createEmptyRunExecutionData(), mode: 'manual' as const, startedAt: new Date(), status: 'success' as const, storedAt: 'db' as const, }; const proxy = getProxyFromFixture(workflow, run, 'Send a text message'); // Should throw helpful error when trying to access Telegram Trigger data let error: ExpressionError | undefined; try { proxy.$('Telegram Trigger').item; } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error).toBeInstanceOf(ExpressionError); expect(error!.message).toBe("Node 'Telegram Trigger' hasn't been executed"); expect(error!.context.type).toBe('no_execution_data'); expect(error!.context.messageTemplate).toBe( 'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, , "") }}', ); }); test('should show helpful error message for different node names', () => { const workflow: IWorkflowBase = { id: '1', name: 'test-workflow', nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [0, 0], parameters: {}, }, { id: '2', name: 'Process Data', type: 'n8n-nodes-base.code', typeVersion: 2, position: [300, 0], parameters: { jsCode: "return $('HTTP Request').all();", }, }, ], connections: { 'HTTP Request': { main: [[{ node: 'Process Data', type: NodeConnectionTypes.Main, index: 0 }]], }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; const run = { data: createEmptyRunExecutionData(), mode: 'manual' as const, startedAt: new Date(), status: 'success' as const, storedAt: 'db' as const, }; const proxy = getProxyFromFixture(workflow, run, 'Process Data'); let error: ExpressionError | undefined; try { proxy.$('HTTP Request').item; } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error!.message).toBe("Node 'HTTP Request' hasn't been executed"); expect(error!.context.type).toBe('no_execution_data'); expect(error!.context.messageTemplate).toBe( 'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, , "") }}', ); }); test('should use improved error for first(), last(), and all() methods', () => { const workflow: IWorkflowBase = { id: '1', name: 'test-workflow', nodes: [ { id: '1', name: 'Start Node', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {}, }, { id: '2', name: 'End Node', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [300, 0], parameters: {}, }, ], connections: { 'Start Node': { main: [[{ node: 'End Node', type: NodeConnectionTypes.Main, index: 0 }]], }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; const run = { data: createEmptyRunExecutionData(), mode: 'manual' as const, startedAt: new Date(), status: 'success' as const, storedAt: 'db' as const, }; const proxy = getProxyFromFixture(workflow, run, 'End Node'); // Test first() method let error: ExpressionError | undefined; try { proxy.$('Start Node').first(); } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error!.message).toBe("Node 'Start Node' hasn't been executed"); expect(error!.context.messageTemplate).toBe( 'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, , "") }}', ); // Test last() method error = undefined; try { proxy.$('Start Node').last(); } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error!.message).toBe("Node 'Start Node' hasn't been executed"); expect(error!.context.messageTemplate).toBe( 'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, , "") }}', ); // Test all() method error = undefined; try { proxy.$('Start Node').all(); } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error!.message).toBe("Node 'Start Node' hasn't been executed"); expect(error!.context.messageTemplate).toBe( 'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, , "") }}', ); }); test('should show helpful error message when accessing non-existent node', () => { const workflow: IWorkflowBase = { id: '1', name: 'test-workflow', nodes: [ { id: '1', name: 'Real Node', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; const run = { data: createRunExecutionData({ resultData: { runData: { 'Real Node': [ { data: { main: [[{ json: { test: 'data' } }]], }, source: [null], startTime: 123, executionTime: 456, executionIndex: 0, }, ], }, }, }), mode: 'manual' as const, startedAt: new Date(), status: 'success' as const, storedAt: 'db' as const, }; const proxy = getProxyFromFixture(workflow, run, 'Real Node'); // Should throw helpful error when trying to access a non-existent node let error: ExpressionError | undefined; try { proxy.$('NonExistentNode').item; } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error).toBeInstanceOf(ExpressionError); expect(error!.message).toBe("Referenced node doesn't exist"); expect(error!.context.descriptionKey).toBe('nodeNotFound'); expect(error!.context.nodeCause).toBe('NonExistentNode'); }); test('should show error when accessing item with invalid index via direct proxy access', () => { // Use existing fixture data to test the item index validation path const fixture = loadFixture('base'); // Create a proxy with itemIndex that exceeds available items for a node const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Set Node', 'manual', { throwOnMissingExecutionData: true, runIndex: 10, // itemIndex way too high }); let error: ExpressionError | undefined; try { // This should trigger the error path for invalid item index proxy.$('Set Node').item; } catch (e) { error = e as ExpressionError; } expect(error).toBeDefined(); expect(error).toBeInstanceOf(ExpressionError); }); }); describe('buildAgentToolInfo', () => { it('should return basic info when nodeType is null', () => { const mockWorkflowWithNullNodeType = new Workflow({ id: '123', name: 'test workflow', nodes: [ { id: 'node1', name: 'Node1', type: 'n8n-nodes-base.unknownNode', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, nodeTypes: { getByNameAndVersion: () => undefined as unknown as INodeType, } as unknown as INodeTypes, settings: {}, }); const dataProxy = new WorkflowDataProxy( mockWorkflowWithNullNodeType, null, 0, 0, 'Node1', [], {}, 'integrated', {}, ); const node: INode = { id: 'node1', name: 'Node1', type: 'n8n-nodes-base.unknownNode', typeVersion: 1, position: [0, 0], parameters: {}, }; const result = (dataProxy as any).buildAgentToolInfo(node); expect(result).toEqual({ name: 'Node1', type: 'n8n-nodes-base.unknownNode', }); }); it('should return full info when nodeType is valid', () => { const mockWorkflow = new Workflow({ id: '123', name: 'test workflow', nodes: [ { id: 'node1', name: 'Node1', type: 'n8n-nodes-base.testNode', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, nodeTypes: Helpers.NodeTypes(), settings: {}, }); const dataProxy = new WorkflowDataProxy( mockWorkflow, null, 0, 0, 'Node1', [], {}, 'integrated', {}, ); const node: INode = { id: 'node1', name: 'Node1', type: 'n8n-nodes-base.testNode', typeVersion: 1, position: [0, 0], parameters: {}, }; const result = (dataProxy as any).buildAgentToolInfo(node); expect(result).toHaveProperty('name'); expect(result).toHaveProperty('type'); expect(result.name).toBe('Node1'); }); it('should handle undefined nodeType gracefully', () => { const mockWorkflowWithUndefinedNodeType = new Workflow({ id: '123', name: 'test workflow', nodes: [ { id: 'node1', name: 'Node1', type: 'n8n-nodes-base.missingNode', typeVersion: 1, position: [0, 0], parameters: {}, }, ], connections: {}, active: false, nodeTypes: { getByNameAndVersion: () => undefined as unknown as INodeType, } as unknown as INodeTypes, settings: {}, }); const dataProxy = new WorkflowDataProxy( mockWorkflowWithUndefinedNodeType, null, 0, 0, 'Node1', [], {}, 'integrated', {}, ); const node: INode = { id: 'node1', name: 'Node1', type: 'n8n-nodes-base.missingNode', typeVersion: 1, position: [0, 0], parameters: {}, }; const result = (dataProxy as any).buildAgentToolInfo(node); expect(result).toEqual({ name: 'Node1', type: 'n8n-nodes-base.missingNode', }); expect(result).not.toHaveProperty('displayName'); expect(result).not.toHaveProperty('params'); }); }); describe('Partial execution: $() referencing executed node from unexecuted active node', () => { // Scenario: Reference → Edit → NoOp → Edit Fields // Only Reference and Edit have been executed (partial execution). // Edit Fields (active, unexecuted) uses $('Edit').item.json.test // "Edit" has data in runData, so the expression should resolve. const workflowData: IWorkflowBase = { id: '123', name: 'partial execution test', nodes: [ { id: 'node1', name: 'Reference', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [256, 16] as [number, number], parameters: {}, }, { id: 'node2', name: 'Edit', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [544, 16] as [number, number], parameters: { assignments: { assignments: [ { id: 'e8d2af0b-147a-4b0d-a106-5ed5a5b753e4', name: 'test', value: '={{ 1111 }}', type: 'string', }, ], }, options: {}, }, }, { id: 'node3', name: 'NoOp', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [832, 32] as [number, number], parameters: {}, }, { id: 'node4', name: 'Edit Fields', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [1136, 0] as [number, number], parameters: {}, }, ], connections: { Reference: { main: [[{ node: 'Edit', type: NodeConnectionTypes.Main, index: 0 }]], }, Edit: { main: [[{ node: 'NoOp', type: NodeConnectionTypes.Main, index: 0 }]], }, NoOp: { main: [[{ node: 'Edit Fields', type: NodeConnectionTypes.Main, index: 0 }]], }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }; // Exact run data from a real partial execution (only Reference and Edit ran) const run: IRun = { data: createRunExecutionData({ resultData: { runData: { Reference: [ { startTime: 1774006769741, executionTime: 1, executionIndex: 0, executionStatus: 'success', source: [], data: { main: [[{ json: {}, pairedItem: { item: 0 } }]], }, }, ], Edit: [ { startTime: 1774006769743, executionTime: 8, executionIndex: 1, executionStatus: 'success', source: [ { previousNode: 'Reference', previousNodeOutput: 0, previousNodeRun: 0, }, ], data: { main: [[{ json: { test: '1111' }, pairedItem: { item: 0 } }]], }, }, ], // NoOp and Edit Fields have NOT been executed }, }, }), mode: 'manual', startedAt: new Date(), status: 'success', storedAt: 'db', }; test('$("Edit").first() should return data when referenced node was executed', () => { const proxy = getProxyFromFixture(workflowData, run, 'Edit Fields', 'manual'); expect(proxy.$('Edit').first().json.test).toBe('1111'); }); test('$("Edit").isExecuted should return true', () => { const proxy = getProxyFromFixture(workflowData, run, 'Edit Fields', 'manual'); expect(proxy.$('Edit').isExecuted).toBe(true); }); test('$("Edit").item.json.test should resolve when the referenced node was executed', () => { // .item uses paired item resolution which requires connectionInputData // (the active node's input). In a partial execution the active node hasn't // run so connectionInputData is empty, but the referenced node "Edit" has // data in runData. The fix falls back to reading from runData directly. const proxy = getProxyFromFixture(workflowData, run, 'Edit Fields', 'manual'); expect(proxy.$('Edit').item.json.test).toBe('1111'); }); }); describe('$self', () => { const node: INode = { id: 'uuid-1', name: 'Test Node', type: 'test.set', typeVersion: 1, position: [0, 0], parameters: {}, }; const createTestWorkflow = () => new Workflow({ id: '123', name: 'test workflow', nodes: [node], connections: {}, active: false, nodeTypes: Helpers.NodeTypes(), }); const readSelf = async (selfData: IDataObject, field: string) => { const workflow = createTestWorkflow(); const dataProxy = new WorkflowDataProxy( workflow, createEmptyRunExecutionData(), 0, 0, node.name, [], {}, 'integrated', {}, undefined, -1, selfData, ); const proxy = dataProxy.getDataProxy({ throwOnMissingExecutionData: false }); await workflow.expression.acquireIsolate(); try { return proxy.$self[field]; } finally { await workflow.expression.releaseIsolate(); } }; test('returns plain (fixed-mode) values verbatim', async () => { expect(await readSelf({ tenantId: 'myTenant' }, 'tenantId')).toBe('myTenant'); }); test('strips the leading "=" from a literal expression-mode value', async () => { expect(await readSelf({ tenantId: '=myTenant' }, 'tenantId')).toBe('myTenant'); }); test('resolves an expression-mode value containing a template', async () => { expect(await readSelf({ tenantId: '={{ "myTenant".toUpperCase() }}' }, 'tenantId')).toBe( 'MYTENANT', ); }); test('does not leak the "=" marker into a mid-string URL template', async () => { const workflow = createTestWorkflow(); const selfData = { tenantId: '=myTenant' }; const accessTokenUrl = '=https://login.microsoftonline.com/{{ $self["tenantId"] }}/oauth2/v2.0/token'; await workflow.expression.acquireIsolate(); try { const resolved = workflow.expression.getComplexParameterValue( node, accessTokenUrl, 'integrated', {}, undefined, undefined, selfData, ); expect(resolved).toBe('https://login.microsoftonline.com/myTenant/oauth2/v2.0/token'); } finally { await workflow.expression.releaseIsolate(); } }); }); }); describe('WorkflowDataProxy → pairedItem traversal through recombining ancestry', () => { // Models a polling loop whose Aggregate-style node pairs its single output // item to all of its inputs: ancestry branches fan out at every pass and // recombine on the shared ancestor, forming a DAG. Traversal must resolve // this in polynomial time - a tree walk would take ROWS^PASSES steps. const ROWS = 8; const PASSES = 25; const makeWorkflow = (): IWorkflowBase => ({ id: '1', name: 'test', nodes: (['Start', 'Rows', 'Agg', 'End'] as const).map((name, i) => ({ id: `uuid-${i}`, name, type: 'n8n-nodes-base.code', typeVersion: 1, position: [i * 100, 0] as [number, number], parameters: {}, })), connections: { Start: { main: [[{ node: 'Rows', type: NodeConnectionTypes.Main, index: 0 }]] }, Rows: { main: [[{ node: 'Agg', type: NodeConnectionTypes.Main, index: 0 }]] }, Agg: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }); const taskData = (source: Array<{ previousNode: string; previousNodeRun?: number } | null>) => ({ startTime: 0, executionTime: 0, executionIndex: 0, source, data: { main: [[]] as INodeExecutionData[][] }, }); const makeRun = ( startItems: INodeExecutionData[], rowsPairing: (row: number) => number, ): IRun => { const start = taskData([null]); start.data.main[0] = startItems; const rowsRuns = []; const aggRuns = []; for (let pass = 0; pass < PASSES; pass++) { const rows = taskData([ pass === 0 ? { previousNode: 'Start' } : { previousNode: 'Agg', previousNodeRun: pass - 1 }, ]); // only pass 0 is sourced from Start (multiple items); later passes are // sourced from Agg, which emits a single item rows.data.main[0] = Array.from({ length: ROWS }, (_, row) => ({ json: { row }, pairedItem: { item: pass === 0 ? rowsPairing(row) : 0 }, })); rowsRuns.push(rows); const agg = taskData([{ previousNode: 'Rows', previousNodeRun: pass }]); agg.data.main[0] = [ { json: { pass }, pairedItem: Array.from({ length: ROWS }, (_, item) => ({ item })) }, ]; aggRuns.push(agg); } const end = taskData([{ previousNode: 'Agg', previousNodeRun: PASSES - 1 }]); end.data.main[0] = [{ json: {}, pairedItem: { item: 0 } }]; return { data: createRunExecutionData({ resultData: { runData: { Start: [start], Rows: rowsRuns, Agg: aggRuns, End: [end] } }, }), mode: 'manual', startedAt: new Date(), status: 'success', storedAt: 'db', }; }; test('resolves when all branches recombine on one ancestor item', () => { const run = makeRun([{ json: { origin: true }, pairedItem: { item: 0 } }], () => 0); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(proxy.$('Start').item.json).toEqual({ origin: true }); }); test('still detects ambiguity when branches resolve to different ancestor items', () => { const startItems = Array.from({ length: ROWS }, (_, item) => ({ json: { item }, pairedItem: { item }, })); // each row pairs back to its own Start item, so resolution is ambiguous const run = makeRun(startItems, (row) => row); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(() => proxy.$('Start').item).toThrowError('Multiple matches'); }); }); describe('WorkflowDataProxy → pairedItem traversal with sourceOverwrite routes', () => { // One item pairs to the same ancestor node run twice: once through its task's // default source and once through a `sourceOverwrite` redirect. The two routes // must resolve as distinct states. const makeWorkflow = (): IWorkflowBase => ({ id: '1', name: 'test', nodes: (['Start', 'Mid', 'Join', 'End'] as const).map((name, i) => ({ id: `uuid-${i}`, name, type: 'n8n-nodes-base.code', typeVersion: 1, position: [i * 100, 0] as [number, number], parameters: {}, })), connections: { Start: { main: [[{ node: 'Mid', type: NodeConnectionTypes.Main, index: 0 }]] }, Mid: { main: [[{ node: 'Join', type: NodeConnectionTypes.Main, index: 0 }]] }, Join: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }); const midRun = (outputs: INodeExecutionData[][]) => ({ startTime: 0, executionTime: 0, executionIndex: 0, source: [{ previousNode: 'Start' }], data: { main: outputs }, }); // Mid run N emits one item paired to Start item `midRunPairing[N]` const midRunsPairedTo = (midRunPairing: number[]) => midRunPairing.map((startItem) => midRun([[{ json: {}, pairedItem: { item: startItem } }]])); const defaultJoinPairing: IPairedItemData[] = [ { item: 0 }, { item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 1 } }, ]; const makeRun = ( startItems: INodeExecutionData[], midRuns: ReturnType[], joinPairing: IPairedItemData[] = defaultJoinPairing, ): IRun => { const start = { startTime: 0, executionTime: 0, executionIndex: 0, source: [null], data: { main: [startItems] }, }; const join = { startTime: 0, executionTime: 0, executionIndex: 0, source: [{ previousNode: 'Mid', previousNodeRun: 0 }], data: { main: [[{ json: {}, pairedItem: joinPairing }]] }, }; const end = { startTime: 0, executionTime: 0, executionIndex: 0, source: [{ previousNode: 'Join' }], data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] }, }; return { data: createRunExecutionData({ resultData: { runData: { Start: [start], Mid: midRuns, Join: [join], End: [end] } }, }), mode: 'manual', startedAt: new Date(), status: 'success', storedAt: 'db', }; }; test('resolves when the default and overwritten routes agree on the ancestor item', () => { const run = makeRun( [{ json: { origin: true }, pairedItem: { item: 0 } }], midRunsPairedTo([0, 0]), ); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(proxy.$('Start').item.json).toEqual({ origin: true }); }); test('detects ambiguity when the overwritten route reaches a different ancestor item', () => { const startItems = [ { json: { item: 0 }, pairedItem: { item: 0 } }, { json: { item: 1 }, pairedItem: { item: 1 } }, ]; const run = makeRun(startItems, midRunsPairedTo([0, 1])); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(() => proxy.$('Start').item).toThrowError('Multiple matches'); }); test('returns the resolving route when another route fails, also on its memoized revisit', () => { const startItems = [{ json: { origin: true }, pairedItem: { item: 0 } }]; // Mid run 1 has no pairedItem, so the overwritten route cannot resolve. // It is listed twice so the second visit replays the memoized error. const brokenRoute = { item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 1 } }; const midRuns = [...midRunsPairedTo([0]), midRun([[{ json: {} }]])]; const okFirst = getProxyFromFixture( makeWorkflow(), makeRun(startItems, midRuns, [{ item: 0 }, brokenRoute, brokenRoute]), 'End', ); expect(okFirst.$('Start').item.json).toEqual({ origin: true }); const okLast = getProxyFromFixture( makeWorkflow(), makeRun(startItems, midRuns, [brokenRoute, brokenRoute, { item: 0 }]), 'End', ); expect(okLast.$('Start').item.json).toEqual({ origin: true }); const allBroken = getProxyFromFixture( makeWorkflow(), makeRun(startItems, midRuns, [brokenRoute, brokenRoute]), 'End', ); expect(() => allBroken.$('Start').item).toThrowError( "Paired item data for item from node 'Mid' is unavailable", ); }); test('treats the same item index on different outputs of one run as distinct states', () => { const startItems = [ { json: { item: 0 }, pairedItem: { item: 0 } }, { json: { item: 1 }, pairedItem: { item: 1 } }, ]; // Mid run 0 output 0 item 0 pairs to Start 0, output 1 item 0 pairs to Start 1 const midRuns = [ midRun([[{ json: {}, pairedItem: { item: 0 } }], [{ json: {}, pairedItem: { item: 1 } }]]), ]; const run = makeRun(startItems, midRuns, [ { item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 0, previousNodeOutput: 0 }, }, { item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 0, previousNodeOutput: 1 }, }, ]); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(() => proxy.$('Start').item).toThrowError('Multiple matches'); }); }); describe('WorkflowDataProxy → pairedItem traversal through a diamond', () => { // Start fans out to Left and Right, which join again in Merge. A Start item is // reached under one state from two different parent nodes. const makeWorkflow = (): IWorkflowBase => ({ id: '1', name: 'test', nodes: (['Start', 'Left', 'Right', 'Merge', 'End'] as const).map((name, i) => ({ id: `uuid-${i}`, name, type: 'n8n-nodes-base.code', typeVersion: 1, position: [i * 100, 0] as [number, number], parameters: {}, })), connections: { Start: { main: [ [ { node: 'Left', type: NodeConnectionTypes.Main, index: 0 }, { node: 'Right', type: NodeConnectionTypes.Main, index: 0 }, ], ], }, Left: { main: [[{ node: 'Merge', type: NodeConnectionTypes.Main, index: 0 }]] }, Right: { main: [[{ node: 'Merge', type: NodeConnectionTypes.Main, index: 1 }]] }, Merge: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] }, }, active: false, activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), }); const task = (source: Array<{ previousNode: string } | null>, items: INodeExecutionData[]) => ({ startTime: 0, executionTime: 0, executionIndex: 0, source, data: { main: [items] }, }); const makeRun = ( startItems: INodeExecutionData[], leftItem: number, rightItem: number, ): IRun => ({ data: createRunExecutionData({ resultData: { runData: { Start: [task([null], startItems)], Left: [task([{ previousNode: 'Start' }], [{ json: {}, pairedItem: { item: leftItem } }])], Right: [ task([{ previousNode: 'Start' }], [{ json: {}, pairedItem: { item: rightItem } }]), ], Merge: [ task( [{ previousNode: 'Left' }, { previousNode: 'Right' }], [ { json: {}, pairedItem: [ { item: 0, input: 0 }, { item: 0, input: 1 }, ], }, ], ), ], End: [task([{ previousNode: 'Merge' }], [{ json: {}, pairedItem: { item: 0 } }])], }, }, }), mode: 'manual', startedAt: new Date(), status: 'success', storedAt: 'db', }); test('resolves when both routes reach one ancestor item', () => { const run = makeRun([{ json: { origin: true }, pairedItem: { item: 0 } }], 0, 0); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(proxy.$('Start').item.json).toEqual({ origin: true }); }); test('detects ambiguity when the routes reach different ancestor items', () => { const startItems = [0, 1].map((item) => ({ json: { item }, pairedItem: { item } })); const run = makeRun(startItems, 0, 1); const proxy = getProxyFromFixture(makeWorkflow(), run, 'End'); expect(() => proxy.$('Start').item).toThrowError('Multiple matches'); }); });