test: Migrate 3 specs from Cypress - Playwright (#19269)

This commit is contained in:
shortstacked
2025-09-08 12:46:08 +01:00
committed by GitHub
parent c6be123ddb
commit f7d225e871
24 changed files with 1154 additions and 470 deletions

View File

@@ -26,9 +26,9 @@ export class TestEntryComposer {
}
/**
* Start UI test from a workflow in a new project
* Start UI test from a workflow in a new project on a new canvas
*/
async fromNewProject() {
async fromNewProjectBlankCanvas() {
// Enable features to allow us to create a new project
await this.n8n.api.enableFeature('projectRole:admin');
await this.n8n.api.enableFeature('projectRole:editor');
@@ -43,6 +43,20 @@ export class TestEntryComposer {
return projectId;
}
async fromNewProject() {
// Enable features to allow us to create a new project
await this.n8n.api.enableFeature('projectRole:admin');
await this.n8n.api.enableFeature('projectRole:editor');
await this.n8n.api.setMaxTeamProjectsQuota(-1);
// Create a project using the API
const response = await this.n8n.api.projectApi.createProject();
const projectId = response.id;
await this.n8n.navigate.toProject(projectId);
return projectId;
}
/**
* Start UI test from the canvas of an imported workflow
* Returns the workflow import result for use in the test

View File

@@ -0,0 +1,216 @@
import type { Page } from '@playwright/test';
/**
* NavigationHelper provides centralized navigation methods for all n8n routes.
* Handles both project-specific and global routes with proper URL construction.
*
* URLs are documented to help users understand where they're navigating:
* - Home workflows: /home/workflows
* - Project workflows: /projects/{projectId}/workflows
* - Variables: /variables (global only, no project scope)
* - Settings: /settings (global only)
* - Credentials: /home/credentials or /projects/{projectId}/credentials
* - Executions: /home/executions or /projects/{projectId}/executions
*/
export class NavigationHelper {
constructor(private page: Page) {}
/**
* Navigate to the home dashboard
* URL: /home
*/
async toHome(): Promise<void> {
await this.page.goto('/home');
}
/**
* Navigate to workflows page
* URLs:
* - Home workflows: /home/workflows
* - Project workflows: /projects/{projectId}/workflows
*/
async toWorkflows(projectId?: string): Promise<void> {
const url = projectId ? `/projects/${projectId}/workflows` : '/home/workflows';
await this.page.goto(url);
}
/**
* Navigate to credentials page
* URLs:
* - Home credentials: /home/credentials
* - Project credentials: /projects/{projectId}/credentials
*/
async toCredentials(projectId?: string): Promise<void> {
const url = projectId ? `/projects/${projectId}/credentials` : '/home/credentials';
await this.page.goto(url);
}
/**
* Navigate to executions page
* URLs:
* - Home executions: /home/executions
* - Project executions: /projects/{projectId}/executions
*/
async toExecutions(projectId?: string): Promise<void> {
const url = projectId ? `/projects/${projectId}/executions` : '/home/executions';
await this.page.goto(url);
}
/**
* Navigate to variables page (global only)
* URL: /variables
* Note: Variables are global and don't have project-specific scoping
*/
async toVariables(): Promise<void> {
await this.page.goto('/variables');
}
/**
* Navigate to settings page (global only)
* URL: /settings
*/
async toSettings(): Promise<void> {
await this.page.goto('/settings');
}
/**
* Navigate to personal settings
* URL: /settings/personal
*/
async toPersonalSettings(): Promise<void> {
await this.page.goto('/settings/personal');
}
/**
* Navigate to projects page
* URL: /projects
*/
async toProjects(): Promise<void> {
await this.page.goto('/projects');
}
/**
* Navigate to a specific project's dashboard
* URL: /projects/{projectId}
*/
async toProject(projectId: string): Promise<void> {
await this.page.goto(`/projects/${projectId}`);
}
/**
* Navigate to project settings
* URL: /projects/{projectId}/settings
*/
async toProjectSettings(projectId: string): Promise<void> {
await this.page.goto(`/projects/${projectId}/settings`);
}
/**
* Navigate to a specific workflow
* URLs:
* - New workflow: /workflow/new
* - Existing workflow: /workflow/{workflowId}
* - Project workflow: /projects/{projectId}/workflow/{workflowId}
*/
async toWorkflow(workflowId: string = 'new', projectId?: string): Promise<void> {
const url = projectId
? `/projects/${projectId}/workflow/${workflowId}`
: `/workflow/${workflowId}`;
await this.page.goto(url);
}
/**
* Navigate to workflow canvas (alias for toWorkflow)
*/
async toCanvas(workflowId: string = 'new', projectId?: string): Promise<void> {
await this.toWorkflow(workflowId, projectId);
}
/**
* Navigate to templates page
* URL: /templates
*/
async toTemplates(): Promise<void> {
await this.page.goto('/templates');
}
/**
* Navigate to a specific template
* URL: /templates/{templateId}
*/
async toTemplate(templateId: string): Promise<void> {
await this.page.goto(`/templates/${templateId}`);
}
/**
* Navigate to community nodes
* URL: /settings/community-nodes
*/
async toCommunityNodes(): Promise<void> {
await this.page.goto('/settings/community-nodes');
}
/**
* Navigate to log streaming settings
* URL: /settings/log-streaming
*/
async toLogStreaming(): Promise<void> {
await this.page.goto('/settings/log-streaming');
}
/**
* Navigate to worker view
* URL: /settings/workers
*/
async toWorkerView(): Promise<void> {
await this.page.goto('/settings/workers');
}
/**
* Navigate to users management
* URL: /settings/users
*/
async toUsers(): Promise<void> {
await this.page.goto('/settings/users');
}
/**
* Navigate to API settings
* URL: /settings/api
*/
async toApiSettings(): Promise<void> {
await this.page.goto('/settings/api');
}
/**
* Navigate to LDAP settings
* URL: /settings/ldap
*/
async toLdapSettings(): Promise<void> {
await this.page.goto('/settings/ldap');
}
/**
* Navigate to SSO settings
* URL: /settings/sso
*/
async toSsoSettings(): Promise<void> {
await this.page.goto('/settings/sso');
}
/**
* Navigate to source control settings
* URL: /settings/source-control
*/
async toSourceControl(): Promise<void> {
await this.page.goto('/settings/source-control');
}
/**
* Navigate to external secrets settings
* URL: /settings/external-secrets
*/
async toExternalSecrets(): Promise<void> {
await this.page.goto('/settings/external-secrets');
}
}

View File

@@ -76,6 +76,10 @@ export class CanvasPage extends BasePage {
await this.nodeCreatorItemByName(text).click();
}
async clickAddToWorkflowButton(): Promise<void> {
await this.page.getByText('Add to workflow').click();
}
/**
* Add a node to the canvas with flexible options
* @param nodeName - The name of the node to search for and add

View File

@@ -0,0 +1,121 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class CommunityNodesPage extends BasePage {
// Element getters
getCommunityCards(): Locator {
return this.page.getByTestId('community-package-card');
}
getActionBox(): Locator {
return this.page.getByTestId('action-box');
}
getInstallButton(): Locator {
// Try action box first (empty state), fallback to header install button
const actionBoxButton = this.getActionBox().locator('button');
const headerInstallButton = this.page.getByRole('button', { name: 'Install' });
return actionBoxButton.or(headerInstallButton);
}
getInstallModal(): Locator {
return this.page.getByTestId('communityPackageInstall-modal');
}
getConfirmModal(): Locator {
return this.page.getByTestId('communityPackageManageConfirm-modal');
}
getPackageNameInput(): Locator {
return this.getInstallModal().locator('input').first();
}
getUserAgreementCheckbox(): Locator {
return this.page.getByTestId('user-agreement-checkbox');
}
getInstallPackageButton(): Locator {
return this.page.getByTestId('install-community-package-button');
}
getActionToggle(): Locator {
return this.page.getByTestId('action-toggle');
}
getUninstallAction(): Locator {
return this.page.getByTestId('action-uninstall');
}
getUpdateButton(): Locator {
return this.getCommunityCards().first().locator('button');
}
getConfirmUpdateButton(): Locator {
return this.getConfirmModal().getByRole('button', { name: 'Confirm update' });
}
getConfirmUninstallButton(): Locator {
return this.getConfirmModal().getByRole('button', { name: 'Confirm uninstall' });
}
// Simple actions
async clickInstallButton(): Promise<void> {
await this.getInstallButton().click();
}
async fillPackageName(packageName: string): Promise<void> {
await this.getPackageNameInput().fill(packageName);
}
async clickUserAgreementCheckbox(): Promise<void> {
await this.getUserAgreementCheckbox().click();
}
async clickInstallPackageButton(): Promise<void> {
await this.getInstallPackageButton().click();
}
async clickActionToggle(): Promise<void> {
await this.getActionToggle().click();
}
async clickUninstallAction(): Promise<void> {
await this.getUninstallAction().click();
}
async clickUpdateButton(): Promise<void> {
await this.getUpdateButton().click();
}
async clickConfirmUpdate(): Promise<void> {
await this.getConfirmUpdateButton().click();
}
async clickConfirmUninstall(): Promise<void> {
await this.getConfirmUninstallButton().click();
}
// Helper methods for common workflows
async installPackage(packageName: string): Promise<void> {
await this.clickInstallButton();
await this.fillPackageName(packageName);
await this.clickUserAgreementCheckbox();
await this.clickInstallPackageButton();
// Wait for install modal to close
await this.getInstallModal().waitFor({ state: 'hidden' });
}
async updatePackage(): Promise<void> {
await this.clickUpdateButton();
await this.clickConfirmUpdate();
}
async uninstallPackage(): Promise<void> {
await this.clickActionToggle();
await this.clickUninstallAction();
await this.clickConfirmUninstall();
}
}

View File

@@ -725,4 +725,7 @@ export class NodeDetailsViewPage extends BasePage {
getInputSelect() {
return this.page.getByTestId('ndv-input-select').locator('input');
}
getCredentialLabel(credentialType: string) {
return this.page.getByText(credentialType);
}
}

View File

@@ -0,0 +1,96 @@
import { expect, type Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class VariablesPage extends BasePage {
getUnavailableResourcesList() {
return this.page.getByTestId('unavailable-resources-list');
}
getResourcesList() {
return this.page.getByTestId('resources-list');
}
getEmptyResourcesList() {
return this.page.getByTestId('empty-resources-list');
}
getEmptyResourcesListNewVariableButton() {
return this.getEmptyResourcesList().locator('button');
}
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
getCreateVariableButton() {
return this.page.getByTestId('resources-list-add');
}
getVariablesRows() {
return this.page.getByTestId('variables-row');
}
getVariablesEditableRows() {
return this.page.getByTestId('variables-row').filter({ has: this.page.locator('input') });
}
getVariableRow(key: string) {
return this.getVariablesRows().filter({ hasText: key });
}
getEditableRowCancelButton(row: Locator) {
return row.getByTestId('variable-row-cancel-button');
}
getEditableRowSaveButton(row: Locator) {
return row.getByTestId('variable-row-save-button');
}
async createVariable(key: string, value: string) {
await this.getCreateVariableButton().click();
const editingRow = this.getVariablesEditableRows().first();
await this.setRowValue(editingRow, 'key', key);
await this.setRowValue(editingRow, 'value', value);
await this.saveRowEditing(editingRow);
}
async createVariableFromEmptyState(key: string, value: string) {
await this.getEmptyResourcesListNewVariableButton().click();
const editingRow = this.getVariablesEditableRows().first();
await this.setRowValue(editingRow, 'key', key);
await this.setRowValue(editingRow, 'value', value);
await this.saveRowEditing(editingRow);
}
async deleteVariable(key: string) {
const row = this.getVariableRow(key);
await row.getByTestId('variable-row-delete-button').click();
// Use a more specific selector to avoid strict mode violation with other dialogs
const modal = this.page.getByRole('dialog').filter({ hasText: 'Delete variable' });
await expect(modal).toBeVisible();
await modal.locator('.btn--confirm').click();
}
async editRow(key: string) {
const row = this.getVariableRow(key);
await row.getByTestId('variable-row-edit-button').click();
}
async setRowValue(row: Locator, field: 'key' | 'value', value: string) {
const input = row.getByTestId(`variable-row-${field}-input`).locator('input, textarea');
await input.selectText();
await input.fill(value);
}
async saveRowEditing(row: Locator) {
await this.getEditableRowSaveButton(row).click();
}
async cancelRowEditing(row: Locator) {
await this.getEditableRowCancelButton(row).click();
}
}

View File

@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test';
import { AIAssistantPage } from './AIAssistantPage';
import { BecomeCreatorCTAPage } from './BecomeCreatorCTAPage';
import { CanvasPage } from './CanvasPage';
import { CommunityNodesPage } from './CommunityNodesPage';
import { CredentialsPage } from './CredentialsPage';
import { DemoPage } from './DemoPage';
import { ExecutionsPage } from './ExecutionsPage';
@@ -14,6 +15,7 @@ import { NpsSurveyPage } from './NpsSurveyPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SettingsPage } from './SettingsPage';
import { SidebarPage } from './SidebarPage';
import { VariablesPage } from './VariablesPage';
import { VersionsPage } from './VersionsPage';
import { WorkerViewPage } from './WorkerViewPage';
import { WorkflowActivationModal } from './WorkflowActivationModal';
@@ -24,6 +26,7 @@ import { CanvasComposer } from '../composables/CanvasComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
import { TestEntryComposer } from '../composables/TestEntryComposer';
import { WorkflowComposer } from '../composables/WorkflowComposer';
import { NavigationHelper } from '../helpers/NavigationHelper';
import type { ApiHelpers } from '../services/api-helper';
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -35,6 +38,7 @@ export class n8nPage {
readonly aiAssistant: AIAssistantPage;
readonly becomeCreatorCTA: BecomeCreatorCTAPage;
readonly canvas: CanvasPage;
readonly communityNodes: CommunityNodesPage;
readonly demo: DemoPage;
readonly iframe: IframePage;
readonly interactions: InteractionsPage;
@@ -42,6 +46,7 @@ export class n8nPage {
readonly npsSurvey: NpsSurveyPage;
readonly projectSettings: ProjectSettingsPage;
readonly settings: SettingsPage;
readonly variables: VariablesPage;
readonly versions: VersionsPage;
readonly workerView: WorkerViewPage;
readonly workflows: WorkflowsPage;
@@ -61,6 +66,9 @@ export class n8nPage {
readonly canvasComposer: CanvasComposer;
readonly start: TestEntryComposer;
// Helpers
readonly navigate: NavigationHelper;
constructor(page: Page, api: ApiHelpers) {
this.page = page;
this.api = api;
@@ -69,6 +77,7 @@ export class n8nPage {
this.aiAssistant = new AIAssistantPage(page);
this.becomeCreatorCTA = new BecomeCreatorCTAPage(page);
this.canvas = new CanvasPage(page);
this.communityNodes = new CommunityNodesPage(page);
this.demo = new DemoPage(page);
this.iframe = new IframePage(page);
this.interactions = new InteractionsPage(page);
@@ -76,6 +85,7 @@ export class n8nPage {
this.npsSurvey = new NpsSurveyPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.settings = new SettingsPage(page);
this.variables = new VariablesPage(page);
this.versions = new VersionsPage(page);
this.workerView = new WorkerViewPage(page);
this.workflows = new WorkflowsPage(page);
@@ -94,6 +104,9 @@ export class n8nPage {
this.projectComposer = new ProjectComposer(this);
this.canvasComposer = new CanvasComposer(this);
this.start = new TestEntryComposer(this);
// Helpers
this.navigate = new NavigationHelper(page);
}
async goHome() {

View File

@@ -11,6 +11,7 @@ import {
import { TestError } from '../Types';
import { CredentialApiHelper } from './credential-api-helper';
import { ProjectApiHelper } from './project-api-helper';
import { VariablesApiHelper } from './variables-api-helper';
import { WorkflowApiHelper } from './workflow-api-helper';
export interface LoginResponseData {
@@ -37,12 +38,14 @@ export class ApiHelpers {
workflowApi: WorkflowApiHelper;
projectApi: ProjectApiHelper;
credentialApi: CredentialApiHelper;
variablesApi: VariablesApiHelper;
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
this.workflowApi = new WorkflowApiHelper(this);
this.projectApi = new ProjectApiHelper(this);
this.credentialApi = new CredentialApiHelper(this);
this.variablesApi = new VariablesApiHelper(this);
}
// ===== MAIN SETUP METHODS =====

View File

@@ -0,0 +1,123 @@
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
interface VariableResponse {
id: string;
key: string;
value: string;
}
interface CreateVariableDto {
key: string;
value: string;
}
interface UpdateVariableDto {
key?: string;
value?: string;
}
export class VariablesApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create a new variable
*/
async createVariable(variable: CreateVariableDto): Promise<VariableResponse> {
const response = await this.api.request.post('/rest/variables', { data: variable });
if (!response.ok()) {
throw new TestError(`Failed to create variable: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Get all variables
*/
async getAllVariables(): Promise<VariableResponse[]> {
const response = await this.api.request.get('/rest/variables');
if (!response.ok()) {
throw new TestError(`Failed to get variables: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Get a variable by ID
*/
async getVariable(id: string): Promise<VariableResponse> {
const response = await this.api.request.get(`/rest/variables/${id}`);
if (!response.ok()) {
throw new TestError(`Failed to get variable: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Update a variable by ID
*/
async updateVariable(id: string, updates: UpdateVariableDto): Promise<VariableResponse> {
const response = await this.api.request.patch(`/rest/variables/${id}`, { data: updates });
if (!response.ok()) {
throw new TestError(`Failed to update variable: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Delete a variable by ID
*/
async deleteVariable(id: string): Promise<void> {
const response = await this.api.request.delete(`/rest/variables/${id}`);
if (!response.ok()) {
throw new TestError(`Failed to delete variable: ${await response.text()}`);
}
}
/**
* Delete all variables (useful for test cleanup)
*/
async deleteAllVariables(): Promise<void> {
const variables = await this.getAllVariables();
// Delete variables in parallel for better performance
await Promise.all(variables.map((variable) => this.deleteVariable(variable.id)));
}
/**
* Create a test variable with a unique key
*/
async createTestVariable(
keyPrefix: string = 'TEST_VAR',
value: string = 'test_value',
): Promise<VariableResponse> {
const key = `${keyPrefix}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
return await this.createVariable({ key, value });
}
/**
* Clean up variables by key pattern (useful for test cleanup)
*/
async cleanupTestVariables(keyPattern?: string): Promise<void> {
const variables = await this.getAllVariables();
const variablesToDelete = keyPattern
? variables.filter((variable) => variable.key.includes(keyPattern))
: variables.filter((variable) => variable.key.startsWith('TEST_'));
await Promise.all(variablesToDelete.map((variable) => this.deleteVariable(variable.id)));
}
}

View File

@@ -7,7 +7,7 @@ import {
} from '../../utils/performance-helper';
async function setupPerformanceTest(n8n: n8nPage, size: number) {
await n8n.start.fromNewProject();
await n8n.start.fromNewProjectBlankCanvas();
await n8n.canvas.importWorkflow('large.json', 'Large Workflow');
await n8n.notifications.closeNotificationByText('Successful');

View File

@@ -0,0 +1,169 @@
import { MANUAL_TRIGGER_NODE_NAME } from '../../config/constants';
import { test, expect } from '../../fixtures/base';
import customCredential from '../../workflows/Custom_credential.json';
import customNodeFixture from '../../workflows/Custom_node.json';
import customNodeWithCustomCredentialFixture from '../../workflows/Custom_node_custom_credential.json';
import customNodeWithN8nCredentialFixture from '../../workflows/Custom_node_n8n_credential.json';
const CUSTOM_NODE_NAME = 'E2E Node';
const CUSTOM_NODE_WITH_N8N_CREDENTIAL = 'E2E Node with native n8n credential';
const CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL = 'E2E Node with custom credential';
const MOCK_PACKAGE = {
createdAt: '2024-07-22T19:08:06.505Z',
updatedAt: '2024-07-22T19:08:06.505Z',
packageName: 'n8n-nodes-chatwork',
installedVersion: '1.0.0',
authorName: null,
authorEmail: null,
installedNodes: [
{
name: 'Chatwork',
type: 'n8n-nodes-chatwork.chatwork',
latestVersion: 1,
},
],
updateAvailable: '1.1.2',
};
test.describe('Community and custom nodes in canvas', () => {
test.beforeEach(async ({ page }) => {
await page.route('/types/nodes.json', async (route) => {
const response = await route.fetch();
const nodes = await response.json();
nodes.push(
customNodeFixture,
customNodeWithN8nCredentialFixture,
customNodeWithCustomCredentialFixture,
);
await route.fulfill({
response,
json: nodes,
headers: { 'cache-control': 'no-cache, no-store' },
});
});
await page.route('/types/credentials.json', async (route) => {
const response = await route.fetch();
const credentials = await response.json();
credentials.push(customCredential);
await route.fulfill({
response,
json: credentials,
headers: { 'cache-control': 'no-cache, no-store' },
});
});
await page.route('/community-node-types', async (route) => {
await route.fulfill({ status: 200, json: { data: [] } });
});
await page.route('**/community-node-types/*', async (route) => {
await route.fulfill({ status: 200, json: null });
});
await page.route('https://registry.npmjs.org/*', async (route) => {
await route.fulfill({ status: 404, json: {} });
});
});
test('should render and select community node', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_NAME);
await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_NAME);
await n8n.canvas.clickAddToWorkflowButton();
await expect(n8n.ndv.getNodeParameters()).toBeVisible();
await expect(n8n.ndv.getParameterInputField('testProp')).toHaveValue('Some default');
await expect(n8n.ndv.getParameterInputField('resource')).toHaveValue('option2');
await n8n.ndv.selectOptionInParameterDropdown('resource', 'option4');
await expect(n8n.ndv.getParameterInputField('resource')).toHaveValue('option4');
});
test('should render custom node with n8n credential', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_WITH_N8N_CREDENTIAL);
await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_WITH_N8N_CREDENTIAL);
await n8n.canvas.clickAddToWorkflowButton();
await n8n.page.getByTestId('credentials-label').click();
await n8n.page.getByTestId('node-credentials-select-item-new').click();
await expect(n8n.page.getByTestId('editCredential-modal')).toContainText('Notion API');
});
test('should render custom node with custom credential', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL);
await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL);
await n8n.canvas.clickAddToWorkflowButton();
await n8n.page.getByTestId('credentials-label').click();
await n8n.page.getByTestId('node-credentials-select-item-new').click();
await expect(n8n.page.getByTestId('editCredential-modal')).toContainText(
'Custom E2E Credential',
);
});
});
test.describe('Community nodes management', () => {
test('can install, update and uninstall community nodes', async ({ n8n, page }) => {
await page.route('**/api.npms.io/v2/search*', async (route) => {
await route.fulfill({ status: 200, json: {} });
});
await page.route('/rest/community-packages', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: { data: [] } });
}
});
await n8n.navigate.toCommunityNodes();
await page.route('/rest/community-packages', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 200, json: { data: MOCK_PACKAGE } });
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: { data: [MOCK_PACKAGE] } });
}
});
await n8n.communityNodes.installPackage('n8n-nodes-chatwork@1.0.0');
await expect(n8n.communityNodes.getCommunityCards()).toHaveCount(1);
await expect(n8n.communityNodes.getCommunityCards().first()).toContainText('v1.0.0');
const updatedPackage = {
...MOCK_PACKAGE,
installedVersion: '1.2.0',
updateAvailable: undefined,
};
await page.route('/rest/community-packages', async (route) => {
if (route.request().method() === 'PATCH') {
await route.fulfill({ status: 200, json: { data: updatedPackage } });
}
});
await n8n.communityNodes.updatePackage();
await expect(n8n.communityNodes.getCommunityCards()).toHaveCount(1);
await expect(n8n.communityNodes.getCommunityCards().first()).not.toContainText('v1.0.0');
await page.route('/rest/community-packages*', async (route) => {
if (route.request().method() === 'DELETE') {
await route.fulfill({ status: 204 });
}
});
await n8n.communityNodes.uninstallPackage();
await expect(n8n.communityNodes.getActionBox()).toBeVisible();
});
});

View File

@@ -0,0 +1,163 @@
import { customAlphabet } from 'nanoid';
import { test, expect } from '../../fixtures/base';
const generateValidId = customAlphabet(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_',
8,
);
test.describe('Variables', () => {
// These tests are serial since it's at an instance level and they interact with the same variables
test.describe.configure({ mode: 'serial' });
test.describe('unlicensed', () => {
test('should show the unlicensed action box when the feature is disabled', async ({
n8n,
api,
}) => {
await api.disableFeature('variables');
await n8n.navigate.toVariables();
await expect(n8n.variables.getUnavailableResourcesList()).toBeVisible();
await expect(n8n.variables.getResourcesList()).toBeHidden();
});
});
test.describe('licensed', () => {
test.beforeEach(async ({ n8n, api }) => {
await api.enableFeature('variables');
await api.variablesApi.deleteAllVariables();
await n8n.navigate.toVariables();
});
test('should create a new variable using empty state', async ({ n8n }) => {
const key = `ENV_VAR_${generateValidId()}`;
const value = 'test_value';
await n8n.variables.createVariableFromEmptyState(key, value);
const variableRow = n8n.variables.getVariableRow(key);
await expect(variableRow).toContainText(value);
await expect(variableRow).toBeVisible();
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
});
test('should create multiple variables', async ({ n8n }) => {
const key1 = `ENV_VAR_NEW_${generateValidId()}`;
const value1 = 'test_value_1';
await n8n.variables.createVariableFromEmptyState(key1, value1);
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
const key2 = `ENV_EXAMPLE_${generateValidId()}`;
const value2 = 'test_value_2';
await n8n.variables.createVariable(key2, value2);
await expect(n8n.variables.getVariablesRows()).toHaveCount(2);
const variableRow1 = n8n.variables.getVariableRow(key1);
await expect(variableRow1).toContainText(value1);
await expect(variableRow1).toBeVisible();
const variableRow2 = n8n.variables.getVariableRow(key2);
await expect(variableRow2).toContainText(value2);
await expect(variableRow2).toBeVisible();
});
test('should get validation errors and cancel variable creation', async ({ n8n }) => {
await n8n.variables.createVariableFromEmptyState(
`ENV_BASE_${generateValidId()}`,
'base_value',
);
const initialCount = await n8n.variables.getVariablesRows().count();
const key = `ENV_VAR_INVALID_${generateValidId()}$`; // Invalid key with special character
const value = 'test_value';
await n8n.variables.getCreateVariableButton().click();
const editingRow = n8n.variables.getVariablesEditableRows().first();
await n8n.variables.setRowValue(editingRow, 'key', key);
await n8n.variables.setRowValue(editingRow, 'value', value);
await n8n.variables.saveRowEditing(editingRow);
await expect(editingRow).toContainText(
'This field may contain only letters, numbers, and underscores',
);
await n8n.variables.cancelRowEditing(editingRow);
await expect(n8n.variables.getVariablesRows()).toHaveCount(initialCount);
});
test('should edit a variable', async ({ n8n }) => {
const key = `ENV_VAR_EDIT_${generateValidId()}`;
const initialValue = 'initial_value';
await n8n.variables.createVariableFromEmptyState(key, initialValue);
const newValue = 'updated_value';
await n8n.variables.editRow(key);
const editingRow = n8n.variables.getVariablesEditableRows().first();
await n8n.variables.setRowValue(editingRow, 'value', newValue);
await n8n.variables.saveRowEditing(editingRow);
const variableRow = n8n.variables.getVariableRow(key);
await expect(variableRow).toContainText(newValue);
await expect(variableRow).toBeVisible();
});
test('should delete a variable', async ({ n8n }) => {
const key = `TO_DELETE_${generateValidId()}`;
const value = 'delete_test_value';
await n8n.variables.createVariableFromEmptyState(key, value);
const initialCount = await n8n.variables.getVariablesRows().count();
await n8n.variables.deleteVariable(key);
await expect(n8n.variables.getVariablesRows()).toHaveCount(initialCount - 1);
await expect(n8n.variables.getVariableRow(key)).toBeHidden();
});
test('should search for a variable', async ({ n8n, page }) => {
const uniqueId = generateValidId();
const key1 = `SEARCH_VAR_${uniqueId}`;
const key2 = `SEARCH_VAR_NEW_${uniqueId}`;
const key3 = `SEARCH_EXAMPLE_${uniqueId}`;
await n8n.variables.createVariableFromEmptyState(key1, 'search_value_1');
await n8n.variables.createVariable(key2, 'search_value_2');
await n8n.variables.createVariable(key3, 'search_value_3');
await n8n.variables.getSearchBar().fill('NEW_');
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
await expect(n8n.variables.getVariableRow(key2)).toBeVisible();
await expect(page).toHaveURL(new RegExp('search=NEW_'));
await n8n.variables.getSearchBar().clear();
await n8n.variables.getSearchBar().fill('SEARCH_VAR_');
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toHaveCount(2);
await expect(n8n.variables.getVariableRow(key1)).toBeVisible();
await expect(n8n.variables.getVariableRow(key2)).toBeVisible();
await expect(page).toHaveURL(new RegExp('search=SEARCH_VAR_'));
await n8n.variables.getSearchBar().clear();
await n8n.variables.getSearchBar().fill('SEARCH_');
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toHaveCount(3);
await expect(n8n.variables.getVariableRow(key1)).toBeVisible();
await expect(n8n.variables.getVariableRow(key2)).toBeVisible();
await expect(n8n.variables.getVariableRow(key3)).toBeVisible();
await expect(page).toHaveURL(new RegExp('search=SEARCH_'));
await n8n.variables.getSearchBar().clear();
await n8n.variables.getSearchBar().fill(`NonExistent_${generateValidId()}`);
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toBeHidden();
await expect(page).toHaveURL(/search=NonExistent_/);
await expect(page.getByText('No variables found')).toBeVisible();
});
});
});

View File

@@ -2,7 +2,7 @@ import { test, expect } from '../../fixtures/base';
test.describe('OAuth Credentials', () => {
test('should create and connect with Google OAuth2', async ({ n8n, page }) => {
const projectId = await n8n.start.fromNewProject();
const projectId = await n8n.start.fromNewProjectBlankCanvas();
await page.goto(`projects/${projectId}/credentials`);
await n8n.credentials.emptyListCreateCredentialButton.click();
await n8n.credentials.openNewCredentialDialogFromCredentialList('Google OAuth2 API');

View File

@@ -259,8 +259,8 @@ test.describe('Logs', () => {
const response = await n8n.page.request.get(webhookUrl!);
expect(response.status()).toBe(200);
await expect(n8n.canvas.getNodesWithSpinner()).not.toBeVisible();
await expect(n8n.canvas.getWaitingNodes()).not.toBeVisible();
await expect(n8n.canvas.getNodesWithSpinner()).toBeHidden();
await expect(n8n.canvas.getWaitingNodes()).toBeHidden();
await expect(
n8n.canvas.logsPanel.getOverviewStatus().filter({ hasText: /Success in [\d.]+m?s/ }),
).toBeVisible();

View File

@@ -0,0 +1,36 @@
import { test, expect } from '../../fixtures/base';
test.describe('HTTP Request node', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should make a request with a URL and receive a response', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request', { closeNDV: false });
await n8n.ndv.setupHelper.httpRequest({
url: 'https://catfact.ninja/fact',
});
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.get()).toContainText('fact');
});
test.describe('Credential-only HTTP Request Node variants', () => {
test('should render a modified HTTP Request Node', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('VirusTotal');
await expect(n8n.ndv.getNodeNameContainer()).toContainText('VirusTotal HTTP Request');
await expect(n8n.ndv.getParameterInputField('url')).toHaveValue(
'https://www.virustotal.com/api/v3/',
);
await expect(n8n.ndv.getParameterInput('authentication')).toBeHidden();
await expect(n8n.ndv.getParameterInput('nodeCredentialType')).toBeHidden();
await expect(n8n.ndv.getCredentialLabel('Credential for VirusTotal')).toBeVisible();
});
});
});

View File

@@ -17,7 +17,7 @@ test.describe('01 - UI Test Entry Points', () => {
test.describe('Entry Point: Basic Workflow Creation', () => {
test('should create a new project and workflow', async ({ n8n }) => {
await n8n.start.fromNewProject();
await n8n.start.fromNewProjectBlankCanvas();
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});

View File

@@ -0,0 +1,21 @@
{
"name": "customE2eCredential",
"displayName": "Custom E2E Credential",
"properties": [
{
"displayName": "API Key",
"name": "apiKey",
"type": "string",
"default": "",
"required": false
}
],
"authenticate": {
"type": "generic",
"properties": {
"qs": {
"auth": "={{$credentials.apiKey}}"
}
}
}
}

View File

@@ -0,0 +1,51 @@
{
"properties": [
{
"displayName": "Test property",
"name": "testProp",
"type": "string",
"required": true,
"noDataExpression": false,
"default": "Some default"
},
{
"displayName": "Resource",
"name": "resource",
"type": "options",
"noDataExpression": true,
"options": [
{
"name": "option1",
"value": "option1"
},
{
"name": "option2",
"value": "option2"
},
{
"name": "option3",
"value": "option3"
},
{
"name": "option4",
"value": "option4"
}
],
"default": "option2"
}
],
"displayName": "E2E Node",
"name": "@e2e/n8n-nodes-e2e",
"group": ["transform"],
"codex": {
"categories": ["Custom Category"]
},
"version": 1,
"description": "Demonstrate rendering of node",
"defaults": {
"name": "E2E Node "
},
"inputs": ["main"],
"outputs": ["main"],
"icon": "fa:network-wired"
}

View File

@@ -0,0 +1,57 @@
{
"properties": [
{
"displayName": "Test property",
"name": "testProp",
"type": "string",
"required": true,
"noDataExpression": false,
"default": "Some default"
},
{
"displayName": "Resource",
"name": "resource",
"type": "options",
"noDataExpression": true,
"options": [
{
"name": "option1",
"value": "option1"
},
{
"name": "option2",
"value": "option2"
},
{
"name": "option3",
"value": "option3"
},
{
"name": "option4",
"value": "option4"
}
],
"default": "option2"
}
],
"displayName": "E2E Node with custom credential",
"name": "@e2e/n8n-nodes-e2e-custom-credential",
"group": ["transform"],
"codex": {
"categories": ["Custom Category"]
},
"version": 1,
"description": "Demonstrate rendering of node with custom credential",
"defaults": {
"name": "E2E Node with custom credential"
},
"inputs": ["main"],
"outputs": ["main"],
"icon": "fa:network-wired",
"credentials": [
{
"name": "customE2eCredential",
"required": true
}
]
}

View File

@@ -0,0 +1,57 @@
{
"properties": [
{
"displayName": "Test property",
"name": "testProp",
"type": "string",
"required": true,
"noDataExpression": false,
"default": "Some default"
},
{
"displayName": "Resource",
"name": "resource",
"type": "options",
"noDataExpression": true,
"options": [
{
"name": "option1",
"value": "option1"
},
{
"name": "option2",
"value": "option2"
},
{
"name": "option3",
"value": "option3"
},
{
"name": "option4",
"value": "option4"
}
],
"default": "option2"
}
],
"displayName": "E2E Node with native n8n credential",
"name": "@e2e/n8n-nodes-e2e-credential",
"group": ["transform"],
"codex": {
"categories": ["Custom Category"]
},
"version": 1,
"description": "Demonstrate rendering of node with native credential",
"defaults": {
"name": "E2E Node with native n8n credential"
},
"inputs": ["main"],
"outputs": ["main"],
"icon": "fa:network-wired",
"credentials": [
{
"name": "notionApi",
"required": true
}
]
}