test: Migrate Cypress test for the log view (#19108)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Suguru Inoue
2025-09-03 14:21:28 +02:00
committed by GitHub
parent 6bd4edf1ec
commit f7479bb2e5
17 changed files with 428 additions and 247 deletions

View File

@@ -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');
});
});

View File

@@ -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]')`.

View File

@@ -93,7 +93,7 @@ export interface TestRequirements {
* }
* ```
*/
workflow?: Record<string, string>;
workflow?: string | Record<string, string>;
/**
* Browser storage values to set before the test

View File

@@ -105,7 +105,7 @@ export const test = base.extend<CloudOnlyFixtures>({
},
// 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<CloudOnlyFixtures>({
},
// 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);
},

View File

@@ -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",

View File

@@ -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<void> {
await this.getManualChatInput().fill(message);
await this.getManualChatModal().locator('.chat-input-send-button').click();
}
async openExecutions() {
await this.page.getByTestId('radio-button-executions').click();
}
}

View File

@@ -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<void> {
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<void> {
const confirmDialog = this.page.locator('.matching-pinned-nodes-confirmation');
await this.page.getByRole('button', { name: action }).click();
}
}

View File

@@ -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<void> {
await this.page.getByTestId('logs-overview-header').click();
}
async clickLogEntryAtRow(rowIndex: number): Promise<void> {
await this.getLogEntries().nth(rowIndex).click();
}
async toggleInputPanel(): Promise<void> {
await this.page.getByTestId('log-details-header').getByText('Input').click();
}
async clickOpenNdvAtRow(rowIndex: number): Promise<void> {
await this.getLogEntries().nth(rowIndex).hover();
await this.getLogEntries().nth(rowIndex).getByLabel('Open...').click();
}
async clickTriggerPartialExecutionAtRow(rowIndex: number): Promise<void> {
await this.getLogEntries().nth(rowIndex).hover();
await this.getLogEntries().nth(rowIndex).getByLabel('Execute step').click();
}
async setInputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema'): Promise<void> {
await this.getInputPanel().hover();
await this.getInputPanel().getByTestId(`radio-button-${mode}`).click();
}
async setOutputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema'): Promise<void> {
await this.getOutputPanel().hover();
await this.getOutputPanel().getByTestId(`radio-button-${mode}`).click();
}
}

View File

@@ -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');
}
}

View File

@@ -91,7 +91,7 @@ export class NotificationsPage {
text: string | RegExp,
options: { timeout?: number; maxRetries?: number } = {},
): Promise<number> {
const { timeout = 1500, maxRetries = 15 } = options;
const { maxRetries = 15 } = options;
let closedCount = 0;
let retries = 0;

View File

@@ -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);

View File

@@ -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');
});
});

View File

@@ -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();