import { DateTime, Duration, Interval } from 'luxon'; import * as Helpers from './helpers'; import { ensureError } from '../src/errors/ensure-error'; import { ExpressionError } from '../src/errors/expression.error'; import { NodeConnectionTypes, type NodeConnectionType, type IExecuteData, type INode, 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('$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('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')); }); }); 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'); }); }); });