diff --git a/cypress/composables/webhooks.ts b/cypress/composables/webhooks.ts index 8ad5dc6861..14dc4d4c01 100644 --- a/cypress/composables/webhooks.ts +++ b/cypress/composables/webhooks.ts @@ -37,7 +37,8 @@ export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => { .find('.parameter-input') .find('input') .clear() - .type(webhookPath); + .type(webhookPath) + .wait(300); if (authentication) { cy.getByTestId('parameter-input-authentication').click(); @@ -46,7 +47,8 @@ export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => { if (responseCode) { cy.get('.param-options').click(); - getVisibleSelect().contains('Response Code').click(); + // wait for selector debounce + getVisibleSelect().contains('Response Code').click().wait(300); cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click(); getVisibleSelect().contains('201').click(); } diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts index 8316ad97e8..1e57a0bd5a 100644 --- a/cypress/e2e/16-form-trigger-node.cy.ts +++ b/cypress/e2e/16-form-trigger-node.cy.ts @@ -78,7 +78,8 @@ describe('n8n Form Trigger', () => { //add optional submitted message cy.get('.param-options').click(); - getVisibleSelect().find('span').contains('Form Response').click(); + // wait for selector debounce + getVisibleSelect().find('span').contains('Form Response').click().wait(300); cy.contains('span', 'Text to Show') .should('exist') .parent() diff --git a/cypress/package.json b/cypress/package.json index 4dcf1ddc32..d77048d4be 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -4,10 +4,10 @@ "scripts": { "typecheck": "tsc --noEmit", "cypress:install": "cypress install", - "test:e2e:ui": "scripts/run-e2e.js ui", - "test:e2e:dev": "scripts/run-e2e.js dev", - "test:e2e:all": "scripts/run-e2e.js all", - "test:flaky": "scripts/run-e2e.js debugFlaky", + "test:e2e:ui": "node scripts/run-e2e.js ui", + "test:e2e:dev": "node scripts/run-e2e.js dev", + "test:e2e:all": "node scripts/run-e2e.js all", + "test:flaky": "node scripts/run-e2e.js debugFlaky", "format": "biome format --write .", "format:check": "biome ci .", "lint": "eslint . --quiet", diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index f296d5c3f5..ce273322bc 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -74,7 +74,9 @@ export class CredentialsModal extends BasePage { .filter(':not([readonly])') .each(($el) => { cy.wrap($el).type('test'); - }); + }) + // wait for text input debounce + .wait(300); saveCredential(); if (closeModal) { this.getters.closeButton().click(); diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.test.ts b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.test.ts index 31cd888ff2..7011323942 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.test.ts +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.test.ts @@ -2,7 +2,7 @@ import { computed, nextTick, ref } from 'vue'; import { createComponentRenderer } from '@/__tests__/render'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; -import { fireEvent } from '@testing-library/vue'; +import { fireEvent, waitFor } from '@testing-library/vue'; import Assignment from './Assignment.vue'; import { defaultSettings } from '@/__tests__/defaults'; import { STORES } from '@n8n/stores'; @@ -53,9 +53,11 @@ describe('Assignment.vue', () => { await userEvent.click(baseElement.querySelectorAll('.option')[3]); - expect(emitted('update:model-value')[0]).toEqual([ - { name: 'New name', type: 'array', value: 'New value' }, - ]); + await waitFor(() => + expect(emitted('update:model-value')[0]).toEqual([ + { name: 'New name', type: 'array', value: 'New value' }, + ]), + ); }); it('can remove itself', async () => { diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.test.ts b/packages/frontend/editor-ui/src/components/ParameterInput.test.ts index 03af6c4404..322b0b56ff 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.test.ts +++ b/packages/frontend/editor-ui/src/components/ParameterInput.test.ts @@ -221,7 +221,9 @@ describe('ParameterInput.vue', () => { await userEvent.click(options[1]); - expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 1 })]); + await waitFor(() => + expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 1 })]), + ); }); test('should render a string parameter', async () => { @@ -241,7 +243,9 @@ describe('ParameterInput.vue', () => { await userEvent.type(input, 'foo'); - expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]); + await waitFor(() => + expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]), + ); }); describe('paste events', () => { @@ -269,17 +273,23 @@ describe('ParameterInput.vue', () => { await userEvent.click(input); await paste(input, 'foo'); - expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]); + await waitFor(() => + expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]), + ); await paste(input, '={{ $json.foo }}'); - expect(emitted('update')).toContainEqual([ - expect.objectContaining({ value: '={{ $json.foo }}' }), - ]); + await waitFor(() => + expect(emitted('update')).toContainEqual([ + expect.objectContaining({ value: '={{ $json.foo }}' }), + ]), + ); await paste(input, '=flDvzj%y1nP'); - expect(emitted('update')).toContainEqual([ - expect.objectContaining({ value: '==flDvzj%y1nP' }), - ]); + await waitFor(() => + expect(emitted('update')).toContainEqual([ + expect.objectContaining({ value: '==flDvzj%y1nP' }), + ]), + ); }); test('should handle pasting an expression into a number parameter', async () => { @@ -299,9 +309,11 @@ describe('ParameterInput.vue', () => { await userEvent.click(input); await paste(input, '{{ $json.foo }}'); - expect(emitted('update')).toContainEqual([ - expect.objectContaining({ value: '={{ $json.foo }}' }), - ]); + await waitFor(() => + expect(emitted('update')).toContainEqual([ + expect.objectContaining({ value: '={{ $json.foo }}' }), + ]), + ); }); }); @@ -603,4 +615,41 @@ describe('ParameterInput.vue', () => { 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' })]); + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.vue b/packages/frontend/editor-ui/src/components/ParameterInput.vue index 8a4a69ac56..9cf3f34341 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInput.vue @@ -1010,6 +1010,8 @@ function onUpdateTextInput(value: string) { onTextInputChange(value); } +const onUpdateTextInputDebounced = debounce(onUpdateTextInput, { debounceTime: 200 }); + function onClickOutsideMapper() { if (!isFocused.value) { isMapperShown.value = false; @@ -1150,7 +1152,8 @@ defineExpose({ }); onBeforeUnmount(() => { - valueChangedDebounced.cancel(); + valueChangedDebounced.flush(); + onUpdateTextInputDebounced.flush(); props.eventBus.off('optionSelected', optionSelected); }); @@ -1289,7 +1292,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper); :node="node" :path="path" :event-bus="eventBus" - @update:model-value="valueChanged" + @update:model-value="valueChangedDebounced" @modal-opener-click="openExpressionEditorModal" @focus="setFocus" @blur="onBlur" @@ -1309,7 +1312,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper); :path="path" :parameter-issues="getIssues" :is-read-only="isReadOnly" - @update:model-value="valueChanged" + @update:model-value="valueChangedDebounced" @modal-opener-click="openExpressionEditorModal" @focus="setFocus" @blur="onBlur" @@ -1574,7 +1577,6 @@ onClickOutside(mapperElRef, onClickOutsideMapper); :rows="editorRows" /> - { return { @@ -107,15 +108,16 @@ describe('ParameterOverrideSelectableList', () => { }); await userEvent.type(getByTestId('parameter-input-field'), '2'); - expect(model.value.extraPropValues.description).toBe('Test description2'); - expect(emitted('update')).toHaveLength(1); - expect(emitted('update')[0]).toEqual([ - { - name: 'parameters.workflowInputs.value["test"]', - value: - "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('test', `Test description2`, 'string') }}", - }, - ]); + await waitFor(() => { + expect(model.value.extraPropValues.description).toBe('Test description2'); + expect(emitted('update')).toContainEqual([ + { + name: 'parameters.workflowInputs.value["test"]', + value: + "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('test', `Test description2`, 'string') }}", + }, + ]); + }); }); it('should reset extra prop back to default when removed', async () => {