From 3788268b15e5ffcce433b820b3255f007a8b4d0c Mon Sep 17 00:00:00 2001 From: shortstacked Date: Mon, 18 Aug 2025 09:05:21 +0100 Subject: [PATCH] test: Migrate 6-code-node tests to Playwright (#18454) --- cypress/e2e/6-code-node.cy.ts | 209 ----------------- .../playwright/pages/NodeDisplayViewPage.ts | 102 +++++++++ .../playwright/tests/ui/6-code-node.spec.ts | 216 ++++++++++++++++++ 3 files changed, 318 insertions(+), 209 deletions(-) delete mode 100644 cypress/e2e/6-code-node.cy.ts create mode 100644 packages/testing/playwright/tests/ui/6-code-node.spec.ts diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts deleted file mode 100644 index 501af802a2..0000000000 --- a/cypress/e2e/6-code-node.cy.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { nanoid } from 'nanoid'; - -import { NDV } from '../pages/ndv'; -import { successToast } from '../pages/notifications'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; - -const WorkflowPage = new WorkflowPageClass(); -const ndv = new NDV(); - -const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible'); -const getEditor = () => getParameter().find('.cm-content').should('exist'); - -describe('Code node', () => { - describe('Code editor', () => { - beforeEach(() => { - WorkflowPage.actions.visit(); - WorkflowPage.actions.addInitialNodeToCanvas('Manual'); - WorkflowPage.actions.addNodeToCanvas('Code', true, true); - }); - - it('should show correct placeholders switching modes', () => { - cy.contains('// Loop over input items and add a new field').should('be.visible'); - - ndv.getters.parameterInput('mode').click(); - ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); - - cy.contains("// Add a new field called 'myNewField'").should('be.visible'); - - ndv.getters.parameterInput('mode').click(); - ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for All Items'); - cy.contains('// Loop over input items and add a new field').should('be.visible'); - }); - - it('should execute the placeholder successfully in both modes', () => { - ndv.actions.execute(); - - successToast().contains('Node executed successfully'); - ndv.getters.parameterInput('mode').click(); - ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); - - ndv.actions.execute(); - - successToast().contains('Node executed successfully'); - }); - - it('should allow switching between sibling code nodes', () => { - // Setup - getEditor().type('{selectall}').paste("console.log('code node 1')"); - ndv.actions.close(); - WorkflowPage.actions.addNodeToCanvas('Code', true, true); - getEditor().type('{selectall}').paste("console.log('code node 2')"); - ndv.actions.close(); - - WorkflowPage.actions.openNode('Code'); - ndv.actions.clickFloatingNode('Code1'); - getEditor().should('have.text', "console.log('code node 2')"); - - ndv.actions.clickFloatingNode('Code'); - getEditor().should('have.text', "console.log('code node 1')"); - }); - - it('should show lint errors in `runOnceForAllItems` mode', () => { - getEditor() - .type('{selectall}') - .paste(`$input.itemMatching() -$input.item -$('When clicking ‘Execute workflow’').item -$input.first(1) - -for (const item of $input.all()) { - item.foo -} - -return -`); - getParameter().get('.cm-lintRange-error').should('have.length', 6); - getParameter().contains('itemMatching').realHover(); - cy.get('.cm-tooltip-lint').should( - 'have.text', - '`.itemMatching()` expects an item index to be passed in as its argument.', - ); - }); - - it('should show lint errors in `runOnceForEachItem` mode', () => { - ndv.getters.parameterInput('mode').click(); - ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); - getEditor() - .type('{selectall}') - .paste(`$input.itemMatching() -$input.all() -$input.first() -$input.item() - -return [] -`); - - getParameter().get('.cm-lintRange-error').should('have.length.gte', 5); - getParameter().contains('all').realHover(); - cy.get('.cm-tooltip-lint').should( - 'have.text', - "Method `$input.all()` is only available in the 'Run Once for All Items' mode.", - ); - }); - }); - - describe('Ask AI', () => { - describe('Enabled', () => { - beforeEach(() => { - cy.enableFeature('askAi'); - WorkflowPage.actions.visit(); - - cy.window().then(() => { - WorkflowPage.actions.addInitialNodeToCanvas('Manual'); - WorkflowPage.actions.addNodeToCanvas('Code', true, true); - }); - }); - - it('tab should exist if experiment selected and be selectable', () => { - cy.getByTestId('code-node-tab-ai').should('exist'); - cy.get('#tab-ask-ai').click(); - cy.contains('Hey AI, generate JavaScript').should('exist'); - }); - - it('generate code button should have correct state & tooltips', () => { - cy.getByTestId('code-node-tab-ai').should('exist'); - cy.get('#tab-ask-ai').click(); - - cy.getByTestId('ask-ai-cta').should('be.disabled'); - cy.getByTestId('ask-ai-cta').realHover(); - cy.getByTestId('ask-ai-cta-tooltip-no-input-data').should('exist'); - ndv.actions.executePrevious(); - cy.getByTestId('ask-ai-cta').realHover(); - cy.getByTestId('ask-ai-cta-tooltip-no-prompt').should('exist'); - cy.getByTestId('ask-ai-prompt-input') - // Type random 14 character string - .type(nanoid(14)); - - cy.getByTestId('ask-ai-cta').realHover(); - cy.getByTestId('ask-ai-cta-tooltip-prompt-too-short').should('exist'); - - cy.getByTestId('ask-ai-prompt-input') - .clear() - // Type random 15 character string - .type(nanoid(15)); - cy.getByTestId('ask-ai-cta').should('be.enabled'); - - cy.getByTestId('ask-ai-prompt-counter').should('contain.text', '15 / 600'); - }); - - it('should send correct schema and replace code', () => { - const prompt = nanoid(20); - cy.get('#tab-ask-ai').click(); - ndv.actions.executePrevious(); - - cy.getByTestId('ask-ai-prompt-input').type(prompt); - - cy.intercept('POST', '/rest/ai/ask-ai', { - statusCode: 200, - body: { - data: { - code: 'console.log("Hello World")', - }, - }, - }).as('ask-ai'); - - cy.getByTestId('ask-ai-cta').click(); - const askAiReq = cy.wait('@ask-ai'); - - askAiReq.its('request.body').should('have.keys', ['question', 'context', 'forNode']); - askAiReq - .its('context') - .should('have.keys', ['schema', 'ndvPushRef', 'pushRef', 'inputSchema']); - - cy.contains('Code generation completed').should('be.visible'); - cy.getByTestId('code-node-tab-code').should('contain.text', 'console.log("Hello World")'); - cy.get('#tab-code').should('have.class', 'is-active'); - }); - - const handledCodes = [ - { code: 400, message: 'Code generation failed due to an unknown reason' }, - { code: 413, message: 'Your workflow data is too large for AI to process' }, - { code: 429, message: "We've hit our rate limit with our AI partner" }, - { - code: 500, - message: - 'Code generation failed with error: Request failed with status code 500. Try again in a few minutes', - }, - ]; - - handledCodes.forEach(({ code, message }) => { - it(`should show error based on status code ${code}`, () => { - const prompt = nanoid(20); - cy.get('#tab-ask-ai').click(); - ndv.actions.executePrevious(); - - cy.getByTestId('ask-ai-prompt-input').type(prompt); - - cy.intercept('POST', '/rest/ai/ask-ai', { - statusCode: code, - status: code, - }).as('ask-ai'); - - cy.getByTestId('ask-ai-cta').click(); - cy.contains(message).should('be.visible'); - }); - }); - }); - }); -}); diff --git a/packages/testing/playwright/pages/NodeDisplayViewPage.ts b/packages/testing/playwright/pages/NodeDisplayViewPage.ts index 4e80d641f6..dc0d0ef20e 100644 --- a/packages/testing/playwright/pages/NodeDisplayViewPage.ts +++ b/packages/testing/playwright/pages/NodeDisplayViewPage.ts @@ -47,4 +47,106 @@ export class NodeDisplayViewPage extends BasePage { getParameterExpressionPreviewValue() { return this.page.getByTestId('parameter-expression-preview-value'); } + + /** + * Get parameter input by name (for Code node and similar) + * @param parameterName - The name of the parameter e.g 'jsCode', 'mode' + */ + getParameterInput(parameterName: string) { + return this.page.getByTestId(`parameter-input-${parameterName}`); + } + + /** + * Select option in parameter dropdown + * @param parameterName - The parameter name + * @param optionText - The text of the option to select + */ + async selectOptionInParameterDropdown(parameterName: string, optionText: string) { + const dropdown = this.getParameterInput(parameterName); + await dropdown.click(); + await this.page.getByRole('option', { name: optionText }).click(); + } + + /** + * Click on a floating node in the NDV (for switching between connected nodes) + * @param nodeName - The name of the node to click + */ + async clickFloatingNode(nodeName: string) { + await this.page.locator(`[data-test-id="floating-node"][data-node-name="${nodeName}"]`).click(); + } + + /** + * Execute the previous node (useful for providing input data) + */ + async executePrevious() { + await this.clickByTestId('execute-previous-node'); + } + + async clickAskAiTab() { + await this.page.locator('#tab-ask-ai').click(); + } + + getAskAiTabPanel() { + return this.page.getByTestId('code-node-tab-ai'); + } + + getAskAiCtaButton() { + return this.page.getByTestId('ask-ai-cta'); + } + + getAskAiPromptInput() { + return this.page.getByTestId('ask-ai-prompt-input'); + } + + getAskAiPromptCounter() { + return this.page.getByTestId('ask-ai-prompt-counter'); + } + + getAskAiCtaTooltipNoInputData() { + return this.page.getByTestId('ask-ai-cta-tooltip-no-input-data'); + } + + getAskAiCtaTooltipNoPrompt() { + return this.page.getByTestId('ask-ai-cta-tooltip-no-prompt'); + } + + getAskAiCtaTooltipPromptTooShort() { + return this.page.getByTestId('ask-ai-cta-tooltip-prompt-too-short'); + } + + getCodeTabPanel() { + return this.page.getByTestId('code-node-tab-code'); + } + + getCodeTab() { + return this.page.locator('#tab-code'); + } + + getCodeEditor() { + return this.getParameterInput('jsCode').locator('.cm-content'); + } + + getLintErrors() { + return this.getParameterInput('jsCode').locator('.cm-lintRange-error'); + } + + getLintTooltip() { + return this.page.locator('.cm-tooltip-lint'); + } + + getPlaceholderText(text: string) { + return this.page.getByText(text); + } + + getHeyAiText() { + return this.page.locator('text=Hey AI, generate JavaScript'); + } + + getCodeGenerationCompletedText() { + return this.page.locator('text=Code generation completed'); + } + + getErrorMessageText(message: string) { + return this.page.locator(`text=${message}`); + } } diff --git a/packages/testing/playwright/tests/ui/6-code-node.spec.ts b/packages/testing/playwright/tests/ui/6-code-node.spec.ts new file mode 100644 index 0000000000..8d11303dd2 --- /dev/null +++ b/packages/testing/playwright/tests/ui/6-code-node.spec.ts @@ -0,0 +1,216 @@ +import { nanoid } from 'nanoid'; + +import { CODE_NODE_NAME, MANUAL_TRIGGER_NODE_NAME } from '../../config/constants'; +import { test, expect } from '../../fixtures/base'; + +test.describe('Code node', () => { + test.describe('Code editor', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.goHome(); + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.addNode(CODE_NODE_NAME); + }); + + test('should show correct placeholders switching modes', async ({ n8n }) => { + await expect( + n8n.ndv.getPlaceholderText('// Loop over input items and add a new field'), + ).toBeVisible(); + + await n8n.ndv.getParameterInput('mode').click(); + await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click(); + + await expect( + n8n.ndv.getPlaceholderText("// Add a new field called 'myNewField'"), + ).toBeVisible(); + + await n8n.ndv.getParameterInput('mode').click(); + await n8n.page.getByRole('option', { name: 'Run Once for All Items' }).click(); + + await expect( + n8n.ndv.getPlaceholderText('// Loop over input items and add a new field'), + ).toBeVisible(); + }); + + test('should execute the placeholder successfully in both modes', async ({ n8n }) => { + await n8n.ndv.execute(); + + await expect( + n8n.notifications.notificationContainerByText('Node executed successfully').first(), + ).toBeVisible(); + + await n8n.ndv.getParameterInput('mode').click(); + await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click(); + + await n8n.ndv.execute(); + + await expect( + n8n.notifications.notificationContainerByText('Node executed successfully').first(), + ).toBeVisible(); + }); + + test('should allow switching between sibling code nodes', async ({ n8n }) => { + await n8n.ndv.getCodeEditor().fill("console.log('code node 1')"); + await n8n.ndv.close(); + + await n8n.canvas.addNode(CODE_NODE_NAME); + + await n8n.ndv.getCodeEditor().fill("console.log('code node 2')"); + await n8n.ndv.close(); + + await n8n.canvas.openNode(CODE_NODE_NAME); + + await n8n.ndv.clickFloatingNode('Code1'); + await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 2')"); + + await n8n.ndv.clickFloatingNode('Code'); + await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 1')"); + }); + + test('should show lint errors in `runOnceForAllItems` mode', async ({ n8n }) => { + await n8n.ndv.getCodeEditor().fill(`$input.itemMatching() +$input.item +$('When clicking ‘Execute workflow’').item +$input.first(1) + +for (const item of $input.all()) { + item.foo +} + +return +`); + await expect(n8n.ndv.getLintErrors()).toHaveCount(6); + await n8n.ndv.getParameterInput('jsCode').getByText('itemMatching').hover(); + await expect(n8n.ndv.getLintTooltip()).toContainText( + '`.itemMatching()` expects an item index to be passed in as its argument.', + ); + }); + + test('should show lint errors in `runOnceForEachItem` mode', async ({ n8n }) => { + await n8n.ndv.getParameterInput('mode').click(); + await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click(); + + await n8n.ndv.getCodeEditor().fill(`$input.itemMatching() +$input.all() +$input.first() +$input.item() + +return [] +`); + await expect(n8n.ndv.getLintErrors()).toHaveCount(5); + await n8n.ndv.getParameterInput('jsCode').getByText('all').hover(); + await expect(n8n.ndv.getLintTooltip()).toContainText( + "Method `$input.all()` is only available in the 'Run Once for All Items' mode.", + ); + }); + }); + + test.describe('Ask AI', () => { + test.describe('Enabled', () => { + test.beforeEach(async ({ api, n8n }) => { + await api.enableFeature('askAi'); + await n8n.goHome(); + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.addNode(CODE_NODE_NAME); + }); + + test('tab should exist if experiment selected and be selectable', async ({ n8n }) => { + await n8n.ndv.clickAskAiTab(); + await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible(); + await expect(n8n.ndv.getHeyAiText()).toBeVisible(); + }); + + test('generate code button should have correct state & tooltips', async ({ n8n }) => { + await n8n.ndv.clickAskAiTab(); + await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible(); + + await expect(n8n.ndv.getAskAiCtaButton()).toBeDisabled(); + await n8n.ndv.getAskAiCtaButton().hover(); + await expect(n8n.ndv.getAskAiCtaTooltipNoInputData()).toBeVisible(); + + await n8n.ndv.executePrevious(); + await n8n.ndv.getAskAiCtaButton().hover(); + await expect(n8n.ndv.getAskAiCtaTooltipNoPrompt()).toBeVisible(); + + await n8n.ndv.getAskAiPromptInput().fill(nanoid(14)); + + await n8n.ndv.getAskAiCtaButton().hover(); + await expect(n8n.ndv.getAskAiCtaTooltipPromptTooShort()).toBeVisible(); + + await n8n.ndv.getAskAiPromptInput().fill(nanoid(15)); + await expect(n8n.ndv.getAskAiCtaButton()).toBeEnabled(); + + await expect(n8n.ndv.getAskAiPromptCounter()).toContainText('15 / 600'); + }); + + test('should send correct schema and replace code', async ({ n8n }) => { + const prompt = nanoid(20); + await n8n.ndv.clickAskAiTab(); + await n8n.ndv.executePrevious(); + + await n8n.ndv.getAskAiPromptInput().fill(prompt); + + await n8n.page.route('**/rest/ai/ask-ai', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + code: 'console.log("Hello World")', + }, + }), + }); + }); + + const [request] = await Promise.all([ + n8n.page.waitForRequest('**/rest/ai/ask-ai'), + n8n.ndv.getAskAiCtaButton().click(), + ]); + + const requestBody = request.postDataJSON(); + expect(requestBody).toHaveProperty('question'); + expect(requestBody).toHaveProperty('context'); + expect(requestBody).toHaveProperty('forNode'); + expect(requestBody.context).toHaveProperty('schema'); + expect(requestBody.context).toHaveProperty('ndvPushRef'); + expect(requestBody.context).toHaveProperty('pushRef'); + expect(requestBody.context).toHaveProperty('inputSchema'); + + await expect(n8n.ndv.getCodeGenerationCompletedText()).toBeVisible(); + await expect(n8n.ndv.getCodeTabPanel()).toContainText('console.log("Hello World")'); + await expect(n8n.ndv.getCodeTab()).toHaveClass(/is-active/); + }); + + const handledCodes = [ + { code: 400, message: 'Code generation failed due to an unknown reason' }, + { code: 413, message: 'Your workflow data is too large for AI to process' }, + { code: 429, message: "We've hit our rate limit with our AI partner" }, + { + code: 500, + message: + 'Code generation failed with error: Request failed with status code 500. Try again in a few minutes', + }, + ]; + + handledCodes.forEach(({ code, message }) => { + test(`should show error based on status code ${code}`, async ({ n8n }) => { + const prompt = nanoid(20); + await n8n.ndv.clickAskAiTab(); + await n8n.ndv.executePrevious(); + + await n8n.ndv.getAskAiPromptInput().fill(prompt); + + await n8n.page.route('**/rest/ai/ask-ai', async (route) => { + await route.fulfill({ + status: code, + }); + }); + + await n8n.ndv.getAskAiCtaButton().click(); + await expect(n8n.ndv.getErrorMessageText(message)).toBeVisible(); + }); + }); + }); + }); +});