test: Migrate UI tests from Cypress -> Playwright (no-changelog) (#18201)

This commit is contained in:
shortstacked
2025-08-12 12:06:42 +01:00
committed by GitHub
parent ecc4f41a11
commit 514825bd51
52 changed files with 2111 additions and 402 deletions

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ trivy_report*
compiled
packages/cli/src/modules/my-feature
.secrets
packages/testing/**/.cursor/rules/

View File

@@ -3,7 +3,9 @@ import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('Schedule Trigger node', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Schedule Trigger node', () => {
beforeEach(() => {
workflowPage.actions.visit();
});

View File

@@ -4,7 +4,9 @@ import { NDV, WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2270 Save button resets on webhook node open', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('ADO-2270 Save button resets on webhook node open', () => {
it('should not reset the save button if webhook node is opened and closed', () => {
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(WEBHOOK_NODE_NAME);

View File

@@ -10,7 +10,9 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
const executionsTab = new WorkflowExecutionsTab();
describe('Debug', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Debug', () => {
beforeEach(() => {
cy.enableFeature('debugInEditor');
});

View File

@@ -1,6 +1,8 @@
const url = '/settings';
describe('Admin user', { disableAutoLogin: true }, () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Admin user', { disableAutoLogin: true }, () => {
it('should see same Settings sub menu items as instance owner', () => {
cy.signinAsOwner();
cy.visit(url);

View File

@@ -7,7 +7,9 @@ import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
const WorkflowsPage = new WorkflowsPageClass();
describe('Become creator CTA', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Become creator CTA', () => {
it('should not show the CTA if user is not eligible', () => {
interceptCtaRequestWithResponse(false).as('cta');
cy.visit(WorkflowsPage.url);

View File

@@ -34,7 +34,9 @@ const credentialsModal = new CredentialsModal();
const ndv = new NDV();
const mainSidebar = new MainSidebar();
describe('Projects', { disableAutoLogin: true }, () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Projects', { disableAutoLogin: true }, () => {
describe('when starting from scratch', () => {
beforeEach(() => {
cy.resetDatabase();

View File

@@ -1,3 +1,4 @@
/* eslint-disable n8n-local-rules/no-skipped-tests */
import { type ICredentialType } from 'n8n-workflow';
import { clickCreateNewCredential, openCredentialSelect } from '../composables/ndv';
@@ -13,7 +14,8 @@ const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const nodeCreatorFeature = new NodeCreator();
describe('AI Assistant::disabled', () => {
// Migrated to Playwright
describe.skip('AI Assistant::disabled', () => {
beforeEach(() => {
aiAssistant.actions.disableAssistant();
wf.actions.visit();
@@ -34,7 +36,7 @@ describe('AI Assistant::enabled', () => {
aiAssistant.actions.disableAssistant();
});
it('renders placeholder UI', () => {
it.skip('renders placeholder UI', () => {
aiAssistant.getters.askAssistantCanvasActionButton().should('be.visible');
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
@@ -80,7 +82,7 @@ describe('AI Assistant::enabled', () => {
});
});
it('should start chat session from node error view', () => {
it.skip('should start chat session from node error view', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
@@ -98,7 +100,7 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled');
});
it('should render chat input correctly', () => {
it.skip('should render chat input correctly', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
@@ -131,7 +133,7 @@ describe('AI Assistant::enabled', () => {
});
});
it('should render and handle quick replies', () => {
it.skip('should render and handle quick replies', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/quick_reply_message_response.json',
@@ -148,7 +150,7 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
});
it('should warn before starting a new session', () => {
it.skip('should warn before starting a new session', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
@@ -273,7 +275,7 @@ describe('AI Assistant::enabled', () => {
);
});
it('should end chat session when `end_session` event is received', () => {
it.skip('should end chat session when `end_session` event is received', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/end_session_response.json',

View File

@@ -2,7 +2,9 @@ import { WorkflowsPage } from '../pages';
const workflowsPage = new WorkflowsPage();
describe('n8n.io iframe', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('n8n.io iframe', () => {
describe('when telemetry is disabled', () => {
it('should not load the iframe when visiting /home/workflows', () => {
cy.overrideSettings({ telemetry: { enabled: false } });

View File

@@ -6,7 +6,9 @@ import {
} from '../composables/workflow';
import { AGENT_NODE_NAME, AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME } from '../constants';
describe('AI-716 Correctly set up agent model shows error', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('AI-716 Correctly set up agent model shows error', () => {
beforeEach(() => {
cy.visit(getCredentialsPageUrl());
createNewCredential('OpenAi', 'OpenAI Account', 'API Key', 'sk-123', true);

View File

@@ -11,7 +11,9 @@ import {
} from '../composables/workflow';
import Workflow from '../fixtures/Test_9999-SUG-38.json';
describe('SUG-38 Inline expression previews are not displayed in NDV', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('SUG-38 Inline expression previews are not displayed in NDV', () => {
it("should show resolved inline expression preview in NDV if the node's input data is populated", () => {
navigateToNewWorkflowPage();
pasteWorkflow(Workflow);

View File

@@ -1,4 +1,6 @@
describe('Environment Feature Flags', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Environment Feature Flags', () => {
it('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', () => {
cy.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: true,

View File

@@ -1,6 +1,152 @@
import type { FrontendSettings } from '@n8n/api-types';
export class TestError extends Error {
constructor(message: string) {
super(message);
this.name = 'TestError';
}
}
/**
* Test requirements for Playwright tests.
*
* This interface allows you to declaratively specify all test setup requirements
* in one place, making tests more readable and maintainable.
* If a workflow is specified, the starting point for the test is now the canvas after the workflow is imported.
*
* @example
* ```typescript
* const requirements: TestRequirements = {
* config: {
* features: {
* aiAssistant: true,
* debugInEditor: true,
* sharing: true
* },
* settings: { telemetry: { enabled: false } }
* },
* workflow: {
* 'ai_assistant_test_workflow.json': 'AI Assistant Test Workflow'
* },
* intercepts: {
* 'ai-chat': {
* url: '*\/rest/ai/chat',
* response: { sessionId: '1', messages: [] }
* }
* },
* storage: {
* 'n8n-telemetry': '{"enabled": true}'
* }
* };
* ```
*/
export interface TestRequirements {
/**
* Configuration settings for the test environment
*/
config?: {
/** Frontend settings to override (merged with default settings) */
settings?: Partial<FrontendSettings>;
/** Feature flags to enable/disable for the test */
features?: Record<string, boolean>;
};
/**
* API route intercepts and their mock responses
*
* @example
* ```typescript
* intercepts: {
* 'ai-chat': {
* url: '*\/rest/ai/chat',
* response: {
* sessionId: '1',
* messages: [{ role: 'assistant', type: 'message', text: 'Hello!' }]
* }
* },
* 'become-creator': {
* url: '*\/rest/cta/become-creator',
* response: true
* },
* 'credentials-test': {
* url: '*\/rest/credentials/test',
* response: { data: { status: 'success', message: 'Tested successfully' } }
* }
* }
* ```
*/
intercepts?: Record<string, InterceptConfig>;
/**
* Single workflow to import for the test
*
* Key: Import file location (relative to workflows folder)
* Value: Name to give the workflow when imported
*
* Note: Only one workflow is supported. Multiple workflows will throw an error.
*
* @example
* ```typescript
* workflow: {
* 'ai_assistant_test_workflow.json': 'AI Assistant Test Workflow'
* }
* ```
*/
workflow?: Record<string, string>;
/**
* Browser storage values to set before the test
*
* Supports localStorage, sessionStorage, and other browser storage APIs
*
* @example
* ```typescript
* storage: {
* 'n8n-telemetry': '{"enabled": true}',
* 'n8n-instance-id': 'test-instance-id'
* }
* ```
*/
storage?: Record<string, string>;
}
/**
* Configuration for API route interception in Playwright
*
* @example
* ```typescript
* {
* url: '*\/rest/ai/chat',
* response: { sessionId: '1', messages: [] },
* status: 200
* }
* ```
*
* @example Network error simulation
* ```typescript
* {
* url: '*\/rest/credentials/test',
* forceNetworkError: true
* }
* ```
*/
export interface InterceptConfig {
/** URL pattern to intercept (supports wildcards) */
url: string;
/** Mock response data */
response?: unknown;
/** HTTP status code to return (default: 200) */
status?: number;
/** HTTP headers to return */
headers?: Record<string, string>;
/** Content type for the response (default: 'application/json') */
contentType?: string;
/** Force network error instead of mock response */
forceNetworkError?: boolean;
}

View File

@@ -1,23 +0,0 @@
import type { Page } from '@playwright/test';
export const getSuggestedActionsButton = (page: Page) => page.getByTestId('suggested-action-count');
export const getSuggestedActionItem = (page: Page, text?: string) => {
const items = page.getByTestId('suggested-action-item');
if (text) {
return items.getByText(text);
}
return items;
};
export const getSuggestedActionsPopover = (page: Page) =>
page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
export const getErrorActionItem = (page: Page) =>
getSuggestedActionItem(page, 'Set up error notifications');
export const getTimeSavedActionItem = (page: Page) =>
getSuggestedActionItem(page, 'Track time saved');
export const getEvaluationsActionItem = (page: Page) =>
getSuggestedActionItem(page, 'Test reliability of AI steps');
export const getIgnoreAllButton = (page: Page) => page.getByTestId('suggested-action-ignore-all');

View File

@@ -1,14 +0,0 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
export const getActivationModal = (page: Page) => page.getByTestId('activation-modal');
export const closeActivationModal = async (page: Page) => {
await expect(getActivationModal(page)).toBeVisible();
// click checkbox so it does not show again
await getActivationModal(page).getByText("Don't show again").click();
// confirm modal
await getActivationModal(page).getByRole('button', { name: 'Got it' }).click();
};

View File

@@ -1,3 +1,5 @@
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
/**
@@ -47,7 +49,7 @@ export class WorkflowComposer {
fileName: string,
name?: string,
): Promise<{ workflowName: string }> {
const workflowName = name ?? `Imported Workflow ${Date.now()}`;
const workflowName = name ?? `Imported Workflow ${nanoid(8)}`;
await this.n8n.goHome();
await this.n8n.workflows.clickAddWorkflowButton();
await this.n8n.canvas.importWorkflow(fileName, workflowName);

View File

@@ -1,9 +0,0 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
// Helper to open workflow settings modal
export const openWorkflowSettings = async (page: Page) => {
await page.getByTestId('workflow-menu').click();
await page.getByTestId('workflow-menu-item-settings').click();
await expect(page.getByTestId('workflow-settings-dialog')).toBeVisible();
};

View File

@@ -1,18 +1,36 @@
import type { FrontendSettings } from '@n8n/api-types';
import type { BrowserContext, Route } from '@playwright/test';
import cloneDeep from 'lodash/cloneDeep';
import merge from 'lodash/merge';
export let settings: Partial<FrontendSettings>;
const contextSettings = new Map<BrowserContext, Partial<Record<string, unknown>>>();
export function setContextSettings(
context: BrowserContext,
settings: Partial<Record<string, unknown>>,
) {
contextSettings.set(context, settings);
}
export function getContextSettings(context: BrowserContext) {
return contextSettings.get(context);
}
export async function setupDefaultInterceptors(target: BrowserContext) {
// Global /rest/settings intercept - always active like Cypress
await target.route('**/rest/settings', async (route: Route) => {
try {
const originalResponse = await route.fetch();
const originalJson = await originalResponse.json();
// Get settings stored for this specific context
const testSettings = getContextSettings(target);
// Deep merge test settings with backend settings (like Cypress)
const modifiedData = {
data: merge(cloneDeep(originalJson.data), settings),
data:
testSettings && Object.keys(testSettings).length > 0
? merge(cloneDeep(originalJson.data), testSettings)
: originalJson.data,
};
await route.fulfill({

View File

@@ -5,7 +5,7 @@ export default [
...baseConfig,
playwrightPlugin.configs['flat/recommended'],
{
ignores: ['playwright-report/**/*'],
ignores: ['playwright-report/**/*', 'ms-playwright-cache/**/*'],
},
{
rules: {
@@ -22,6 +22,43 @@ export default [
'playwright/max-nested-describe': 'warn',
'playwright/no-conditional-in-test': 'error',
'playwright/no-skipped-test': 'warn',
// Allow any naming convention for TestRequirements object properties
// This is specifically for workflow filenames and intercept keys that may not follow camelCase
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'allow',
},
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
{
selector: 'property',
format: ['camelCase', 'snake_case', 'UPPER_CASE'],
filter: {
// Allow any format for properties in TestRequirements objects (workflow files, intercept keys, etc.)
regex: '^(workflow|intercepts|storage|config)$',
match: false,
},
},
{
selector: 'objectLiteralProperty',
format: null, // Allow any format for object literal properties in TestRequirements
filter: {
// This allows workflow filenames and intercept keys to use any naming convention
regex: '\\.(json|spec\\.ts)$|[a-zA-Z0-9_-]+',
match: true,
},
},
],
'import-x/no-extraneous-dependencies': [
'error',
{

View File

@@ -1,4 +1,4 @@
import { test as base, expect, type TestInfo } from '@playwright/test';
import { test as base, expect } from '@playwright/test';
import type { N8NStack } from 'n8n-containers/n8n-test-container-creation';
import { createN8NStack } from 'n8n-containers/n8n-test-container-creation';
import { ContainerTestHelpers } from 'n8n-containers/n8n-test-container-helpers';
@@ -7,12 +7,14 @@ import { setTimeout as wait } from 'node:timers/promises';
import { setupDefaultInterceptors } from '../config/intercepts';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { TestError } from '../Types';
import { TestError, type TestRequirements } from '../Types';
import { setupTestRequirements } from '../utils/requirements';
type TestFixtures = {
n8n: n8nPage;
api: ApiHelpers;
baseURL: string;
setupRequirements: (requirements: TestRequirements) => Promise<void>;
};
type WorkerFixtures = {
@@ -40,8 +42,10 @@ interface ContainerConfig {
export const test = base.extend<TestFixtures, WorkerFixtures>({
// Container configuration from the project use options
containerConfig: [
async ({}, use, testInfo: TestInfo) => {
const config = (testInfo.project.use?.containerConfig as ContainerConfig) || {};
async ({}, use, workerInfo) => {
const config =
(workerInfo.project.use as unknown as { containerConfig?: ContainerConfig })
?.containerConfig ?? {};
config.env = {
...config.env,
E2E_TESTS: 'true',
@@ -122,6 +126,11 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
// Browser, baseURL, and dbSetup are required here to ensure they run first.
// This is how Playwright does dependency graphs
context: async ({ context, browser, baseURL, dbSetup }, use) => {
// Dependencies: browser, baseURL, dbSetup (ensure they run first)
void browser;
void baseURL;
void dbSetup;
await setupDefaultInterceptors(context);
await use(context);
},
@@ -145,6 +154,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const api = new ApiHelpers(context.request);
await use(api);
},
setupRequirements: async ({ page, context }, use) => {
const setupFunction = async (requirements: TestRequirements): Promise<void> => {
await setupTestRequirements(page, context, requirements);
};
await use(setupFunction);
},
});
export { expect };

View File

@@ -28,6 +28,8 @@
"eslint-plugin-playwright": "2.2.2",
"generate-schema": "2.6.0",
"n8n-containers": "workspace:*",
"tsx": "catalog:"
"nanoid": "catalog:",
"tsx": "catalog:",
"@n8n/api-types": "workspace:^"
}
}

View File

@@ -0,0 +1,66 @@
import { BasePage } from './BasePage';
export class AIAssistantPage extends BasePage {
getAskAssistantFloatingButton() {
return this.page.getByTestId('ask-assistant-floating-button');
}
getAskAssistantCanvasActionButton() {
return this.page.getByTestId('ask-assistant-canvas-action-button');
}
getAskAssistantChat() {
return this.page.getByTestId('ask-assistant-chat');
}
getPlaceholderMessage() {
return this.page.getByTestId('placeholder-message');
}
getChatInput() {
return this.page.getByTestId('chat-input');
}
getSendMessageButton() {
return this.page.getByTestId('send-message-button');
}
getCloseChatButton() {
return this.page.getByTestId('close-chat-button');
}
getAskAssistantSidebarResizer() {
return this.page
.getByTestId('ask-assistant-sidebar')
.locator('[class*="_resizer"][data-dir="left"]')
.first();
}
getNodeErrorViewAssistantButton() {
return this.page.getByTestId('node-error-view-ask-assistant-button').locator('button').first();
}
getChatMessagesAll() {
return this.page.locator('[data-test-id^="chat-message"]');
}
getChatMessagesAssistant() {
return this.page.getByTestId('chat-message-assistant');
}
getChatMessagesUser() {
return this.page.getByTestId('chat-message-user');
}
getChatMessagesSystem() {
return this.page.getByTestId('chat-message-system');
}
getQuickReplyButtons() {
return this.page.getByTestId('quick-replies').locator('button');
}
getNewAssistantSessionModal() {
return this.page.getByTestId('new-assistant-session-modal');
}
}

View File

@@ -0,0 +1,15 @@
import { BasePage } from './BasePage';
export class BecomeCreatorCTAPage extends BasePage {
getBecomeTemplateCreatorCta() {
return this.page.getByTestId('become-template-creator-cta');
}
getCloseBecomeTemplateCreatorCtaButton() {
return this.page.getByTestId('close-become-template-creator-cta');
}
async closeBecomeTemplateCreatorCta() {
await this.getCloseBecomeTemplateCreatorCtaButton().click();
}
}

View File

@@ -1,4 +1,5 @@
import type { Locator } from '@playwright/test';
import { nanoid } from 'nanoid';
import { BasePage } from './BasePage';
import { resolveFromRoot } from '../utils/path-helper';
@@ -8,6 +9,10 @@ export class CanvasPage extends BasePage {
return this.page.getByRole('button', { name: 'Save' });
}
workflowSaveButton(): Locator {
return this.page.getByTestId('workflow-save-button');
}
canvasAddButton(): Locator {
return this.page.getByTestId('canvas-add-button');
}
@@ -132,7 +137,6 @@ export class CanvasPage extends BasePage {
async clickExecutionsTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Executions' }).click();
}
async setWorkflowName(name: string): Promise<void> {
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', name);
@@ -161,7 +165,6 @@ export class CanvasPage extends BasePage {
getWorkflowTags() {
return this.page.getByTestId('workflow-tags').locator('.el-tag');
}
async activateWorkflow() {
const responsePromise = this.page.waitForResponse(
(response) =>
@@ -170,4 +173,90 @@ export class CanvasPage extends BasePage {
await this.page.getByTestId('workflow-activate-switch').click();
await responsePromise;
}
async clickZoomToFitButton(): Promise<void> {
await this.clickByTestId('zoom-to-fit');
}
/**
* Get node issues for a specific node
*/
getNodeIssuesByName(nodeName: string) {
return this.nodeByName(nodeName).getByTestId('node-issues');
}
/**
* Add tags to the workflow
* @param count - The number of tags to add
* @returns An array of tag names
*/
async addTags(count: number = 1): Promise<string[]> {
const tags: string[] = [];
for (let i = 0; i < count; i++) {
const tag = `tag-${nanoid(8)}-${i}`;
tags.push(tag);
if (i === 0) {
await this.clickByText('Add tag');
} else {
await this.page
.getByTestId('tags-dropdown')
.getByText(tags[i - 1])
.click();
}
await this.page.getByRole('combobox').first().fill(tag);
await this.page.getByRole('combobox').first().press('Enter');
}
await this.page.click('body');
return tags;
}
// Production Checklist methods
getProductionChecklistButton(): Locator {
return this.page.getByTestId('suggested-action-count');
}
getProductionChecklistPopover(): Locator {
return this.page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
}
getProductionChecklistActionItem(text?: string): Locator {
const items = this.page.getByTestId('suggested-action-item');
if (text) {
return items.getByText(text);
}
return items;
}
getProductionChecklistIgnoreAllButton(): Locator {
return this.page.getByTestId('suggested-action-ignore-all');
}
getErrorActionItem(): Locator {
return this.getProductionChecklistActionItem('Set up error notifications');
}
getTimeSavedActionItem(): Locator {
return this.getProductionChecklistActionItem('Track time saved');
}
getEvaluationsActionItem(): Locator {
return this.getProductionChecklistActionItem('Test reliability of AI steps');
}
async clickProductionChecklistButton(): Promise<void> {
await this.getProductionChecklistButton().click();
}
async clickProductionChecklistIgnoreAll(): Promise<void> {
await this.getProductionChecklistIgnoreAllButton().click();
}
async clickProductionChecklistAction(actionText: string): Promise<void> {
await this.getProductionChecklistActionItem(actionText).click();
}
}

View File

@@ -0,0 +1,15 @@
import { BasePage } from './BasePage';
export class IframePage extends BasePage {
getIframe() {
return this.page.locator('iframe');
}
getIframeBySrc(src: string) {
return this.page.locator(`iframe[src="${src}"]`);
}
async waitForIframeRequest(url: string) {
await this.page.waitForResponse(url);
}
}

View File

@@ -35,4 +35,16 @@ export class NodeDisplayViewPage extends BasePage {
async close() {
await this.clickBackToCanvasButton();
}
async execute() {
await this.clickByTestId('node-execute-button');
}
getOutputPanel() {
return this.page.getByTestId('output-panel');
}
getParameterExpressionPreviewValue() {
return this.page.getByTestId('parameter-expression-preview-value');
}
}

View File

@@ -0,0 +1,15 @@
import { BasePage } from './BasePage';
export class SettingsPage extends BasePage {
getMenuItems() {
return this.page.getByTestId('menu-item');
}
getMenuItem(id: string) {
return this.page.getByTestId('menu-item').getByTestId(id);
}
async goToSettings() {
await this.page.goto('/settings');
}
}

View File

@@ -0,0 +1,35 @@
import { BasePage } from './BasePage';
export class VersionsPage extends BasePage {
getVersionUpdatesPanelOpenButton() {
return this.page.getByTestId('version-update-next-versions-link');
}
getVersionUpdatesPanel() {
return this.page.getByTestId('version-updates-panel');
}
getVersionUpdatesPanelCloseButton() {
return this.getVersionUpdatesPanel().getByRole('button', { name: 'Close' });
}
getVersionCard() {
return this.page.getByTestId('version-card');
}
getWhatsNewMenuItem() {
return this.page.getByTestId('menu-item').getByTestId('whats-new');
}
async openWhatsNewMenu() {
await this.getWhatsNewMenuItem().click();
}
async openVersionUpdatesPanel() {
await this.getVersionUpdatesPanelOpenButton().click();
}
async closeVersionUpdatesPanel() {
await this.getVersionUpdatesPanelCloseButton().click();
}
}

View File

@@ -0,0 +1,31 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowActivationModal extends BasePage {
getModal(): Locator {
return this.page.getByTestId('activation-modal');
}
getDontShowAgainCheckbox(): Locator {
return this.getModal().getByText("Don't show again");
}
getGotItButton(): Locator {
return this.getModal().getByRole('button', { name: 'Got it' });
}
async close(): Promise<void> {
await this.getDontShowAgainCheckbox().click();
await this.getGotItButton().click();
}
async clickDontShowAgain(): Promise<void> {
await this.getDontShowAgainCheckbox().click();
}
async clickGotIt(): Promise<void> {
await this.getGotItButton().click();
}
}

View File

@@ -0,0 +1,39 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowSettingsModal extends BasePage {
getModal(): Locator {
return this.page.getByTestId('workflow-settings-dialog');
}
getWorkflowMenu(): Locator {
return this.page.getByTestId('workflow-menu');
}
getSettingsMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-settings');
}
getErrorWorkflowField(): Locator {
return this.page.getByTestId('workflow-settings-error-workflow');
}
getSaveButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
async open(): Promise<void> {
await this.getWorkflowMenu().click();
await this.getSettingsMenuItem().click();
}
async clickSave(): Promise<void> {
await this.getSaveButton().click();
}
async selectErrorWorkflow(workflowName: string): Promise<void> {
await this.getErrorWorkflowField().click();
await this.page.getByRole('option', { name: workflowName }).first().click();
}
}

View File

@@ -1,12 +1,19 @@
import type { Page } from '@playwright/test';
import { AIAssistantPage } from './AIAssistantPage';
import { BecomeCreatorCTAPage } from './BecomeCreatorCTAPage';
import { CanvasPage } from './CanvasPage';
import { CredentialsPage } from './CredentialsPage';
import { ExecutionsPage } from './ExecutionsPage';
import { IframePage } from './IframePage';
import { NodeDisplayViewPage } from './NodeDisplayViewPage';
import { NotificationsPage } from './NotificationsPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SettingsPage } from './SettingsPage';
import { SidebarPage } from './SidebarPage';
import { VersionsPage } from './VersionsPage';
import { WorkflowActivationModal } from './WorkflowActivationModal';
import { WorkflowSettingsModal } from './WorkflowSettingsModal';
import { WorkflowSharingModal } from './WorkflowSharingModal';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
@@ -18,18 +25,28 @@ export class n8nPage {
readonly page: Page;
// Pages
readonly aiAssistant: AIAssistantPage;
readonly becomeCreatorCTA: BecomeCreatorCTAPage;
readonly canvas: CanvasPage;
readonly iframe: IframePage;
readonly ndv: NodeDisplayViewPage;
readonly projectSettings: ProjectSettingsPage;
readonly settings: SettingsPage;
readonly versions: VersionsPage;
readonly workflows: WorkflowsPage;
readonly notifications: NotificationsPage;
readonly credentials: CredentialsPage;
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
readonly workflowSettingsModal: WorkflowSettingsModal;
readonly workflowSharingModal: WorkflowSharingModal;
// Composables
readonly workflowComposer: WorkflowComposer;
readonly workflowSharingModal: WorkflowSharingModal;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
@@ -37,9 +54,15 @@ export class n8nPage {
this.page = page;
// Pages
this.aiAssistant = new AIAssistantPage(page);
this.becomeCreatorCTA = new BecomeCreatorCTAPage(page);
this.canvas = new CanvasPage(page);
this.iframe = new IframePage(page);
this.ndv = new NodeDisplayViewPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.settings = new SettingsPage(page);
this.versions = new VersionsPage(page);
this.workflows = new WorkflowsPage(page);
this.notifications = new NotificationsPage(page);
this.credentials = new CredentialsPage(page);
@@ -47,6 +70,10 @@ export class n8nPage {
this.sideBar = new SidebarPage(page);
this.workflowSharingModal = new WorkflowSharingModal(page);
// Modals
this.workflowActivationModal = new WorkflowActivationModal(page);
this.workflowSettingsModal = new WorkflowSettingsModal(page);
// Composables
this.workflowComposer = new WorkflowComposer(this);
this.projectComposer = new ProjectComposer(this);

View File

@@ -144,6 +144,41 @@ export class ApiHelpers {
});
}
// ===== FEATURE FLAG METHODS =====
async setEnvFeatureFlags(flags: Record<string, string>): Promise<{
data: {
success: boolean;
message: string;
flags: Record<string, string>;
};
}> {
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
data: { flags },
});
return await response.json();
}
async clearEnvFeatureFlags(): Promise<{
data: {
success: boolean;
message: string;
flags: Record<string, string>;
};
}> {
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
data: { flags: {} },
});
return await response.json();
}
async getEnvFeatureFlags(): Promise<{
data: Record<string, string>;
}> {
const response = await this.request.get('/rest/e2e/env-feature-flags');
return await response.json();
}
// ===== CONVENIENCE METHODS =====
async enableFeature(feature: string): Promise<void> {

View File

@@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import * as fs from 'fs';
// @ts-expect-error - 'generate-schema' is not typed, so we ignore the TS error.
import GenerateSchema from 'generate-schema';
import generateSchema from 'generate-schema';
import * as path from 'path';
import { findPackagesRoot } from '../../utils/path-helper';
@@ -136,7 +136,7 @@ test.describe('Workflow Tests', () => {
// Optionally, validate the output against a JSON schema snapshot if enabled.
if (SCHEMA_MODE && result.data && workflow.enableSchemaValidation) {
const schema = GenerateSchema.json(result.data);
const schema = generateSchema.json(result.data);
expect(JSON.stringify(schema, null, 2)).toMatchSnapshot(
`workflow-${workflow.id}-schema.snap`,
);

View File

@@ -1,3 +1,5 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../fixtures/base';
const NOTIFICATIONS = {
@@ -18,7 +20,9 @@ test.describe('Workflows', () => {
await expect(n8n.canvas.getWorkflowTags()).toHaveText(['some-tag-1', 'some-tag-2']);
});
test('should create a new workflow using add workflow button', async ({ n8n }) => {
test('should create a new workflow using add workflow button and save successfully', async ({
n8n,
}) => {
await n8n.workflows.clickAddWorkflowButton();
const workflowName = `Test Workflow ${Date.now()}`;
@@ -31,9 +35,9 @@ test.describe('Workflows', () => {
});
test('should search for workflows', async ({ n8n }) => {
const date = Date.now();
const specificName = `Specific Test ${date}`;
const genericName = `Generic Test ${date}`;
const uniqueId = nanoid(8);
const specificName = `Specific Test ${uniqueId}`;
const genericName = `Generic Test ${uniqueId}`;
await n8n.workflowComposer.createWorkflow(specificName);
await n8n.goHome();
@@ -47,7 +51,7 @@ test.describe('Workflows', () => {
// Search with partial term
await n8n.workflows.clearSearch();
await n8n.workflows.searchWorkflows(date.toString());
await n8n.workflows.searchWorkflows(uniqueId);
await expect(n8n.workflows.getWorkflowItems()).toHaveCount(2);
// Search for non-existent

View File

@@ -0,0 +1,18 @@
import { test, expect } from '../../fixtures/base';
test.describe('Schedule Trigger node', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
});
test('should execute schedule trigger node and return timestamp in output', async ({ n8n }) => {
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputPanel()).toContainText('timestamp');
await n8n.ndv.clickBackToCanvasButton();
});
});

View File

@@ -0,0 +1,20 @@
import { test, expect } from '../../fixtures/base';
test.describe('ADO-2270 Save button resets on webhook node open', () => {
test('should not reset the save button if webhook node is opened and closed', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode('Webhook');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickSaveWorkflowButton();
await n8n.canvas.openNode('Webhook');
await n8n.ndv.clickBackToCanvasButton();
await expect(n8n.canvas.workflowSaveButton()).toContainText('Saved');
});
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from '../../fixtures/base';
test.describe('Admin user', () => {
test('should see same Settings sub menu items as instance owner', async ({ n8n, api }) => {
await api.setupTest('signin-only', 'owner');
await n8n.settings.goToSettings();
const ownerMenuItems = await n8n.settings.getMenuItems().count();
await api.setupTest('signin-only', 'admin');
await n8n.settings.goToSettings();
await expect(n8n.settings.getMenuItems()).toHaveCount(ownerMenuItems);
});
});

View File

@@ -0,0 +1,276 @@
import { test, expect } from '../../fixtures/base';
import type { TestRequirements } from '../../Types';
const aiDisabledRequirements: TestRequirements = {
config: {
features: { aiAssistant: false },
},
};
const aiEnabledRequirements: TestRequirements = {
config: {
features: { aiAssistant: true },
},
};
const aiEnabledWithWorkflowRequirements: TestRequirements = {
config: {
features: { aiAssistant: true },
},
workflow: {
'ai_assistant_test_workflow.json': 'AI_Assistant_Test_Workflow',
},
intercepts: {
aiChat: {
url: '**/rest/ai/chat',
response: {
sessionId: '1',
messages: [
{
role: 'assistant',
type: 'message',
text: 'Hey, this is an assistant message',
},
],
},
},
},
};
const aiEnabledWithQuickRepliesRequirements: TestRequirements = {
config: {
features: { aiAssistant: true },
},
workflow: {
'ai_assistant_test_workflow.json': 'AI_Assistant_Test_Workflow',
},
intercepts: {
aiChat: {
url: '**/rest/ai/chat',
response: {
sessionId: '1',
messages: [
{
role: 'assistant',
type: 'message',
text: 'Hey, this is an assistant message',
quickReplies: [
{
text: "Sure, let's do it",
type: 'yes',
},
{
text: "Nah, doesn't sound good",
type: 'no',
},
],
},
],
},
},
},
};
const aiEnabledWithEndSessionRequirements: TestRequirements = {
config: {
features: { aiAssistant: true },
},
workflow: {
'ai_assistant_test_workflow.json': 'AI_Assistant_Test_Workflow',
},
intercepts: {
aiChat: {
url: '**/rest/ai/chat',
response: {
sessionId: '1',
messages: [
{
role: 'assistant',
type: 'message',
title: 'Glad to Help',
text: "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!",
},
{
role: 'assistant',
type: 'event',
eventName: 'end-session',
},
],
},
},
},
};
test.describe('AI Assistant::disabled', () => {
test('does not show assistant button if feature is disabled', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiDisabledRequirements);
await expect(n8n.aiAssistant.getAskAssistantFloatingButton()).toHaveCount(0);
});
});
test.describe('AI Assistant::enabled', () => {
test('renders placeholder UI', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.goto('/workflow/new');
await expect(n8n.aiAssistant.getAskAssistantCanvasActionButton()).toBeVisible();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await expect(n8n.aiAssistant.getPlaceholderMessage()).toBeVisible();
await expect(n8n.aiAssistant.getChatInput()).toBeVisible();
await expect(n8n.aiAssistant.getSendMessageButton()).toBeDisabled();
await expect(n8n.aiAssistant.getCloseChatButton()).toBeVisible();
await n8n.aiAssistant.getCloseChatButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeHidden();
});
test('should show resizer when chat is open', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.goto('/workflow/new');
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantSidebarResizer()).toBeVisible();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await n8n.aiAssistant.getAskAssistantSidebarResizer().hover();
await n8n.aiAssistant.getCloseChatButton().click();
});
test('should start chat session from node error view', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithWorkflowRequirements);
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeVisible();
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeEnabled();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesAll().first()).toContainText(
'Hey, this is an assistant message',
);
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeDisabled();
});
test('should render chat input correctly', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithWorkflowRequirements);
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await expect(n8n.aiAssistant.getChatInput()).toBeVisible();
await expect(n8n.aiAssistant.getSendMessageButton()).toBeDisabled();
await n8n.aiAssistant.getChatInput().fill('Test message');
await expect(n8n.aiAssistant.getChatInput()).toHaveValue('Test message');
await expect(n8n.aiAssistant.getSendMessageButton()).toBeEnabled();
await n8n.aiAssistant.getSendMessageButton().click();
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatInput()).toHaveValue('');
});
test('should render and handle quick replies', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithQuickRepliesRequirements);
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getQuickReplyButtons()).toHaveCount(2);
await expect(n8n.aiAssistant.getQuickReplyButtons()).toHaveCount(2);
await n8n.aiAssistant.getQuickReplyButtons().first().click();
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toContainText("Sure, let's do it");
});
test('should warn before starting a new session', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithWorkflowRequirements);
await n8n.canvas.openNode('Edit Fields');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
await n8n.aiAssistant.getCloseChatButton().click();
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getNewAssistantSessionModal()).toBeVisible();
await n8n.aiAssistant
.getNewAssistantSessionModal()
.getByRole('button', { name: 'Start new session' })
.click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
});
test('should end chat session when `end_session` event is received', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithEndSessionRequirements);
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesSystem().first()).toContainText(
'session has ended',
);
});
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from '../../fixtures/base';
import type { TestRequirements } from '../../Types';
const telemetryDisabledRequirements: TestRequirements = {
config: {
settings: {
telemetry: { enabled: false },
},
},
storage: {
'n8n-telemetry': JSON.stringify({ enabled: false }),
},
};
const telemetryEnabledRequirements: TestRequirements = {
config: {
settings: {
telemetry: { enabled: true },
instanceId: 'test-instance-id',
},
},
storage: {
'n8n-telemetry': JSON.stringify({ enabled: true }),
'n8n-instance-id': 'test-instance-id',
},
intercepts: {
iframeRequest: {
url: 'https://n8n.io/self-install*',
response: '<html><body>Test iframe content</body></html>',
contentType: 'text/html',
},
},
};
test.describe('n8n.io iframe', () => {
test.describe('when telemetry is disabled', () => {
test('should not load the iframe when visiting /home/workflows', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(telemetryDisabledRequirements);
await n8n.page.goto('/');
await n8n.page.waitForLoadState();
await expect(n8n.iframe.getIframe()).not.toBeAttached();
});
});
test.describe('when telemetry is enabled', () => {
test('should load the iframe when visiting /home/workflows @auth:owner', async ({
n8n,
setupRequirements,
api,
}) => {
await setupRequirements(telemetryEnabledRequirements);
// Get current user ID from the API
const currentUser = await api.get('/rest/login');
const testInstanceId = 'test-instance-id';
const testUserId = currentUser.id;
const iframeUrl = `https://n8n.io/self-install?instanceId=${testInstanceId}&userId=${testUserId}`;
await n8n.page.goto('/');
await n8n.page.waitForLoadState();
const iframeElement = n8n.iframe.getIframeBySrc(iframeUrl);
await expect(iframeElement).toBeAttached();
await expect(iframeElement).toHaveAttribute('src', iframeUrl);
});
});
});

View File

@@ -1,14 +1,3 @@
import {
getErrorActionItem,
getEvaluationsActionItem,
getIgnoreAllButton,
getSuggestedActionItem,
getSuggestedActionsButton,
getSuggestedActionsPopover,
getTimeSavedActionItem,
} from '../../composables/ProductionChecklist';
import { closeActivationModal } from '../../composables/WorkflowActivationModal';
import { openWorkflowSettings } from '../../composables/WorkflowSettingsModal';
import { test, expect } from '../../fixtures/base';
const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
@@ -22,35 +11,29 @@ test.describe('Workflow Production Checklist', () => {
test('should show suggested actions automatically when workflow is first activated', async ({
n8n,
}) => {
// Add a schedule trigger node (activatable)
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
// Verify suggested actions button is not visible
await expect(getSuggestedActionsButton(n8n.page)).toBeHidden();
await expect(n8n.canvas.getProductionChecklistButton()).toBeHidden();
// Activate the workflow
await n8n.canvas.activateWorkflow();
// Activation Modal should be visible since it's first activation
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
// Verify suggested actions button and popover is visible
await expect(getSuggestedActionsButton(n8n.page)).toBeVisible();
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await expect(getSuggestedActionItem(n8n.page)).toHaveCount(2);
await expect(getErrorActionItem(n8n.page)).toBeVisible();
await expect(getTimeSavedActionItem(n8n.page)).toBeVisible();
await expect(n8n.canvas.getProductionChecklistButton()).toBeVisible();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await expect(n8n.canvas.getProductionChecklistActionItem()).toHaveCount(2);
await expect(n8n.canvas.getErrorActionItem()).toBeVisible();
await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible();
});
test('should display evaluations action when AI node exists and feature is enabled', async ({
n8n,
api,
}) => {
// Enable evaluations feature
await api.enableFeature('evaluation');
// Add schedule trigger and AI node
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.addNodeAndCloseNDV('OpenAI', 'Create an assistant');
@@ -58,56 +41,50 @@ test.describe('Workflow Production Checklist', () => {
await n8n.canvas.saveWorkflow();
await n8n.canvas.activateWorkflow();
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
// Suggested actions should be open
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await expect(getSuggestedActionItem(n8n.page)).toHaveCount(3);
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await expect(n8n.canvas.getProductionChecklistActionItem()).toHaveCount(3);
// Verify evaluations action is present
await expect(getEvaluationsActionItem(n8n.page)).toBeVisible();
await getEvaluationsActionItem(n8n.page).click();
await expect(n8n.canvas.getEvaluationsActionItem()).toBeVisible();
await n8n.canvas.getEvaluationsActionItem().click();
// Verify navigation to evaluations page
await expect(n8n.page).toHaveURL(/\/evaluation/);
});
test('should open workflow settings modal when error workflow action is clicked', async ({
n8n,
}) => {
// Add schedule trigger
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.activateWorkflow();
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
// Click error workflow action
const errorAction = getErrorActionItem(n8n.page);
const errorAction = n8n.canvas.getErrorActionItem();
await expect(errorAction).toBeVisible();
await errorAction.click();
// Verify workflow settings modal opens
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
await expect(n8n.page.getByTestId('workflow-settings-error-workflow')).toBeVisible();
});
test('should open workflow settings modal when time saved action is clicked', async ({ n8n }) => {
// Add schedule trigger
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.activateWorkflow();
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
// Click time saved action
const timeAction = getTimeSavedActionItem(n8n.page);
const timeAction = n8n.canvas.getTimeSavedActionItem();
await expect(timeAction).toBeVisible();
await timeAction.click();
// Verify workflow settings modal opens
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
});
@@ -115,73 +92,64 @@ test.describe('Workflow Production Checklist', () => {
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.activateWorkflow();
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
// Suggested actions popover should be open
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
// Verify error workflow action is visible
await expect(getSuggestedActionItem(n8n.page).first()).toContainText('error');
await getSuggestedActionItem(n8n.page).first().getByTitle('Ignore').click();
await n8n.page.waitForTimeout(500); // items disappear after timeout, not arbitrary
await expect(getErrorActionItem(n8n.page)).toBeHidden();
await expect(n8n.canvas.getProductionChecklistActionItem().first()).toContainText('error');
await n8n.canvas.getProductionChecklistActionItem().first().getByTitle('Ignore').click();
await expect(n8n.canvas.getErrorActionItem()).toBeHidden();
// Close and reopen popover
await n8n.page.locator('body').click({ position: { x: 0, y: 0 } });
await getSuggestedActionsButton(n8n.page).click();
await n8n.canvas.clickProductionChecklistButton();
// Verify error workflow action is still no longer visible
await expect(getErrorActionItem(n8n.page)).toBeHidden();
await expect(getTimeSavedActionItem(n8n.page)).toBeVisible();
await expect(n8n.canvas.getErrorActionItem()).toBeHidden();
await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible();
});
test('should show completed state for configured actions', async ({ n8n }) => {
// Add schedule trigger and activate workflow
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.activateWorkflow();
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
// Open workflow settings and set error workflow
await openWorkflowSettings(n8n.page);
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getModal()).toBeVisible();
// Set an error workflow (we'll use a dummy value)
await n8n.page.getByTestId('workflow-settings-error-workflow').click();
await n8n.page.getByRole('option', { name: 'My workflow' }).first().click();
await n8n.page.getByRole('button', { name: 'Save' }).click();
await n8n.workflowSettingsModal.selectErrorWorkflow('My workflow');
await n8n.workflowSettingsModal.clickSave();
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeHidden();
// Open suggested actions
await getSuggestedActionsButton(n8n.page).click();
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await n8n.canvas.clickProductionChecklistButton();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
// Verify error workflow action shows as completed
await expect(
getSuggestedActionItem(n8n.page).first().locator('svg[data-icon="circle-check"]'),
n8n.canvas
.getProductionChecklistActionItem()
.first()
.locator('svg[data-icon="circle-check"]'),
).toBeVisible();
});
test('should allow ignoring all actions with confirmation', async ({ n8n }) => {
// Add schedule trigger
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.activateWorkflow();
await closeActivationModal(n8n.page);
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
// Suggested actions should be open
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
// Click ignore all button
await getIgnoreAllButton(n8n.page).click();
await n8n.canvas.clickProductionChecklistIgnoreAll();
// Confirm in the dialog
await expect(n8n.page.locator('.el-message-box')).toBeVisible();
await n8n.page
.locator('.el-message-box__btns button')
.filter({ hasText: /ignore for all workflows/i })
.click();
// Verify suggested actions button is no longer visible
await expect(getSuggestedActionsButton(n8n.page)).toBeHidden();
await expect(n8n.canvas.getProductionChecklistButton()).toBeHidden();
});
});

View File

@@ -0,0 +1,20 @@
import { test, expect } from '../../fixtures/base';
test.describe('AI-716 Correctly set up agent model shows error', () => {
test('should not show error when adding a sub-node with credential set-up', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode('AI Agent');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.addNode('OpenAI Chat Model');
await n8n.credentials.createAndSaveNewCredential('apiKey', 'sk-123');
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getNodeIssuesByName('OpenAI Chat Model')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,29 @@
import { test, expect } from '../../fixtures/base';
import type { TestRequirements } from '../../Types';
const requirements: TestRequirements = {
workflow: {
'Test_9999_SUG_38.json': 'SUG_38_Test_Workflow',
},
};
test.describe('SUG-38 Inline expression previews are not displayed in NDV', () => {
test("should show resolved inline expression preview in NDV if the node's input data is populated", async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Repro1');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toBeVisible();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toHaveText('hello there');
});
});

View File

@@ -1,26 +1,18 @@
import { test, expect } from '../../fixtures/base';
test('default signin is as owner', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test.describe('Authentication', () => {
const testCases = [
{ role: 'default', expectedUrl: /\/workflow/, auth: '' },
{ role: 'owner', expectedUrl: /\/workflow/, auth: '@auth:owner' },
{ role: 'admin', expectedUrl: /\/workflow/, auth: '@auth:admin' },
{ role: 'member', expectedUrl: /\/workflow/, auth: '@auth:member' },
{ role: 'none', expectedUrl: /\/signin/, auth: '@auth:none' },
];
test('owner can access dashboard @auth:owner', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('admin can access dashboard @auth:admin', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('member can access dashboard @auth:member', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('no auth can not access dashboard @auth:none', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/signin/);
for (const { role, expectedUrl, auth } of testCases) {
test(`${role} authentication ${auth}`, async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(expectedUrl);
});
}
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from '../../fixtures/base';
test.describe
.serial('Environment Feature Flags', () => {
test('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', async ({
api,
}) => {
const setResponse = await api.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: 'true',
});
expect(setResponse.data.success).toBe(true);
expect(setResponse.data.message).toBe('Environment feature flags updated');
expect(setResponse.data.flags).toBeInstanceOf(Object);
expect(setResponse.data.flags['N8N_ENV_FEAT_TEST']).toBe('true');
const currentFlags = await api.getEnvFeatureFlags();
expect(currentFlags).toBeInstanceOf(Object);
expect(currentFlags.data['N8N_ENV_FEAT_TEST']).toBe('true');
});
test('should reset feature flags at runtime', async ({ api }) => {
const setResponse1 = await api.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: 'true',
});
expect(setResponse1.data.success).toBe(true);
expect(setResponse1.data.flags['N8N_ENV_FEAT_TEST']).toBe('true');
const clearResponse = await api.clearEnvFeatureFlags();
expect(clearResponse.data.success).toBe(true);
expect(clearResponse.data.flags).toBeInstanceOf(Object);
expect(clearResponse.data.flags['N8N_ENV_FEAT_TEST']).toBeUndefined();
const currentFlags = await api.getEnvFeatureFlags();
expect(currentFlags).toBeInstanceOf(Object);
expect(currentFlags.data['N8N_ENV_FEAT_TEST']).toBeUndefined();
});
});

View File

@@ -1,9 +1,7 @@
import { expect, test } from '../../fixtures/base';
// Example of importing a workflow from a file
test.describe('PDF Test', () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Can read and write PDF files and extract text', async ({ n8n }) => {
test('Can read and write PDF files and extract text', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');

View File

@@ -44,5 +44,6 @@ const playwrightRoot = findProjectRoot('playwright.config.ts');
* @returns An absolute path to the file or directory.
*/
export function resolveFromRoot(...pathSegments: string[]): string {
// eslint-disable-next-line n8n-local-rules/no-argument-spread
return path.join(playwrightRoot, ...pathSegments);
}

View File

@@ -0,0 +1,70 @@
import type { Page, BrowserContext } from '@playwright/test';
import { setContextSettings } from '../config/intercepts';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { TestError, type TestRequirements } from '../Types';
export async function setupTestRequirements(
page: Page,
context: BrowserContext,
requirements: TestRequirements,
): Promise<void> {
const n8n = new n8nPage(page);
const api = new ApiHelpers(context.request);
// 1. Setup frontend settings override
if (requirements.config?.settings) {
// Store settings for this context
setContextSettings(context, requirements.config.settings);
}
// 2. Setup feature flags
if (requirements.config?.features) {
for (const [feature, enabled] of Object.entries(requirements.config.features)) {
if (enabled) {
await api.enableFeature(feature);
} else {
await api.disableFeature(feature);
}
}
}
// 3. Setup API intercepts
if (requirements.intercepts) {
for (const [name, config] of Object.entries(requirements.intercepts)) {
await page.route(config.url, async (route) => {
await route.fulfill({
status: config.status ?? 200,
contentType: config.contentType ?? 'application/json',
body:
typeof config.response === 'string' ? config.response : JSON.stringify(config.response),
});
});
}
}
// 4. Setup workflows
if (requirements.workflow) {
for (const [name, workflowData] of Object.entries(requirements.workflow)) {
try {
// Import workflow using the n8n page object
await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.importWorkflow(name, workflowData);
} catch (error) {
throw new TestError(`Failed to create workflow ${name}: ${String(error)}`);
}
}
}
// 5. Setup browser storage
if (requirements.storage) {
await context.addInitScript((storage) => {
// Set localStorage items
for (const [key, value] of Object.entries(storage)) {
window.localStorage.setItem(key, value);
}
}, requirements.storage);
}
}

View File

@@ -0,0 +1,72 @@
{
"name": "Manual wait set",
"nodes": [
{
"parameters": {
"amount": 2,
"unit": "seconds"
},
"id": "ed6e0168-1145-43d0-9082-970b8a8f3cb5",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"typeVersion": 1,
"position": [900, 580],
"webhookId": "0f6f94a4-c28d-46f9-8468-6ab315a9fec9"
},
{
"parameters": {},
"id": "59467b99-4e7c-4f19-8fc2-4329788f0951",
"name": "Manual",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [680, 580]
},
{
"parameters": {
"options": {}
},
"id": "6ddf089f-4d01-4691-928f-6de168e3b089",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1120, 580]
}
],
"pinData": {},
"connections": {
"Wait": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Manual": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"saveExecutionProgress": true,
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "f11ff1bf-4273-46cb-bbec-65c7b2fa13cb",
"id": "1037",
"meta": {
"instanceId": "8a47b83b4479b11330fdf21ccc96d4a8117035a968612e452b4c87bfd09c16c7"
},
"tags": []
}

View File

@@ -0,0 +1,80 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-240, 180],
"id": "cd9b8124-567e-43d9-b4d1-638b111cd049",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3a40d9f2-0eed-4a92-9287-9d6ec9ce90e8",
"name": "message",
"value": "hello there",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-20, 180],
"id": "6e58ae14-4851-4e9d-9465-4155b6e2f278",
"name": "Edit Fields1"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "9e957377-c5f2-4254-89d8-334d32a8cfb6",
"name": "test",
"value": "={{ $json.message }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [200, 180],
"id": "c4e9d792-51e9-4296-ba66-afac3cf378dd",
"name": "Repro1"
}
],
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Edit Fields1",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields1": {
"main": [
[
{
"node": "Repro1",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "cdc3bfdf3e6244f221ab6e71b2115a631406ae45a034bfca5e9731cf64f4eb64"
}
}

View File

@@ -0,0 +1,61 @@
{
"nodes": [
{
"parameters": {
"options": {}
},
"id": "bd816131-d8ad-4b4c-90d6-59fdab2e6307",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [720, 460]
},
{
"parameters": {
"httpMethod": "POST",
"path": "23fc3930-b8f9-41d9-89db-b647291a2201",
"options": {}
},
"id": "82fe0f6c-854a-4eb9-b311-d7b43025c047",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [460, 460],
"webhookId": "23fc3930-b8f9-41d9-89db-b647291a2201"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"Webhook": [
{
"headers": {
"host": "localhost:5678",
"content-length": "37",
"accept": "*/*",
"content-type": "application/json",
"accept-encoding": "gzip"
},
"params": {},
"query": {},
"body": {
"here": "be",
"dragons": true
},
"webhookUrl": "http://localhost:5678/webhook-test/23fc3930-b8f9-41d9-89db-b647291a2201",
"executionMode": "test"
}
]
}
}

View File

@@ -0,0 +1,76 @@
{
"nodes": [
{
"parameters": {},
"id": "ebfced75-2ce1-4c41-a971-6c3b83522c4d",
"name": "When clicking 'Execute workflow'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 220]
},
{
"parameters": {
"errorMessage": "This is an error message"
},
"id": "f2e60459-401a-49d5-acfc-7b2b31cfdcf7",
"name": "Stop and Error",
"type": "n8n-nodes-base.stopAndError",
"typeVersion": 1,
"position": [1020, 220]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"id": "b54d4db9-b257-41a8-862f-26d293115bad",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [840, 320]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "053ada73-f7db-4e6a-8cc8-85756cc6ca4e",
"name": "age",
"value": "={{ 32asd }}",
"type": "number"
}
]
},
"options": {}
},
"id": "5fd89612-a871-4679-b7b0-d659e09c6a0e",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [600, 100]
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "Stop and Error",
"type": "main",
"index": 0
},
{
"node": "Code",
"type": "main",
"index": 0
},
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

743
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff