test(editor): Add e2e test cases for the logs view (#15060)

This commit is contained in:
Suguru Inoue
2025-05-08 05:59:25 +02:00
committed by GitHub
parent 662358cc43
commit abdbe50907
11 changed files with 448 additions and 58 deletions

View File

@@ -9,6 +9,9 @@ export const getWorkflowExecutionPreviewIframe = () => cy.getByTestId('workflow-
export const getExecutionPreviewBody = () =>
getWorkflowExecutionPreviewIframe()
.its('0.contentDocument.body')
.should((body) => {
expect(body.querySelector('[data-test-id="canvas-wrapper"]')).to.exist;
})
.then((el) => cy.wrap(el));
export const getExecutionPreviewBodyNodes = () =>
@@ -21,9 +24,23 @@ export function getExecutionPreviewOutputPanelRelatedExecutionLink() {
return getExecutionPreviewBody().findChildByTestId('related-execution-link');
}
export function getLogsOverviewStatus() {
return getExecutionPreviewBody().findChildByTestId('logs-overview-status');
}
export function getLogEntries() {
return getExecutionPreviewBody().findChildByTestId('logs-overview-body').find('[role=treeitem]');
}
export function getManualChatMessages() {
return getExecutionPreviewBody().find('.chat-messages-list .chat-message');
}
/**
* Actions
*/
export const openExecutionPreviewNode = (name: string) =>
getExecutionPreviewBodyNodesByName(name).dblclick();
export const toggleAutoRefresh = () => cy.getByTestId('auto-refresh-checkbox').click();

View File

@@ -2,8 +2,20 @@
* Accessors
*/
export function getLogEntryAtRow(rowIndex: number) {
return cy.getByTestId('logs-overview-body').find('[role=treeitem]').eq(rowIndex);
export function getOverviewStatus() {
return cy.getByTestId('logs-overview-status');
}
export function getLogEntries() {
return cy.getByTestId('logs-overview-body').find('[role=treeitem]');
}
export function getSelectedLogEntry() {
return cy.getByTestId('logs-overview-body').find('[role=treeitem][aria-selected=true]');
}
export function getInputPanel() {
return cy.getByTestId('log-details-input');
}
export function getInputTableRows() {
@@ -14,6 +26,22 @@ export function getInputTbodyCell(row: number, col: number) {
return cy.getByTestId('log-details-input').find('table tr').eq(row).find('td').eq(col);
}
export function getNodeErrorMessageHeader() {
return cy.getByTestId('log-details-output').findChildByTestId('node-error-message');
}
export function getOutputPanel() {
return cy.getByTestId('log-details-output');
}
export function getOutputTableRows() {
return cy.getByTestId('log-details-output').find('table tr');
}
export function getOutputTbodyCell(row: number, col: number) {
return cy.getByTestId('log-details-output').find('table tr').eq(row).find('td').eq(col);
}
/**
* Actions
*/
@@ -22,8 +50,12 @@ export function openLogsPanel() {
cy.getByTestId('logs-overview-header').click();
}
export function pressClearExecutionButton() {
cy.getByTestId('logs-overview-header').find('button').contains('Clear execution').click();
}
export function clickLogEntryAtRow(rowIndex: number) {
getLogEntryAtRow(rowIndex).click();
getLogEntries().eq(rowIndex).click();
}
export function toggleInputPanel() {
@@ -31,11 +63,21 @@ export function toggleInputPanel() {
}
export function clickOpenNdvAtRow(rowIndex: number) {
getLogEntryAtRow(rowIndex).realHover();
getLogEntryAtRow(rowIndex).find('[aria-label="Open..."]').click();
getLogEntries().eq(rowIndex).realHover();
getLogEntries().eq(rowIndex).find('[aria-label="Open..."]').click();
}
export function setInputDisplayMode(mode: 'table') {
export function clickTriggerPartialExecutionAtRow(rowIndex: number) {
getLogEntries().eq(rowIndex).realHover();
getLogEntries().eq(rowIndex).find('[aria-label="Test step"]').click();
}
export function setInputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema') {
cy.getByTestId('log-details-input').realHover();
cy.getByTestId('log-details-input').findChildByTestId(`radio-button-${mode}`).click();
}
export function setOutputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema') {
cy.getByTestId('log-details-output').realHover();
cy.getByTestId('log-details-output').findChildByTestId(`radio-button-${mode}`).click();
}

View File

@@ -25,14 +25,6 @@ export type EndpointType =
* Getters
*/
export function executeWorkflowAndWait(waitForSuccessBannerToDisappear = true) {
cy.get('[data-test-id="execute-workflow-button"]').click();
cy.contains('Workflow executed successfully', { timeout: 4000 }).should('be.visible');
if (waitForSuccessBannerToDisappear) {
cy.contains('Workflow executed successfully', { timeout: 10000 }).should('not.exist');
}
}
export function getCanvas() {
return cy.getByTestId('canvas');
}
@@ -141,7 +133,7 @@ export function getWorkflowHistoryCloseButton() {
export function disableNode(name: string) {
const target = getNodeByName(name);
target.rightclick(name ? 'center' : 'topLeft', { force: true });
target.trigger('contextmenu');
cy.getByTestId('context-menu-item-toggle_activation').click();
}
@@ -201,6 +193,22 @@ export function getNodeIssuesByName(nodeName: string) {
* Actions
*/
export function executeWorkflow() {
cy.get('[data-test-id="execute-workflow-button"]').click();
}
export function waitForSuccessBannerToAppear() {
cy.contains(/(Workflow|Node) executed successfully/, { timeout: 4000 }).should('be.visible');
}
export function executeWorkflowAndWait(waitForSuccessBannerToDisappear = true) {
executeWorkflow();
waitForSuccessBannerToAppear();
if (waitForSuccessBannerToDisappear) {
cy.contains('Workflow executed successfully', { timeout: 10000 }).should('not.exist');
}
}
export function addNodeToCanvas(
nodeDisplayName: string,
plusButtonClick = true,
@@ -373,3 +381,7 @@ export function openContextMenu(
export function clickContextMenuAction(action: string) {
getContextMenuAction(action).click({ force: true });
}
export function openExecutions() {
cy.getByTestId('radio-button-executions').click();
}

View File

@@ -1,19 +1,120 @@
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 from '../fixtures/Workflow_if.json';
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';
describe('Logs', () => {
beforeEach(() => {
cy.overrideSettings({ logsView: { enabled: true } });
});
it('should show input and output data of correct run index and branch', () => {
it('should populate logs as manual execution progresses', () => {
workflow.navigateToNewWorkflowPage();
workflow.pasteWorkflow(Workflow);
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 Test 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();
@@ -55,4 +156,41 @@ describe('Logs', () => {
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');
});
});

View File

@@ -0,0 +1,66 @@
{
"nodes": [
{
"parameters": {
"options": {}
},
"id": "5eb6b347-b34e-4112-9601-f7aa94f26575",
"name": "When chat message received",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1.1,
"position": [0, 0],
"webhookId": "4fb58136-3481-494a-a30f-d9e064dac186"
},
{
"parameters": {
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.9,
"position": [220, 0],
"id": "32534841-9474-4890-9998-65d6a56bdf0c",
"name": "AI Agent"
},
{
"parameters": {
"response": "Hello from **e2e** model!!!"
},
"type": "@n8n/n8n-nodes-langchain.lmChatE2eTest",
"typeVersion": 1,
"position": [308, 220],
"id": "2f239d5b-95ef-4949-92b6-5a7541e1029f",
"name": "E2E Chat Model"
}
],
"connections": {
"When chat message received": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"AI Agent": {
"main": [[]]
},
"E2E Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "14f5bd03485879885c6d92999d35d4d24556536fa2b675f932eb27193691e2b2"
}
}

View File

@@ -8,8 +8,8 @@
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [-900, 60],
"id": "e6b8fc7c-442e-4283-a0cd-604dc7c9e816",
"position": [0, 0],
"id": "4c4f02e3-f0cc-4e1a-b084-9888dd75ccaf",
"name": "Schedule Trigger"
},
{
@@ -38,8 +38,8 @@
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [-460, 135],
"id": "f5c96b5b-9e22-4348-a258-fdb0417f5ff5",
"position": [660, 80],
"id": "3273dcdb-845c-43cc-8ed0-ffaef7b68b1c",
"name": "If"
},
{
@@ -58,8 +58,8 @@
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-240, 60],
"id": "2a6fc40d-5d8c-4c35-bf53-ee910267619f",
"position": [880, 0],
"id": "0522012f-fe99-41e0-ba19-7e18f22758d9",
"name": "Edit Fields"
},
{
@@ -68,9 +68,20 @@
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-680, 60],
"id": "12ae07e7-be34-43b6-806b-4c24be169ee6",
"position": [440, 0],
"id": "268c2da4-23c8-46c5-8992-37a92b8e4aad",
"name": "Code"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [220, 0],
"id": "0e2d1621-607d-4d53-98a3-07afc0d6230d",
"name": "Edit Fields1",
"disabled": true
}
],
"connections": {
@@ -78,7 +89,7 @@
"main": [
[
{
"node": "Code",
"node": "Edit Fields1",
"type": "main",
"index": 0
}
@@ -118,10 +129,21 @@
}
]
]
},
"Edit Fields1": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "db1f26b45a71ad9a8df79dde8d35bf1be13616c3b23eb55be8ecf642dd31500c"
"instanceId": "ddb4b2493e5f30d4af3bb43dd3c9acf1f60cbaa91c89e84d2967725474533c77"
}
}

View File

@@ -0,0 +1,110 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, -10],
"id": "cc68bd44-e150-403c-afaf-bd0bac0459dd",
"name": "When clicking Test workflow"
},
{
"parameters": {
"jsCode": "return [{data:1},{data:2},{data:3}]"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [220, -10],
"id": "461b130c-0efb-4201-8210-d5e794e88ed8",
"name": "Code"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [440, -10],
"id": "16860d82-1b2c-4882-ad76-43cc2042d695",
"name": "Loop Over Items"
},
{
"parameters": {
"amount": 2
},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [660, 40],
"id": "9ede6c97-a3c5-4f42-adcc-dfe66fc7a2a8",
"name": "Wait",
"webhookId": "36d32e2d-a9cf-4dc7-b138-70d7966c96d7"
},
{
"parameters": {
"jsCode": "throw Error('test!!!')"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [660, -160],
"id": "05ad4f4d-10fc-4bec-9145-0e5c20f455c4",
"name": "Code1"
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[
{
"node": "Code1",
"type": "main",
"index": 0
}
],
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "74f18f142a6ddf6880a6f8c5b30685f621743a7a66d8d94c8f164937f4dd5515"
}
}

View File

@@ -167,6 +167,7 @@ watch(
<template v-else>
<ExecutionSummary
v-if="execution"
data-test-id="logs-overview-status"
:class="$style.summary"
:status="execution.status"
:consumed-tokens="consumedTokens"

View File

@@ -77,6 +77,7 @@ function isLastChild(level: number) {
role="treeitem"
tabindex="0"
:aria-expanded="props.data.children.length > 0 && props.expanded"
:aria-selected="props.isSelected"
:class="{
[$style.container]: true,
[$style.compact]: props.isCompact,

View File

@@ -32,7 +32,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
export type SimplifiedExecution = Pick<
IExecutionResponse,
'workflowId' | 'workflowData' | 'data' | 'status' | 'startedAt' | 'stoppedAt'
'workflowId' | 'workflowData' | 'data' | 'status' | 'startedAt' | 'stoppedAt' | 'id'
>;
/**
@@ -74,9 +74,10 @@ export async function executionFinished(
let successToastAlreadyShown = false;
let execution: SimplifiedExecution | undefined;
if (data.rawData) {
const { workflowId, status, rawData } = data;
const { executionId, workflowId, status, rawData } = data;
execution = {
id: executionId,
workflowId,
workflowData: workflowsStore.workflow,
data: parse(rawData),
@@ -125,6 +126,7 @@ export async function fetchExecutionData(
}
return {
id: executionId,
workflowId: executionResponse.workflowId,
workflowData: workflowsStore.workflow,
data: parse(executionResponse.data as unknown as string),
@@ -450,7 +452,6 @@ export function handleExecutionFinishedWithOther(
export function setRunExecutionData(
execution: SimplifiedExecution,
runExecutionData: IRunExecutionData,
normalize = true,
) {
const workflowsStore = useWorkflowsStore();
const nodeHelpers = useNodeHelpers();
@@ -467,13 +468,12 @@ export function setRunExecutionData(
workflowsStore.executingNode.length = 0;
if (normalize) {
// As a temporary workaround for https://linear.app/n8n/issue/PAY-2762,
// remove runs that is still 'running' status when execution is finished
removeRunningTaskData(execution as IExecutionResponse);
}
workflowsStore.setWorkflowExecutionData(workflowExecution as IExecutionResponse);
workflowsStore.setWorkflowExecutionData({
...workflowExecution,
status: execution.status,
id: execution.id,
stoppedAt: execution.stoppedAt,
} as IExecutionResponse);
workflowsStore.setWorkflowExecutionRunData(runExecutionData);
workflowsStore.setActiveExecutionId(undefined);
@@ -505,22 +505,3 @@ export function setRunExecutionData(
const lineNumber = runExecutionData.resultData?.error?.lineNumber;
codeNodeEditorEventBus.emit('highlightLine', lineNumber ?? 'last');
}
function removeRunningTaskData(execution: IExecutionResponse): void {
if (execution.data) {
execution.data = {
...execution.data,
resultData: {
...execution.data.resultData,
runData: Object.fromEntries(
Object.entries(execution.data.resultData.runData)
.map(([nodeName, runs]) => [
nodeName,
runs.filter((run) => run.executionStatus !== 'running'),
])
.filter(([, runs]) => runs.length > 0),
),
},
};
}
}

View File

@@ -42,5 +42,5 @@ export async function executionRecovered(
handleExecutionFinishedWithOther(false, options);
}
setRunExecutionData(execution, runExecutionData, false);
setRunExecutionData(execution, runExecutionData);
}