diff --git a/cypress/e2e/50-logs.cy.ts b/cypress/e2e/50-logs.cy.ts deleted file mode 100644 index f370bf33d4..0000000000 --- a/cypress/e2e/50-logs.cy.ts +++ /dev/null @@ -1,232 +0,0 @@ -import * as executions from '../composables/executions'; -import * as logs from '../composables/logs'; -import * as chat from '../composables/modals/chat-modal'; -import * as ndv from '../composables/ndv'; -import * as workflow from '../composables/workflow'; -import Workflow_chat from '../fixtures/Workflow_ai_agent.json'; -import Workflow_if from '../fixtures/Workflow_if.json'; -import Workflow_loop from '../fixtures/Workflow_loop.json'; -import Workflow_wait_for_webhook from '../fixtures/Workflow_wait_for_webhook.json'; - -describe('Logs', () => { - it('should populate logs as manual execution progresses', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_loop); - workflow.clickZoomToFit(); - logs.openLogsPanel(); - logs.getLogEntries().should('have.length', 0); - - workflow.executeWorkflow(); - logs.getOverviewStatus().contains('Running').should('exist'); - - logs.getLogEntries().should('have.length', 4); - logs.getLogEntries().eq(0).should('contain.text', 'When clicking ‘Execute workflow’'); - logs.getLogEntries().eq(1).should('contain.text', 'Code'); - logs.getLogEntries().eq(2).should('contain.text', 'Loop Over Items'); - logs.getLogEntries().eq(3).should('contain.text', 'Wait'); - - logs.getLogEntries().should('have.length', 6); - logs.getLogEntries().eq(4).should('contain.text', 'Loop Over Items'); - logs.getLogEntries().eq(5).should('contain.text', 'Wait'); - - logs.getLogEntries().should('have.length', 8); - logs.getLogEntries().eq(6).should('contain.text', 'Loop Over Items'); - logs.getLogEntries().eq(7).should('contain.text', 'Wait'); - - logs.getLogEntries().should('have.length', 10); - logs.getLogEntries().eq(8).should('contain.text', 'Loop Over Items'); - logs.getLogEntries().eq(9).should('contain.text', 'Code1'); - logs - .getOverviewStatus() - .contains(/Error in [\d\.]+s/) - .should('exist'); - logs.getSelectedLogEntry().should('contain.text', 'Code1'); // Errored node is automatically selected - logs.getNodeErrorMessageHeader().should('contain.text', 'test!!! [line 1]'); - workflow.getNodeIssuesByName('Code1').should('exist'); - - logs.pressClearExecutionButton(); - logs.getLogEntries().should('have.length', 0); - workflow.getNodeIssuesByName('Code1').should('not.exist'); - }); - - it('should allow to trigger partial execution', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_if); - workflow.clickZoomToFit(); - logs.openLogsPanel(); - - workflow.executeWorkflowAndWait(false); - logs.getLogEntries().should('have.length', 6); - logs.getLogEntries().eq(0).should('contain.text', 'Schedule Trigger'); - logs.getLogEntries().eq(1).should('contain.text', 'Code'); - logs.getLogEntries().eq(2).should('contain.text', 'Edit Fields'); - logs.getLogEntries().eq(3).should('contain.text', 'If'); - logs.getLogEntries().eq(4).should('contain.text', 'Edit Fields'); - logs.getLogEntries().eq(5).should('contain.text', 'Edit Fields'); - - logs.clickTriggerPartialExecutionAtRow(3); - logs.getLogEntries().should('have.length', 3); - logs.getLogEntries().eq(0).should('contain.text', 'Schedule Trigger'); - logs.getLogEntries().eq(1).should('contain.text', 'Code'); - logs.getLogEntries().eq(2).should('contain.text', 'If'); - }); - - // TODO: make it possible to test workflows with AI model end-to-end - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - it.skip('should show input and output data in the selected display mode', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_chat); - workflow.clickZoomToFit(); - logs.openLogsPanel(); - chat.sendManualChatMessage('Hi!'); - workflow.waitForSuccessBannerToAppear(); - chat.getManualChatMessages().eq(0).should('contain.text', 'Hi!'); - chat.getManualChatMessages().eq(1).should('contain.text', 'Hello from e2e model!!!'); - logs.getLogEntries().eq(2).should('have.text', 'E2E Chat Model'); - logs.getLogEntries().eq(2).click(); - - logs.getOutputPanel().should('contain.text', 'Hello from e2e model!!!'); - logs.setOutputDisplayMode('table'); - logs.getOutputTbodyCell(1, 0).should('contain.text', 'text:Hello from **e2e** model!!!'); - logs.getOutputTbodyCell(1, 1).should('contain.text', 'completionTokens:20'); - logs.setOutputDisplayMode('schema'); - logs.getOutputPanel().should('contain.text', 'generations[0]'); - logs.getOutputPanel().should('contain.text', 'Hello from **e2e** model!!!'); - logs.setOutputDisplayMode('json'); - logs.getOutputPanel().should('contain.text', '[{"response": {"generations": ['); - - logs.toggleInputPanel(); - logs.getInputPanel().should('contain.text', 'Human: Hi!'); - logs.setInputDisplayMode('table'); - logs.getInputTbodyCell(1, 0).should('contain.text', '0:Human: Hi!'); - logs.setInputDisplayMode('schema'); - logs.getInputPanel().should('contain.text', 'messages[0]'); - logs.getInputPanel().should('contain.text', 'Human: Hi!'); - logs.setInputDisplayMode('json'); - logs.getInputPanel().should('contain.text', '[{"messages": ["Human: Hi!"],'); - }); - - it('should show input and output data of correct run index and branch', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_if); - workflow.clickZoomToFit(); - logs.openLogsPanel(); - workflow.executeWorkflow(); - - logs.clickLogEntryAtRow(2); // Run #1 of 'Edit Fields' node; input is 'Code' node - logs.toggleInputPanel(); - logs.setInputDisplayMode('table'); - logs.getInputTableRows().should('have.length', 11); - logs.getInputTbodyCell(1, 0).should('contain.text', '0'); - logs.getInputTbodyCell(10, 0).should('contain.text', '9'); - logs.clickOpenNdvAtRow(2); - ndv.setInputDisplayMode('Table'); - ndv.getInputSelect().should('have.value', 'Code '); - ndv.getInputTableRows().should('have.length', 11); - ndv.getInputTbodyCell(1, 0).should('contain.text', '0'); - ndv.getInputTbodyCell(10, 0).should('contain.text', '9'); - ndv.getOutputRunSelectorInput().should('have.value', '1 of 3 (10 items)'); - - ndv.clickGetBackToCanvas(); - - logs.clickLogEntryAtRow(4); // Run #2 of 'Edit Fields' node; input is false branch of 'If' node - logs.getInputTableRows().should('have.length', 6); - logs.getInputTbodyCell(1, 0).should('contain.text', '5'); - logs.getInputTbodyCell(5, 0).should('contain.text', '9'); - logs.clickOpenNdvAtRow(4); - ndv.getInputSelect().should('have.value', 'If '); - ndv.getInputTableRows().should('have.length', 6); - ndv.getInputTbodyCell(1, 0).should('contain.text', '5'); - ndv.getInputTbodyCell(5, 0).should('contain.text', '9'); - ndv.getOutputRunSelectorInput().should('have.value', '2 of 3 (5 items)'); - - ndv.clickGetBackToCanvas(); - - logs.clickLogEntryAtRow(5); // Run #3 of 'Edit Fields' node; input is true branch of 'If' node - logs.getInputTableRows().should('have.length', 6); - logs.getInputTbodyCell(1, 0).should('contain.text', '0'); - logs.getInputTbodyCell(5, 0).should('contain.text', '4'); - logs.clickOpenNdvAtRow(5); - ndv.getInputSelect().should('have.value', 'If '); - ndv.getInputTableRows().should('have.length', 6); - ndv.getInputTbodyCell(1, 0).should('contain.text', '0'); - ndv.getInputTbodyCell(5, 0).should('contain.text', '4'); - ndv.getOutputRunSelectorInput().should('have.value', '3 of 3 (5 items)'); - }); - - it('should keep populated logs unchanged when workflow get edits after the execution', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_if); - workflow.clickZoomToFit(); - logs.openLogsPanel(); - - workflow.executeWorkflowAndWait(false); - logs.getLogEntries().should('have.length', 6); - workflow.disableNode('Edit Fields'); - logs.getLogEntries().should('have.length', 6); - workflow.deleteNode('If'); - logs.getLogEntries().should('have.length', 6); - }); - - // TODO: make it possible to test workflows with AI model end-to-end - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - it.skip('should show logs for a past execution', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_chat); - workflow.clickZoomToFit(); - logs.openLogsPanel(); - chat.sendManualChatMessage('Hi!'); - workflow.waitForSuccessBannerToAppear(); - workflow.openExecutions(); - executions.toggleAutoRefresh(); // Stop unnecessary background requests - executions.getManualChatMessages().eq(0).should('contain.text', 'Hi!'); - executions.getManualChatMessages().eq(1).should('contain.text', 'Hello from e2e model!!!'); - executions - .getLogsOverviewStatus() - .contains(/Success in [\d\.]+m?s/) - .should('exist'); - executions.getLogEntries().should('have.length', 3); - executions.getLogEntries().eq(0).should('contain.text', 'When chat message received'); - executions.getLogEntries().eq(1).should('contain.text', 'AI Agent'); - executions.getLogEntries().eq(2).should('contain.text', 'E2E Chat Model'); - }); - - it('should show logs for a workflow with a node that waits for webhook', () => { - workflow.navigateToNewWorkflowPage(); - workflow.pasteWorkflow(Workflow_wait_for_webhook); - workflow.getCanvas().click('topLeft'); // click canvas to deselect nodes - workflow.clickZoomToFit(); - logs.openLogsPanel(); - - workflow.executeWorkflow(); - - workflow.getNodesWithSpinner().should('contain.text', 'Wait'); - workflow.getWaitingNodes().should('contain.text', 'Wait'); - logs.getLogEntries().should('have.length', 2); - logs.getLogEntries().eq(1).should('contain.text', 'Wait node'); - logs.getLogEntries().eq(1).should('contain.text', 'Waiting'); - - workflow.openNode('Wait node'); - ndv - .getOutputPanelDataContainer() - .find('a') - .should('have.attr', 'href') - .then((url) => { - cy.request(url as unknown as string).then((response) => { - expect(response.status).to.eq(200); - }); - }); - ndv.getBackToCanvasButton().click(); - - workflow.getNodesWithSpinner().should('not.exist'); - workflow.getWaitingNodes().should('not.exist'); - logs - .getOverviewStatus() - .contains(/Success in [\d\.]+m?s/) - .should('exist'); - logs.getLogEntries().eq(1).click(); // click selected row to deselect - logs.getLogEntries().should('have.length', 2); - logs.getLogEntries().eq(1).should('contain.text', 'Wait node'); - logs.getLogEntries().eq(1).should('contain.text', 'Success'); - }); -}); diff --git a/packages/testing/playwright/CLAUDE.md b/packages/testing/playwright/CLAUDE.md new file mode 100644 index 0000000000..41ce0c61d4 --- /dev/null +++ b/packages/testing/playwright/CLAUDE.md @@ -0,0 +1,11 @@ +# CLAUDE.md + +## Commands + +- Use `pnpm test:local --reporter=line --grep="..."` to execute tests. + + +## Code Styles + +- In writing locators, use specialized methods when available. + For example, prefer `page.getByRole('button')` over `page.locator('[role=button]')`. \ No newline at end of file diff --git a/packages/testing/playwright/Types.ts b/packages/testing/playwright/Types.ts index 3f4cee5403..52ff51df94 100644 --- a/packages/testing/playwright/Types.ts +++ b/packages/testing/playwright/Types.ts @@ -93,7 +93,7 @@ export interface TestRequirements { * } * ``` */ - workflow?: Record; + workflow?: string | Record; /** * Browser storage values to set before the test diff --git a/packages/testing/playwright/fixtures/cloud.ts b/packages/testing/playwright/fixtures/cloud.ts index 826755f922..0230e7c3d8 100644 --- a/packages/testing/playwright/fixtures/cloud.ts +++ b/packages/testing/playwright/fixtures/cloud.ts @@ -105,7 +105,7 @@ export const test = base.extend({ }, // Browser context with cloud container URL and interceptors - context: async ({ context, baseURL }, use) => { + context: async ({ context }, use) => { await setupDefaultInterceptors(context); await use(context); }, @@ -123,8 +123,8 @@ export const test = base.extend({ }, // n8n page object - n8n: async ({ page }, use) => { - const n8nInstance = new n8nPage(page); + n8n: async ({ page, api }, use) => { + const n8nInstance = new n8nPage(page, api); await use(n8nInstance); }, diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index d8057f2c49..e72ed64ed0 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -20,7 +20,8 @@ "install-browsers:ci": "PLAYWRIGHT_BROWSERS_PATH=./ms-playwright-cache playwright install chromium --with-deps", "browsers:uninstall": "playwright uninstall --all", "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit" }, "devDependencies": { "@currents/playwright": "^1.15.3", diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index 19bedbdeb2..985aeb1bd8 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -360,6 +360,10 @@ export class CanvasPage extends BasePage { return this.page.getByTestId('canvas-wrapper'); } + canvasBody(): Locator { + return this.page.getByTestId('canvas'); + } + toggleFocusPanelButton(): Locator { return this.page.getByTestId('toggle-focus-panel-button'); } @@ -526,8 +530,24 @@ export class CanvasPage extends BasePage { return this.getManualChatModal().locator('.chat-messages-list .chat-message'); } + getNodesWithSpinner(): Locator { + return this.page.getByTestId('canvas-node').filter({ + has: this.page.locator('[data-icon=refresh-cw]'), + }); + } + + getWaitingNodes(): Locator { + return this.page.getByTestId('canvas-node').filter({ + has: this.page.locator('[data-icon=clock]'), + }); + } + async sendManualChatMessage(message: string): Promise { await this.getManualChatInput().fill(message); await this.getManualChatModal().locator('.chat-input-send-button').click(); } + + async openExecutions() { + await this.page.getByTestId('radio-button-executions').click(); + } } diff --git a/packages/testing/playwright/pages/ExecutionsPage.ts b/packages/testing/playwright/pages/ExecutionsPage.ts index bbfa505a33..ea6640339a 100644 --- a/packages/testing/playwright/pages/ExecutionsPage.ts +++ b/packages/testing/playwright/pages/ExecutionsPage.ts @@ -20,6 +20,26 @@ export class ExecutionsPage extends BasePage { return executionItems.nth(0); } + getAutoRefreshButton() { + return this.page.getByTestId('auto-refresh-checkbox'); + } + + getPreviewIframe() { + return this.page.getByTestId('workflow-preview-iframe').contentFrame(); + } + + getManualChatMessages(): Locator { + return this.getPreviewIframe().locator('.chat-messages-list .chat-message'); + } + + getLogsOverviewStatus() { + return this.getPreviewIframe().getByTestId('logs-overview-status'); + } + + getLogEntries(): Locator { + return this.getPreviewIframe().getByTestId('logs-overview-body').getByRole('treeitem'); + } + async clickLastExecutionItem(): Promise { const executionItem = this.getLastExecutionItem(); await executionItem.click(); @@ -30,7 +50,6 @@ export class ExecutionsPage extends BasePage { * @param action - The action to take. */ async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise { - const confirmDialog = this.page.locator('.matching-pinned-nodes-confirmation'); await this.page.getByRole('button', { name: action }).click(); } } diff --git a/packages/testing/playwright/pages/LogsPage.ts b/packages/testing/playwright/pages/LogsPage.ts new file mode 100644 index 0000000000..126e8b0688 --- /dev/null +++ b/packages/testing/playwright/pages/LogsPage.ts @@ -0,0 +1,92 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +export class LogsPage extends BasePage { + constructor(page: Page) { + super(page); + } + + /** + * Accessors + */ + + getOverviewStatus(): Locator { + return this.page.getByTestId('logs-overview-status'); + } + + getClearExecutionButton(): Locator { + return this.page + .getByTestId('logs-overview-header') + .locator('button') + .filter({ hasText: 'Clear execution' }); + } + + getLogEntries(): Locator { + return this.page.getByTestId('logs-overview-body').getByRole('treeitem'); + } + + getSelectedLogEntry(): Locator { + return this.page.getByTestId('logs-overview-body').getByRole('treeitem', { selected: true }); + } + + getInputPanel(): Locator { + return this.page.getByTestId('log-details-input'); + } + + getInputTableRows(): Locator { + return this.getInputPanel().locator('table tr'); + } + + getInputTbodyCell(row: number, col: number): Locator { + return this.getInputPanel().locator('table tbody tr').nth(row).locator('td').nth(col); + } + + getNodeErrorMessageHeader(): Locator { + return this.getOutputPanel().getByTestId('node-error-message'); + } + + getOutputPanel(): Locator { + return this.page.getByTestId('log-details-output'); + } + + getOutputTbodyCell(row: number, col: number): Locator { + return this.getOutputPanel().locator('table tbody tr').nth(row).locator('td').nth(col); + } + + /** + * Actions + */ + + async openLogsPanel(): Promise { + await this.page.getByTestId('logs-overview-header').click(); + } + + async clickLogEntryAtRow(rowIndex: number): Promise { + await this.getLogEntries().nth(rowIndex).click(); + } + + async toggleInputPanel(): Promise { + await this.page.getByTestId('log-details-header').getByText('Input').click(); + } + + async clickOpenNdvAtRow(rowIndex: number): Promise { + await this.getLogEntries().nth(rowIndex).hover(); + await this.getLogEntries().nth(rowIndex).getByLabel('Open...').click(); + } + + async clickTriggerPartialExecutionAtRow(rowIndex: number): Promise { + await this.getLogEntries().nth(rowIndex).hover(); + await this.getLogEntries().nth(rowIndex).getByLabel('Execute step').click(); + } + + async setInputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema'): Promise { + await this.getInputPanel().hover(); + await this.getInputPanel().getByTestId(`radio-button-${mode}`).click(); + } + + async setOutputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema'): Promise { + await this.getOutputPanel().hover(); + await this.getOutputPanel().getByTestId(`radio-button-${mode}`).click(); + } +} diff --git a/packages/testing/playwright/pages/NodeDetailsViewPage.ts b/packages/testing/playwright/pages/NodeDetailsViewPage.ts index 9223356c5a..788857b699 100644 --- a/packages/testing/playwright/pages/NodeDetailsViewPage.ts +++ b/packages/testing/playwright/pages/NodeDetailsViewPage.ts @@ -513,12 +513,8 @@ export class NodeDetailsViewPage extends BasePage { return this.getInputPanel().locator('table th').nth(index); } - getInputTableCell(row: number, col: number) { - return this.getInputPanel().locator('table tbody tr').nth(row).locator('td').nth(col); - } - getInputTbodyCell(row: number, col: number) { - return this.getInputTableCell(row, col); + return this.getInputPanel().locator('table tbody tr').nth(row).locator('td').nth(col); } getAssignmentName(paramName: string, index = 0) { @@ -581,7 +577,7 @@ export class NodeDetailsViewPage extends BasePage { } getInputTableCellSpan(row: number, col: number, dataName: string) { - return this.getInputTableCell(row, col).locator(`span[data-name="${dataName}"]`).first(); + return this.getInputTbodyCell(row, col).locator(`span[data-name="${dataName}"]`).first(); } getAddFieldToSortByButton() { @@ -630,4 +626,16 @@ export class NodeDetailsViewPage extends BasePage { await input.clear(); await input.fill(value); } + + getInputSelect() { + return this.page.getByTestId('ndv-input-select').locator('input'); + } + + getInputTableRows() { + return this.getInputTable().locator('tr'); + } + + getOutputRunSelectorInput() { + return this.getOutputPanel().locator('[data-test-id="run-selector"] input'); + } } diff --git a/packages/testing/playwright/pages/NotificationsPage.ts b/packages/testing/playwright/pages/NotificationsPage.ts index 3fe609783e..eb81b34782 100644 --- a/packages/testing/playwright/pages/NotificationsPage.ts +++ b/packages/testing/playwright/pages/NotificationsPage.ts @@ -91,7 +91,7 @@ export class NotificationsPage { text: string | RegExp, options: { timeout?: number; maxRetries?: number } = {}, ): Promise { - const { timeout = 1500, maxRetries = 15 } = options; + const { maxRetries = 15 } = options; let closedCount = 0; let retries = 0; diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index bf2732f336..a410a699e1 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -8,6 +8,7 @@ import { DemoPage } from './DemoPage'; import { ExecutionsPage } from './ExecutionsPage'; import { IframePage } from './IframePage'; import { InteractionsPage } from './InteractionsPage'; +import { LogsPage } from './LogsPage'; import { NodeDetailsViewPage } from './NodeDetailsViewPage'; import { NotificationsPage } from './NotificationsPage'; import { NpsSurveyPage } from './NpsSurveyPage'; @@ -38,6 +39,7 @@ export class n8nPage { readonly demo: DemoPage; readonly iframe: IframePage; readonly interactions: InteractionsPage; + readonly logs: LogsPage; readonly ndv: NodeDetailsViewPage; readonly npsSurvey: NpsSurveyPage; readonly projectSettings: ProjectSettingsPage; @@ -72,6 +74,7 @@ export class n8nPage { this.demo = new DemoPage(page); this.iframe = new IframePage(page); this.interactions = new InteractionsPage(page); + this.logs = new LogsPage(page); this.ndv = new NodeDetailsViewPage(page); this.npsSurvey = new NpsSurveyPage(page); this.projectSettings = new ProjectSettingsPage(page); diff --git a/packages/testing/playwright/tests/ui/50-logs.spec.ts b/packages/testing/playwright/tests/ui/50-logs.spec.ts new file mode 100644 index 0000000000..fcc54ae679 --- /dev/null +++ b/packages/testing/playwright/tests/ui/50-logs.spec.ts @@ -0,0 +1,254 @@ +import { test, expect } from '../../fixtures/base'; + +// Node name constants +const NODES = { + MANUAL_TRIGGER: 'When clicking ‘Execute workflow’', + CODE: 'Code', + LOOP_OVER_ITEMS: 'Loop Over Items', + WAIT: 'Wait', + CODE1: 'Code1', + SCHEDULE_TRIGGER: 'Schedule Trigger', + EDIT_FIELDS: 'Edit Fields', + IF: 'If', + WAIT_NODE: 'Wait node', +}; + +test.describe('Logs', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.goHome(); + }); + + test('should populate logs as manual execution progresses', async ({ + n8n, + setupRequirements, + }) => { + await setupRequirements({ workflow: 'Workflow_loop.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + await expect(n8n.logs.getLogEntries()).toHaveCount(0); + + await n8n.canvas.clickExecuteWorkflowButton(); + await expect(n8n.logs.getOverviewStatus().filter({ hasText: 'Running' })).toBeVisible(); + + await expect(n8n.logs.getLogEntries()).toHaveCount(4); + await expect(n8n.logs.getLogEntries().nth(0)).toContainText(NODES.MANUAL_TRIGGER); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText(NODES.CODE); + await expect(n8n.logs.getLogEntries().nth(2)).toContainText(NODES.LOOP_OVER_ITEMS); + await expect(n8n.logs.getLogEntries().nth(3)).toContainText(NODES.WAIT); + + await expect(n8n.logs.getLogEntries()).toHaveCount(6); + await expect(n8n.logs.getLogEntries().nth(4)).toContainText(NODES.LOOP_OVER_ITEMS); + await expect(n8n.logs.getLogEntries().nth(5)).toContainText(NODES.WAIT); + + await expect(n8n.logs.getLogEntries()).toHaveCount(8); + await expect(n8n.logs.getLogEntries().nth(6)).toContainText(NODES.LOOP_OVER_ITEMS); + await expect(n8n.logs.getLogEntries().nth(7)).toContainText(NODES.WAIT); + + await expect(n8n.logs.getLogEntries()).toHaveCount(10); + await expect(n8n.logs.getLogEntries().nth(8)).toContainText(NODES.LOOP_OVER_ITEMS); + await expect(n8n.logs.getLogEntries().nth(9)).toContainText(NODES.CODE1); + await expect( + n8n.logs.getOverviewStatus().filter({ hasText: /Error in [\d.]+s/ }), + ).toBeVisible(); + await expect(n8n.logs.getSelectedLogEntry()).toContainText(NODES.CODE1); // Errored node is automatically selected + await expect(n8n.logs.getNodeErrorMessageHeader()).toContainText('test!!! [line 1]'); + await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).toBeVisible(); + + await n8n.logs.getClearExecutionButton().click(); + await expect(n8n.logs.getLogEntries()).toHaveCount(0); + await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).not.toBeVisible(); + }); + + test('should allow to trigger partial execution', async ({ n8n, setupRequirements }) => { + await setupRequirements({ workflow: 'Workflow_if.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + + await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful'); + await expect(n8n.logs.getLogEntries()).toHaveCount(6); + await expect(n8n.logs.getLogEntries().nth(0)).toContainText(NODES.SCHEDULE_TRIGGER); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText(NODES.CODE); + await expect(n8n.logs.getLogEntries().nth(2)).toContainText(NODES.EDIT_FIELDS); + await expect(n8n.logs.getLogEntries().nth(3)).toContainText(NODES.IF); + await expect(n8n.logs.getLogEntries().nth(4)).toContainText(NODES.EDIT_FIELDS); + await expect(n8n.logs.getLogEntries().nth(5)).toContainText(NODES.EDIT_FIELDS); + + await n8n.logs.clickTriggerPartialExecutionAtRow(3); + await expect(n8n.logs.getLogEntries()).toHaveCount(3); + await expect(n8n.logs.getLogEntries().nth(0)).toContainText(NODES.SCHEDULE_TRIGGER); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText(NODES.CODE); + await expect(n8n.logs.getLogEntries().nth(2)).toContainText(NODES.IF); + }); + + // TODO: make it possible to test workflows with AI model end-to-end + test.skip('should show input and output data in the selected display mode', async ({ + n8n, + setupRequirements, + }) => { + await setupRequirements({ workflow: 'Workflow_ai_agent.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + await n8n.canvas.sendManualChatMessage('Hi!'); + await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful'); + await expect(n8n.canvas.getManualChatMessages().nth(0)).toContainText('Hi!'); + await expect(n8n.canvas.getManualChatMessages().nth(1)).toContainText( + 'Hello from e2e model!!!', + ); + await expect(n8n.logs.getLogEntries().nth(2)).toHaveText('E2E Chat Model'); + await n8n.logs.getLogEntries().nth(2).click(); + + await expect(n8n.logs.getOutputPanel()).toContainText('Hello from e2e model!!!'); + await n8n.logs.setOutputDisplayMode('table'); + await expect(n8n.logs.getOutputTbodyCell(0, 0)).toContainText( + 'text:Hello from **e2e** model!!!', + ); + await expect(n8n.logs.getOutputTbodyCell(0, 1)).toContainText('completionTokens:20'); + await n8n.logs.setOutputDisplayMode('schema'); + await expect(n8n.logs.getOutputPanel()).toContainText('generations[0]'); + await expect(n8n.logs.getOutputPanel()).toContainText('Hello from **e2e** model!!!'); + await n8n.logs.setOutputDisplayMode('json'); + await expect(n8n.logs.getOutputPanel()).toContainText('[{"response": {"generations": ['); + + await n8n.logs.toggleInputPanel(); + await expect(n8n.logs.getInputPanel()).toContainText('Human: Hi!'); + await n8n.logs.setInputDisplayMode('table'); + await expect(n8n.logs.getInputTbodyCell(0, 0)).toContainText('0:Human: Hi!'); + await n8n.logs.setInputDisplayMode('schema'); + await expect(n8n.logs.getInputPanel()).toContainText('messages[0]'); + await expect(n8n.logs.getInputPanel()).toContainText('Human: Hi!'); + await n8n.logs.setInputDisplayMode('json'); + await expect(n8n.logs.getInputPanel()).toContainText('[{"messages": ["Human: Hi!"],'); + }); + + test('should show input and output data of correct run index and branch', async ({ + n8n, + setupRequirements, + }) => { + await setupRequirements({ workflow: 'Workflow_if.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + await n8n.canvas.clickExecuteWorkflowButton(); + + await n8n.logs.clickLogEntryAtRow(2); // Run #1 of 'Edit Fields' node; input is 'Code' node + await n8n.logs.toggleInputPanel(); + await n8n.logs.setInputDisplayMode('table'); + await expect(n8n.logs.getInputTableRows()).toHaveCount(11); + await expect(n8n.logs.getInputTbodyCell(0, 0)).toContainText('0'); + await expect(n8n.logs.getInputTbodyCell(9, 0)).toContainText('9'); + await n8n.logs.clickOpenNdvAtRow(2); + await n8n.ndv.switchInputMode('Table'); + await expect(n8n.ndv.getInputSelect()).toHaveValue(`${NODES.CODE} `); + await expect(n8n.ndv.getInputTableRows()).toHaveCount(11); + await expect(n8n.ndv.getInputTbodyCell(0, 0)).toContainText('0'); + await expect(n8n.ndv.getInputTbodyCell(9, 0)).toContainText('9'); + await expect(n8n.ndv.getOutputRunSelectorInput()).toHaveValue('1 of 3 (10 items)'); + + await n8n.ndv.clickBackToCanvasButton(); + + await n8n.logs.clickLogEntryAtRow(4); // Run #2 of 'Edit Fields' node; input is false branch of 'If' node + await expect(n8n.logs.getInputTableRows()).toHaveCount(6); + await expect(n8n.logs.getInputTbodyCell(0, 0)).toContainText('5'); + await expect(n8n.logs.getInputTbodyCell(4, 0)).toContainText('9'); + await n8n.logs.clickOpenNdvAtRow(4); + await expect(n8n.ndv.getInputSelect()).toHaveValue(`${NODES.IF} `); + await expect(n8n.ndv.getInputTableRows()).toHaveCount(6); + await expect(n8n.ndv.getInputTbodyCell(0, 0)).toContainText('5'); + await expect(n8n.ndv.getInputTbodyCell(4, 0)).toContainText('9'); + await expect(n8n.ndv.getOutputRunSelectorInput()).toHaveValue('2 of 3 (5 items)'); + + await n8n.ndv.clickBackToCanvasButton(); + + await n8n.logs.clickLogEntryAtRow(5); // Run #3 of 'Edit Fields' node; input is true branch of 'If' node + await expect(n8n.logs.getInputTableRows()).toHaveCount(6); + await expect(n8n.logs.getInputTbodyCell(0, 0)).toContainText('0'); + await expect(n8n.logs.getInputTbodyCell(4, 0)).toContainText('4'); + await n8n.logs.clickOpenNdvAtRow(5); + await expect(n8n.ndv.getInputSelect()).toHaveValue(`${NODES.IF} `); + await expect(n8n.ndv.getInputTableRows()).toHaveCount(6); + await expect(n8n.ndv.getInputTbodyCell(0, 0)).toContainText('0'); + await expect(n8n.ndv.getInputTbodyCell(4, 0)).toContainText('4'); + await expect(n8n.ndv.getOutputRunSelectorInput()).toHaveValue('3 of 3 (5 items)'); + }); + + test('should keep populated logs unchanged when workflow get edits after the execution', async ({ + n8n, + setupRequirements, + }) => { + await setupRequirements({ workflow: 'Workflow_if.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + + await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful'); + await expect(n8n.logs.getLogEntries()).toHaveCount(6); + await n8n.canvas.nodeDisableButton(NODES.EDIT_FIELDS).click(); + await expect(n8n.logs.getLogEntries()).toHaveCount(6); + await n8n.canvas.deleteNodeByName(NODES.IF); + await expect(n8n.logs.getLogEntries()).toHaveCount(6); + }); + + // TODO: make it possible to test workflows with AI model end-to-end + test.skip('should show logs for a past execution', async ({ n8n, setupRequirements }) => { + await setupRequirements({ workflow: 'Workflow_ai_agent.json' }); + + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + + await n8n.canvas.sendManualChatMessage('Hi!'); + await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful'); + await n8n.canvas.openExecutions(); + await n8n.executions.getAutoRefreshButton().click(); + await expect(n8n.executions.getManualChatMessages().nth(0)).toContainText('Hi!'); + await expect(n8n.executions.getManualChatMessages().nth(1)).toContainText( + 'Hello from e2e model!!!', + ); + await expect( + n8n.executions.getLogsOverviewStatus().filter({ hasText: /Success in [\d.]+m?s/ }), + ).toBeVisible(); + await expect(n8n.executions.getLogEntries()).toHaveCount(3); + await expect(n8n.executions.getLogEntries().nth(0)).toContainText('When chat message received'); + await expect(n8n.executions.getLogEntries().nth(1)).toContainText('AI Agent'); + await expect(n8n.executions.getLogEntries().nth(2)).toContainText('E2E Chat Model'); + }); + + test('should show logs for a workflow with a node that waits for webhook', async ({ + n8n, + setupRequirements, + }) => { + await setupRequirements({ workflow: 'Workflow_wait_for_webhook.json' }); + + await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } }); // click logs panel to deselect nodes in canvas + await n8n.canvas.clickZoomToFitButton(); + await n8n.logs.openLogsPanel(); + + await n8n.canvas.clickExecuteWorkflowButton(); + + await expect(n8n.canvas.getNodesWithSpinner()).toContainText(NODES.WAIT_NODE); + await expect(n8n.canvas.getWaitingNodes()).toContainText(NODES.WAIT_NODE); + await expect(n8n.logs.getLogEntries()).toHaveCount(2); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText('Waiting'); + + await n8n.canvas.openNode(NODES.WAIT_NODE); + const webhookUrl = await n8n.ndv.getOutputDataContainer().locator('a').getAttribute('href'); + await n8n.ndv.clickBackToCanvasButton(); + + // Trigger the webhook + const response = await n8n.page.request.get(webhookUrl!); + expect(response.status()).toBe(200); + + await expect(n8n.canvas.getNodesWithSpinner()).not.toBeVisible(); + await expect(n8n.canvas.getWaitingNodes()).not.toBeVisible(); + await expect( + n8n.logs.getOverviewStatus().filter({ hasText: /Success in [\d.]+m?s/ }), + ).toBeVisible(); + await n8n.logs.getLogEntries().nth(1).click(); // click selected row to deselect + await expect(n8n.logs.getLogEntries()).toHaveCount(2); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE); + await expect(n8n.logs.getLogEntries().nth(1)).toContainText('Success'); + }); +}); diff --git a/packages/testing/playwright/utils/requirements.ts b/packages/testing/playwright/utils/requirements.ts index b859ad205f..754c21a92a 100644 --- a/packages/testing/playwright/utils/requirements.ts +++ b/packages/testing/playwright/utils/requirements.ts @@ -42,7 +42,7 @@ export async function setupTestRequirements( // 3. Setup API intercepts if (requirements.intercepts) { - for (const [name, config] of Object.entries(requirements.intercepts)) { + for (const config of Object.values(requirements.intercepts)) { await page.route(config.url, async (route) => { await route.fulfill({ status: config.status ?? 200, @@ -56,7 +56,12 @@ export async function setupTestRequirements( // 4. Setup workflows if (requirements.workflow) { - for (const [name, workflowData] of Object.entries(requirements.workflow)) { + const entries = + typeof requirements.workflow === 'string' + ? [[requirements.workflow, requirements.workflow]] + : Object.entries(requirements.workflow); + + for (const [name, workflowData] of entries) { try { // Import workflow using the n8n page object await n8n.goHome(); diff --git a/cypress/fixtures/Workflow_ai_agent.json b/packages/testing/playwright/workflows/Workflow_ai_agent.json similarity index 100% rename from cypress/fixtures/Workflow_ai_agent.json rename to packages/testing/playwright/workflows/Workflow_ai_agent.json diff --git a/cypress/fixtures/Workflow_if.json b/packages/testing/playwright/workflows/Workflow_if.json similarity index 100% rename from cypress/fixtures/Workflow_if.json rename to packages/testing/playwright/workflows/Workflow_if.json diff --git a/cypress/fixtures/Workflow_loop.json b/packages/testing/playwright/workflows/Workflow_loop.json similarity index 100% rename from cypress/fixtures/Workflow_loop.json rename to packages/testing/playwright/workflows/Workflow_loop.json diff --git a/cypress/fixtures/Workflow_wait_for_webhook.json b/packages/testing/playwright/workflows/Workflow_wait_for_webhook.json similarity index 100% rename from cypress/fixtures/Workflow_wait_for_webhook.json rename to packages/testing/playwright/workflows/Workflow_wait_for_webhook.json