test: Migrate 6-code-node tests to Playwright (#18454)

This commit is contained in:
shortstacked
2025-08-18 09:05:21 +01:00
committed by GitHub
parent dc86984ae0
commit 3788268b15
3 changed files with 318 additions and 209 deletions

View File

@@ -1,209 +0,0 @@
import { nanoid } from 'nanoid';
import { NDV } from '../pages/ndv';
import { successToast } from '../pages/notifications';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
describe('Code node', () => {
describe('Code editor', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code', true, true);
});
it('should show correct placeholders switching modes', () => {
cy.contains('// Loop over input items and add a new field').should('be.visible');
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item');
cy.contains("// Add a new field called 'myNewField'").should('be.visible');
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for All Items');
cy.contains('// Loop over input items and add a new field').should('be.visible');
});
it('should execute the placeholder successfully in both modes', () => {
ndv.actions.execute();
successToast().contains('Node executed successfully');
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item');
ndv.actions.execute();
successToast().contains('Node executed successfully');
});
it('should allow switching between sibling code nodes', () => {
// Setup
getEditor().type('{selectall}').paste("console.log('code node 1')");
ndv.actions.close();
WorkflowPage.actions.addNodeToCanvas('Code', true, true);
getEditor().type('{selectall}').paste("console.log('code node 2')");
ndv.actions.close();
WorkflowPage.actions.openNode('Code');
ndv.actions.clickFloatingNode('Code1');
getEditor().should('have.text', "console.log('code node 2')");
ndv.actions.clickFloatingNode('Code');
getEditor().should('have.text', "console.log('code node 1')");
});
it('should show lint errors in `runOnceForAllItems` mode', () => {
getEditor()
.type('{selectall}')
.paste(`$input.itemMatching()
$input.item
$('When clicking Execute workflow').item
$input.first(1)
for (const item of $input.all()) {
item.foo
}
return
`);
getParameter().get('.cm-lintRange-error').should('have.length', 6);
getParameter().contains('itemMatching').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
'`.itemMatching()` expects an item index to be passed in as its argument.',
);
});
it('should show lint errors in `runOnceForEachItem` mode', () => {
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item');
getEditor()
.type('{selectall}')
.paste(`$input.itemMatching()
$input.all()
$input.first()
$input.item()
return []
`);
getParameter().get('.cm-lintRange-error').should('have.length.gte', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
"Method `$input.all()` is only available in the 'Run Once for All Items' mode.",
);
});
});
describe('Ask AI', () => {
describe('Enabled', () => {
beforeEach(() => {
cy.enableFeature('askAi');
WorkflowPage.actions.visit();
cy.window().then(() => {
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code', true, true);
});
});
it('tab should exist if experiment selected and be selectable', () => {
cy.getByTestId('code-node-tab-ai').should('exist');
cy.get('#tab-ask-ai').click();
cy.contains('Hey AI, generate JavaScript').should('exist');
});
it('generate code button should have correct state & tooltips', () => {
cy.getByTestId('code-node-tab-ai').should('exist');
cy.get('#tab-ask-ai').click();
cy.getByTestId('ask-ai-cta').should('be.disabled');
cy.getByTestId('ask-ai-cta').realHover();
cy.getByTestId('ask-ai-cta-tooltip-no-input-data').should('exist');
ndv.actions.executePrevious();
cy.getByTestId('ask-ai-cta').realHover();
cy.getByTestId('ask-ai-cta-tooltip-no-prompt').should('exist');
cy.getByTestId('ask-ai-prompt-input')
// Type random 14 character string
.type(nanoid(14));
cy.getByTestId('ask-ai-cta').realHover();
cy.getByTestId('ask-ai-cta-tooltip-prompt-too-short').should('exist');
cy.getByTestId('ask-ai-prompt-input')
.clear()
// Type random 15 character string
.type(nanoid(15));
cy.getByTestId('ask-ai-cta').should('be.enabled');
cy.getByTestId('ask-ai-prompt-counter').should('contain.text', '15 / 600');
});
it('should send correct schema and replace code', () => {
const prompt = nanoid(20);
cy.get('#tab-ask-ai').click();
ndv.actions.executePrevious();
cy.getByTestId('ask-ai-prompt-input').type(prompt);
cy.intercept('POST', '/rest/ai/ask-ai', {
statusCode: 200,
body: {
data: {
code: 'console.log("Hello World")',
},
},
}).as('ask-ai');
cy.getByTestId('ask-ai-cta').click();
const askAiReq = cy.wait('@ask-ai');
askAiReq.its('request.body').should('have.keys', ['question', 'context', 'forNode']);
askAiReq
.its('context')
.should('have.keys', ['schema', 'ndvPushRef', 'pushRef', 'inputSchema']);
cy.contains('Code generation completed').should('be.visible');
cy.getByTestId('code-node-tab-code').should('contain.text', 'console.log("Hello World")');
cy.get('#tab-code').should('have.class', 'is-active');
});
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{
code: 500,
message:
'Code generation failed with error: Request failed with status code 500. Try again in a few minutes',
},
];
handledCodes.forEach(({ code, message }) => {
it(`should show error based on status code ${code}`, () => {
const prompt = nanoid(20);
cy.get('#tab-ask-ai').click();
ndv.actions.executePrevious();
cy.getByTestId('ask-ai-prompt-input').type(prompt);
cy.intercept('POST', '/rest/ai/ask-ai', {
statusCode: code,
status: code,
}).as('ask-ai');
cy.getByTestId('ask-ai-cta').click();
cy.contains(message).should('be.visible');
});
});
});
});
});

View File

@@ -47,4 +47,106 @@ export class NodeDisplayViewPage extends BasePage {
getParameterExpressionPreviewValue() {
return this.page.getByTestId('parameter-expression-preview-value');
}
/**
* Get parameter input by name (for Code node and similar)
* @param parameterName - The name of the parameter e.g 'jsCode', 'mode'
*/
getParameterInput(parameterName: string) {
return this.page.getByTestId(`parameter-input-${parameterName}`);
}
/**
* Select option in parameter dropdown
* @param parameterName - The parameter name
* @param optionText - The text of the option to select
*/
async selectOptionInParameterDropdown(parameterName: string, optionText: string) {
const dropdown = this.getParameterInput(parameterName);
await dropdown.click();
await this.page.getByRole('option', { name: optionText }).click();
}
/**
* Click on a floating node in the NDV (for switching between connected nodes)
* @param nodeName - The name of the node to click
*/
async clickFloatingNode(nodeName: string) {
await this.page.locator(`[data-test-id="floating-node"][data-node-name="${nodeName}"]`).click();
}
/**
* Execute the previous node (useful for providing input data)
*/
async executePrevious() {
await this.clickByTestId('execute-previous-node');
}
async clickAskAiTab() {
await this.page.locator('#tab-ask-ai').click();
}
getAskAiTabPanel() {
return this.page.getByTestId('code-node-tab-ai');
}
getAskAiCtaButton() {
return this.page.getByTestId('ask-ai-cta');
}
getAskAiPromptInput() {
return this.page.getByTestId('ask-ai-prompt-input');
}
getAskAiPromptCounter() {
return this.page.getByTestId('ask-ai-prompt-counter');
}
getAskAiCtaTooltipNoInputData() {
return this.page.getByTestId('ask-ai-cta-tooltip-no-input-data');
}
getAskAiCtaTooltipNoPrompt() {
return this.page.getByTestId('ask-ai-cta-tooltip-no-prompt');
}
getAskAiCtaTooltipPromptTooShort() {
return this.page.getByTestId('ask-ai-cta-tooltip-prompt-too-short');
}
getCodeTabPanel() {
return this.page.getByTestId('code-node-tab-code');
}
getCodeTab() {
return this.page.locator('#tab-code');
}
getCodeEditor() {
return this.getParameterInput('jsCode').locator('.cm-content');
}
getLintErrors() {
return this.getParameterInput('jsCode').locator('.cm-lintRange-error');
}
getLintTooltip() {
return this.page.locator('.cm-tooltip-lint');
}
getPlaceholderText(text: string) {
return this.page.getByText(text);
}
getHeyAiText() {
return this.page.locator('text=Hey AI, generate JavaScript');
}
getCodeGenerationCompletedText() {
return this.page.locator('text=Code generation completed');
}
getErrorMessageText(message: string) {
return this.page.locator(`text=${message}`);
}
}

View File

@@ -0,0 +1,216 @@
import { nanoid } from 'nanoid';
import { CODE_NODE_NAME, MANUAL_TRIGGER_NODE_NAME } from '../../config/constants';
import { test, expect } from '../../fixtures/base';
test.describe('Code node', () => {
test.describe('Code editor', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME);
});
test('should show correct placeholders switching modes', async ({ n8n }) => {
await expect(
n8n.ndv.getPlaceholderText('// Loop over input items and add a new field'),
).toBeVisible();
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click();
await expect(
n8n.ndv.getPlaceholderText("// Add a new field called 'myNewField'"),
).toBeVisible();
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for All Items' }).click();
await expect(
n8n.ndv.getPlaceholderText('// Loop over input items and add a new field'),
).toBeVisible();
});
test('should execute the placeholder successfully in both modes', async ({ n8n }) => {
await n8n.ndv.execute();
await expect(
n8n.notifications.notificationContainerByText('Node executed successfully').first(),
).toBeVisible();
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click();
await n8n.ndv.execute();
await expect(
n8n.notifications.notificationContainerByText('Node executed successfully').first(),
).toBeVisible();
});
test('should allow switching between sibling code nodes', async ({ n8n }) => {
await n8n.ndv.getCodeEditor().fill("console.log('code node 1')");
await n8n.ndv.close();
await n8n.canvas.addNode(CODE_NODE_NAME);
await n8n.ndv.getCodeEditor().fill("console.log('code node 2')");
await n8n.ndv.close();
await n8n.canvas.openNode(CODE_NODE_NAME);
await n8n.ndv.clickFloatingNode('Code1');
await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 2')");
await n8n.ndv.clickFloatingNode('Code');
await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 1')");
});
test('should show lint errors in `runOnceForAllItems` mode', async ({ n8n }) => {
await n8n.ndv.getCodeEditor().fill(`$input.itemMatching()
$input.item
$('When clicking Execute workflow').item
$input.first(1)
for (const item of $input.all()) {
item.foo
}
return
`);
await expect(n8n.ndv.getLintErrors()).toHaveCount(6);
await n8n.ndv.getParameterInput('jsCode').getByText('itemMatching').hover();
await expect(n8n.ndv.getLintTooltip()).toContainText(
'`.itemMatching()` expects an item index to be passed in as its argument.',
);
});
test('should show lint errors in `runOnceForEachItem` mode', async ({ n8n }) => {
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click();
await n8n.ndv.getCodeEditor().fill(`$input.itemMatching()
$input.all()
$input.first()
$input.item()
return []
`);
await expect(n8n.ndv.getLintErrors()).toHaveCount(5);
await n8n.ndv.getParameterInput('jsCode').getByText('all').hover();
await expect(n8n.ndv.getLintTooltip()).toContainText(
"Method `$input.all()` is only available in the 'Run Once for All Items' mode.",
);
});
});
test.describe('Ask AI', () => {
test.describe('Enabled', () => {
test.beforeEach(async ({ api, n8n }) => {
await api.enableFeature('askAi');
await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME);
});
test('tab should exist if experiment selected and be selectable', async ({ n8n }) => {
await n8n.ndv.clickAskAiTab();
await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible();
await expect(n8n.ndv.getHeyAiText()).toBeVisible();
});
test('generate code button should have correct state & tooltips', async ({ n8n }) => {
await n8n.ndv.clickAskAiTab();
await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible();
await expect(n8n.ndv.getAskAiCtaButton()).toBeDisabled();
await n8n.ndv.getAskAiCtaButton().hover();
await expect(n8n.ndv.getAskAiCtaTooltipNoInputData()).toBeVisible();
await n8n.ndv.executePrevious();
await n8n.ndv.getAskAiCtaButton().hover();
await expect(n8n.ndv.getAskAiCtaTooltipNoPrompt()).toBeVisible();
await n8n.ndv.getAskAiPromptInput().fill(nanoid(14));
await n8n.ndv.getAskAiCtaButton().hover();
await expect(n8n.ndv.getAskAiCtaTooltipPromptTooShort()).toBeVisible();
await n8n.ndv.getAskAiPromptInput().fill(nanoid(15));
await expect(n8n.ndv.getAskAiCtaButton()).toBeEnabled();
await expect(n8n.ndv.getAskAiPromptCounter()).toContainText('15 / 600');
});
test('should send correct schema and replace code', async ({ n8n }) => {
const prompt = nanoid(20);
await n8n.ndv.clickAskAiTab();
await n8n.ndv.executePrevious();
await n8n.ndv.getAskAiPromptInput().fill(prompt);
await n8n.page.route('**/rest/ai/ask-ai', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
code: 'console.log("Hello World")',
},
}),
});
});
const [request] = await Promise.all([
n8n.page.waitForRequest('**/rest/ai/ask-ai'),
n8n.ndv.getAskAiCtaButton().click(),
]);
const requestBody = request.postDataJSON();
expect(requestBody).toHaveProperty('question');
expect(requestBody).toHaveProperty('context');
expect(requestBody).toHaveProperty('forNode');
expect(requestBody.context).toHaveProperty('schema');
expect(requestBody.context).toHaveProperty('ndvPushRef');
expect(requestBody.context).toHaveProperty('pushRef');
expect(requestBody.context).toHaveProperty('inputSchema');
await expect(n8n.ndv.getCodeGenerationCompletedText()).toBeVisible();
await expect(n8n.ndv.getCodeTabPanel()).toContainText('console.log("Hello World")');
await expect(n8n.ndv.getCodeTab()).toHaveClass(/is-active/);
});
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{
code: 500,
message:
'Code generation failed with error: Request failed with status code 500. Try again in a few minutes',
},
];
handledCodes.forEach(({ code, message }) => {
test(`should show error based on status code ${code}`, async ({ n8n }) => {
const prompt = nanoid(20);
await n8n.ndv.clickAskAiTab();
await n8n.ndv.executePrevious();
await n8n.ndv.getAskAiPromptInput().fill(prompt);
await n8n.page.route('**/rest/ai/ask-ai', async (route) => {
await route.fulfill({
status: code,
});
});
await n8n.ndv.getAskAiCtaButton().click();
await expect(n8n.ndv.getErrorMessageText(message)).toBeVisible();
});
});
});
});
});