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,42 @@
import type { Locator, Page } from '@playwright/test';
/**
* AddResource component for creating workflows, credentials, folders, and data tables.
* Represents the "add resource" functionality in the project header.
*
* @example
* // Access via workflows page
* await n8n.workflows.addResource.workflow();
* await n8n.workflows.addResource.credential();
* await n8n.workflows.addResource.folder();
* await n8n.workflows.addResource.dataTable();
*/
export class AddResource {
constructor(private page: Page) {}
getWorkflowButton(): Locator {
return this.page.getByTestId('add-resource-workflow');
}
async workflow(): Promise<void> {
await this.getWorkflowButton().click();
}
async credential(): Promise<void> {
await this.page.getByTestId('add-resource-credential').click();
}
async folder(): Promise<void> {
await this.page.getByTestId('add-resource').click();
await this.page.getByTestId('action-folder').click();
}
async dataTable(fromDataTableTab: boolean = true): Promise<void> {
if (fromDataTableTab) {
await this.page.getByTestId('add-resource-dataTable').click();
} else {
await this.page.getByTestId('add-resource').click();
await this.page.getByTestId('action-dataTable').click();
}
}
}
@@ -0,0 +1,32 @@
import type { Page } from '@playwright/test';
import { FloatingUiHelper } from './FloatingUiHelper';
/**
* Base modal component for handling modal dialogs.
*/
export class BaseModal extends FloatingUiHelper {
constructor(protected readonly page: Page) {
super(page);
}
get container() {
return this.page.getByRole('dialog');
}
getCloseButton() {
return this.container.getByRole('button', { name: /close/i });
}
async waitForModal() {
await this.container.waitFor({ state: 'visible' });
}
async fillInput(text: string) {
await this.container.getByRole('textbox').fill(text);
}
async clickButton(buttonText: string | RegExp) {
await this.container.getByRole('button', { name: buttonText }).click();
}
}
@@ -0,0 +1,42 @@
import type { Page } from '@playwright/test';
export class Breadcrumbs {
constructor(private readonly page: Page) {}
getBreadcrumbs() {
return this.page.getByTestId('breadcrumbs-item');
}
getBreadcrumb(resourceName: string) {
return this.getBreadcrumbs().filter({ hasText: resourceName });
}
getCurrentBreadcrumb() {
return this.page.getByTestId('breadcrumbs-item-current');
}
getHiddenBreadcrumbs() {
return this.page.getByTestId('hidden-items-menu');
}
getHomeProjectBreadcrumb() {
return this.page.getByTestId('home-project');
}
getActionToggleDropdown(resourceName: string) {
return this.page.getByTestId('action-toggle-dropdown').getByTestId(`action-${resourceName}`);
}
getFolderBreadcrumbsActionToggle() {
return this.page.getByTestId('folder-breadcrumbs-actions');
}
/**
* Rename the current breadcrumb by activating inline edit mode
* @param newName - The new name for the breadcrumb item
*/
async renameCurrentBreadcrumb(newName: string) {
await this.getCurrentBreadcrumb().getByTestId('inline-edit-preview').click();
await this.getCurrentBreadcrumb().getByTestId('inline-edit-input').fill(newName);
await this.page.keyboard.press('Enter');
}
}
@@ -0,0 +1,17 @@
import type { Locator } from '@playwright/test';
import { BaseModal } from './BaseModal';
export class ChatHubCredentialModal extends BaseModal {
constructor(private root: Locator) {
super(root.page());
}
getCredentialSelector(): Locator {
return this.root.getByRole('combobox');
}
getCreateButton(): Locator {
return this.page.getByTestId('node-credentials-select-item-new');
}
}
@@ -0,0 +1,33 @@
import type { Locator } from '@playwright/test';
import { BaseModal } from './BaseModal';
export class ChatHubPersonalAgentModal extends BaseModal {
constructor(protected readonly root: Locator) {
super(root.page());
}
getRoot() {
return this.root;
}
getNameField() {
return this.root.getByPlaceholder(/Enter agent name/);
}
getDescriptionField() {
return this.root.getByPlaceholder(/Enter agent description/);
}
getSystemPromptField() {
return this.root.getByPlaceholder(/Enter system prompt/);
}
getModelSelectorButton(): Locator {
return this.root.getByTestId('chat-model-selector');
}
getSaveButton() {
return this.root.getByText('Save');
}
}
@@ -0,0 +1,37 @@
import type { Locator } from '@playwright/test';
import { BaseModal } from './BaseModal';
export class ChatHubProviderSettingsModal extends BaseModal {
constructor(protected readonly root: Locator) {
super(root.page());
}
getRoot(): Locator {
return this.root;
}
getEnabledToggle(): Locator {
return this.root.getByLabel(/^Enable /).locator('..');
}
getCredentialPicker(): Locator {
return this.root.getByLabel('Default credential');
}
getEditCredentialButton(): Locator {
return this.root.getByTitle('Update Credential');
}
getLimitModelsToggle(): Locator {
return this.root.getByLabel('Limit models').locator('..');
}
getModelSelector(): Locator {
return this.root.getByLabel('Models', { exact: true });
}
getConfirmButton(): Locator {
return this.root.getByRole('button', { name: 'Confirm' });
}
}
@@ -0,0 +1,13 @@
import type { Locator } from '@playwright/test';
export class ChatHubSidebar {
constructor(private root: Locator) {}
getPersonalAgentButton() {
return this.root.getByRole('menuitem', { name: 'Personal agents' });
}
getConversations() {
return this.root.getByTestId('chat-conversation-list').getByRole('link');
}
}
@@ -0,0 +1,42 @@
import type { Locator } from '@playwright/test';
export class ChatHubToolsModal {
constructor(private root: Locator) {}
getRoot(): Locator {
return this.root;
}
/** Click "Add" button next to a tool in the available tools list */
getAddButton(toolDisplayName: string): Locator {
return this.root
.locator('[class*="item"]')
.filter({ hasText: toolDisplayName })
.getByRole('button', { name: /add/i });
}
/** Credential selector rendered by NodeCredentials inside settings view */
getCredentialSelect(): Locator {
return this.root.getByTestId('node-credentials-select');
}
/** Save button in the settings view header */
getSaveButton(): Locator {
return this.root.getByRole('button', { name: /save/i });
}
/** Get a parameter input by parameter name (e.g. "operation") */
getParameterInput(parameterName: string): Locator {
return this.root.getByTestId(`parameter-input-${parameterName}`);
}
/** Get the "from AI" override button scoped to a specific parameter */
getFromAiOverrideButton(parameterName: string): Locator {
return this.getParameterInput(parameterName).getByTestId('from-ai-override-button');
}
/** Close button (X) shown in list view */
getCloseButton(): Locator {
return this.root.locator('.el-dialog__close').first();
}
}
@@ -0,0 +1,37 @@
import type { Locator } from '@playwright/test';
import { BasePage } from '../BasePage';
/**
* Convert to Sub-workflow Modal component for converting nodes to sub-workflows.
* Used within CanvasPage as `n8n.canvas.convertToSubworkflowModal.*`
*
* @example
* // Access via canvas page
* await n8n.canvas.rightClickNode('My Node');
* await n8n.canvas.clickContextMenuAction('Convert node to sub-workflow');
* await n8n.canvas.convertToSubworkflowModal.waitForModal();
* await n8n.canvas.convertToSubworkflowModal.clickSubmitButton();
* await n8n.canvas.convertToSubworkflowModal.waitForClose();
*/
export class ConvertToSubworkflowModal extends BasePage {
constructor(private root: Locator) {
super(root.page());
}
getSubmitButton(): Locator {
return this.root.getByTestId('submit-button');
}
async waitForModal(): Promise<void> {
await this.root.waitFor({ state: 'visible' });
}
async clickSubmitButton(): Promise<void> {
await this.getSubmitButton().click();
}
async waitForClose(): Promise<void> {
await this.root.waitFor({ state: 'hidden' });
}
}
@@ -0,0 +1,220 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { BaseModal } from './BaseModal';
/**
* Credential modal component for canvas and credentials interactions.
* Used within CanvasPage as `n8n.canvas.credentialModal.*`
* Used within CredentialsPage as `n8n.credentials.modal.*`
*
* @example
* // Access via canvas page or credentials page
* await n8n.canvas.credentialModal.addCredential();
* await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
*/
export class CredentialModal extends BaseModal {
constructor(private root: Locator) {
super(root.page());
}
getModal(): Locator {
return this.root;
}
getCredentialName(): Locator {
return this.root.getByTestId('credential-name');
}
getNameInput(): Locator {
return this.getCredentialName().getByTestId('inline-edit-input');
}
getCredentialInputs(): Locator {
return this.root.getByTestId('credential-connection-parameter');
}
async waitForModal(): Promise<void> {
await this.root.waitFor({ state: 'visible' });
}
async fillField(key: string, value: string): Promise<void> {
const parameterInput = this.root.getByTestId(`parameter-input-${key}`);
const input = parameterInput.locator('input, textarea');
// Wait for input to be visible before filling
await input.waitFor({ state: 'visible', timeout: 10000 });
await input.fill(value);
await expect(input).toHaveValue(value);
}
async fillAllFields(values: Record<string, string>): Promise<void> {
for (const [key, val] of Object.entries(values)) {
await this.fillField(key, val);
}
}
getSaveButton(): Locator {
return this.root.getByTestId('credential-save-button');
}
/**
* Wait for save to fully complete.
* After saving (and optional credential testing), the button becomes
* disabled (no unsaved changes) and is no longer loading
*/
async waitForSaveComplete(): Promise<void> {
const btn = this.getSaveButton().locator('button');
await expect(async () => {
await expect(btn).toBeDisabled();
await expect(btn).not.toHaveAttribute('aria-busy', 'true');
}).toPass({ timeout: 10000 });
}
async save(): Promise<void> {
await this.getSaveButton().click();
await this.waitForSaveComplete();
}
async close(): Promise<void> {
const closeBtn = this.root.locator('.el-dialog__close').first();
if (await closeBtn.isVisible()) {
await closeBtn.click();
}
}
/**
* Add a credential to the modal
* @param fields - The fields to fill in the modal
* @param options - The options to pass to the modal
* @param options.closeDialog - Whether to close the modal after saving
* @param options.name - The name of the credential
*/
async addCredential(
fields: Record<string, string>,
options?: { closeDialog?: boolean; skipSave?: boolean; name?: string },
): Promise<void> {
await this.fillAllFields(fields);
if (options?.name) {
await this.getCredentialName().click();
await this.getNameInput().fill(options.name);
}
if (!options?.skipSave) {
await this.save();
}
const shouldClose = options?.closeDialog ?? true;
if (shouldClose) {
await this.close();
}
}
get oauthConnectButton() {
return this.root.getByTestId('oauth-connect-button');
}
get oauthConnectSuccessBanner() {
return this.root.getByTestId('oauth-connect-success-banner');
}
async editCredential(): Promise<void> {
await this.root.page().getByTestId('credential-edit-button').click();
}
async deleteCredential(): Promise<void> {
await this.root.page().getByTestId('credential-delete-button').click();
}
async confirmDelete(): Promise<void> {
await this.root.page().getByRole('button', { name: 'Yes' }).click();
}
async renameCredential(newName: string): Promise<void> {
await this.getCredentialName().click();
await this.getNameInput().fill(newName);
await this.getNameInput().press('Enter');
}
getOAuthRedirectUrl() {
return this.root.page().getByTestId('oauth-redirect-url');
}
getModeSelector() {
return this.root.getByTestId('credential-mode-selector');
}
getModeDropdownTrigger() {
return this.root.getByTestId('credential-mode-dropdown-trigger');
}
async selectAuthTypeFromDropdown(optionName: string | RegExp): Promise<void> {
await this.getModeDropdownTrigger().click();
await this.root.page().getByRole('menuitem', { name: optionName }).click();
}
async changeTab(tabName: 'Sharing'): Promise<void> {
await this.root.getByTestId('menu-item').filter({ hasText: tabName }).click();
}
/**
* Get a specific credential field input
*/
getFieldInput(key: string): Locator {
return this.root.getByTestId(`parameter-input-${key}`).locator('input, textarea');
}
/**
* Get the users select dropdown in the Sharing tab
*/
getUsersSelect(): Locator {
return this.root.getByTestId('project-sharing-select').filter({ visible: true });
}
/**
* Get the visible dropdown popper (for sharing dropdown interactions)
*/
getVisibleDropdown(): Locator {
return this.root.page().locator('.el-popper[aria-hidden="false"]');
}
/**
* Add a user to credential sharing
* @param emailOrName - User email or name to share with
*/
async addUserToSharing(emailOrName: string): Promise<void> {
await this.getUsersSelect().click();
const dropdown = this.getVisibleDropdown();
// Wait for dropdown content to load
await dropdown.locator('.el-select-dropdown__item').first().waitFor({ state: 'visible' });
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const byEmail = dropdown.getByText(emailOrName.toLowerCase(), { exact: false });
if ((await byEmail.count()) > 0) {
await byEmail.click();
} else {
// For personal projects, try matching by name part of email
const namePart = emailOrName.split('@')[0].replace(/[.-]/g, ' ');
await dropdown.getByText(new RegExp(namePart, 'i')).first().click();
}
}
/**
* Save credential sharing (different from regular save - hits /share endpoint)
*/
async saveSharing(): Promise<void> {
const saveBtn = this.getSaveButton();
await saveBtn.click();
// Wait for share API call to complete
await this.root
.page()
.waitForResponse(
(response) =>
response.url().includes('/rest/credentials/') &&
response.url().includes('/share') &&
response.request().method() === 'PUT',
);
await this.waitForSaveComplete();
}
}
@@ -0,0 +1,32 @@
import type { Locator, Page } from '@playwright/test';
type GetByRoleName = NonNullable<Parameters<Locator['getByRole']>[1]>['name'];
type GetByRoleOptionsWithoutName = Omit<Parameters<Locator['getByRole']>[1], 'name'>;
export class FloatingUiHelper {
constructor(protected readonly page: Page) {}
getVisiblePoppers() {
// Match Reka UI popovers (data-side is unique to Reka UI positioned content)
return this.page.locator('[data-state="open"][data-side]');
}
getVisiblePopper() {
// Match both Element+ poppers (.el-popper:visible) and Reka UI poppers ([data-state="open"])
return this.page.locator(
'.el-popper:visible, [data-state="open"][role="dialog"], [data-state="open"][role="menu"], [data-state="open"][role="listbox"]',
);
}
getVisiblePopoverMenuItem(name?: GetByRoleName, options: GetByRoleOptionsWithoutName = {}) {
return this.getVisiblePopper()
.getByRole('menuitem', { name, ...options })
.filter({ visible: true });
}
getVisiblePopoverOption(name?: GetByRoleName, options: GetByRoleOptionsWithoutName = {}) {
return this.getVisiblePopper()
.getByRole('option', { name, ...options })
.filter({ visible: true });
}
}
@@ -0,0 +1,24 @@
import type { Locator } from '@playwright/test';
export class FocusPanel {
constructor(private root: Locator) {}
/**
* Accessors
*/
getHeaderNodeName(): Locator {
return this.root.locator('header').getByTestId('inline-edit-preview');
}
getParameterInputField(path: string): Locator {
return this.root.locator(
`[data-test-id="parameter-input-field"][title="Parameter: \\"${path}\\""]`,
);
}
getMapper(): Locator {
// find from the entire page because the mapper is rendered as portal
return this.root.page().getByRole('dialog').getByTestId('ndv-input-panel');
}
}
@@ -0,0 +1,119 @@
import type { Locator } from '@playwright/test';
import { RunDataPanel } from './RunDataPanel';
import type { ClipboardHelper } from '../../helpers/ClipboardHelper';
/**
* Page object for the log view with configurable root element.
*
* @example
* // Include in a page
* class ExamplePage {
* readonly logsPanel = new LogsPanel(this.page.getByTestId('logs-panel'));
* }
*
* // Usage in a test
* await expect(n8n.example.logsPage.getLogEntries()).toHaveCount(2);
*/
export class LogsPanel {
readonly inputPanel = new RunDataPanel(this.root.getByTestId('log-details-input'));
readonly outputPanel = new RunDataPanel(this.root.getByTestId('log-details-output'));
constructor(private root: Locator) {}
/**
* Accessors
*/
getOverviewStatus(): Locator {
return this.root.getByTestId('logs-overview-status');
}
getClearExecutionButton(): Locator {
return this.root
.getByTestId('logs-overview-header')
.locator('button')
.filter({ hasText: 'Clear execution' });
}
getLogEntries(): Locator {
return this.root.getByTestId('logs-overview-body').getByRole('treeitem');
}
getSelectedLogEntry(): Locator {
return this.root.getByTestId('logs-overview-body').getByRole('treeitem', { selected: true });
}
getManualChatModal(): Locator {
return this.root.getByTestId('canvas-chat');
}
getManualChatInput(): Locator {
return this.getManualChatModal().locator('.chat-inputs textarea');
}
getManualChatMessages(): Locator {
return this.getManualChatModal().locator('.chat-messages-list .chat-message');
}
getSessionIdButton(): Locator {
return this.getManualChatModal().getByTestId('chat-session-id');
}
getRefreshSessionButton(): Locator {
return this.getManualChatModal().getByTestId('refresh-session-button');
}
/**
* Actions
*/
async open(): Promise<void> {
await this.root.getByTestId('logs-overview-header').click();
}
async clickLogEntryAtRow(rowIndex: number): Promise<void> {
await this.getLogEntries().nth(rowIndex).click();
}
async toggleInputPanel(): Promise<void> {
await this.root.getByTestId('log-details-header').getByText('Input').click();
}
async clickOpenNdvAtRow(rowIndex: number): Promise<void> {
await this.getLogEntries().nth(rowIndex).hover();
await this.getLogEntries().nth(rowIndex).getByLabel('Open...').click();
}
async clickTriggerPartialExecutionAtRow(rowIndex: number): Promise<void> {
await this.getLogEntries().nth(rowIndex).hover();
await this.getLogEntries().nth(rowIndex).getByLabel('Execute step').click();
}
async clearExecutionData(): Promise<void> {
await this.root.getByTestId('clear-execution-data-button').click();
}
async sendManualChatMessage(message: string): Promise<void> {
await this.getManualChatInput().fill(message);
await this.getManualChatModal().locator('.chat-input-send-button').click();
}
/**
* Clicks the session ID button to copy the session ID to clipboard and returns it.
* @param clipboard - ClipboardHelper instance for reading clipboard
* @returns The full session ID string
*/
async getSessionId(clipboard: ClipboardHelper): Promise<string> {
await clipboard.grant();
await this.getSessionIdButton().click();
return await clipboard.readText();
}
/**
* Clicks the refresh session button to reset the chat session.
*/
async refreshSession(): Promise<void> {
await this.getRefreshSessionButton().click();
}
}
@@ -0,0 +1,101 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Node Creator component for adding nodes to workflows.
* Used within CanvasPage as `n8n.canvas.nodeCreator.*`
*
* @example
* // Access via canvas page
* await n8n.canvas.nodeCreator.open();
* await n8n.canvas.nodeCreator.searchFor('Gmail');
* await n8n.canvas.nodeCreator.selectItem('Gmail');
*/
export class NodeCreator {
constructor(private page: Page) {}
// Core locators
getRoot(): Locator {
return this.page.getByTestId('node-creator');
}
getSearchBar(): Locator {
return this.page.getByTestId('node-creator-search-bar');
}
getNodeItems(): Locator {
return this.page.getByTestId('item-iterator-item');
}
getCategoryItems(): Locator {
return this.page.getByTestId('node-creator-category-item');
}
getActiveSubcategory(): Locator {
return this.page.getByTestId('nodes-list-header').first();
}
getNoResults(): Locator {
return this.page.getByTestId('node-creator-no-results');
}
getNoTriggersCallout(): Locator {
return this.page.getByTestId('actions-panel-no-triggers-callout');
}
getActivationCallout(): Locator {
return this.page.getByTestId('actions-panel-activation-callout');
}
getTriggerText(): Locator {
return this.page.getByText('What triggers this workflow?');
}
getNextText(): Locator {
return this.page.getByText('What happens next?');
}
// Item getters
getItem(text: string): Locator {
return this.getNodeItems().filter({ hasText: text }).first();
}
getCategoryItem(text: string): Locator {
return this.getCategoryItems().filter({ hasText: text });
}
// Actions
async open(): Promise<void> {
await this.page.getByTestId('node-creator-plus-button').click();
await expect(this.getRoot()).toBeVisible();
}
async close(): Promise<void> {
await this.page.keyboard.press('Escape');
}
async searchFor(text: string): Promise<void> {
await this.getSearchBar().fill(text);
}
async clearSearch(): Promise<void> {
await this.getSearchBar().clear();
}
async selectItem(text: string): Promise<void> {
await this.getItem(text).click();
}
async selectCategoryItem(text: string): Promise<void> {
await this.getCategoryItem(text).click();
}
async navigateToSubcategory(category: string): Promise<void> {
await this.getItem(category).click();
await expect(this.getActiveSubcategory()).toContainText(category);
}
async goBackFromSubcategory(): Promise<void> {
await this.getActiveSubcategory().locator('button').click();
}
}
@@ -0,0 +1,37 @@
import type { Page } from '@playwright/test';
/**
* ProjectTabs component - navigation tabs within a project view
* Mirrors the ProjectTabs.vue component in the frontend
*/
export class ProjectTabsComponent {
constructor(private readonly page: Page) {}
async clickCredentialsTab() {
await this.page
.getByTestId('project-tabs')
.getByRole('link', { name: /credentials/i })
.click();
}
async clickWorkflowsTab() {
await this.page
.getByTestId('project-tabs')
.getByRole('link', { name: /workflows/i })
.click();
}
async clickDataTablesTab() {
await this.page
.getByTestId('project-tabs')
.getByRole('link', { name: /data tables/i })
.click();
}
async clickVariablesTab() {
await this.page
.getByTestId('project-tabs')
.getByRole('link', { name: /variables/i })
.click();
}
}
@@ -0,0 +1,73 @@
import type { Locator, Page } from '@playwright/test';
/**
* ResourceCards component for handling folder, workflow, credential, and data store cards.
* All cards are contained within a resources-list-wrapper.
*/
export class ResourceCards {
constructor(private page: Page) {}
getFolders(): Locator {
return this.page.getByTestId('folder-card');
}
getWorkflows(): Locator {
return this.page.getByTestId('resources-list-item-workflow');
}
getCredentials(): Locator {
return this.page.getByTestId('resources-list-item');
}
getFolder(name: string): Locator {
return this.page.locator(`[data-test-id="folder-card"][data-resourcename="${name}"]`);
}
getWorkflow(name: string): Locator {
return this.getWorkflows().filter({ hasText: name });
}
getCredential(name: string): Locator {
return this.getCredentials().filter({
has: this.page.getByTestId('card-content').locator('h2').filter({ hasText: name }),
});
}
getCardActionToggle(card: Locator): Locator {
return card
.getByTestId('card-append')
.locator('[class*="action-toggle"]')
.filter({ visible: true });
}
getCardAction(actionName: string): Locator {
return this.page.getByTestId(`action-${actionName}`).filter({ visible: true });
}
async openCardActions(card: Locator): Promise<void> {
await this.getCardActionToggle(card).click();
}
async clickCardAction(card: Locator, actionName: string): Promise<void> {
await this.openCardActions(card);
await this.getCardAction(actionName).click();
}
async openFolder(folderName: string): Promise<void> {
const folderCard = this.getFolder(folderName);
await this.clickCardAction(folderCard, 'open');
}
async deleteFolder(folderName: string): Promise<void> {
const folderCard = this.getFolder(folderName);
await this.clickCardAction(folderCard, 'delete');
}
async clickWorkflowCard(workflowName: string): Promise<void> {
await this.getWorkflow(workflowName).getByTestId('card-content').click();
}
async clickCredentialCard(credentialName: string): Promise<void> {
await this.getCredential(credentialName).getByTestId('card-content').click();
}
}
@@ -0,0 +1,52 @@
import type { Locator, Page } from '@playwright/test';
/**
* Page object for interacting with move resource modals (MoveToFolderModal for workflows, ProjectMoveResourceModal for credentials).
*/
export class ResourceMoveModal {
constructor(private page: Page) {}
getProjectSelect(): Locator {
return this.page.getByTestId('project-sharing-select');
}
getProjectSelectCredential(): Locator {
return this.page.getByTestId('project-move-resource-modal-select');
}
getMoveConfirmButton(): Locator {
return this.page.getByTestId('confirm-move-folder-button');
}
getMoveCredentialButton(): Locator {
return this.page.getByRole('button', { name: 'Move credential' });
}
getFolderSelect(): Locator {
return this.page.getByTestId('move-to-folder-dropdown');
}
async selectProjectOption(projectNameOrEmail: string): Promise<void> {
const options = this.page.getByRole('option');
// Try to find by exact text (project name or email)
const byExact = options.filter({ hasText: projectNameOrEmail });
if ((await byExact.count()) > 0) {
await byExact.click();
} else {
// For personal projects, the email is not shown, so try matching by name part of email
const namePart = projectNameOrEmail.split('@')[0].replace(/[.-]/g, ' ');
await options
.filter({ hasText: new RegExp(namePart, 'i') })
.first()
.click();
}
}
async clickMoveCredentialButton(): Promise<void> {
await this.getMoveCredentialButton().click();
}
async clickConfirmMoveButton(): Promise<void> {
await this.getMoveConfirmButton().click();
}
}
@@ -0,0 +1,133 @@
import type { Locator } from '@playwright/test';
/**
* Page object for the run data view with configurable root element.
*
* @example
* // Include in a page
* class ExamplePage {
* readonly runDataPanel = new RunDataPanel(this.page.getByTestId('run-data'));
* }
*
* // Usage in a test
* await n8n.example.runDataPanel.getRunSelector().click();
*/
export class RunDataPanel {
constructor(private root: Locator) {}
get() {
return this.root;
}
getRunSelector() {
return this.root.getByTestId('run-selector');
}
getRunSelectorInput() {
return this.root.locator('[data-test-id="run-selector"] input');
}
getItemsCount() {
return this.root.getByTestId('ndv-items-count');
}
getSearchInput() {
return this.root.getByTestId('ndv-search');
}
getSearchContainer() {
return this.root.getByTestId('ndv-search-container');
}
getDataContainer() {
return this.root.getByTestId('ndv-data-container');
}
getPinDataButton() {
return this.root.getByTestId('ndv-pin-data');
}
getTable() {
return this.root.locator('table');
}
getTableHeaders() {
return this.root.locator('table th');
}
getTableHeader(index: number) {
return this.root.locator('table th').nth(index);
}
getTableRows() {
return this.root.locator('tr');
}
getTableRow(index: number) {
return this.root.locator('tr').nth(index);
}
getTbodyCell(row: number, col: number) {
return this.root.locator('table tbody tr').nth(row).locator('td').nth(col);
}
getTableCellSpan(row: number, col: number, dataName: string) {
return this.getTbodyCell(row, col).locator(`span[data-name="${dataName}"]`).first();
}
getJsonDataContainer() {
return this.root.locator('.json-data');
}
getJsonProperty(propertyName: string) {
return this.root
.locator('.json-data')
.locator('span')
.filter({ hasText: new RegExp(`^"${propertyName}"$`) })
.first();
}
getJsonPropertyContaining(text: string) {
return this.root
.locator('.json-data')
.locator('span')
.filter({ hasText: `"${text}"` })
.first();
}
getSchemaItems() {
return this.root.getByTestId('run-data-schema-item');
}
getSchemaItem(text: string) {
return this.getSchemaItems().filter({ hasText: text }).first();
}
getSchemaItemText(text: string) {
return this.getSchemaItems().locator('span').filter({ hasText: text }).first();
}
getNodeInputOptions() {
return this.root.getByTestId('ndv-input-select');
}
getLinkRun() {
return this.root.getByTestId('link-run');
}
getRelatedExecutionLink() {
return this.root.getByTestId('related-execution-link');
}
getNodeErrorMessageHeader(): Locator {
return this.root.getByTestId('node-error-message');
}
async toggleInputRunLinking() {
await this.root.getByTestId('link-run').click();
}
async switchDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema' | 'binary'): Promise<void> {
await this.root.getByTestId(`radio-button-${mode}`).click();
}
}
@@ -0,0 +1,29 @@
import type { Locator } from '@playwright/test';
/**
* Save Changes Modal component for handling unsaved changes dialogs.
* Appears when navigating away from workflow with unsaved changes.
*/
export class SaveChangesModal {
constructor(private root: Locator) {}
getModal(): Locator {
return this.root.filter({ hasText: 'Save changes before leaving?' });
}
getCancelButton(): Locator {
return this.root.locator('.btn--cancel');
}
getCloseButton(): Locator {
return this.root.locator('.el-message-box__headerbtn');
}
async clickCancel(): Promise<void> {
await this.getCancelButton().click();
}
async clickClose(): Promise<void> {
await this.getCloseButton().click();
}
}
@@ -0,0 +1,46 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from '../BasePage';
/**
* Sticky note component for canvas interactions.
* Used within CanvasPage as `n8n.canvas.sticky.*`
*
* @example
* // Access via canvas page
* await n8n.canvas.sticky.addSticky();
* await expect(n8n.canvas.sticky.getStickies()).toHaveCount(1);
*/
export class StickyComponent extends BasePage {
constructor(page: Page) {
super(page);
}
getAddButton(): Locator {
return this.page.getByTestId('add-sticky-button');
}
getStickies(): Locator {
return this.page.getByTestId('sticky');
}
async addSticky(): Promise<void> {
await this.getAddButton().click();
}
/**
* Add a sticky from the context menu, targets top left corner of canvas, so could fail if it's covered
* @param canvasPane - The canvas pane locator
*/
async addFromContextMenu(canvasPane: Locator): Promise<void> {
await canvasPane.click({
button: 'right',
position: { x: 10, y: 10 },
});
await this.page.getByText('Add sticky note').click();
}
getDefaultStickyGuideLink(): Locator {
return this.getStickies().first().getByRole('link', { name: 'Guide' });
}
}
@@ -0,0 +1,60 @@
import type { Locator } from '@playwright/test';
import { BasePage } from '../BasePage';
/**
* Tags Manager Modal component for managing workflow tags.
* Used within CanvasPage as `n8n.canvas.tagsManagerModal.*`
*
* @example
* // Access via canvas page
* await n8n.canvas.openTagManagerModal();
* await n8n.canvas.tagsManagerModal.clickAddNewButton();
* await expect(n8n.canvas.tagsManagerModal.getTable()).toBeVisible();
*/
export class TagsManagerModal extends BasePage {
constructor(private root: Locator) {
super(root.page());
}
getModal(): Locator {
return this.root;
}
getTable(): Locator {
return this.root.getByTestId('tags-table');
}
getTagInputInModal(): Locator {
return this.getTable().locator('input').first();
}
getFirstTagRow(): Locator {
return this.getTable().locator('tbody tr').first();
}
getDeleteTagButton(): Locator {
return this.root.getByTestId('delete-tag-button');
}
getDeleteTagConfirmButton(): Locator {
return this.root.getByText('Delete tag', { exact: true });
}
getDeleteConfirmationMessage(): Locator {
return this.root.getByText('Are you sure you want to delete this tag?');
}
/**
* Start adding a new tag, handling both empty state ("Create a tag") and existing tags ("Add new")
*/
async addTag(): Promise<void> {
const addNewButton = this.root.getByRole('button', { name: 'Add new' });
const createTagButton = this.root.getByRole('button', { name: 'Create a tag' });
await addNewButton.or(createTagButton).click();
}
async clickDoneButton(): Promise<void> {
await this.clickButtonByName('Done');
}
}
@@ -0,0 +1,62 @@
import type { Locator } from '@playwright/test';
/**
* Variable modal component for canvas and variables interactions.
* Used within VariablesPage as `n8n.variables.modal.*`
*
* @example
* // Access via canvas page or variables page
* await n8n.variables.modal.addVariable();
* await expect(n8n.variables.modal.getModal()).toBeVisible();
*/
export class VariableModal {
constructor(private root: Locator) {}
getKeyInput(): Locator {
return this.root.getByTestId('variable-modal-key-input').getByRole('textbox');
}
getValueInput(): Locator {
return this.root.getByTestId('variable-modal-value-input').getByRole('textbox');
}
async waitForModal(): Promise<void> {
await this.root.waitFor({ state: 'visible' });
}
getSaveButton(): Locator {
return this.root.getByTestId('variable-modal-save-button');
}
async save(): Promise<void> {
const saveBtn = this.getSaveButton();
await saveBtn.click();
}
async close(): Promise<void> {
const closeBtn = this.root.locator('.el-dialog__close').first();
if (await closeBtn.isVisible()) {
await closeBtn.click();
}
}
/**
* Add a variable to the modal
* @param key - The variable key
* @param value - The variable value
* @param options - The options to pass to the modal
* @param options.closeDialog - Whether to close the modal after saving
*/
async addVariable(
key: string,
value: string,
{ shouldSave }: { shouldSave: boolean } = { shouldSave: true },
): Promise<void> {
await this.getKeyInput().fill(key);
await this.getValueInput().fill(value);
if (shouldSave) {
console.log('Saving variable from modal');
await this.save();
}
}
}