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
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user