import { createComponentRenderer } from '@/__tests__/render'; import ParameterInput from '@/components/ParameterInput.vue'; import type { useNDVStore } from '@/stores/ndv.store'; import type { CompletionResult } from '@codemirror/autocomplete'; import { createTestingPinia } from '@pinia/testing'; import { faker } from '@faker-js/faker'; import { fireEvent, waitFor, within } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import type { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useSettingsStore } from '@/stores/settings.store'; import { mockedStore } from '@/__tests__/utils'; import { createEventBus } from '@n8n/utils/event-bus'; import { createTestExpressionLocalResolveContext, createMockEnterpriseSettings, createTestNode, createTestWorkflowObject, createTestNodeProperties, } from '@/__tests__/mocks'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-workflow'; import type { IWorkflowDb, WorkflowListResource } from '@/Interface'; import { mock } from 'vitest-mock-extended'; import { ExpressionLocalResolveContextSymbol } from '@/constants'; function getNdvStateMock(): Partial> { return { hasInputData: true, activeNode: { id: faker.string.uuid(), name: faker.word.words(3), parameters: {}, position: [faker.number.int(), faker.number.int()], type: 'test', typeVersion: 1, }, isInputPanelEmpty: false, isOutputPanelEmpty: false, ndvInputDataWithPinnedData: [], getHoveringItem: undefined, expressionOutputItemIndex: 0, isTableHoverOnboarded: false, setHighlightDraggables: vi.fn(), setNDVPanelDataIsEmpty: vi.fn(), setNDVBranchIndex: vi.fn(), }; } function getNodeTypesStateMock(): Partial> { return { allNodeTypes: [], }; } let mockNdvState = getNdvStateMock(); let mockNodeTypesState = getNodeTypesStateMock(); let mockCompletionResult: Partial = {}; beforeEach(() => { mockNdvState = getNdvStateMock(); mockNodeTypesState = getNodeTypesStateMock(); mockCompletionResult = {}; }); vi.mock('@/stores/ndv.store', () => { return { useNDVStore: vi.fn(() => mockNdvState), }; }); vi.mock('@/stores/nodeTypes.store', () => { return { useNodeTypesStore: vi.fn(() => mockNodeTypesState), }; }); vi.mock('@/plugins/codemirror/completions/datatype.completions', () => { return { datatypeCompletions: vi.fn(() => mockCompletionResult), }; }); vi.mock('vue-router', () => { const push = vi.fn(); return { useRouter: () => ({ push, resolve: vi.fn().mockReturnValue({ href: '/projects/1/folders/1', }), }), useRoute: () => ({}), RouterLink: vi.fn(), }; }); const renderComponent = createComponentRenderer(ParameterInput, { pinia: createTestingPinia(), }); const settingsStore = mockedStore(useSettingsStore); const workflowsStore = mockedStore(useWorkflowsStore); describe('ParameterInput.vue', () => { beforeEach(() => { mockNdvState = { hasInputData: true, activeNode: { id: faker.string.uuid(), name: faker.word.words(3), parameters: {}, position: [faker.number.int(), faker.number.int()], type: 'test', typeVersion: 1, }, isInputPanelEmpty: false, isOutputPanelEmpty: false, ndvInputDataWithPinnedData: [], getHoveringItem: undefined, expressionOutputItemIndex: 0, isTableHoverOnboarded: false, setHighlightDraggables: vi.fn(), setNDVPanelDataIsEmpty: vi.fn(), setNDVBranchIndex: vi.fn(), }; mockNodeTypesState = { allNodeTypes: [], getNodeType: vi.fn().mockReturnValue(null), }; settingsStore.settings.enterprise = createMockEnterpriseSettings(); }); afterEach(() => { vi.clearAllMocks(); }); test('should render an options parameter (select)', async () => { const { container, baseElement, emitted } = renderComponent({ props: { path: 'operation', parameter: { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['sheet'] } }, options: [ { name: 'Append or Update Row', value: 'appendOrUpdate', description: 'Append a new row or update an existing one (upsert)', action: 'Append or update row in sheet', }, { name: 'Append Row', value: 'append', description: 'Create a new row in a sheet', action: 'Append row in sheet', }, ], default: 'appendOrUpdate', }, modelValue: 'appendOrUpdate', }, }); const select = container.querySelector('input') as HTMLInputElement; const selectTrigger = container.querySelector('.select-trigger') as HTMLElement; expect(select).toBeInTheDocument(); expect(selectTrigger).toBeInTheDocument(); await waitFor(() => expect(select).toHaveValue('Append or Update Row')); await userEvent.click(selectTrigger); const options = baseElement.querySelectorAll('.list-option'); expect(options.length).toEqual(2); expect(options[0].querySelector('.option-headline')).toHaveTextContent('Append or Update Row'); expect(options[0].querySelector('.option-description')).toHaveTextContent( 'Append a new row or update an existing one (upsert)', ); expect(options[1].querySelector('.option-headline')).toHaveTextContent('Append Row'); expect(options[1].querySelector('.option-description')).toHaveTextContent( 'Create a new row in a sheet', ); await userEvent.click(options[1]); expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'append' })]); }); test('should render an options parameter even if it has invalid fields (like displayName)', async () => { // Test case based on the Schedule node // type=options parameters shouldn't have a displayName field, but some do const { container, baseElement, emitted } = renderComponent({ props: { path: 'operation', parameter: { displayName: 'Trigger at Hour', name: 'triggerAtHour', type: 'options', default: 0, options: [ { name: 'Midnight', displayName: 'Midnight', value: 0, }, { name: '1am', displayName: '1am', value: 1, }, ], description: 'The hour of the day to trigger', }, modelValue: 0, }, }); const select = container.querySelector('input') as HTMLInputElement; const selectTrigger = container.querySelector('.select-trigger') as HTMLElement; await waitFor(() => expect(select).toHaveValue('Midnight')); await userEvent.click(selectTrigger); const options = baseElement.querySelectorAll('.list-option'); expect(options.length).toEqual(2); expect(options[0].querySelector('.option-headline')).toHaveTextContent('Midnight'); expect(options[1].querySelector('.option-headline')).toHaveTextContent('1am'); await userEvent.click(options[1]); await waitFor(() => expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 1 })]), ); }); test('should render a string parameter', async () => { const { container, emitted } = renderComponent({ props: { path: 'tag', parameter: { displayName: 'Tag', name: 'tag', type: 'string', }, modelValue: '', }, }); const input = container.querySelector('input') as HTMLInputElement; expect(input).toBeInTheDocument(); await userEvent.type(input, 'foo'); await waitFor(() => expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]), ); }); describe('paste events', () => { async function paste(input: HTMLInputElement, text: string) { const expression = new DataTransfer(); expression.setData('text', text); await userEvent.clear(input); await userEvent.paste(expression); } test('should handle pasting into a string parameter', async () => { const { container, emitted } = renderComponent({ props: { path: 'tag', parameter: { displayName: 'Tag', name: 'tag', type: 'string', }, modelValue: '', }, }); const input = container.querySelector('input') as HTMLInputElement; expect(input).toBeInTheDocument(); await userEvent.click(input); await paste(input, 'foo'); await waitFor(() => expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]), ); await paste(input, '={{ $json.foo }}'); await waitFor(() => expect(emitted('update')).toContainEqual([ expect.objectContaining({ value: '={{ $json.foo }}' }), ]), ); await paste(input, '=flDvzj%y1nP'); await waitFor(() => expect(emitted('update')).toContainEqual([ expect.objectContaining({ value: '==flDvzj%y1nP' }), ]), ); }); test('should handle pasting an expression into a number parameter', async () => { const { container, emitted } = renderComponent({ props: { path: 'percentage', parameter: { displayName: 'Percentage', name: 'percentage', type: 'number', }, modelValue: 1, }, }); const input = container.querySelector('input') as HTMLInputElement; expect(input).toBeInTheDocument(); await userEvent.click(input); await paste(input, '{{ $json.foo }}'); await waitFor(() => expect(emitted('update')).toContainEqual([ expect.objectContaining({ value: '={{ $json.foo }}' }), ]), ); }); }); test('should not reset the value of a multi-select with loadOptionsMethod on load', async () => { mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => [ { name: 'ID', value: 'id' }, { name: 'Title', value: 'title' }, { name: 'Description', value: 'description' }, ]); const { emitted, container } = renderComponent({ props: { path: 'columns', parameter: { displayName: 'Columns', name: 'columns', type: 'multiOptions', typeOptions: { loadOptionsMethod: 'getColumnsMultiOptions' }, }, modelValue: ['id', 'title'], }, }); const input = container.querySelector('input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Nothing should be emitted expect(emitted('update')).toBeUndefined(); }); test('should show message when can not load options without credentials', async () => { mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => { throw new Error('Node does not have any credentials set'); }); // @ts-expect-error Readonly property mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({ displayName: 'Test', credentials: [ { name: 'openAiApi', required: true, }, ], }); const { emitted, container, getByTestId } = renderComponent({ props: { path: 'columns', parameter: { displayName: 'Columns', name: 'columns', type: 'options', typeOptions: { loadOptionsMethod: 'getColumnsMultiOptions' }, }, modelValue: 'id', }, }); await waitFor(() => expect(getByTestId('parameter-input-field')).toBeInTheDocument()); const input = container.querySelector('input') as HTMLInputElement; expect(input).toBeInTheDocument(); expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled(); expect(input.value.toLowerCase()).not.toContain('error'); expect(input).toHaveValue('Set up credential to see options'); expect(emitted('update')).toBeUndefined(); }); test('should render workflow selector without issues when selected workflow is not archived', async () => { const workflowId = faker.string.uuid(); const modelValue = { mode: 'id', value: workflowId, }; workflowsStore.fetchWorkflowsPage.mockResolvedValue([ mock({ id: workflowId, name: 'Test', active: false, isArchived: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // nodes: [], // connections: {}, versionId: faker.string.uuid(), }), ]); const { emitted, container, getByTestId, queryByTestId } = renderComponent({ props: { path: 'columns', parameter: { displayName: 'Workflow', name: 'workflowId', type: 'workflowSelector', default: '', }, modelValue, }, }); await waitFor(() => expect(getByTestId('resource-locator-workflowId')).toBeInTheDocument()); expect(container.querySelector('.has-issues')).not.toBeInTheDocument(); const inputs = container.querySelectorAll('input'); const mode = inputs[0]; expect(mode).toBeInTheDocument(); expect(mode).toHaveValue('By ID'); const value = inputs[1]; expect(value).toBeInTheDocument(); expect(value).toHaveValue(workflowId); expect(queryByTestId('parameter-issues')).not.toBeInTheDocument(); expect(emitted('update')).toBeUndefined(); }); test('should show error when workflow selector has archived workflow selected', async () => { const workflowId = faker.string.uuid(); const modelValue: INodeParameterResourceLocator = { __rl: true, mode: 'id', value: workflowId, }; const workflowBase = { id: workflowId, name: 'Test', active: false, isArchived: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), versionId: faker.string.uuid(), }; workflowsStore.allWorkflows = [mock(workflowBase)]; workflowsStore.fetchWorkflowsPage.mockResolvedValue([mock(workflowBase)]); const { emitted, container, getByTestId } = renderComponent({ props: { path: 'columns', parameter: { displayName: 'Workflow', name: 'workflowId', type: 'workflowSelector', default: '', }, modelValue, }, }); await waitFor(() => expect(getByTestId('resource-locator-workflowId')).toBeInTheDocument()); expect(container.querySelector('.has-issues')).toBeInTheDocument(); const inputs = container.querySelectorAll('input'); const mode = inputs[0]; expect(mode).toBeInTheDocument(); expect(mode).toHaveValue('By ID'); const value = inputs[1]; expect(value).toBeInTheDocument(); expect(value).toHaveValue(workflowId); expect(getByTestId('parameter-issues')).toBeInTheDocument(); expect(emitted('update')).toBeUndefined(); }); test('should reset bool on eventBus:removeExpression', async () => { const eventBus = createEventBus(); const { emitted } = renderComponent({ props: { path: 'aSwitch', parameter: { displayName: 'A Switch', name: 'aSwitch', type: 'boolean', default: true, }, modelValue: '={{ }}', // note that this makes a syntax error eventBus, }, }); eventBus.emit('optionSelected', 'removeExpression'); expect(emitted('update')).toContainEqual([expect.objectContaining({ value: true })]); }); test('should reset bool with undefined evaluation on eventBus:removeExpression', async () => { const eventBus = createEventBus(); const { emitted } = renderComponent({ props: { path: 'aSwitch', parameter: { displayName: 'A Switch', name: 'aSwitch', type: 'boolean', default: true, }, modelValue: undefined, eventBus, }, }); eventBus.emit('optionSelected', 'removeExpression'); expect(emitted('update')).toContainEqual([expect.objectContaining({ value: true })]); }); test('should reset number on eventBus:removeExpression', async () => { const eventBus = createEventBus(); const { emitted } = renderComponent({ props: { path: 'aNum', parameter: { displayName: 'A Num', name: 'aNum', type: 'number', default: 6, }, modelValue: '={{ }}', // note that this makes a syntax error eventBus, }, }); eventBus.emit('optionSelected', 'removeExpression'); expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 6 })]); }); test('should reset string on eventBus:removeExpression', async () => { const eventBus = createEventBus(); const { emitted } = renderComponent({ props: { path: 'aStr', parameter: { displayName: 'A Str', name: 'aStr', type: 'string', default: 'some default', }, modelValue: '={{ }}', // note that this makes a syntax error eventBus, }, }); eventBus.emit('optionSelected', 'removeExpression'); expect(emitted('update')).toContainEqual([expect.objectContaining({ value: '{{ }}' })]); }); test('should maintain focus after changing to expression', async () => { const { rerender, getByRole, getByTestId } = renderComponent({ props: { path: 'name', parameter: { displayName: 'Name', name: 'name', type: 'string', }, modelValue: 'test', }, }); const input = getByRole('textbox'); expect(input).toBeInTheDocument(); await userEvent.click(input); await rerender({ modelValue: '={{ $json.foo }}' }); const expressionEditor = getByTestId('inline-expression-editor-input'); expect(expressionEditor).toBeInTheDocument(); const expressionEditorInput = within(expressionEditor).getByRole('textbox'); await waitFor(() => expect(expressionEditorInput).toHaveFocus()); }); describe('when not in focus', () => { test('should not focus after changing to expression ', async () => { const { rerender, getByRole, getByTestId } = renderComponent({ props: { path: 'name', parameter: { displayName: 'Name', name: 'name', type: 'string', }, modelValue: 'test', }, }); const input = getByRole('textbox'); expect(input).toBeInTheDocument(); await rerender({ modelValue: '={{ $json.foo }}' }); const expressionEditor = getByTestId('inline-expression-editor-input'); expect(expressionEditor).toBeInTheDocument(); const expressionEditorInput = within(expressionEditor).getByRole('textbox'); await waitFor(() => expect(expressionEditorInput).not.toHaveFocus()); }); }); describe('debounced input', () => { test('should debounce text input and emit update event only once', async () => { const { container, emitted } = renderComponent({ props: { path: 'textField', parameter: { displayName: 'Text Field', name: 'textField', type: 'string', }, modelValue: '', }, }); const input = container.querySelector('input') as HTMLInputElement; expect(input).toBeInTheDocument(); await userEvent.click(input); await userEvent.type(input, 'h'); await userEvent.type(input, 'e'); await userEvent.type(input, 'l'); await userEvent.type(input, 'l'); await userEvent.type(input, 'o'); // by now the update event should not have been emitted because of debouncing expect(emitted('update')).not.toContainEqual([expect.objectContaining({ value: 'hello' })]); // Now the update event should have been emitted await waitFor(() => { const updateEvents = emitted('update'); expect(updateEvents).toBeDefined(); expect(updateEvents.length).toBeLessThan(5); expect(updateEvents).toContainEqual([expect.objectContaining({ value: 'hello' })]); }); }); }); describe('data mapper', () => { const workflow = createTestWorkflowObject({ nodes: [createTestNode({ name: 'n0' }), createTestNode({ name: 'n1' })], connections: { n1: { [NodeConnectionTypes.Main]: [[{ node: 'n0', index: 0, type: NodeConnectionTypes.Main }]], }, }, }); const ctx = createTestExpressionLocalResolveContext({ workflow, nodeName: 'n0', inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 }, }); it('should render mapper when the current value is empty', async () => { const rendered = renderComponent({ global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } }, props: { path: 'name', parameter: createTestNodeProperties(), modelValue: '', }, }); await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!); expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument(); }); it('should render mapper when editor type is specified in the parameter', async () => { const rendered = renderComponent({ global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } }, props: { path: 'name', parameter: createTestNodeProperties({ typeOptions: { editor: 'sqlEditor' } }), modelValue: 'SELECT 1;', }, }); await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!); expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument(); }); it('should render mapper when the current value is an expression', async () => { const rendered = renderComponent({ global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } }, props: { path: 'name', parameter: createTestNodeProperties(), modelValue: '={{$today}}', }, }); await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!); expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument(); }); it('should not render mapper if given node property is a node setting', async () => { const rendered = renderComponent({ global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } }, props: { path: 'name', parameter: createTestNodeProperties({ isNodeSetting: true }), modelValue: '', }, }); await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!); expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument(); }); it('should not render mapper if given node property has datetime type', async () => { const rendered = renderComponent({ global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } }, props: { path: 'name', parameter: createTestNodeProperties({ type: 'dateTime' }), modelValue: '', }, }); await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!); expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument(); }); }); });