From c0b553e4646ae353dd514ade75dfe7f2e0be6014 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Mon, 22 Sep 2025 11:32:40 +0100 Subject: [PATCH] test: Refactor node creator tests to Playwright (#19808) --- cypress/e2e/group1/4-node-creator.cy.ts | 576 ------------------ .../testing/playwright/AI-TEST-CHEAT-SHEET.md | 255 ++++++++ .../testing/playwright/pages/CanvasPage.ts | 10 + .../pages/components/NodeCreator.ts | 114 ++++ .../playwright/tests/ui/39-projects.spec.ts | 10 +- .../tests/ui/54-focus-panel.spec.ts | 4 +- .../building-blocks/02-canvas-actions.spec.ts | 6 +- .../tests/ui/node-creator/actions.spec.ts | 63 ++ .../tests/ui/node-creator/categories.spec.ts | 101 +++ .../tests/ui/node-creator/navigation.spec.ts | 71 +++ .../ui/node-creator/special-nodes.spec.ts | 50 ++ .../ui/node-creator/vector-stores.spec.ts | 46 ++ .../tests/ui/node-creator/workflows.spec.ts | 32 + 13 files changed, 751 insertions(+), 587 deletions(-) delete mode 100644 cypress/e2e/group1/4-node-creator.cy.ts create mode 100644 packages/testing/playwright/AI-TEST-CHEAT-SHEET.md create mode 100644 packages/testing/playwright/pages/components/NodeCreator.ts create mode 100644 packages/testing/playwright/tests/ui/node-creator/actions.spec.ts create mode 100644 packages/testing/playwright/tests/ui/node-creator/categories.spec.ts create mode 100644 packages/testing/playwright/tests/ui/node-creator/navigation.spec.ts create mode 100644 packages/testing/playwright/tests/ui/node-creator/special-nodes.spec.ts create mode 100644 packages/testing/playwright/tests/ui/node-creator/vector-stores.spec.ts create mode 100644 packages/testing/playwright/tests/ui/node-creator/workflows.spec.ts diff --git a/cypress/e2e/group1/4-node-creator.cy.ts b/cypress/e2e/group1/4-node-creator.cy.ts deleted file mode 100644 index 31a9fd1a77f..00000000000 --- a/cypress/e2e/group1/4-node-creator.cy.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { clickGetBackToCanvas } from '../../composables/ndv'; -import { - addNodeToCanvas, - addRetrieverNodeToParent, - addVectorStoreNodeToParent, - addVectorStoreToolToParent, - getNodeCreatorItems, -} from '../../composables/workflow'; -import { AGENT_NODE_NAME, IF_NODE_NAME, MANUAL_CHAT_TRIGGER_NODE_NAME } from '../../constants'; -import { NodeCreator } from '../../pages/features/node-creator'; -import { NDV } from '../../pages/ndv'; -import { WorkflowPage as WorkflowPageClass } from '../../pages/workflow'; -import { getVisibleSelect } from '../../utils'; - -const nodeCreatorFeature = new NodeCreator(); -const WorkflowPage = new WorkflowPageClass(); -const NDVModal = new NDV(); - -describe('Node Creator', () => { - beforeEach(() => { - WorkflowPage.actions.visit(); - }); - - it('should open node creator on trigger tab if no trigger is on canvas', () => { - nodeCreatorFeature.getters.canvasAddButton().click(); - - nodeCreatorFeature.getters - .nodeCreator() - .contains('What triggers this workflow?') - .should('be.visible'); - }); - - it('should navigate subcategory', () => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.getCreatorItem('On app event').click(); - nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'On app event'); - // Go back - nodeCreatorFeature.getters.activeSubcategory().find('button').click(); - nodeCreatorFeature.getters.activeSubcategory().should('not.have.text', 'On app event'); - }); - - it('should search for nodes', () => { - nodeCreatorFeature.actions.openNodeCreator(); - - nodeCreatorFeature.getters.searchBar().find('input').type('manual'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 1); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('manual123'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 0); - nodeCreatorFeature.getters - .noResults() - .should('exist') - .should('contain.text', "We didn't make that... yet"); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 1); - - nodeCreatorFeature.getters - .searchBar() - .find('input') - .clear() - .type('this node totally does not exist'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 0); - - nodeCreatorFeature.getters.searchBar().find('input').clear(); - nodeCreatorFeature.getters.getCreatorItem('On app event').click(); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image'); - nodeCreatorFeature.getters.getCategoryItem('Results in other categories').should('exist'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 1); - nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist'); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image123123'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 0); - }); - - it('should check correct view panels', () => { - nodeCreatorFeature.getters.canvasAddButton().click(); - WorkflowPage.actions.addNodeToCanvas('Manual', false); - - nodeCreatorFeature.getters.canvasAddButton().should('not.exist'); - nodeCreatorFeature.getters.nodeCreator().should('not.exist'); - // TODO: Replace once we have canvas feature utils - cy.get('div').contains('Add first step').should('not.exist'); - - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible'); - - nodeCreatorFeature.getters.getCreatorItem('Add another trigger').click(); - nodeCreatorFeature.getters - .nodeCreator() - .contains('What triggers this workflow?') - .should('be.visible'); - nodeCreatorFeature.getters.activeSubcategory().find('button').should('exist'); - nodeCreatorFeature.getters.activeSubcategory().find('button').click(); - nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible'); - }); - - it('should add node to canvas from actions panel', () => { - const editImageNode = 'Edit Image'; - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type(editImageNode); - nodeCreatorFeature.getters.getCreatorItem(editImageNode).click(); - nodeCreatorFeature.getters.activeSubcategory().should('have.text', editImageNode); - nodeCreatorFeature.getters.getCreatorItem('Crop Image').click(); - NDVModal.getters.parameterInput('operation').find('input').should('have.value', 'Crop'); - }); - - it('should search through actions and confirm added action', () => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('ftp'); - nodeCreatorFeature.getters.searchBar().find('input').type('{rightarrow}'); - nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'FTP'); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('file'); - // The 1st trigger is selected, up 1x to the collapsable header, up 2x to the last action (rename) - nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{uparrow}{rightarrow}'); - NDVModal.getters.parameterInput('operation').find('input').should('have.value', 'Rename'); - }); - - it('should not show actions for single action nodes', () => { - const singleActionNodes = [ - 'DHL', - 'Edit Fields', - 'LingvaNex', - 'Mailcheck', - 'MSG91', - 'OpenThesaurus', - 'Spontit', - 'Vonage', - 'Toggl Trigger', - ]; - const doubleActionNode = 'OpenWeatherMap'; - - nodeCreatorFeature.actions.openNodeCreator(); - singleActionNodes.forEach((node) => { - nodeCreatorFeature.getters.searchBar().find('input').clear().type(node); - nodeCreatorFeature.getters - .getCreatorItem(node) - .find('button[class*="panelIcon"]') - .should('not.exist'); - }); - nodeCreatorFeature.getters.searchBar().find('input').clear().type(doubleActionNode); - nodeCreatorFeature.getters.getCreatorItem(doubleActionNode).click(); - nodeCreatorFeature.getters.creatorItem().should('have.length', 4); - }); - - it('should have "Actions" section collapsed when opening actions view from Trigger root view', () => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('ActiveCampaign'); - nodeCreatorFeature.getters.getCreatorItem('ActiveCampaign').click(); - nodeCreatorFeature.getters.getCategoryItem('Actions').should('exist'); - nodeCreatorFeature.getters.getCategoryItem('Triggers').should('exist'); - - nodeCreatorFeature.getters - .getCategoryItem('Triggers') - .parent() - .should('have.attr', 'data-category-collapsed', 'false'); - nodeCreatorFeature.getters - .getCategoryItem('Actions') - .parent() - .should('have.attr', 'data-category-collapsed', 'true'); - nodeCreatorFeature.getters.getCategoryItem('Actions').click(); - nodeCreatorFeature.getters - .getCategoryItem('Actions') - .parent() - .should('have.attr', 'data-category-collapsed', 'false'); - }); - - it('should have "Triggers" section collapsed when opening actions view from Regular root view', () => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.getCreatorItem('Trigger manually').click(); - - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); - nodeCreatorFeature.getters.getCreatorItem('n8n').click(); - - nodeCreatorFeature.getters - .getCategoryItem('Actions') - .parent() - .should('have.attr', 'data-category-collapsed', 'false'); - nodeCreatorFeature.getters.getCategoryItem('Actions').click(); - nodeCreatorFeature.getters - .getCategoryItem('Actions') - .parent() - .should('have.attr', 'data-category-collapsed', 'true'); - nodeCreatorFeature.getters - .getCategoryItem('Triggers') - .parent() - .should('have.attr', 'data-category-collapsed', 'true'); - nodeCreatorFeature.getters.getCategoryItem('Triggers').click(); - nodeCreatorFeature.getters - .getCategoryItem('Triggers') - .parent() - .should('have.attr', 'data-category-collapsed', 'false'); - }); - - it('should show callout and two suggested nodes if node has no trigger actions', () => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters - .searchBar() - .find('input') - .clear() - .type('Customer Datastore (n8n training)'); - nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); - - cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible'); - nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible'); - nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible'); - }); - - it('should show intro callout if user has not made a production execution', () => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters - .searchBar() - .find('input') - .clear() - .type('Customer Datastore (n8n training)'); - nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); - - cy.getByTestId('actions-panel-activation-callout').should('be.visible'); - nodeCreatorFeature.getters.activeSubcategory().find('button').click(); - nodeCreatorFeature.getters.searchBar().find('input').clear(); - - nodeCreatorFeature.getters.getCreatorItem('On a schedule').click(); - - // Setup 1s interval execution - cy.getByTestId('parameter-input-field').click(); - getVisibleSelect().find('.option-headline').contains('Seconds').click(); - cy.getByTestId('parameter-input-secondsInterval').clear().type('1'); - - NDVModal.actions.close(); - - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters - .searchBar() - .find('input') - .clear() - .type('Customer Datastore (n8n training)'); - nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); - nodeCreatorFeature.getters.getCreatorItem('Get All People').click(); - NDVModal.actions.close(); - - WorkflowPage.actions.saveWorkflowOnButtonClick(); - WorkflowPage.actions.activateWorkflow(); - WorkflowPage.getters.activatorSwitch().should('have.class', 'is-checked'); - - // Wait for schedule 1s execution to mark user as having made a production execution - cy.wait(1500); - cy.reload(); - - // Action callout should not be visible after user has made a production execution - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters - .searchBar() - .find('input') - .clear() - .type('Customer Datastore (n8n training)'); - nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); - - cy.getByTestId('actions-panel-activation-callout').should('not.exist'); - }); - - it('should show Trigger and Actions sections during search', () => { - nodeCreatorFeature.actions.openNodeCreator(); - - nodeCreatorFeature.getters - .searchBar() - .find('input') - .clear() - .type('Customer Datastore (n8n training)'); - nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('Non existent action name'); - - nodeCreatorFeature.getters.getCategoryItem('Triggers').should('be.visible'); - nodeCreatorFeature.getters.getCategoryItem('Actions').should('be.visible'); - cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible'); - nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible'); - nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible'); - }); - - describe('should correctly append manual trigger for regular actions', () => { - // For these sources, manual node should be added - const sourcesWithAppend = [ - { - name: 'canvas add button', - handler: () => nodeCreatorFeature.getters.canvasAddButton().click(), - }, - { - name: 'plus button', - handler: () => nodeCreatorFeature.getters.plusButton().click(), - }, - // We can't test this one because it's not possible to trigger tab key in Cypress - // only way is to use `realPress` which is hanging the tests in Electron for some reason - // { - // name: 'tab key', - // handler: () => cy.realPress('Tab'), - // }, - ]; - sourcesWithAppend.forEach((source) => { - it(`should append manual trigger when source is ${source.name}`, () => { - source.handler(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); - nodeCreatorFeature.getters.getCreatorItem('n8n').click(); - nodeCreatorFeature.getters.getCategoryItem('Actions').click(); - nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); - NDVModal.actions.close(); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - }); - }); - - // @TODO FIX ADDING 2 NODES IN ONE GO - it('should not append manual trigger when source is canvas related', () => { - nodeCreatorFeature.getters.canvasAddButton().click(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); - nodeCreatorFeature.getters.getCreatorItem('n8n').click(); - nodeCreatorFeature.getters.getCategoryItem('Actions').click(); - nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); - NDVModal.actions.close(); - WorkflowPage.actions.deleteNode('When clicking ‘Execute workflow’'); - WorkflowPage.getters.canvasNodePlusEndpointByName('Create a credential').click(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); - nodeCreatorFeature.getters.getCreatorItem('n8n').click(); - nodeCreatorFeature.getters.getCategoryItem('Actions').click(); - nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); - NDVModal.actions.close(); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - WorkflowPage.actions.zoomToFit(); - WorkflowPage.actions.addNodeBetweenNodes( - 'Create a credential', - 'Create a credential1', - 'Summarize', - ); - WorkflowPage.getters.canvasNodes().should('have.length', 3); - }); - }); - - it('should correctly append a No Op node when Loop Over Items node is added (from add button)', () => { - nodeCreatorFeature.actions.openNodeCreator(); - - nodeCreatorFeature.getters.searchBar().find('input').type('Loop Over Items'); - nodeCreatorFeature.getters.getCreatorItem('Loop Over Items').click(); - NDVModal.actions.close(); - - WorkflowPage.getters.canvasNodes().should('have.length', 3); - WorkflowPage.getters.nodeConnections().should('have.length', 3); - - WorkflowPage.getters.getConnectionBetweenNodes('Loop Over Items', 'Replace Me').should('exist'); - WorkflowPage.getters.getConnectionBetweenNodes('Replace Me', 'Loop Over Items').should('exist'); - }); - - it('should correctly append a No Op node when Loop Over Items node is added (from connection)', () => { - WorkflowPage.actions.addNodeToCanvas('Manual'); - - cy.getByTestId('canvas-handle-plus').click(); - - nodeCreatorFeature.getters.searchBar().find('input').type('Loop Over Items'); - nodeCreatorFeature.getters.getCreatorItem('Loop Over Items').click(); - NDVModal.actions.close(); - - WorkflowPage.getters.canvasNodes().should('have.length', 3); - WorkflowPage.getters.nodeConnections().should('have.length', 3); - - WorkflowPage.getters.getConnectionBetweenNodes('Loop Over Items', 'Replace Me').should('exist'); - WorkflowPage.getters.getConnectionBetweenNodes('Replace Me', 'Loop Over Items').should('exist'); - }); - - // Skipping while we wait for a decision on how to handle the search results - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - it.skip('should have most relevant nodes on top when searching', () => { - nodeCreatorFeature.getters.canvasAddButton().click(); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('email'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Email Trigger (IMAP)'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('Set'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); - nodeCreatorFeature.getters.searchBar().find('input').clear().type('Edit F'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('IF'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Switch'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('swit'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Switch'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('red'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Redis'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Reddit'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('redd'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Reddit'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('wh'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Webhook'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('web'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Webflow'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Webhook'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('webh'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Webhook'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('func'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Code'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('cod'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Coda'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Code'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('code'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Code'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('js'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Code'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Edit Fields (Set)'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('fi'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Filter'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('filt'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Filter'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('manu'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Manual Trigger'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('sse'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'SSE Trigger'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('cmpar'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Compare Datasets'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('fb'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Facebook Trigger'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('crn'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Schedule Trigger'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('cron'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Schedule Trigger'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('sch'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Schedule Trigger'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('time'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Schedule Trigger'); - nodeCreatorFeature.getters.nodeItemName().eq(2).should('have.text', 'Wait'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('mail'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Mailgun'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('mailc'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Mailcheck'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Mailchimp'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('api'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'HTTP Request'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('s3'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'S3'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('no op'); - nodeCreatorFeature.getters - .nodeItemName() - .first() - .should('have.text', 'No Operation, do nothing'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('do no'); - nodeCreatorFeature.getters - .nodeItemName() - .first() - .should('have.text', 'No Operation, do nothing'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('htt'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'HTTP Request'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Webhook'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('http'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'HTTP Request'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Webhook'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('wa'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Wait'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('wait'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Wait'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('spreadsheet'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Convert to File'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Extract from File'); - nodeCreatorFeature.getters.nodeItemName().eq(2).should('have.text', 'Google Sheets'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('sheets'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Google Sheets'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('ggle she'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Google Sheets'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('hub'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'HubSpot'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('git'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Git'); - nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'GitHub'); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('gith'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'GitHub'); - }); - - it('should show vector stores actions', () => { - const actions = [ - 'Get ranked documents from vector store', - 'Add documents to vector store', - 'Retrieve documents for Chain/Tool as Vector Store', - ]; - - nodeCreatorFeature.actions.openNodeCreator(); - - nodeCreatorFeature.getters.searchBar().find('input').clear().type('Vector Store'); - - getNodeCreatorItems().then((items) => { - const vectorStores = items.map((_i, el) => el.innerText); - - // Loop over all vector stores and check if they have the three actions - vectorStores.each((_i, vectorStore) => { - if (vectorStore.includes('RAG starter template')) { - return; - } - - nodeCreatorFeature.getters.getCreatorItem(vectorStore).click(); - actions.forEach((action) => { - nodeCreatorFeature.getters.getCreatorItem(action).should('be.visible').realHover(); - }); - cy.realPress('ArrowLeft'); - }); - }); - }); - - it('should add node directly for sub-connection as vector store', () => { - addNodeToCanvas('Question and Answer Chain', true); - addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain'); - cy.realPress('Escape'); - addVectorStoreNodeToParent('Simple Vector Store', 'Vector Store Retriever'); - cy.realPress('Escape'); - WorkflowPage.getters.canvasNodes().should('have.length', 4); - }); - - it('should add node directly for sub-connection as tool', () => { - addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true, false, undefined, true); - addNodeToCanvas(AGENT_NODE_NAME, true, true); - clickGetBackToCanvas(); - - addVectorStoreToolToParent('Simple Vector Store', AGENT_NODE_NAME); - }); - - it('should insert node to canvas with sendAndWait operation selected', () => { - nodeCreatorFeature.getters.canvasAddButton().click(); - WorkflowPage.actions.addNodeToCanvas('Manual', false); - nodeCreatorFeature.actions.openNodeCreator(); - cy.contains('Human in the loop').click(); - nodeCreatorFeature.getters.getCreatorItem('Slack').click(); - cy.contains('Send and Wait for Response').should('exist'); - }); -}); diff --git a/packages/testing/playwright/AI-TEST-CHEAT-SHEET.md b/packages/testing/playwright/AI-TEST-CHEAT-SHEET.md new file mode 100644 index 00000000000..a6aa439d5a9 --- /dev/null +++ b/packages/testing/playwright/AI-TEST-CHEAT-SHEET.md @@ -0,0 +1,255 @@ +# 🚀 n8n Playwright Test Writing Cheat Sheet + +> **For AI Assistants**: This guide provides quick reference patterns for writing n8n Playwright tests using the established architecture. + +## Quick Start Navigation Methods + +### **n8n.start.*** Methods (Test Entry Points) +```typescript +// Start from home page +await n8n.start.fromHome(); + +// Start with blank canvas for new workflow +await n8n.start.fromBlankCanvas(); + +// Start with new project + blank canvas (returns projectId) +const projectId = await n8n.start.fromNewProjectBlankCanvas(); + +// Start with just a new project (no canvas) +const projectId = await n8n.start.fromNewProject(); + +// Import and start from existing workflow JSON +const result = await n8n.start.fromImportedWorkflow('simple-webhook-test.json'); +const { workflowId, webhookPath } = result; +``` + +### **n8n.navigate.*** Methods (Page Navigation) +```typescript +// Basic navigation +await n8n.navigate.toHome(); +await n8n.navigate.toWorkflow('new'); +await n8n.navigate.toWorkflows(projectId); + +// Settings & admin +await n8n.navigate.toVariables(); +await n8n.navigate.toCredentials(projectId); +await n8n.navigate.toLogStreaming(); +await n8n.navigate.toCommunityNodes(); + +// Project-specific navigation +await n8n.navigate.toProject(projectId); +await n8n.navigate.toProjectSettings(projectId); +``` + +## Common Test Patterns + +### **Basic Workflow Test** +```typescript +test('should create and execute workflow', async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Set'); + await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Success'); +}); +``` + +### **Imported Workflow Test** +```typescript +test('should import and test webhook', async ({ n8n }) => { + const { webhookPath } = await n8n.start.fromImportedWorkflow('webhook-test.json'); + + await n8n.canvas.clickExecuteWorkflowButton(); + const response = await n8n.page.request.post(`/webhook-test/${webhookPath}`, { + data: { message: 'Hello' } + }); + expect(response.ok()).toBe(true); +}); +``` + +### **Project-Scoped Test** +```typescript +test('should create credential in project', async ({ n8n }) => { + const projectId = await n8n.start.fromNewProject(); + await n8n.navigate.toCredentials(projectId); + + await n8n.credentialsComposer.createFromList( + 'Notion API', + { apiKey: '12345' }, + { name: `cred-${nanoid()}` } + ); +}); +``` + +### **Node Configuration Test** +```typescript +test('should configure HTTP Request node', async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('HTTP Request'); + + await n8n.ndv.fillParameterInput('URL', 'https://api.example.com'); + await n8n.ndv.close(); + await n8n.canvas.saveWorkflow(); +}); +``` + +## Test Setup Patterns + +### **Feature Flags Setup** +```typescript +test.beforeEach(async ({ n8n, api }) => { + await api.enableFeature('sharing'); + await api.enableFeature('folders'); + await api.enableFeature('projectRole:admin'); + await api.setMaxTeamProjectsQuota(-1); + await n8n.goHome(); +}); +``` + +### **API + UI Combined Test** +```typescript +test('should use API-created credential in UI', async ({ n8n, api }) => { + const projectId = await n8n.start.fromNewProjectBlankCanvas(); + + // Create via API + await api.credentialApi.createCredential({ + name: 'test-cred', + type: 'notionApi', + data: { apiKey: '12345' }, + projectId + }); + + // Verify in UI + await n8n.canvas.addNode('Notion'); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue('test-cred'); +}); +``` + +### **Error/Edge Case Testing** +```typescript +test('should handle workflow execution error', async ({ n8n }) => { + await n8n.start.fromImportedWorkflow('failing-workflow.json'); + await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Problem in node'); + await expect(n8n.canvas.getErrorIcon()).toBeVisible(); +}); +``` + +## Architecture Guidelines + +### **Four-Layer UI Testing Architecture** +``` +Tests (*.spec.ts) + ↓ uses +Composables (*Composer.ts) - Business workflows + ↓ orchestrates +Page Objects (*Page.ts) - UI interactions + ↓ extends +BasePage - Common utilities +``` + +### **When to Use Each Layer** +- **Tests**: High-level scenarios, readable business logic +- **Composables**: Multi-step workflows (e.g., `executeWorkflowAndWaitForNotification`) +- **Page Objects**: Simple UI actions (e.g., `clickSaveButton`, `fillInput`) +- **BasePage**: Generic interactions (e.g., `clickByTestId`, `fillByTestId`) + +### **Method Naming Conventions** +```typescript +// Page Object Getters (No async, return Locator) +getSearchBar() { return this.page.getByTestId('search'); } + +// Page Object Actions (async, return void) +async clickSaveButton() { await this.clickButtonByName('Save'); } + +// Page Object Queries (async, return data) +async getNotificationCount(): Promise { /* ... */ } +``` + +## Quick Reference + +### **Most Common Entry Points** +- `n8n.start.fromBlankCanvas()` - New workflow from scratch +- `n8n.start.fromImportedWorkflow('file.json')` - Test existing workflow +- `n8n.start.fromNewProjectBlankCanvas()` - Project-scoped testing + +### **Most Common Navigation** +- `n8n.navigate.toCredentials(projectId)` - Credential management +- `n8n.navigate.toVariables()` - Environment variables +- `n8n.navigate.toWorkflow('new')` - New workflow canvas + +### **Essential Assertions** +```typescript +// UI state verification +await expect(n8n.canvas.canvasPane()).toBeVisible(); +await expect(n8n.notifications.getNotificationByTitle('Success')).toBeVisible(); +await expect(n8n.ndv.getCredentialSelect()).toHaveValue(name); + +// Node and workflow verification +await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); +await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible(); +``` + +### **Common Composable Methods** +```typescript +// Workflow operations +await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Success'); +await n8n.workflowComposer.createWorkflow('My Workflow'); + +// Project operations +const { projectName, projectId } = await n8n.projectComposer.createProject(); + +// Credential operations +await n8n.credentialsComposer.createFromList('Notion API', { apiKey: '123' }); +await n8n.credentialsComposer.createFromNdv({ apiKey: '123' }); +``` + +### **Dynamic Data Patterns** +```typescript +// Use nanoid for unique identifiers +import { nanoid } from 'nanoid'; +const workflowName = `Test Workflow ${nanoid()}`; +const credentialName = `cred-${nanoid()}`; + +// Use timestamps for uniqueness +const projectName = `Project ${Date.now()}`; +``` + +## AI Guidelines + +### **✅ DO** +- Always use `n8n.start.*` methods for test entry points +- Use composables for business workflows, not page objects directly in tests +- Use `nanoid()` or timestamps for unique test data +- Follow the 4-layer architecture pattern +- Use proper waiting with `expect().toBeVisible()` instead of `waitForTimeout` + +### **❌ DON'T** +- Use raw `page.goto()` instead of navigation helpers +- Mix business logic in page objects (move to composables) +- Use hardcoded selectors in tests (use page object getters) +- Create overly specific methods (keep them reusable) +- Use `any` types or `waitForTimeout` + +### **Test Structure Template** +```typescript +import { test, expect } from '../../fixtures/base'; + +test.describe('Feature Name', () => { + test.beforeEach(async ({ n8n, api }) => { + // Feature flags and setup + await api.enableFeature('requiredFeature'); + await n8n.goHome(); + }); + + test('should perform specific action', async ({ n8n }) => { + // 1. Setup/Navigation + await n8n.start.fromBlankCanvas(); + + // 2. Actions using composables + await n8n.workflowComposer.createBasicWorkflow(); + + // 3. Assertions + await expect(n8n.notifications.getNotificationByTitle('Success')).toBeVisible(); + }); +}); +``` \ No newline at end of file diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index 0e18b7f07f3..aa131cf9f95 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -7,6 +7,7 @@ import { resolveFromRoot } from '../utils/path-helper'; import { CredentialModal } from './components/CredentialModal'; import { FocusPanel } from './components/FocusPanel'; import { LogsPanel } from './components/LogsPanel'; +import { NodeCreator } from './components/NodeCreator'; import { StickyComponent } from './components/StickyComponent'; export class CanvasPage extends BasePage { @@ -14,6 +15,7 @@ export class CanvasPage extends BasePage { readonly logsPanel = new LogsPanel(this.page.getByTestId('logs-panel')); readonly focusPanel = new FocusPanel(this.page.getByTestId('focus-panel')); readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal')); + readonly nodeCreator = new NodeCreator(this.page); saveWorkflowButton(): Locator { return this.page.getByRole('button', { name: 'Save' }); @@ -387,6 +389,10 @@ export class CanvasPage extends BasePage { return this.page.locator('[data-test-id="edge"]'); } + getConnectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator { + return this.connectionBetweenNodes(sourceNodeName, targetNodeName); + } + canvasNodePlusEndpointByName(nodeName: string): Locator { return this.page .locator( @@ -736,4 +742,8 @@ export class CanvasPage extends BasePage { getNodeWarningStatusIndicator(nodeName: string): Locator { return this.nodeByName(nodeName).getByTestId('canvas-node-status-warning'); } + + getCanvasPlusButton(): Locator { + return this.page.getByTestId('canvas-plus-button'); + } } diff --git a/packages/testing/playwright/pages/components/NodeCreator.ts b/packages/testing/playwright/pages/components/NodeCreator.ts new file mode 100644 index 00000000000..5c467f8691f --- /dev/null +++ b/packages/testing/playwright/pages/components/NodeCreator.ts @@ -0,0 +1,114 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Node Creator component for adding nodes to workflows. + * Used within CanvasPage as `n8n.canvas.nodeCreator.*` + * + * @example + * // Access via canvas page + * await n8n.canvas.nodeCreator.open(); + * await n8n.canvas.nodeCreator.searchFor('Gmail'); + * await n8n.canvas.nodeCreator.selectItem('Gmail'); + */ +export class NodeCreator { + constructor(private page: Page) {} + + // Core locators + getRoot(): Locator { + return this.page.getByTestId('node-creator'); + } + + getSearchBar(): Locator { + return this.page.getByTestId('node-creator-search-bar'); + } + + getNodeItems(): Locator { + return this.page.getByTestId('item-iterator-item'); + } + + getActionItems(): Locator { + return this.page.getByTestId('node-creator-action-item'); + } + + getCategoryItems(): Locator { + return this.page.getByTestId('node-creator-category-item'); + } + + getTabs(): Locator { + return this.page.getByTestId('node-creator-type-selector'); + } + + getSelectedTab(): Locator { + return this.getTabs().locator('.is-active'); + } + + getActiveSubcategory(): Locator { + return this.page.getByTestId('nodes-list-header').first(); + } + + getNoResults(): Locator { + return this.page.getByTestId('node-creator-no-results'); + } + + getTriggerText(): Locator { + return this.page.getByText('What triggers this workflow?'); + } + + getNextText(): Locator { + return this.page.getByText('What happens next?'); + } + + // Item getters + getItem(text: string): Locator { + return this.getNodeItems().filter({ hasText: text }).first(); + } + + getCategoryItem(text: string): Locator { + return this.getCategoryItems().filter({ hasText: text }); + } + + getPanelIcon(nodeName: string): Locator { + return this.getItem(nodeName).locator('[class*="panelIcon"]'); + } + + // Actions + async open(): Promise { + await this.page.getByTestId('canvas-plus-button').click(); + await expect(this.getRoot()).toBeVisible(); + } + + async close(): Promise { + await this.page.keyboard.press('Escape'); + } + + async searchFor(text: string): Promise { + await this.getSearchBar().fill(text); + } + + async clearSearch(): Promise { + await this.getSearchBar().clear(); + } + + async selectItem(text: string): Promise { + await this.getItem(text).click(); + } + + async selectCategoryItem(text: string): Promise { + await this.getCategoryItem(text).click(); + } + + async navigateToSubcategory(category: string): Promise { + await this.getItem(category).click(); + await expect(this.getActiveSubcategory()).toContainText(category); + } + + async goBackFromSubcategory(): Promise { + await this.getActiveSubcategory().locator('button').click(); + } + + async selectWithKeyboard(direction: 'up' | 'down' | 'right'): Promise { + const key = direction === 'up' ? 'ArrowUp' : direction === 'down' ? 'ArrowDown' : 'ArrowRight'; + await this.page.keyboard.press(key); + } +} diff --git a/packages/testing/playwright/tests/ui/39-projects.spec.ts b/packages/testing/playwright/tests/ui/39-projects.spec.ts index 0489a16542d..67f885cbb88 100644 --- a/packages/testing/playwright/tests/ui/39-projects.spec.ts +++ b/packages/testing/playwright/tests/ui/39-projects.spec.ts @@ -182,7 +182,7 @@ test.describe('Projects', () => { // Verify owner cannot change their own role const ownerRow = memberRows.first(); const roleDropdown = ownerRow.getByTestId('project-member-role-dropdown'); - await expect(roleDropdown).not.toBeVisible(); + await expect(roleDropdown).toBeHidden(); }); test('should display role dropdown for members but not for current user @auth:owner', async ({ @@ -197,7 +197,7 @@ test.describe('Projects', () => { // Current user (owner) should not have a role dropdown const currentUserRow = n8n.page.locator('tbody tr').first(); - await expect(currentUserRow.getByTestId('project-member-role-dropdown')).not.toBeVisible(); + await expect(currentUserRow.getByTestId('project-member-role-dropdown')).toBeHidden(); // The role should be displayed as static text for the current user await expect(currentUserRow.getByText('Admin')).toBeVisible(); @@ -246,7 +246,7 @@ test.describe('Projects', () => { await n8n.projectSettings.fillProjectName('Valid Project Name'); // Save button should now be enabled - await expect(saveButton).not.toBeDisabled(); + await expect(saveButton).toBeEnabled(); }); test('should handle unsaved changes state @auth:owner', async ({ n8n }) => { @@ -265,8 +265,8 @@ test.describe('Projects', () => { await n8n.projectSettings.fillProjectName('Modified Name'); // Save and cancel buttons should now be enabled - await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeDisabled(); - await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeDisabled(); + await expect(n8n.page.getByTestId('project-settings-save-button')).toBeEnabled(); + await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeEnabled(); // Unsaved changes message should be visible await expect(n8n.page.getByText('You have unsaved changes')).toBeVisible(); diff --git a/packages/testing/playwright/tests/ui/54-focus-panel.spec.ts b/packages/testing/playwright/tests/ui/54-focus-panel.spec.ts index f0d09b5bb0f..3ade94ba33b 100644 --- a/packages/testing/playwright/tests/ui/54-focus-panel.spec.ts +++ b/packages/testing/playwright/tests/ui/54-focus-panel.spec.ts @@ -26,14 +26,14 @@ test.describe('Focus panel', () => { // Assert that mapper is closed but the Set node is still selected and shown in await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } }); - await expect(n8n.canvas.focusPanel.getMapper()).not.toBeVisible(); + await expect(n8n.canvas.focusPanel.getMapper()).toBeHidden(); await expect(n8n.canvas.focusPanel.getHeader()).toHaveText('Set'); await expect(n8n.canvas.selectedNodes()).toHaveCount(1); // Assert that another click on canvas does de-select the Set node await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } }); - await expect(n8n.canvas.focusPanel.getHeader()).not.toBeVisible(); + await expect(n8n.canvas.focusPanel.getHeader()).toBeHidden(); await expect(n8n.canvas.selectedNodes()).toHaveCount(0); }); }); diff --git a/packages/testing/playwright/tests/ui/building-blocks/02-canvas-actions.spec.ts b/packages/testing/playwright/tests/ui/building-blocks/02-canvas-actions.spec.ts index 4befd24f732..b94016fcfb5 100644 --- a/packages/testing/playwright/tests/ui/building-blocks/02-canvas-actions.spec.ts +++ b/packages/testing/playwright/tests/ui/building-blocks/02-canvas-actions.spec.ts @@ -86,12 +86,10 @@ test.describe('Canvas Node Actions', () => { test('should filter nodes by search term', async ({ n8n }) => { await n8n.canvas.clickCanvasPlusButton(); - const initialCount = await n8n.canvas.nodeCreatorNodeItems().count(); await n8n.canvas.fillNodeCreatorSearchBar('HTTP'); - const filteredCount = await n8n.canvas.nodeCreatorNodeItems().count(); - expect(filteredCount).toBeLessThan(initialCount); - expect(filteredCount).toBeGreaterThan(0); + const filteredItems = n8n.canvas.nodeCreatorNodeItems(); + await expect(filteredItems.first()).toContainText('HTTP'); }); }); }); diff --git a/packages/testing/playwright/tests/ui/node-creator/actions.spec.ts b/packages/testing/playwright/tests/ui/node-creator/actions.spec.ts new file mode 100644 index 00000000000..e41f076a268 --- /dev/null +++ b/packages/testing/playwright/tests/ui/node-creator/actions.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '../../../fixtures/base'; + +test.describe('Node Creator Actions', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should add node to canvas from actions panel', async ({ n8n }) => { + const editImageNode = 'Edit Image'; + + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor(editImageNode); + await n8n.canvas.nodeCreator.selectItem(editImageNode); + + await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText(editImageNode); + await n8n.canvas.nodeCreator.selectItem('Crop Image'); + await expect(n8n.ndv.getContainer()).toBeVisible(); + await n8n.page.keyboard.press('Escape'); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); + + test('should search through actions and confirm added action', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('ftp'); + await n8n.canvas.nodeCreator.selectItem('FTP'); + + await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('FTP'); + await n8n.canvas.nodeCreator.clearSearch(); + await n8n.canvas.nodeCreator.searchFor('rename'); + await n8n.canvas.nodeCreator.selectItem('Rename'); + + await expect(n8n.ndv.getContainer()).toBeVisible(); + await n8n.page.keyboard.press('Escape'); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); + + test('should show multiple actions for multi-action nodes', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('OpenWeatherMap'); + await n8n.canvas.nodeCreator.selectItem('OpenWeatherMap'); + + await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('OpenWeatherMap'); + await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getNodeItems().nth(1)).toBeVisible(); + + await n8n.canvas.nodeCreator.getNodeItems().first().click(); + await n8n.page.keyboard.press('Escape'); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); + + test('should add node with specific operation configuration', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('Slack'); + await n8n.canvas.nodeCreator.selectItem('Slack'); + + await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('Slack'); + await n8n.canvas.nodeCreator.getNodeItems().first().click(); + await n8n.page.keyboard.press('Escape'); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); + }); +}); diff --git a/packages/testing/playwright/tests/ui/node-creator/categories.spec.ts b/packages/testing/playwright/tests/ui/node-creator/categories.spec.ts new file mode 100644 index 00000000000..063ecb08cda --- /dev/null +++ b/packages/testing/playwright/tests/ui/node-creator/categories.spec.ts @@ -0,0 +1,101 @@ +import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants'; +import { test, expect } from '../../../fixtures/base'; + +test.describe('Node Creator Categories', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should have "Actions" section collapsed when opening actions view from Trigger root view', async ({ + n8n, + }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('ActiveCampaign'); + await n8n.canvas.nodeCreator.selectItem('ActiveCampaign'); + + await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions')).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers')).toBeVisible(); + + await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'false', + ); + + await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'true', + ); + + await n8n.canvas.nodeCreator.selectCategoryItem('Actions'); + await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'false', + ); + }); + + test('should have "Triggers" section collapsed when opening actions view from Regular root view', async ({ + n8n, + }) => { + await n8n.canvas.addNode('Manual Trigger'); + + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.nodeCreator.searchFor('n8n'); + await n8n.canvas.nodeCreator.getNodeItems().filter({ hasText: 'n8n' }).first().click(); + + await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'false', + ); + + await n8n.canvas.nodeCreator.selectCategoryItem('Actions'); + await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'true', + ); + + await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'true', + ); + + await n8n.canvas.nodeCreator.selectCategoryItem('Triggers'); + await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers').locator('..')).toHaveAttribute( + 'data-category-collapsed', + 'false', + ); + }); + + test('should show callout and two suggested nodes if node has no trigger actions', async ({ + n8n, + }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('Customer Datastore (n8n training)'); + await n8n.canvas.nodeCreator.selectItem('Customer Datastore (n8n training)'); + + await expect(n8n.page.getByTestId('actions-panel-no-triggers-callout')).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getItem('On a Schedule')).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getItem('On a Webhook call')).toBeVisible(); + }); + + test('should show intro callout if user has not made a production execution', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('Customer Datastore (n8n training)'); + await n8n.canvas.nodeCreator.selectItem('Customer Datastore (n8n training)'); + + await expect(n8n.page.getByTestId('actions-panel-activation-callout')).toBeVisible(); + }); + + test('should show Trigger and Actions sections during search', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('Customer Datastore (n8n training)'); + await n8n.canvas.nodeCreator.selectItem('Customer Datastore (n8n training)'); + + await n8n.canvas.nodeCreator.searchFor('Non existent action name'); + + await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers')).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions')).toBeVisible(); + await expect(n8n.page.getByTestId('actions-panel-no-triggers-callout')).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getItem('On a Schedule')).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getItem('On a Webhook call')).toBeVisible(); + }); +}); diff --git a/packages/testing/playwright/tests/ui/node-creator/navigation.spec.ts b/packages/testing/playwright/tests/ui/node-creator/navigation.spec.ts new file mode 100644 index 00000000000..51475dc838d --- /dev/null +++ b/packages/testing/playwright/tests/ui/node-creator/navigation.spec.ts @@ -0,0 +1,71 @@ +import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants'; +import { test, expect } from '../../../fixtures/base'; + +test.describe('Node Creator Navigation', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should open node creator on trigger tab if no trigger is on canvas', async ({ n8n }) => { + await n8n.canvas.clickCanvasPlusButton(); + await expect(n8n.canvas.nodeCreator.getRoot()).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getTriggerText()).toBeVisible(); + }); + + test('should navigate subcategory and return', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + + await n8n.canvas.nodeCreator.navigateToSubcategory('On app event'); + await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('On app event'); + + await n8n.canvas.nodeCreator.goBackFromSubcategory(); + await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).not.toContainText('On app event'); + }); + + test('should search for nodes with various queries', async ({ n8n }) => { + await n8n.canvas.nodeCreator.open(); + + await n8n.canvas.nodeCreator.searchFor('manual'); + await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(1); + + await n8n.canvas.nodeCreator.clearSearch(); + await n8n.canvas.nodeCreator.searchFor('manual123'); + await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(0); + await expect(n8n.canvas.nodeCreator.getNoResults()).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getNoResults()).toContainText("We didn't make that... yet"); + + await n8n.canvas.nodeCreator.clearSearch(); + await n8n.canvas.nodeCreator.searchFor('edit image'); + await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(1); + + await n8n.canvas.nodeCreator.clearSearch(); + await n8n.canvas.nodeCreator.searchFor('this node totally does not exist'); + await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(0); + + await n8n.canvas.nodeCreator.clearSearch(); + await n8n.canvas.nodeCreator.navigateToSubcategory('On app event'); + + await n8n.canvas.nodeCreator.searchFor('edit image'); + await expect( + n8n.canvas.nodeCreator.getCategoryItem('Results in other categories'), + ).toBeVisible(); + await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(1); + await expect(n8n.canvas.nodeCreator.getItem('Edit Image')).toBeVisible(); + + await n8n.canvas.nodeCreator.clearSearch(); + await n8n.canvas.nodeCreator.searchFor('edit image123123'); + await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(0); + }); + + test('should check correct view panels after adding manual trigger', async ({ n8n }) => { + await n8n.canvas.clickCanvasPlusButton(); + await expect(n8n.canvas.nodeCreator.getTriggerText()).toBeVisible(); + await n8n.canvas.nodeCreator.close(); + + await n8n.canvas.addNode('Manual Trigger'); + await expect(n8n.canvas.getCanvasPlusButton()).toBeHidden(); + + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await expect(n8n.canvas.nodeCreator.getNextText()).toBeVisible(); + }); +}); diff --git a/packages/testing/playwright/tests/ui/node-creator/special-nodes.spec.ts b/packages/testing/playwright/tests/ui/node-creator/special-nodes.spec.ts new file mode 100644 index 00000000000..08937a3a635 --- /dev/null +++ b/packages/testing/playwright/tests/ui/node-creator/special-nodes.spec.ts @@ -0,0 +1,50 @@ +import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants'; +import { test, expect } from '../../../fixtures/base'; + +test.describe('Node Creator Special Nodes', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should correctly append a No Op node when Loop Over Items node is added (from add button)', async ({ + n8n, + }) => { + await n8n.canvas.nodeCreator.open(); + await n8n.canvas.nodeCreator.searchFor('Loop Over Items'); + await n8n.canvas.nodeCreator.selectItem('Loop Over Items'); + await n8n.ndv.close(); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3); + await expect(n8n.canvas.nodeConnections()).toHaveCount(3); + await expect(n8n.canvas.nodeByName('Loop Over Items')).toBeVisible(); + await expect(n8n.canvas.nodeByName('Replace Me')).toBeVisible(); + }); + + test('should correctly append a No Op node when Loop Over Items node is added (from connection)', async ({ + n8n, + }) => { + await n8n.canvas.addNode('Manual Trigger'); + + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.nodeCreator.searchFor('Loop Over Items'); + await n8n.canvas.nodeCreator.selectItem('Loop Over Items'); + await n8n.ndv.close(); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3); + await expect(n8n.canvas.nodeConnections()).toHaveCount(3); + await expect(n8n.canvas.nodeByName('Loop Over Items')).toBeVisible(); + await expect(n8n.canvas.nodeByName('Replace Me')).toBeVisible(); + }); + + test('should add a Send and Wait for Response node', async ({ n8n }) => { + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + + await n8n.canvas.nodeCreator.selectItem('Human in the loop'); + + await n8n.canvas.nodeCreator.selectItem('Slack'); + await n8n.ndv.setupHelper.setParameter('operation', 'Send and Wait for Response'); + await n8n.ndv.close(); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); +}); diff --git a/packages/testing/playwright/tests/ui/node-creator/vector-stores.spec.ts b/packages/testing/playwright/tests/ui/node-creator/vector-stores.spec.ts new file mode 100644 index 00000000000..7d53f1d4b2e --- /dev/null +++ b/packages/testing/playwright/tests/ui/node-creator/vector-stores.spec.ts @@ -0,0 +1,46 @@ +import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants'; +import { test, expect } from '../../../fixtures/base'; + +test.describe('Node Creator Vector Stores', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + await n8n.canvas.addNode('Manual Trigger'); + }); + + test('should show vector stores actions', async ({ n8n }) => { + const expectedActions = [ + 'Get ranked documents from vector store', + 'Add documents to vector store', + 'Retrieve documents for Chain/Tool as Vector Store', + 'Retrieve documents for AI Agent as Tool', + ]; + + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.nodeCreator.searchFor('Vector Store'); + + await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible(); + + await n8n.canvas.nodeCreator.getItem('Simple Vector Store').click(); + + for (const action of expectedActions) { + await expect(n8n.canvas.nodeCreator.getItem(action)).toBeVisible(); + } + + await n8n.canvas.nodeCreator.goBackFromSubcategory(); + await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible(); + }); + + test('should find vector store nodes in creator', async ({ n8n }) => { + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.nodeCreator.searchFor('Vector Store'); + + await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible(); + }); + + test('should search for specific vector store nodes', async ({ n8n }) => { + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.nodeCreator.searchFor('Simple Vector Store'); + + await expect(n8n.canvas.nodeCreator.getItem('Simple Vector Store')).toBeVisible(); + }); +}); diff --git a/packages/testing/playwright/tests/ui/node-creator/workflows.spec.ts b/packages/testing/playwright/tests/ui/node-creator/workflows.spec.ts new file mode 100644 index 00000000000..a117b8024eb --- /dev/null +++ b/packages/testing/playwright/tests/ui/node-creator/workflows.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '../../../fixtures/base'; + +test.describe('Node Creator Workflow Building', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should append manual trigger when adding action node from canvas add button', async ({ + n8n, + }) => { + await n8n.canvas.clickCanvasPlusButton(); + await n8n.canvas.nodeCreator.searchFor('n8n'); + await n8n.canvas.nodeCreator.selectItem('n8n'); + await n8n.canvas.nodeCreator.selectCategoryItem('Actions'); + await n8n.canvas.nodeCreator.selectItem('Create a credential'); + await n8n.page.keyboard.press('Escape'); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + await expect(n8n.canvas.nodeConnections()).toHaveCount(1); + }); + + test('should append manual trigger when adding action node from plus button', async ({ n8n }) => { + await n8n.canvas.clickCanvasPlusButton(); + await n8n.canvas.nodeCreator.searchFor('n8n'); + await n8n.canvas.nodeCreator.selectItem('n8n'); + await n8n.canvas.nodeCreator.selectCategoryItem('Actions'); + await n8n.canvas.nodeCreator.selectItem('Create a credential'); + await n8n.page.keyboard.press('Escape'); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); +});