first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,150 @@
import { expect } from '@playwright/test';
import type { n8nPage } from '../pages/n8nPage';
export class CanvasComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Pin the data on a node. Then close the node.
* @param nodeName - The name of the node to pin the data on.
*/
async pinNodeData(nodeName: string) {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.togglePinData();
await this.n8n.ndv.close();
}
/**
* Copy selected nodes and verify success toast
*/
async copySelectedNodesWithToast(): Promise<void> {
await this.n8n.clipboard.grant();
await this.n8n.canvas.copyNodes();
await this.n8n.notifications.waitForNotificationAndClose('Copied to clipboard');
}
/**
* Select all nodes and copy them
*/
async selectAllAndCopy(): Promise<void> {
await this.n8n.clipboard.grant();
await this.n8n.canvas.selectAll();
await this.copySelectedNodesWithToast();
}
/**
* Get workflow JSON from clipboard
* Grants permissions, selects all, copies, and returns parsed workflow
* @returns The parsed workflow object from clipboard
*/
async getWorkflowFromClipboard(): Promise<{
nodes: Array<{ credentials?: Record<string, unknown> }>;
meta?: Record<string, unknown>;
}> {
await this.n8n.clipboard.grant();
await this.n8n.canvas.selectAll();
await this.n8n.canvas.copyNodes();
const workflowJSON = await this.n8n.clipboard.readText();
return JSON.parse(workflowJSON);
}
/**
* Switch between editor and workflow history and back
*/
async switchBetweenEditorAndHistory(): Promise<void> {
await this.n8n.canvas.openWorkflowHistory();
await this.n8n.canvas.closeWorkflowHistory();
await this.n8n.page.waitForLoadState();
await expect(this.n8n.canvas.getCanvasNodes().first()).toBeVisible();
await expect(this.n8n.canvas.getCanvasNodes().last()).toBeVisible();
}
/**
* Switch between editor and workflow list and back
*/
async switchBetweenEditorAndWorkflowList(): Promise<void> {
await this.n8n.sideBar.clickHomeButton();
await this.n8n.workflows.cards.getWorkflows().first().click();
await expect(this.n8n.canvas.getCanvasNodes().first()).toBeVisible();
await expect(this.n8n.canvas.getCanvasNodes().last()).toBeVisible();
}
/**
* Zoom in and validate that zoom functionality works
*/
async zoomInAndCheckNodes(): Promise<void> {
await this.n8n.canvas.getCanvasNodes().first().waitFor();
const initialNodeSize = await this.n8n.page.evaluate(() => {
const firstNode = document.querySelector('[data-test-id="canvas-node"]');
if (!firstNode) {
throw new Error('Canvas node not found during initial measurement');
}
return firstNode.getBoundingClientRect().width;
});
for (let i = 0; i < 4; i++) {
await this.n8n.canvas.clickZoomInButton();
}
const finalNodeSize = await this.n8n.page.evaluate(() => {
const firstNode = document.querySelector('[data-test-id="canvas-node"]');
if (!firstNode) {
throw new Error('Canvas node not found during final measurement');
}
return firstNode.getBoundingClientRect().width;
});
// Validate zoom increased node sizes by at least 50%
const zoomWorking = finalNodeSize > initialNodeSize * 1.5;
if (!zoomWorking) {
throw new Error(
"Zoom functionality not working: nodes didn't scale properly. " +
`Initial: ${initialNodeSize.toFixed(1)}px, Final: ${finalNodeSize.toFixed(1)}px`,
);
}
}
/**
* Rename a node using keyboard shortcut
* @param oldName - The current name of the node
* @param newName - The new name for the node
*/
async renameNodeViaShortcut(oldName: string, newName: string): Promise<void> {
await this.n8n.canvas.nodeByName(oldName).click();
await this.n8n.page.keyboard.press('F2');
await expect(this.n8n.canvas.getRenamePrompt()).toBeVisible();
await this.n8n.page.keyboard.type(newName);
await this.n8n.page.keyboard.press('Enter');
}
/**
* Reload the page and wait for canvas to be ready
*/
async reloadAndWaitForCanvas(): Promise<void> {
await this.n8n.page.reload();
await expect(this.n8n.canvas.getNodeViewLoader()).toBeHidden();
await expect(this.n8n.canvas.getLoadingMask()).toBeHidden();
}
/**
* Wait for workflow save to complete and URL to be updated with the workflow ID.
* Use this when you need the workflow URL/ID immediately after saving.
* @returns The workflow URL after save
*/
async waitForWorkflowSaveAndUrl(): Promise<string> {
const isNewWorkflow = this.n8n.page.url().includes('/workflow/new');
if (isNewWorkflow) {
await this.n8n.canvas.waitForSaveWorkflowCompleted();
// Wait for URL to update after response
await this.n8n.page.waitForURL(/\/workflow\/[a-zA-Z0-9]+$/);
} else {
await this.n8n.canvas.waitForSaveWorkflowCompleted();
}
return this.n8n.page.url();
}
}
@@ -0,0 +1,51 @@
import type { CreateCredentialDto } from '@n8n/api-types';
import type { n8nPage } from '../pages/n8nPage';
export class CredentialsComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Create a credential through the Credentials list UI.
* Expects the visible label of the credential type (e.g. 'Notion API').
*/
async createFromList(
credentialType: string,
fields: Record<string, string>,
options?: { name?: string; projectId?: string; closeDialog?: boolean },
) {
if (options?.projectId) {
await this.n8n.navigate.toCredentials(options.projectId);
} else {
await this.n8n.navigate.toCredentials();
}
await this.n8n.credentials.addResource.credential();
await this.n8n.credentials.createCredentialFromCredentialPicker(credentialType, fields, {
name: options?.name,
closeDialog: options?.closeDialog,
});
}
/**
* Create a credential through the NDV flow.
* Type is implied by the open node's credential requirement.
*/
async createFromNdv(
fields: Record<string, string>,
options?: { name?: string; closeDialog?: boolean },
) {
await this.n8n.ndv.clickCreateNewCredential();
await this.n8n.canvas.credentialModal.addCredential(fields, {
name: options?.name,
closeDialog: options?.closeDialog,
});
}
/**
* Create a credential directly via API. Returns created credential object.
*/
async createFromApi(payload: CreateCredentialDto & { projectId?: string }) {
return await this.n8n.api.credentials.createCredential(payload);
}
}
@@ -0,0 +1,42 @@
import type { n8nPage } from '../pages/n8nPage';
export class DataTableComposer {
constructor(private readonly n8n: n8nPage) {}
async createNewDataTable(name: string) {
const nameInput = this.n8n.dataTable.getNewDataTableNameInput();
await nameInput.fill(name);
await this.n8n.dataTable.getFromScratchOption().click();
await this.n8n.dataTable.getProceedFromSelectButton().click();
}
/**
* Creates project and data table inside it, navigating to project 'Data Table' tab
* @param projectName
* @param dataTableName
* @param source - from where the creation is initiated (empty state or header dropdown)
*/
async createDataTableInNewProject(
projectName: string,
dataTableName: string,
source: 'empty-state' | 'header-dropdown',
fromDataTableTab: boolean = true,
) {
await this.n8n.projectComposer.createProject(projectName);
const { projectId } = await this.n8n.projectComposer.createProject();
if (fromDataTableTab) {
await this.n8n.page.goto(`projects/${projectId}/datatables`);
} else {
await this.n8n.page.goto(`projects/${projectId}`);
}
if (source === 'empty-state') {
await this.n8n.dataTable.clickEmptyStateButton();
} else {
await this.n8n.dataTable.clickAddDataTableAction(fromDataTableTab);
}
await this.n8n.dataTableComposer.createNewDataTable(dataTableName);
await this.n8n.page.goto(`projects/${projectId}/datatables`);
}
}
@@ -0,0 +1,59 @@
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for user interactions with workflow executions that go across multiple pages.
*/
export class ExecutionsComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Creates workflow executions by executing the workflow multiple times.
* Waits for each execution to complete (by waiting for the POST /rest/workflows/:id/run response)
* before starting the next one.
*
* @param count - Number of executions to create
* @example
* // Create 10 executions
* await n8n.executionsComposer.createExecutions(10);
*/
async createExecutions(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/run') &&
response.request().method() === 'POST',
);
await this.n8n.canvas.clickExecuteWorkflowButton();
await responsePromise;
}
}
/**
* Execute a specific node and capture the workflow run request payload.
* Sets up request interception before executing the node, then returns the parsed request body.
* Useful for testing the payload structure sent to the workflow run API.
*
* @param nodeName - The name of the node to execute
* @returns The parsed request body from the workflow run API call
* @example
* // Execute a node and verify payload structure
* const payload = await n8n.executionsComposer.executeNodeAndCapturePayload('Process The Data');
* expect(payload).toHaveProperty('runData');
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async executeNodeAndCapturePayload(nodeName: string): Promise<any> {
const workflowRunPromise = this.n8n.page.waitForRequest(
(request) =>
request.url().includes('/rest/workflows/') &&
request.url().includes('/run') &&
request.method() === 'POST',
);
await this.n8n.canvas.executeNode(nodeName);
const workflowRunRequest = await workflowRunPromise;
return workflowRunRequest.postDataJSON();
}
}
@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { authenticator } from 'otplib';
import type { n8nPage } from '../pages/n8nPage';
export class MfaComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Enable MFA for a user using predefined secret
* @param email - User email
* @param password - User password
* @param mfaSecret - Known MFA secret to use for token generation
*/
async enableMfa(email: string, password: string, mfaSecret: string): Promise<void> {
await this.n8n.signIn.loginWithEmailAndPassword(email, password, true);
await this.n8n.settingsPersonal.goto();
await this.n8n.settingsPersonal.clickEnableMfa();
await this.n8n.mfaSetupModal.getModalContainer().waitFor({ state: 'visible' });
await this.n8n.mfaSetupModal.clickCopySecretToClipboard();
const token = authenticator.generate(mfaSecret);
await this.n8n.mfaSetupModal.fillToken(token);
await expect(this.n8n.mfaSetupModal.getDownloadRecoveryCodesButton()).toBeVisible();
await this.n8n.mfaSetupModal.clickDownloadRecoveryCodes();
await this.n8n.mfaSetupModal.clickSave();
await this.n8n.mfaSetupModal.waitForHidden();
}
/**
* Login with MFA code
* @param email - User email
* @param password - User password
* @param mfaSecret - Known MFA secret for token generation
*/
async loginWithMfaCode(email: string, password: string, mfaSecret: string): Promise<void> {
await this.n8n.signIn.fillEmail(email);
await this.n8n.signIn.fillPassword(password);
await this.n8n.signIn.clickSubmit();
await expect(this.n8n.mfaLogin.getForm()).toBeVisible();
const loginMfaCode = authenticator.generate(mfaSecret);
await this.n8n.mfaLogin.submitMfaCode(loginMfaCode);
await expect(this.n8n.page).toHaveURL(/workflows/);
}
/**
* Login with MFA recovery code
* @param email - User email
* @param password - User password
* @param recoveryCode - Known recovery code
*/
async loginWithMfaRecoveryCode(
email: string,
password: string,
recoveryCode: string,
): Promise<void> {
await this.n8n.signIn.fillEmail(email);
await this.n8n.signIn.fillPassword(password);
await this.n8n.signIn.clickSubmit();
await expect(this.n8n.mfaLogin.getForm()).toBeVisible();
await this.n8n.mfaLogin.submitMfaRecoveryCode(recoveryCode);
await expect(this.n8n.page).toHaveURL(/workflows/);
}
}
@@ -0,0 +1,66 @@
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for user interactions with Node Details View (NDV) that involve multi-step workflows.
*/
export class NodeDetailsViewComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Selects a workflow from the resource locator list by name
* @param paramName - The parameter name for the resource locator
* @param workflowName - The name of the workflow to select
*/
async selectWorkflowFromList(paramName: string, workflowName: string): Promise<void> {
await this.n8n.ndv.openResourceLocator(paramName);
const items = this.n8n.page.getByTestId('rlc-item');
const targetItem = items.filter({ hasText: workflowName });
await targetItem.first().click();
}
/**
* Filters the resource locator list by search term
* @param paramName - The parameter name for the resource locator
* @param searchTerm - The term to search for
*/
async filterWorkflowList(paramName: string, searchTerm: string): Promise<void> {
await this.n8n.ndv.openResourceLocator(paramName);
await this.n8n.ndv.getResourceLocatorSearch(paramName).fill(searchTerm);
}
/**
* Selects the first workflow item from a filtered list
*/
async selectFirstFilteredWorkflow(): Promise<void> {
const items = this.n8n.page.getByTestId('rlc-item');
await items.first().click();
}
/**
* Switches a resource locator to expression mode
* @param paramName - The parameter name for the resource locator
* @param workflowName - The workflow to select before switching to expression mode
*/
async switchToExpressionMode(paramName: string, workflowName?: string): Promise<void> {
if (workflowName) {
await this.selectWorkflowFromList(paramName, workflowName);
}
// Switch to expression mode
await this.n8n.page.getByTestId('radio-button-expression').nth(1).click();
}
/**
* Clicks add resource option to create a new sub-workflow
* @param paramName - The parameter name for the resource locator
*/
async createNewSubworkflow(paramName: string): Promise<void> {
await this.n8n.ndv.openResourceLocator(paramName);
const addResourceItem = this.n8n.page.getByTestId('rlc-item-add-resource').first();
await addResourceItem.waitFor({ state: 'visible' });
await addResourceItem.click();
}
}
@@ -0,0 +1,30 @@
import type { n8nPage } from '../pages/n8nPage';
/**
* Composer for OIDC-related operations in E2E tests.
* Handles configuring OIDC settings.
*/
export class OidcComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Configure OIDC via UI form.
*
* @param discoveryUrl - The discovery URL for n8n backend (e.g., https://keycloak:8443/...)
* @param clientId - The OIDC client ID
* @param clientSecret - The OIDC client secret
*/
async configureOidc(discoveryUrl: string, clientId: string, clientSecret: string): Promise<void> {
const { settingsSso } = this.n8n;
await settingsSso.goto();
await settingsSso.selectOidcProtocol();
await settingsSso.fillOidcForm({
discoveryEndpoint: discoveryUrl,
clientId,
clientSecret,
enableLogin: true,
});
await settingsSso.saveOidcConfig();
}
}
@@ -0,0 +1,112 @@
import { expect } from '@playwright/test';
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for partial execution testing workflows that involve
* complex multi-step scenarios across pages.
*/
export class PartialExecutionComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Sets up partial execution version 2 in localStorage
* This enables the v2 partial execution feature
*/
async enablePartialExecutionV2(): Promise<void> {
await this.n8n.page.evaluate(() => {
window.localStorage.setItem('PartialExecution.version', '2');
});
}
/**
* Executes a full workflow and verifies all nodes show success status
* @param nodeNames - Array of node names to verify
*/
async executeFullWorkflowAndVerifySuccess(nodeNames: string[]): Promise<void> {
await this.n8n.canvas.clickExecuteWorkflowButton();
// Verify all nodes show success status
for (const nodeName of nodeNames) {
await expect(this.n8n.canvas.getNodeSuccessStatusIndicator(nodeName)).toBeVisible();
}
}
/**
* Captures output data from a node for later comparison
* @param nodeName - The node to capture data from
* @returns The captured text content
*/
async captureNodeOutputData(nodeName: string): Promise<string> {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.outputPanel.getTable().waitFor();
// Note: Using row 0 for tbody (equivalent to row 1 in Cypress which includes header)
const cell = this.n8n.ndv.outputPanel.getTbodyCell(0, 0);
await expect(cell).toHaveText(/.+/);
const beforeText = await cell.textContent();
await this.n8n.ndv.close();
return beforeText!;
}
/**
* Modifies a node parameter to trigger stale state
* @param nodeName - The node to modify
*/
async modifyNodeToTriggerStaleState(nodeName: string): Promise<void> {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.clickAssignmentCollectionDropArea();
// Verify stale node indicator appears after parameter change
await expect(this.n8n.ndv.getStaleNodeIndicator()).toBeVisible();
await this.n8n.ndv.close();
}
/**
* Verifies node states after parameter change for partial execution v2
* @param unchangedNodes - Nodes that should still show success
* @param modifiedNodes - Nodes that should show warning (need re-execution)
*/
async verifyNodeStatesAfterChange(
unchangedNodes: string[],
modifiedNodes: string[],
): Promise<void> {
// Verify unchanged nodes still show success
for (const nodeName of unchangedNodes) {
await expect(this.n8n.canvas.getNodeSuccessStatusIndicator(nodeName)).toBeVisible();
}
// Verify modified nodes show warning status
for (const nodeName of modifiedNodes) {
await expect(this.n8n.canvas.getNodeWarningStatusIndicator(nodeName)).toBeVisible();
}
}
/**
* Performs partial execution on a node and verifies all nodes return to success
* @param targetNodeName - The node to execute from
* @param allNodeNames - All nodes that should show success after partial execution
*/
async performPartialExecutionAndVerifySuccess(
targetNodeName: string,
allNodeNames: string[],
): Promise<void> {
// Perform partial execution by clicking execute button on target node
await this.n8n.canvas.executeNode(targetNodeName);
// Verify all nodes show success status after partial execution
for (const nodeName of allNodeNames) {
await expect(this.n8n.canvas.getNodeSuccessStatusIndicator(nodeName)).toBeVisible();
}
}
/**
* Opens a node for data verification (test should handle the assertion)
* @param nodeName - The node to open for verification
* @returns Promise that resolves when node is open and ready for verification
*/
async openNodeForDataVerification(nodeName: string): Promise<void> {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.outputPanel.getTable().waitFor();
}
}
@@ -0,0 +1,53 @@
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
export class ProjectComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Create a project and return the project name and ID. If no project name is provided, a unique name will be generated.
* @param projectName - The name of the project to create.
* @returns The project name and ID.
*/
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click();
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${nanoid(8)}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
/**
* Add a new credential to a project.
* @param projectName - The name of the project to add the credential to.
* @param credentialType - The type of credential to add by visible name e.g 'Notion API'
* @param credentialFieldName - The name of the field to add the credential to. e.g. 'apiKey' which would be data-test-id='parameter-input-apiKey'
* @param credentialValue - The value of the credential to add.
*/
async addCredentialToProject(
projectName: string,
credentialType: string,
credentialFieldName: string,
credentialValue: string,
) {
await this.n8n.sideBar.openNewCredentialDialogForProject(projectName);
await this.n8n.credentials.createCredentialFromCredentialPicker(credentialType, {
[credentialFieldName]: credentialValue,
});
}
extractIdFromUrl(url: string, beforeWord: string, afterWord: string): string {
const path = url.includes('://') ? new URL(url).pathname : url;
const match = path.match(new RegExp(`/${beforeWord}/([^/]+)/${afterWord}`));
return match?.[1] ?? '';
}
extractProjectIdFromPage(beforeWord: string, afterWord: string): string {
return this.extractIdFromUrl(this.n8n.page.url(), beforeWord, afterWord);
}
}
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for user interactions with templates that go across multiple pages.
*/
export class TemplatesComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Navigates to templates page, waits for loading to complete,
* selects the first available template, and imports it to a new workflow
* @returns Promise that resolves when the template has been imported
*/
async importFirstTemplate(): Promise<void> {
await this.n8n.navigate.toTemplates();
await expect(this.n8n.templates.getSkeletonLoader()).toBeHidden();
await expect(this.n8n.templates.getFirstTemplateCard()).toBeVisible();
await expect(this.n8n.templates.getTemplatesLoadingContainer()).toBeHidden();
await this.n8n.templates.clickFirstTemplateCard();
await expect(this.n8n.templates.getUseTemplateButton()).toBeVisible();
await this.n8n.templates.clickUseTemplateButton();
// New workflows redirect to /workflow/<id>?new=true with optional templateId
await expect(this.n8n.page).toHaveURL(/\/workflow\/[a-zA-Z0-9_-]+\?.*new=true/);
}
/**
* Fill in dummy credentials for an app in the template credential setup flow
* Opens credential creation, fills name, saves, and closes modal
* @param appName - The name of the app (e.g. 'Shopify', 'X (Formerly Twitter)')
*/
async fillDummyCredentialForApp(
appName: string,
{ fields }: { fields: Record<string, string> } = { fields: {} },
): Promise<void> {
await this.n8n.templateCredentialSetup.openCredentialCreation(appName);
await this.n8n.templateCredentialSetup.credentialModal.getCredentialName().click();
await this.n8n.templateCredentialSetup.credentialModal.getNameInput().fill('test');
await this.n8n.templateCredentialSetup.credentialModal.fillAllFields(fields);
await this.n8n.templateCredentialSetup.credentialModal.save();
await this.n8n.templateCredentialSetup.credentialModal.close();
}
/**
* Fill in dummy credentials for an OAuth app.
* OAuth credentials have no Save button — clicking Connect implicitly saves.
* @param appName - The name of the app (e.g. 'X (Formerly Twitter)')
*/
async fillDummyCredentialForOAuthApp(
appName: string,
{ fields }: { fields: Record<string, string> } = { fields: {} },
): Promise<void> {
await this.n8n.templateCredentialSetup.openCredentialCreation(appName);
await this.n8n.templateCredentialSetup.credentialModal.getCredentialName().click();
await this.n8n.templateCredentialSetup.credentialModal.getNameInput().fill('test');
await this.n8n.templateCredentialSetup.credentialModal.fillAllFields(fields);
await this.n8n.templateCredentialSetup.credentialModal.oauthConnectButton.click();
await this.n8n.templateCredentialSetup.credentialModal.close();
await this.n8n.templateCredentialSetup.dismissMessageBox();
}
}
@@ -0,0 +1,110 @@
import type { Page } from '@playwright/test';
import { setupDefaultInterceptors } from '../config/intercepts';
import type { n8nPage } from '../pages/n8nPage';
import type { TestUser } from '../services/user-api-helper';
/**
* Composer for UI test entry points. All methods in this class navigate to or verify UI state.
* For API-only testing, use the standalone `api` fixture directly instead.
*/
export class TestEntryComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Start UI test from the home page and navigate to canvas
*/
async fromHome() {
await this.n8n.goHome();
await this.n8n.page.waitForURL('/home/workflows');
}
/**
* Start UI test from a blank canvas (assumes already on canvas)
*/
async fromBlankCanvas() {
await this.n8n.navigate.toWorkflow('new');
// Verify we're on canvas
await this.n8n.canvas.canvasPane().isVisible();
}
/**
* Start UI test from a workflow in a new project on a new canvas
*/
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');
await this.n8n.api.setMaxTeamProjectsQuota(-1);
// Create a project using the API
const response = await this.n8n.api.projects.createProject();
const projectId = response.id;
await this.n8n.page.goto(`workflow/new?projectId=${projectId}`);
await this.n8n.canvas.canvasPane().isVisible();
return projectId;
}
async fromNewProject() {
const response = await this.n8n.api.projects.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
*/
async fromImportedWorkflow(workflowFile: string) {
const workflowImportResult = await this.n8n.api.workflows.importWorkflowFromFile(workflowFile);
await this.n8n.page.goto(`workflow/${workflowImportResult.workflowId}`);
return workflowImportResult;
}
/**
* Start UI test on a new page created by an action
* @param action - The action that will create a new page
* @returns n8nPage instance for the new page
*/
async fromNewPage(action: () => Promise<void>): Promise<n8nPage> {
const newPagePromise = this.n8n.page.waitForEvent('popup');
await action();
const newPage = await newPagePromise;
await newPage.waitForLoadState('domcontentloaded');
// Use the constructor from the current instance to avoid circular dependency
const n8nPageConstructor = this.n8n.constructor as new (page: Page) => n8nPage;
return new n8nPageConstructor(newPage);
}
/**
* Enable project feature set
* Allow project creation, sharing, and folder creation
*/
async withProjectFeatures() {
await this.n8n.api.enableFeature('sharing');
await this.n8n.api.enableFeature('folders');
await this.n8n.api.enableFeature('advancedPermissions');
await this.n8n.api.enableFeature('projectRole:admin');
await this.n8n.api.enableFeature('projectRole:editor');
await this.n8n.api.setMaxTeamProjectsQuota(-1);
}
/**
* Create a new isolated user context with fresh page and authentication.
* Use this when you need a browser context for UI interactions.
* For API-only operations, use `api.createApiForUser()` instead.
* @param user - User with email and password
* @returns Fresh n8nPage instance with user authentication
*/
async withUser(user: Pick<TestUser, 'email' | 'password'>): Promise<n8nPage> {
const browser = this.n8n.page.context().browser()!;
const context = await browser.newContext();
await setupDefaultInterceptors(context);
const page = await context.newPage();
const newN8n = new (this.n8n.constructor as new (page: Page) => n8nPage)(page);
await newN8n.api.login({ email: user.email, password: user.password });
return newN8n;
}
}
@@ -0,0 +1,159 @@
import { expect } from '@playwright/test';
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for user interactions with workflows that go across multiple pages.
*/
export class WorkflowComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Executes a successful workflow and waits for the notification to be closed.
* This waits for http calls and also closes the notification.
*/
async executeWorkflowAndWaitForNotification(
notificationMessage: string,
options: { timeout?: number } = {},
) {
const { timeout = 3000 } = options;
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/run') &&
response.request().method() === 'POST',
);
await this.n8n.canvas.clickExecuteWorkflowButton();
await responsePromise;
await this.n8n.notifications.waitForNotificationAndClose(notificationMessage, { timeout });
}
/**
* Creates a new workflow by clicking the add workflow button and setting the name
* Workflow is autosaved after a name update
* @param workflowName - The name of the workflow to create
*/
async createWorkflow(workflowName = 'My New Workflow') {
await this.n8n.workflows.addResource.workflow();
await this.n8n.canvas.setWorkflowName(workflowName);
await this.n8n.page.keyboard.press('Enter');
await this.n8n.canvas.waitForSaveWorkflowCompleted();
}
/**
* Creates a new workflow by clicking the add workflow button
* Workflow is autosaved after a name update
* @param workflowName - The name of the workflow to create
*/
async createWorkflowFromSidebar(workflowName = 'My New Workflow') {
await this.n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await this.n8n.canvas.setWorkflowName(workflowName);
await this.n8n.page.keyboard.press('Enter');
await this.n8n.canvas.waitForSaveWorkflowCompleted();
}
/**
* Creates a new workflow by importing a JSON file
* @param fileName - The workflow JSON file name (e.g., 'test_pdf_workflow.json', will search in workflows folder)
* @param name - Optional custom name. If not provided, generates a unique name
* @returns The actual workflow name that was used
*/
async createWorkflowFromJsonFile(
fileName: string,
name?: string,
): Promise<{ workflowName: string }> {
const workflowName = name ?? `Imported Workflow ${nanoid(8)}`;
await this.n8n.goHome();
await this.n8n.workflows.addResource.workflow();
await this.n8n.canvas.importWorkflow(fileName, workflowName);
return { workflowName };
}
/**
* Duplicates a workflow via the duplicate modal UI.
* Verifies the form interaction completes without errors.
* Note: This opens a new window/tab with the duplicated workflow but doesn't interact with it.
* @param name - The name for the duplicated workflow
* @param tag - Optional tag to add to the workflow
*/
async duplicateWorkflow(name: string, tag?: string): Promise<void> {
await this.n8n.workflowSettingsModal.getWorkflowMenu().click();
await this.n8n.workflowSettingsModal.getDuplicateMenuItem().click();
const modal = this.n8n.workflowSettingsModal.getDuplicateModal();
await expect(modal).toBeVisible();
const nameInput = this.n8n.workflowSettingsModal.getDuplicateNameInput();
await expect(nameInput).toBeVisible();
await nameInput.press('ControlOrMeta+a');
await nameInput.fill(name);
if (tag) {
const tagsInput = this.n8n.workflowSettingsModal.getDuplicateTagsInput();
await tagsInput.fill(tag);
await tagsInput.press('Enter');
await tagsInput.press('Escape');
}
const saveButton = this.n8n.workflowSettingsModal.getDuplicateSaveButton();
await expect(saveButton).toBeVisible();
await saveButton.click();
}
/**
* Moves a workflow to a different project or user.
* @param workflowName - The name of the workflow to move
* @param projectNameOrEmail - The destination project name or user email
* @param folder - The folder name (e.g., 'My Folder') or 'No folder (project root)' to place the workflow at project root level.
* Pass null when moving to another user's personal project, as users cannot create folders in other users' personal spaces,
* so the folder dropdown will not be shown. Defaults to 'No folder (project root)' which places the workflow at the root level.
*/
async moveToProject(
workflowName: string,
projectNameOrEmail: string,
folder: string | null = 'No folder (project root)',
): Promise<void> {
const workflowCard = this.n8n.workflows.cards.getWorkflow(workflowName);
await this.n8n.workflows.cards.openCardActions(workflowCard);
await this.n8n.workflows.cards.getCardAction('moveToFolder').click();
await this.selectProjectInMoveModal(projectNameOrEmail);
if (folder !== null) {
// Wait for folder dropdown to appear after project selection
await this.n8n.resourceMoveModal.getFolderSelect().waitFor({ state: 'visible' });
await this.selectFolderInMoveModal(folder);
}
await this.n8n.resourceMoveModal.clickConfirmMoveButton();
}
private async selectProjectInMoveModal(projectNameOrEmail: string): Promise<void> {
const workflowSelect = this.n8n.resourceMoveModal.getProjectSelect();
const input = workflowSelect.locator('input');
await input.click();
await input.waitFor({ state: 'visible' });
await this.n8n.page.keyboard.press('ControlOrMeta+a');
await this.n8n.page.keyboard.press('Backspace');
await this.n8n.page.keyboard.type(projectNameOrEmail, { delay: 50 });
const projectOption = this.n8n.page
.getByTestId('project-sharing-info')
.getByText(projectNameOrEmail)
.first();
await projectOption.waitFor({ state: 'visible' });
await projectOption.click();
}
private async selectFolderInMoveModal(folderName: string): Promise<void> {
await this.n8n.resourceMoveModal.getFolderSelect().locator('input').click();
await this.n8n.page.keyboard.type(folderName, { delay: 50 });
const folderOption = this.n8n.page.getByTestId('move-to-folder-option').getByText(folderName);
await folderOption.waitFor({ state: 'visible' });
await folderOption.click();
}
}