From 7c80086a6bfa47f4fe4449354398238667443dad Mon Sep 17 00:00:00 2001 From: shortstacked Date: Mon, 18 Aug 2025 12:28:32 +0100 Subject: [PATCH] test: Migrate data pinning tests from Cypress to Playwright (#18462) --- cypress/e2e/13-pinning.cy.ts | 210 ---------------- .../testing/playwright/pages/CanvasPage.ts | 13 + .../playwright/pages/NodeDisplayViewPage.ts | 126 ++++++++++ .../playwright/pages/NotificationsPage.ts | 54 +++- .../playwright/tests/ui/1-workflows.spec.ts | 20 +- .../tests/ui/12-canvas-actions.spec.ts | 2 +- .../playwright/tests/ui/13-pinning.spec.ts | 234 ++++++++++++++++++ .../playwright/tests/ui/6-code-node.spec.ts | 4 +- .../testing/playwright/tests/ui/pdf.spec.ts | 2 +- .../tests/ui/security-notifications.spec.ts | 18 +- .../workflows/Pinned_webhook_node.json | 36 +++ .../Test_workflow_webhook_with_pin_data.json | 136 ++++++++++ 12 files changed, 603 insertions(+), 252 deletions(-) delete mode 100644 cypress/e2e/13-pinning.cy.ts create mode 100644 packages/testing/playwright/tests/ui/13-pinning.spec.ts create mode 100644 packages/testing/playwright/workflows/Pinned_webhook_node.json create mode 100644 packages/testing/playwright/workflows/Test_workflow_webhook_with_pin_data.json diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts deleted file mode 100644 index d24b2fe15e..0000000000 --- a/cypress/e2e/13-pinning.cy.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - HTTP_REQUEST_NODE_NAME, - MANUAL_TRIGGER_NODE_NAME, - PIPEDRIVE_NODE_NAME, - EDIT_FIELDS_SET_NODE_NAME, - BACKEND_BASE_URL, -} from '../constants'; -import { WorkflowPage, NDV } from '../pages'; -import { errorToast } from '../pages/notifications'; -import { getVisiblePopper } from '../utils'; - -const workflowPage = new WorkflowPage(); -const ndv = new NDV(); - -describe('Data pinning', () => { - const maxPinnedDataSize = 16384; - - beforeEach(() => { - workflowPage.actions.visit(); - cy.window().then((win) => { - win.maxPinnedDataSize = maxPinnedDataSize; - }); - }); - - it('Should be able to pin node output', () => { - workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true }); - ndv.getters.container().should('be.visible'); - ndv.getters.pinDataButton().should('not.exist'); - ndv.getters.editPinnedDataButton().should('be.visible'); - - ndv.actions.execute(); - - ndv.getters.outputDataContainer().should('be.visible'); - // We hover over the table to get rid of the pinning tooltip which would overlay the table - // slightly and cause the test to fail - ndv.getters.outputDataContainer().get('table').realHover().should('be.visible'); - ndv.getters.outputTableRows().should('have.length', 2); - ndv.getters.outputTableHeaders().should('have.length.at.least', 10); - ndv.getters.outputTableHeaders().first().should('include.text', 'timestamp'); - ndv.getters.outputTableHeaders().eq(1).should('include.text', 'Readable date'); - - ndv.getters - .outputTbodyCell(1, 0) - .invoke('text') - .then((prevValue) => { - ndv.actions.pinData(); - ndv.actions.close(); - - workflowPage.actions.executeWorkflow(); - workflowPage.actions.openNode('Schedule Trigger'); - - ndv.getters.outputTbodyCell(1, 0).invoke('text').should('eq', prevValue); - }); - }); - - it('Should be able to set pinned data', () => { - workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true }); - ndv.getters.container().should('be.visible'); - ndv.getters.pinDataButton().should('not.exist'); - ndv.getters.editPinnedDataButton().should('be.visible'); - - ndv.actions.setPinnedData([{ test: 1 }]); - - ndv.getters.outputTableRows().should('have.length', 2); - ndv.getters.outputTableHeaders().should('have.length', 2); - ndv.getters.outputTableHeaders().first().should('include.text', 'test'); - ndv.getters.outputTbodyCell(1, 0).should('include.text', 1); - - ndv.actions.close(); - - workflowPage.actions.saveWorkflowOnButtonClick(); - - workflowPage.actions.openNode('Schedule Trigger'); - - ndv.getters.outputTableHeaders().first().should('include.text', 'test'); - ndv.getters.outputTbodyCell(1, 0).should('include.text', 1); - }); - - it('should display pin data edit button for Webhook node', () => { - workflowPage.actions.addInitialNodeToCanvas('Webhook', { keepNdvOpen: true }); - - ndv.getters - .runDataPaneHeader() - .find('button') - .filter(':visible') - .should('have.attr', 'title', 'Edit Output'); - }); - - it('Should be duplicating pin data when duplicating node', () => { - workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); - ndv.getters.container().should('be.visible'); - ndv.getters.pinDataButton().should('not.exist'); - ndv.getters.editPinnedDataButton().should('be.visible'); - - ndv.actions.setPinnedData([{ test: 1 }]); - ndv.actions.close(); - - workflowPage.actions.duplicateNode(EDIT_FIELDS_SET_NODE_NAME); - - workflowPage.actions.saveWorkflowOnButtonClick(); - - workflowPage.actions.openNode('Edit Fields1'); - - ndv.getters.outputTableHeaders().first().should('include.text', 'test'); - ndv.getters.outputTbodyCell(1, 0).should('include.text', 1); - }); - - it('Should show an error when maximum pin data size is exceeded', () => { - workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); - ndv.getters.container().should('be.visible'); - ndv.getters.pinDataButton().should('not.exist'); - ndv.getters.editPinnedDataButton().should('be.visible'); - - ndv.actions.pastePinnedData([ - { - test: '1'.repeat(maxPinnedDataSize), - }, - ]); - errorToast().should('contain', 'Workflow has reached the maximum allowed pinned data size'); - }); - - it('Should show an error when pin data JSON in invalid', () => { - workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); - ndv.getters.container().should('be.visible'); - ndv.getters.pinDataButton().should('not.exist'); - ndv.getters.editPinnedDataButton().should('be.visible'); - - ndv.actions.setPinnedData('[ { "name": "First item", "code": 2dsa }]'); - errorToast().should('contain', 'Unable to save due to invalid JSON'); - }); - - it('Should be able to reference paired items in a node located before pinned data', () => { - workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true); - ndv.actions.setPinnedData([{ http: 123 }]); - ndv.actions.close(); - - workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true); - ndv.actions.setPinnedData(Array(3).fill({ pipedrive: 123 })); - ndv.actions.close(); - - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); - - setExpressionOnStringValueInSet(`{{ $('${HTTP_REQUEST_NODE_NAME}').item`); - - const output = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]'; - - cy.get('div').contains(output).should('be.visible'); - }); - - it('should use pin data in manual executions that are started by a webhook', () => { - cy.createFixtureWorkflow('Test_workflow_webhook_with_pin_data.json', 'Test'); - - workflowPage.actions.executeWorkflow(); - - cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/b0d79ddb-df2d-49b1-8555-9fa2b482608f`).then( - (response) => { - expect(response.status).to.eq(200); - }, - ); - - workflowPage.actions.openNode('End'); - - ndv.getters.outputTableRow(1).should('exist'); - ndv.getters.outputTableRow(1).should('have.text', 'pin-overwritten'); - }); - - it('should not use pin data in production executions that are started by a webhook', () => { - cy.createFixtureWorkflow('Test_workflow_webhook_with_pin_data.json', 'Test'); - - workflowPage.actions.activateWorkflow(); - cy.request('GET', `${BACKEND_BASE_URL}/webhook/b0d79ddb-df2d-49b1-8555-9fa2b482608f`).then( - (response) => { - expect(response.status).to.eq(200); - // Assert that we get the data hard coded in the edit fields node, - // instead of the data pinned in said node. - expect(response.body).to.deep.equal({ - nodeData: 'pin', - }); - }, - ); - }); - - it('should not show pinned data tooltip', () => { - cy.createFixtureWorkflow('Pinned_webhook_node.json', 'Test'); - workflowPage.actions.executeWorkflow(); - - // hide other visible popper on workflow execute button - workflowPage.getters.canvasNodes().eq(0).click(); - - getVisiblePopper().should('have.length', 0); - }); -}); - -function setExpressionOnStringValueInSet(expression: string) { - cy.get('button').contains('Execute step').click(); - - ndv.getters.assignmentCollectionAdd('assignments').click(); - ndv.getters.assignmentValue('assignments').contains('Expression').invoke('show').click(); - - ndv.getters - .inlineExpressionEditorInput() - .clear() - .type(expression, { parseSpecialCharSequences: false }) - // hide autocomplete - .type('{esc}'); -} diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index 2beebfc7d7..f291228f5b 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -37,6 +37,10 @@ export class CanvasPage extends BasePage { await this.clickByTestId('canvas-plus-button'); } + getCanvasNodes() { + return this.page.getByTestId('canvas-node'); + } + async clickNodeCreatorPlusButton(): Promise { await this.clickByTestId('node-creator-plus-button'); } @@ -81,6 +85,10 @@ export class CanvasPage extends BasePage { await this.clickSaveWorkflowButton(); } + getExecuteWorkflowButton(): Locator { + return this.page.getByTestId('execute-workflow-button'); + } + async clickExecuteWorkflowButton(): Promise { await this.page.getByTestId('execute-workflow-button').click(); } @@ -256,6 +264,11 @@ export class CanvasPage extends BasePage { await this.getProductionChecklistActionItem(actionText).click(); } + async duplicateNode(nodeName: string): Promise { + await this.nodeByName(nodeName).click({ button: 'right' }); + await this.page.getByTestId('context-menu').getByText('Duplicate').click(); + } + getCanvasNodes(): Locator { return this.page.getByTestId('canvas-node'); } diff --git a/packages/testing/playwright/pages/NodeDisplayViewPage.ts b/packages/testing/playwright/pages/NodeDisplayViewPage.ts index dc0d0ef20e..b62374f5a9 100644 --- a/packages/testing/playwright/pages/NodeDisplayViewPage.ts +++ b/packages/testing/playwright/pages/NodeDisplayViewPage.ts @@ -44,10 +44,136 @@ export class NodeDisplayViewPage extends BasePage { return this.page.getByTestId('output-panel'); } + getContainer() { + return this.page.getByTestId('ndv'); + } + getParameterExpressionPreviewValue() { return this.page.getByTestId('parameter-expression-preview-value'); } + getEditPinnedDataButton() { + return this.page.getByTestId('ndv-edit-pinned-data'); + } + + getPinDataButton() { + return this.getOutputPanel().getByTestId('ndv-pin-data'); + } + + getRunDataPaneHeader() { + return this.page.getByTestId('run-data-pane-header'); + } + + getOutputTable() { + return this.getOutputPanel().getByTestId('ndv-data-container').locator('table'); + } + + getOutputDataContainer() { + return this.getOutputPanel().getByTestId('ndv-data-container'); + } + + getOutputTableRows() { + return this.getOutputTable().locator('tr'); + } + + getOutputTableHeaders() { + return this.getOutputTable().locator('thead th'); + } + + getOutputTableRow(row: number) { + return this.getOutputTableRows().nth(row); + } + + getOutputTableCell(row: number, col: number) { + return this.getOutputTableRow(row).locator('td').nth(col); + } + + getOutputTbodyCell(row: number, col: number) { + return this.getOutputTableRow(row).locator('td').nth(col); + } + + // Pin data operations + async setPinnedData(data: object | string) { + const pinnedData = typeof data === 'string' ? data : JSON.stringify(data); + await this.getEditPinnedDataButton().click(); + + // Wait for editor to appear and use broader selector + const editor = this.getOutputPanel().locator('[contenteditable="true"]'); + await editor.waitFor(); + await editor.click(); + await editor.fill(pinnedData); + + await this.savePinnedData(); + } + + async pastePinnedData(data: object) { + await this.getEditPinnedDataButton().click(); + + const editor = this.getOutputPanel().locator('[contenteditable="true"]'); + await editor.waitFor(); + await editor.click(); + await editor.fill(''); + + // Set clipboard data and paste + await this.page.evaluate(async (jsonData) => { + await navigator.clipboard.writeText(JSON.stringify(jsonData)); + }, data); + await this.page.keyboard.press('ControlOrMeta+V'); + + await this.savePinnedData(); + } + + async savePinnedData() { + await this.getRunDataPaneHeader().locator('button:visible').filter({ hasText: 'Save' }).click(); + } + + // Assignment collection methods for advanced tests + getAssignmentCollectionAdd(paramName: string) { + return this.page + .getByTestId(`assignment-collection-${paramName}`) + .getByTestId('assignment-collection-drop-area'); + } + + getAssignmentValue(paramName: string) { + return this.page + .getByTestId(`assignment-collection-${paramName}`) + .getByTestId('assignment-value'); + } + + getInlineExpressionEditorInput() { + return this.page.getByTestId('inline-expression-editor-input'); + } + + getNodeParameters() { + return this.page.getByTestId('node-parameters'); + } + + getParameterInputHint() { + return this.page.getByTestId('parameter-input-hint'); + } + + async makeWebhookRequest(path: string) { + return await this.page.request.get(path); + } + + getVisiblePoppers() { + return this.page.locator('.el-popper:visible'); + } + + async clearExpressionEditor() { + const editor = this.getInlineExpressionEditorInput(); + await editor.click(); + await this.page.keyboard.press('ControlOrMeta+A'); + await this.page.keyboard.press('Delete'); + } + + async typeInExpressionEditor(text: string) { + const editor = this.getInlineExpressionEditorInput(); + await editor.click(); + // We have to use type() instead of fill() because the editor is a CodeMirror editor + await editor.type(text); + } + /** * Get parameter input by name (for Code node and similar) * @param parameterName - The name of the parameter e.g 'jsCode', 'mode' diff --git a/packages/testing/playwright/pages/NotificationsPage.ts b/packages/testing/playwright/pages/NotificationsPage.ts index ecac374778..3fe609783e 100644 --- a/packages/testing/playwright/pages/NotificationsPage.ts +++ b/packages/testing/playwright/pages/NotificationsPage.ts @@ -8,16 +8,52 @@ export class NotificationsPage { } /** - * Gets the main container locator for a notification by its visible text. + * Gets the main container locator for a notification by searching in its title text. * @param text The text or a regular expression to find within the notification's title. * @returns A Locator for the notification container element. */ - notificationContainerByText(text: string | RegExp): Locator { + getNotificationByTitle(text: string | RegExp): Locator { return this.page.getByRole('alert').filter({ has: this.page.locator('.el-notification__title').filter({ hasText: text }), }); } + /** + * Gets the main container locator for a notification by searching in its content/body text. + * This is useful for finding notifications where the detailed message is in the content + * rather than the title (e.g., error messages with detailed descriptions). + * @param text The text or a regular expression to find within the notification's content. + * @returns A Locator for the notification container element. + */ + getNotificationByContent(text: string | RegExp): Locator { + return this.page.getByRole('alert').filter({ + has: this.page.locator('.el-notification__content').filter({ hasText: text }), + }); + } + + /** + * Gets the main container locator for a notification by searching in both title and content. + * This is the most flexible method as it will find notifications regardless of whether + * the text appears in the title or content section. + * @param text The text or a regular expression to find within the notification's title or content. + * @returns A Locator for the notification container element. + */ + getNotificationByTitleOrContent(text: string | RegExp): Locator { + return this.page.getByRole('alert').filter({ hasText: text }); + } + + /** + * Gets the main container locator for a notification by searching in both title and content, + * filtered to a specific node name. This is useful when multiple notifications might be present + * and you want to ensure you're checking the right one for a specific node. + * @param text The text or a regular expression to find within the notification's title or content. + * @param nodeName The name of the node to filter notifications for. + * @returns A Locator for the notification container element. + */ + getNotificationByTitleOrContentForNode(text: string | RegExp, nodeName: string): Locator { + return this.page.getByRole('alert').filter({ hasText: text }).filter({ hasText: nodeName }); + } + /** * Clicks the close button on the FIRST notification matching the text. * Fast execution with short timeouts for snappy notifications. @@ -31,7 +67,7 @@ export class NotificationsPage { const { timeout = 2000 } = options; try { - const notification = this.notificationContainerByText(text).first(); + const notification = this.getNotificationByTitle(text).first(); await notification.waitFor({ state: 'visible', timeout }); const closeBtn = notification.locator('.el-notification__closeBtn'); @@ -61,7 +97,7 @@ export class NotificationsPage { while (retries < maxRetries) { try { - const notifications = this.notificationContainerByText(text); + const notifications = this.getNotificationByTitle(text); const count = await notifications.count(); if (count === 0) { @@ -105,7 +141,7 @@ export class NotificationsPage { const { timeout = 500 } = options; try { - const notification = this.notificationContainerByText(text).first(); + const notification = this.getNotificationByTitle(text).first(); await notification.waitFor({ state: 'visible', timeout }); return true; } catch { @@ -126,7 +162,7 @@ export class NotificationsPage { const { timeout = 5000 } = options; try { - const notification = this.notificationContainerByText(text).first(); + const notification = this.getNotificationByTitle(text).first(); await notification.waitFor({ state: 'visible', timeout }); return true; } catch { @@ -186,9 +222,7 @@ export class NotificationsPage { */ async getNotificationCount(text?: string | RegExp): Promise { try { - const notifications = text - ? this.notificationContainerByText(text) - : this.page.getByRole('alert'); + const notifications = text ? this.getNotificationByTitle(text) : this.page.getByRole('alert'); return await notifications.count(); } catch { return 0; @@ -202,7 +236,7 @@ export class NotificationsPage { */ async quickClose(text: string | RegExp): Promise { try { - const notification = this.notificationContainerByText(text).first(); + const notification = this.getNotificationByTitle(text).first(); if (await notification.isVisible({ timeout: 100 })) { await notification.locator('.el-notification__closeBtn').click({ timeout: 200 }); } diff --git a/packages/testing/playwright/tests/ui/1-workflows.spec.ts b/packages/testing/playwright/tests/ui/1-workflows.spec.ts index e9fac563f7..d4b19543b9 100644 --- a/packages/testing/playwright/tests/ui/1-workflows.spec.ts +++ b/packages/testing/playwright/tests/ui/1-workflows.spec.ts @@ -29,9 +29,7 @@ test.describe('Workflows', () => { await n8n.canvas.setWorkflowName(workflowName); await n8n.canvas.clickSaveWorkflowButton(); - await expect( - n8n.notifications.notificationContainerByText(NOTIFICATIONS.CREATED), - ).toBeVisible(); + await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.CREATED)).toBeVisible(); }); test('should search for workflows', async ({ n8n }) => { @@ -72,18 +70,14 @@ test.describe('Workflows', () => { const workflow = n8n.workflows.getWorkflowByName(workflowName); await n8n.workflows.archiveWorkflow(workflow); - await expect( - n8n.notifications.notificationContainerByText(NOTIFICATIONS.ARCHIVED), - ).toBeVisible(); + await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.ARCHIVED)).toBeVisible(); await expect(workflow).toBeHidden(); await n8n.workflows.toggleShowArchived(); await expect(workflow).toBeVisible(); await n8n.workflows.unarchiveWorkflow(workflow); - await expect( - n8n.notifications.notificationContainerByText(NOTIFICATIONS.UNARCHIVED), - ).toBeVisible(); + await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.UNARCHIVED)).toBeVisible(); }); test('should delete an archived workflow', async ({ n8n }) => { @@ -95,16 +89,12 @@ test.describe('Workflows', () => { const workflow = n8n.workflows.getWorkflowByName(workflowName); await n8n.workflows.archiveWorkflow(workflow); - await expect( - n8n.notifications.notificationContainerByText(NOTIFICATIONS.ARCHIVED), - ).toBeVisible(); + await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.ARCHIVED)).toBeVisible(); await n8n.workflows.toggleShowArchived(); await n8n.workflows.deleteWorkflow(workflow); - await expect( - n8n.notifications.notificationContainerByText(NOTIFICATIONS.DELETED), - ).toBeVisible(); + await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.DELETED)).toBeVisible(); await expect(workflow).toBeHidden(); }); diff --git a/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts b/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts index a90888ccc4..3921a1d2ec 100644 --- a/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts +++ b/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts @@ -109,7 +109,7 @@ test.describe('Canvas Actions', () => { await n8n.canvas.executeNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME); await expect( - n8n.notifications.notificationContainerByText('Node executed successfully'), + n8n.notifications.getNotificationByTitle('Node executed successfully'), ).toHaveCount(1); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); }); diff --git a/packages/testing/playwright/tests/ui/13-pinning.spec.ts b/packages/testing/playwright/tests/ui/13-pinning.spec.ts new file mode 100644 index 0000000000..7ef9bdcd12 --- /dev/null +++ b/packages/testing/playwright/tests/ui/13-pinning.spec.ts @@ -0,0 +1,234 @@ +import { test, expect } from '../../fixtures/base'; +import type { TestRequirements } from '../../Types'; + +const NODES = { + MANUAL_TRIGGER: 'Manual Trigger', + SCHEDULE_TRIGGER: 'Schedule Trigger', + WEBHOOK: 'Webhook', + HTTP_REQUEST: 'HTTP Request', + PIPEDRIVE: 'Pipedrive', + EDIT_FIELDS: 'Edit Fields (Set)', // Use the full node name that appears in the Node List, although when it's added to the canvas it's called "Edit Fields" + CODE: 'Code', + END: 'End', +}; + +const webhookTestRequirements: TestRequirements = { + workflow: { + 'Test_workflow_webhook_with_pin_data.json': 'Test', + }, +}; + +const pinnedWebhookRequirements: TestRequirements = { + workflow: { + 'Pinned_webhook_node.json': 'Test', + }, +}; + +test.describe('Data pinning', () => { + const maxPinnedDataSize = 16384; + + test.beforeEach(async ({ n8n }) => { + await n8n.goHome(); + }); + + test.describe('Pin data operations', () => { + test('should be able to pin node output', async ({ n8n }) => { + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER); + + await n8n.ndv.execute(); + await expect(n8n.ndv.getOutputPanel()).toBeVisible(); + + const prevValue = await n8n.ndv.getOutputTbodyCell(1, 0).textContent(); + + await n8n.ndv.togglePinData(); + await n8n.ndv.close(); + + // Execute workflow and verify pinned data persists + await n8n.canvas.clickExecuteWorkflowButton(); + await n8n.canvas.openNode(NODES.SCHEDULE_TRIGGER); + + await expect(n8n.ndv.getOutputTbodyCell(1, 0)).toHaveText(prevValue ?? ''); + }); + + test('should be able to set custom pinned data', async ({ n8n }) => { + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER); + + await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible(); + await expect(n8n.ndv.getPinDataButton()).toBeHidden(); + + await n8n.ndv.setPinnedData([{ test: 1 }]); + + await expect(n8n.ndv.getOutputTableRows()).toHaveCount(2); + await expect(n8n.ndv.getOutputTableHeaders()).toHaveCount(2); + await expect(n8n.ndv.getOutputTableHeaders().first()).toContainText('test'); + await expect(n8n.ndv.getOutputTbodyCell(1, 0)).toContainText('1'); + + await n8n.ndv.close(); + await n8n.canvas.clickSaveWorkflowButton(); + await n8n.canvas.openNode(NODES.SCHEDULE_TRIGGER); + + await expect(n8n.ndv.getOutputTableHeaders().first()).toContainText('test'); + await expect(n8n.ndv.getOutputTbodyCell(1, 0)).toContainText('1'); + }); + + test('should display pin data edit button for Webhook node', async ({ n8n }) => { + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.WEBHOOK); + + const runDataHeader = n8n.ndv.getRunDataPaneHeader(); + const editButton = runDataHeader.getByRole('button', { name: 'Edit Output' }); + await expect(editButton).toBeVisible(); + }); + + test('should duplicate pinned data when duplicating node', async ({ n8n }) => { + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER); + await n8n.ndv.close(); + + await n8n.canvas.addNode(NODES.EDIT_FIELDS); + + await expect(n8n.ndv.getContainer()).toBeVisible(); + + await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible(); + await expect(n8n.ndv.getPinDataButton()).toBeHidden(); + + await n8n.ndv.setPinnedData([{ test: 1 }]); + await n8n.ndv.close(); + + await n8n.canvas.duplicateNode('Edit Fields'); + await n8n.canvas.clickSaveWorkflowButton(); + await n8n.canvas.openNode('Edit Fields1'); + + await expect(n8n.ndv.getOutputTableHeaders().first()).toContainText('test'); + await expect(n8n.ndv.getOutputTbodyCell(1, 0)).toContainText('1'); + }); + }); + + test.describe('Error handling', () => { + test('should show error when maximum pin data size is exceeded', async ({ n8n }) => { + await n8n.page.evaluate((maxSize) => { + (window as { maxPinnedDataSize?: number }).maxPinnedDataSize = maxSize; + }, maxPinnedDataSize); + + const actualMaxSize = await n8n.page.evaluate(() => { + return (window as { maxPinnedDataSize?: number }).maxPinnedDataSize; + }); + expect(actualMaxSize).toBe(maxPinnedDataSize); + + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER); + await n8n.ndv.close(); + + await n8n.canvas.addNode(NODES.EDIT_FIELDS); + + await expect(n8n.ndv.getContainer()).toBeVisible(); + await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible(); + await expect(n8n.ndv.getPinDataButton()).toBeHidden(); + + const largeData = [{ test: '1'.repeat(maxPinnedDataSize + 1000) }]; + await n8n.ndv.setPinnedData(largeData); + + await expect( + n8n.notifications.getNotificationByContent( + 'Workflow has reached the maximum allowed pinned data size', + ), + ).toBeVisible(); + }); + + test('should show error when pin data JSON is invalid', async ({ n8n }) => { + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER); + await n8n.ndv.close(); + + await n8n.canvas.addNode(NODES.EDIT_FIELDS); + + await expect(n8n.ndv.getContainer()).toBeVisible(); + await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible(); + await expect(n8n.ndv.getPinDataButton()).toBeHidden(); + + await n8n.ndv.setPinnedData('[ { "name": "First item", "code": 2dsa }]'); + + await expect( + n8n.notifications.getNotificationByTitle('Unable to save due to invalid JSON'), + ).toBeVisible(); + }); + }); + + test.describe('Advanced pinning scenarios', () => { + test('should be able to reference paired items in node before pinned data', async ({ n8n }) => { + await n8n.workflows.clickAddWorkflowButton(); + await n8n.canvas.addNode(NODES.MANUAL_TRIGGER); + + await n8n.canvas.addNode(NODES.HTTP_REQUEST); + await n8n.ndv.setPinnedData([{ http: 123 }]); + await n8n.ndv.close(); + + await n8n.canvas.addNodeWithSubItem(NODES.PIPEDRIVE, 'Create an activity'); + await n8n.ndv.setPinnedData(Array(3).fill({ pipedrive: 123 })); + await n8n.ndv.close(); + + await n8n.canvas.addNode(NODES.EDIT_FIELDS); + + await n8n.ndv.execute(); + + await expect(n8n.ndv.getNodeParameters()).toBeVisible(); + + await expect(n8n.ndv.getAssignmentCollectionAdd('assignments')).toBeVisible(); + await n8n.ndv.getAssignmentCollectionAdd('assignments').click(); + await n8n.ndv.getAssignmentValue('assignments').getByText('Expression').click(); + + const expressionInput = n8n.ndv.getInlineExpressionEditorInput(); + await expressionInput.click(); + await n8n.ndv.clearExpressionEditor(); + await n8n.ndv.typeInExpressionEditor(`{{ $('${NODES.HTTP_REQUEST}').item`); + await n8n.page.keyboard.press('Escape'); + + const expectedOutput = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]'; + await expect(n8n.ndv.getParameterInputHint().getByText(expectedOutput)).toBeVisible(); + }); + + test('should use pin data in manual webhook executions', async ({ n8n, setupRequirements }) => { + await setupRequirements(webhookTestRequirements); + await n8n.canvas.clickExecuteWorkflowButton(); + await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText( + 'Waiting for trigger event from Webhook', + ); + + const webhookPath = '/webhook-test/b0d79ddb-df2d-49b1-8555-9fa2b482608f'; + const response = await n8n.ndv.makeWebhookRequest(webhookPath); + expect(response.status()).toBe(200); + + await n8n.canvas.openNode(NODES.END); + + await expect(n8n.ndv.getOutputTableRow(1)).toBeVisible(); + await expect(n8n.ndv.getOutputTableRow(1)).toContainText('pin-overwritten'); + }); + + test('should not use pin data in production webhook executions', async ({ + n8n, + setupRequirements, + }) => { + await setupRequirements(webhookTestRequirements); + await n8n.canvas.activateWorkflow(); + + const webhookUrl = '/webhook/b0d79ddb-df2d-49b1-8555-9fa2b482608f'; + const response = await n8n.ndv.makeWebhookRequest(webhookUrl); + expect(response.status()).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ nodeData: 'pin' }); + }); + + test('should not show pinned data tooltip', async ({ n8n, setupRequirements }) => { + await setupRequirements(pinnedWebhookRequirements); + await n8n.canvas.clickExecuteWorkflowButton(); + + await n8n.canvas.getCanvasNodes().first().click(); + + const poppers = n8n.ndv.getVisiblePoppers(); + await expect(poppers).toHaveCount(0); + }); + }); +}); diff --git a/packages/testing/playwright/tests/ui/6-code-node.spec.ts b/packages/testing/playwright/tests/ui/6-code-node.spec.ts index 8d11303dd2..ba16e8a95b 100644 --- a/packages/testing/playwright/tests/ui/6-code-node.spec.ts +++ b/packages/testing/playwright/tests/ui/6-code-node.spec.ts @@ -36,7 +36,7 @@ test.describe('Code node', () => { await n8n.ndv.execute(); await expect( - n8n.notifications.notificationContainerByText('Node executed successfully').first(), + n8n.notifications.getNotificationByTitle('Node executed successfully').first(), ).toBeVisible(); await n8n.ndv.getParameterInput('mode').click(); @@ -45,7 +45,7 @@ test.describe('Code node', () => { await n8n.ndv.execute(); await expect( - n8n.notifications.notificationContainerByText('Node executed successfully').first(), + n8n.notifications.getNotificationByTitle('Node executed successfully').first(), ).toBeVisible(); }); diff --git a/packages/testing/playwright/tests/ui/pdf.spec.ts b/packages/testing/playwright/tests/ui/pdf.spec.ts index 221dcbd321..924d2de7c9 100644 --- a/packages/testing/playwright/tests/ui/pdf.spec.ts +++ b/packages/testing/playwright/tests/ui/pdf.spec.ts @@ -7,7 +7,7 @@ test.describe('PDF Test', () => { await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow'); await n8n.canvas.clickExecuteWorkflowButton(); await expect( - n8n.notifications.notificationContainerByText('Workflow executed successfully'), + n8n.notifications.getNotificationByTitle('Workflow executed successfully'), ).toBeVisible(); }); }); diff --git a/packages/testing/playwright/tests/ui/security-notifications.spec.ts b/packages/testing/playwright/tests/ui/security-notifications.spec.ts index 43130a7f82..a2fe00c7cd 100644 --- a/packages/testing/playwright/tests/ui/security-notifications.spec.ts +++ b/packages/testing/playwright/tests/ui/security-notifications.spec.ts @@ -129,9 +129,7 @@ test.describe('Security Notifications', () => { await n8n.goHome(); // Verify security notification appears with default message - const notification = n8n.notifications.notificationContainerByText( - 'Critical update available', - ); + const notification = n8n.notifications.getNotificationByTitle('Critical update available'); await expect(notification).toBeVisible(); await expect(notification).toContainText('Please update to latest version.'); await expect(notification).toContainText('More info'); @@ -153,7 +151,7 @@ test.describe('Security Notifications', () => { await n8n.goHome(); // Verify notification shows specific fix version (dynamically generated) - const notificationWithFixVersion = n8n.notifications.notificationContainerByText( + const notificationWithFixVersion = n8n.notifications.getNotificationByTitle( 'Critical update available', ); await expect(notificationWithFixVersion).toBeVisible(); @@ -174,9 +172,7 @@ test.describe('Security Notifications', () => { await n8n.goHome(); // Wait for and click the security notification - const notification = n8n.notifications.notificationContainerByText( - 'Critical update available', - ); + const notification = n8n.notifications.getNotificationByTitle('Critical update available'); await expect(notification).toBeVisible(); await notification.click(); @@ -199,9 +195,7 @@ test.describe('Security Notifications', () => { await n8n.goHome(); // Verify no security notification appears when no security issue - const notification = n8n.notifications.notificationContainerByText( - 'Critical update available', - ); + const notification = n8n.notifications.getNotificationByTitle('Critical update available'); await expect(notification).toBeHidden(); }); @@ -212,9 +206,7 @@ test.describe('Security Notifications', () => { await n8n.goHome(); // Verify no security notification appears on API failure - const notification = n8n.notifications.notificationContainerByText( - 'Critical update available', - ); + const notification = n8n.notifications.getNotificationByTitle('Critical update available'); await expect(notification).toBeHidden(); // Verify the app still functions normally diff --git a/packages/testing/playwright/workflows/Pinned_webhook_node.json b/packages/testing/playwright/workflows/Pinned_webhook_node.json new file mode 100644 index 0000000000..e58ad42e7c --- /dev/null +++ b/packages/testing/playwright/workflows/Pinned_webhook_node.json @@ -0,0 +1,36 @@ +{ + "nodes": [ + { + "parameters": { + "path": "FwrbSiaua2Xmvn6-Z-7CQ", + "options": {} + }, + "id": "8fcc7e5f-2cef-4938-9564-eea504c20aa0", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [360, 220], + "webhookId": "9c778f2a-e882-46ed-a0e4-c8e2f76ccd65" + } + ], + "connections": {}, + "pinData": { + "Webhook": [ + { + "headers": { + "connection": "keep-alive", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "accept": "*/*", + "cookie": "n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNiM2FhOTE5LWRhZDgtNDE5MS1hZWZiLTlhZDIwZTZkMjJjNiIsImhhc2giOiJ1ZVAxR1F3U2paIiwiaWF0IjoxNzI4OTE1NTQyLCJleHAiOjE3Mjk1MjAzNDJ9.fV02gpUnSiUoMxHwfB0npBjcjct7Mv9vGfj-jRTT3-I", + "host": "localhost:5678", + "accept-encoding": "gzip, deflate" + }, + "params": {}, + "query": {}, + "body": {}, + "webhookUrl": "http://localhost:5678/webhook-test/FwrbSiaua2Xmvn6-Z-7CQ", + "executionMode": "test" + } + ] + } +} diff --git a/packages/testing/playwright/workflows/Test_workflow_webhook_with_pin_data.json b/packages/testing/playwright/workflows/Test_workflow_webhook_with_pin_data.json new file mode 100644 index 0000000000..ce7fa2758d --- /dev/null +++ b/packages/testing/playwright/workflows/Test_workflow_webhook_with_pin_data.json @@ -0,0 +1,136 @@ +{ + "name": "PinData Test", + "nodes": [ + { + "parameters": {}, + "id": "0a60e507-7f34-41c0-a0f9-697d852033b6", + "name": "When clicking ‘Execute workflow’", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [780, 320] + }, + { + "parameters": { + "path": "b0d79ddb-df2d-49b1-8555-9fa2b482608f", + "responseMode": "lastNode", + "options": {} + }, + "id": "66425ce3-450d-4aa6-a53b-a701ab89c2de", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1.1, + "position": [780, 540], + "webhookId": "b0d79ddb-df2d-49b1-8555-9fa2b482608f" + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "nodeData", + "stringValue": "init" + } + ] + }, + "include": "none", + "options": {} + }, + "id": "3211b3c5-49e9-4694-8f86-7a5783bc653a", + "name": "Init Data", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [1000, 320] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "nodeData", + "stringValue": "pin" + } + ] + }, + "options": {} + }, + "id": "97b31120-4720-4632-9d35-356f345119f7", + "name": "Pin Data", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [1240, 320] + }, + { + "parameters": {}, + "id": "1ee7be4f-7006-43bf-bb0c-29db3058a399", + "name": "End", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1460, 320] + } + ], + "pinData": { + "Pin Data": [ + { + "json": { + "nodeData": "pin-overwritten" + } + } + ] + }, + "connections": { + "When clicking ‘Execute workflow’": { + "main": [ + [ + { + "node": "Init Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook": { + "main": [ + [ + { + "node": "Init Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Init Data": { + "main": [ + [ + { + "node": "Pin Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pin Data": { + "main": [ + [ + { + "node": "End", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "ded8577a-3ed2-4611-842c-a7922ec58b98", + "id": "weofVLZo0ssmPDrV", + "meta": { + "instanceId": "021d3c82ba2d3bc090cbf4fc81c9312668bcc34297e022bb3438c5c88a43a5ff" + }, + "tags": [] +}