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,142 @@
import { BasePage } from './BasePage';
export class AIAssistantPage extends BasePage {
// #region Getters
getAskAssistantFloatingButton() {
return this.page.getByTestId('ask-assistant-floating-button');
}
getAskAssistantCanvasActionButton() {
return this.page.getByTestId('ask-assistant-canvas-action-button');
}
getAskAssistantChat() {
return this.page.getByTestId('ask-assistant-chat');
}
getAskAssistantSidebar() {
return this.page.getByTestId('ask-assistant-sidebar');
}
getPlaceholderMessage() {
return this.page.getByTestId('placeholder-message');
}
getChatInput() {
// Try suggestions input first (shown when suggestions are visible),
// fall back to regular input (shown when there are messages),
// or the mention input (shown when focused nodes feature is enabled)
const suggestionsInput = this.page.getByTestId('chat-suggestions-input').locator('textarea');
const regularInput = this.page.getByTestId('chat-input').locator('textarea');
const mentionInput = this.page.getByTestId('chat-input-with-mention').locator('textarea');
// Return the first one that's visible
return suggestionsInput.or(regularInput).or(mentionInput);
}
getSendMessageButton() {
return this.page.getByTestId('send-message-button');
}
getCloseChatButton() {
return this.page.getByTestId('close-chat-button');
}
getAskAssistantSidebarResizer() {
return this.getAskAssistantSidebar().locator('[class*="_resizer"][data-dir="left"]').first();
}
getNodeErrorViewAssistantButton() {
return this.page.getByTestId('node-error-view-ask-assistant-button').locator('button').first();
}
getChatMessagesAll() {
return this.page.locator('[data-test-id^="chat-message"]');
}
getChatMessagesAssistant() {
return this.page.getByTestId('chat-message-assistant');
}
getChatMessagesUser() {
return this.page.getByTestId('chat-message-user');
}
getChatMessagesSystem() {
return this.page.getByTestId('chat-message-system');
}
getQuickReplyButtons() {
return this.page.getByTestId('quick-replies').locator('button');
}
getNewAssistantSessionModal() {
return this.page.getByTestId('new-assistant-session-modal');
}
getCodeDiffs() {
return this.page.getByTestId('code-diff-suggestion');
}
getApplyCodeDiffButtons() {
return this.page.getByTestId('replace-code-button');
}
getUndoReplaceCodeButtons() {
return this.page.getByTestId('undo-replace-button');
}
getCodeReplacedMessage() {
return this.page.getByTestId('code-replaced-message');
}
getCredentialEditAssistantButton() {
return this.page.getByTestId('credential-edit-ask-assistant-button');
}
getCodeSnippet() {
return this.page.getByTestId('assistant-code-snippet-content');
}
// #endregion
// #region Actions
async sendMessage(
message: string,
method: 'send-message-button' | 'enter-key' = 'send-message-button',
) {
// Only type if there's a message to type (e.g., skip for pre-populated suggestion pills)
if (message) {
await this.getChatInput().pressSequentially(message, { delay: 20 });
}
if (method === 'enter-key') {
await this.getChatInput().press('Enter');
} else {
await this.getSendMessageButton().click();
}
}
async waitForStreamingComplete(options?: { timeout?: number }) {
const timeout = options?.timeout ?? 60000;
// Wait for at least one assistant message to appear (indicating streaming has produced output)
await this.getChatMessagesAssistant().first().waitFor({ state: 'visible', timeout });
// Wait for streaming to end by checking for the send button's arrow-up icon
// During streaming, a stop button with filled-square icon is shown instead
// After streaming, the send button with arrow-up icon appears
await this.page.waitForFunction(
() => {
const sendButton = document.querySelector('[data-test-id="send-message-button"]');
if (!sendButton) return false;
// The arrow-up icon indicates the send button (not streaming)
// The filled-square icon indicates the stop button (streaming)
const sendIcon = sendButton.querySelector('[data-icon="arrow-up"]');
return sendIcon !== null;
},
{ timeout },
);
}
// #endregion
}
@@ -0,0 +1,46 @@
import type { Page } from '@playwright/test';
/**
* Page object for AI Workflow Builder interactions
*/
export class AIBuilderPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
// #region Locators
getWorkflowSuggestions() {
return this.page.getByTestId('workflow-suggestions');
}
getSuggestionPills() {
// Get buttons within the pills container section, not the prompt input section
return this.getWorkflowSuggestions()
.locator('section[aria-label="Workflow suggestions"]')
.getByRole('button');
}
getCanvasBuildWithAIButton() {
return this.page.getByTestId('canvas-build-with-ai-button');
}
// #endregion
// #region Actions
async waitForWorkflowBuildComplete(options?: { timeout?: number }) {
const timeout = options?.timeout ?? 300000; // Default 5 minutes
const workingIndicator = this.page.getByText('Working...');
// First wait for the indicator to appear (building has started)
await workingIndicator.waitFor({ state: 'visible', timeout });
// Then wait for it to disappear (building is complete)
await workingIndicator.waitFor({ state: 'hidden', timeout });
}
// #endregion
}
@@ -0,0 +1,65 @@
import type { Page } from '@playwright/test';
import { BaseModal } from './components/BaseModal';
import { FloatingUiHelper } from './components/FloatingUiHelper';
export abstract class BasePage extends FloatingUiHelper {
protected readonly baseModal: BaseModal;
constructor(protected readonly page: Page) {
super(page);
this.baseModal = new BaseModal(this.page);
}
protected async clickByTestId(testId: string) {
await this.page.getByTestId(testId).click();
}
protected async fillByTestId(testId: string, value: string) {
await this.page.getByTestId(testId).fill(value);
}
protected async clickByText(text: string) {
await this.page.getByText(text).click();
}
protected async clickButtonByName(name: string) {
await this.page.getByRole('button', { name }).click();
}
protected async waitForRestResponse(
url: string | RegExp,
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
) {
if (typeof url === 'string') {
return await this.page.waitForResponse((res) => {
const matches = res.url().includes(url);
return matches && (method ? res.request().method() === method : true);
});
}
return await this.page.waitForResponse((res) => {
const matches = url.test(res.url());
return matches && (method ? res.request().method() === method : true);
});
}
/**
* Wait for debounce to complete.
* Respects the N8N_DEBOUNCE_MULTIPLIER sessionStorage setting.
* With multiplier=0 (test mode), returns immediately.
* @param baseTime - Base debounce time in milliseconds (default: 150)
*/
protected async waitForDebounce(baseTime = 150): Promise<void> {
const effectiveTime = await this.page.evaluate((time) => {
const stored = sessionStorage.getItem('N8N_DEBOUNCE_MULTIPLIER');
const multiplier = stored !== null ? parseFloat(stored) : 1;
return Math.round(time * (Number.isNaN(multiplier) ? 1 : multiplier));
}, baseTime);
if (effectiveTime > 0) {
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(effectiveTime);
}
}
}
@@ -0,0 +1,998 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { ROUTES } from '../config/constants';
import { resolveFromRoot } from '../utils/path-helper';
import { ConvertToSubworkflowModal } from './components/ConvertToSubworkflowModal';
import { CredentialModal } from './components/CredentialModal';
import { FocusPanel } from './components/FocusPanel';
import { LogsPanel } from './components/LogsPanel';
import { NodeCreator } from './components/NodeCreator';
import { SaveChangesModal } from './components/SaveChangesModal';
import { StickyComponent } from './components/StickyComponent';
import { TagsManagerModal } from './components/TagsManagerModal';
export class CanvasPage extends BasePage {
readonly sticky = new StickyComponent(this.page);
readonly logsPanel = new LogsPanel(this.page.getByTestId('logs-panel'));
readonly focusPanel = new FocusPanel(this.page.getByTestId('focus-panel'));
readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal'));
readonly nodeCreator = new NodeCreator(this.page);
readonly saveChangesModal = new SaveChangesModal(this.page.locator('.el-overlay'));
readonly tagsManagerModal = new TagsManagerModal(
this.page.getByRole('dialog').filter({ hasText: 'Manage tags' }),
);
readonly convertToSubworkflowModal = new ConvertToSubworkflowModal(
this.page.getByRole('dialog').filter({ hasText: 'Convert' }),
);
nodeCreatorItemByName(text: string): Locator {
return this.page.getByTestId('node-creator-item-name').getByText(text, { exact: true });
}
nodeCreatorSubItem(subItemText: string): Locator {
return this.page.getByTestId('node-creator-item-name').getByText(subItemText, { exact: true });
}
getNodeCreatorHeader(text?: string) {
const header = this.page.getByTestId('nodes-list-header');
return text ? header.filter({ hasText: text }) : header.first();
}
nodeByName(nodeName: string): Locator {
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
}
nodeIssuesBadge(nodeName: string) {
return this.nodeByName(nodeName).getByTestId('node-issues');
}
nodeToolbar(nodeName: string): Locator {
return this.nodeByName(nodeName).getByTestId('canvas-node-toolbar');
}
nodeDeleteButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('delete-node-button');
}
nodeDisableButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('disable-node-button');
}
async clickCanvasPlusButton(): Promise<void> {
await this.clickByTestId('canvas-plus-button');
}
getCanvasNodes() {
return this.page.getByTestId('canvas-node');
}
async clickNodeCreatorPlusButton(): Promise<void> {
await this.clickByTestId('node-creator-plus-button');
}
async fillNodeCreatorSearchBar(text: string): Promise<void> {
await this.nodeCreatorSearchBar().fill(text);
}
async clickNodeCreatorItemName(text: string): Promise<void> {
await this.nodeCreatorItemByName(text).click();
}
async clickAddToWorkflowButton(): Promise<void> {
await this.page.getByText('Add to workflow').click();
}
/**
* Add a node to the canvas with flexible options
* @param nodeName - The name of the node to search for and add
* @param options - Configuration options for node addition
* @param options.closeNDV - Whether to close the NDV after adding (default: false, keeps open)
* @param options.action - Specific action to select (Actions tab is default)
* @param options.trigger - Specific trigger to select (will switch to Triggers)
* @example
* // Basic node addition
* await canvas.addNode('Code');
*
* // Add with specific action
* await canvas.addNode('Linear', { action: 'Create an issue' });
*
* // Add with trigger
* await canvas.addNode('Jira', { trigger: 'On issue created' });
*
* // Add and explicitly close with back button
* await canvas.addNode('Code', { closeNDV: true });
*/
async addNode(
nodeName: string,
options?: {
closeNDV?: boolean;
action?: string;
trigger?: string;
fromNode?: string;
},
): Promise<void> {
if (options?.fromNode) {
await this.clickNodePlusEndpoint(options.fromNode);
} else {
// Always start with canvas plus button
await this.clickNodeCreatorPlusButton();
}
// Search for and select the node, works on exact name match only
await this.fillNodeCreatorSearchBar(nodeName);
await this.clickNodeCreatorItemName(nodeName);
if (options?.action) {
// Check if Actions category is collapsed and expand if needed
const actionsCategory = this.page
.getByTestId('node-creator-category-item')
.getByText('Actions');
if ((await actionsCategory.getAttribute('data-category-collapsed')) === 'true') {
await actionsCategory.click();
}
await this.nodeCreatorSubItem(options.action).click();
} else if (options?.trigger) {
// Check if Triggers category is collapsed and expand if needed
const triggersCategory = this.page
.getByTestId('node-creator-category-item')
.getByText('Triggers');
if ((await triggersCategory.getAttribute('data-category-collapsed')) === 'true') {
await triggersCategory.click();
}
await this.nodeCreatorSubItem(options.trigger).click();
}
if (options?.closeNDV) {
await this.page.getByTestId('ndv-close-button').click();
}
}
async deleteNodeByName(nodeName: string): Promise<void> {
await this.nodeDeleteButton(nodeName).click();
}
async waitForSaveWorkflowCompleted() {
return await this.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') &&
(response.request().method() === 'POST' || response.request().method() === 'PATCH'),
{ timeout: 2000 }, // Wait longer than autosave debounce (1500ms)
);
}
getExecuteWorkflowButton(triggerNodeName?: string): Locator {
const testId = triggerNodeName
? `execute-workflow-button-${triggerNodeName}`
: 'execute-workflow-button';
return this.page.getByTestId(testId);
}
async clickExecuteWorkflowButton(triggerNodeName?: string): Promise<void> {
await this.getExecuteWorkflowButton(triggerNodeName).click();
}
async openNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).dblclick();
}
getRenamePrompt(): Locator {
return this.page.locator('.rename-prompt');
}
getRenameInput(): Locator {
return this.getRenamePrompt().locator('input');
}
/**
* Get the names of all pinned nodes on the canvas.
* @returns An array of node names.
*/
async getPinnedNodeNames(): Promise<string[]> {
const pinnedNodesLocator = this.page
.getByTestId('canvas-node')
.filter({ has: this.page.getByTestId('canvas-node-status-pinned') });
const names: string[] = [];
const count = await pinnedNodesLocator.count();
for (let i = 0; i < count; i++) {
const node = pinnedNodesLocator.nth(i);
const name = await node.getAttribute('data-node-name');
if (name) {
names.push(name);
}
}
return names;
}
async clickExecutionsTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Executions' }).click();
}
async clickEditorTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Editor' }).click();
}
async setWorkflowName(name: string): Promise<void> {
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', name);
}
/**
* Import a workflow from a fixture file
* @param fixtureKey - The key of the fixture file to import
* @param workflowName - The name of the workflow to import
* Naming the file causes the workflow to save so we don't need to click save
*/
async importWorkflow(fixtureKey: string, workflowName: string) {
await this.clickByTestId('workflow-menu');
const [fileChooser] = await Promise.all([
this.page.waitForEvent('filechooser'),
this.clickByTestId('workflow-menu-item-import-from-file'),
]);
await fileChooser.setFiles(resolveFromRoot('workflows', fixtureKey));
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', workflowName);
await this.page.getByTestId('inline-edit-input').press('Enter');
}
// Import workflow locators
getImportURLInput(): Locator {
return this.page.getByTestId('workflow-url-import-input');
}
// Import workflow actions
async clickWorkflowMenu(): Promise<void> {
await this.clickByTestId('workflow-menu');
}
async clickImportFromURL(): Promise<void> {
await this.clickByTestId('workflow-menu-item-import-from-url');
}
async fillImportURLInput(url: string): Promise<void> {
await this.getImportURLInput().fill(url);
}
async clickConfirmImportURL(): Promise<void> {
await this.clickByTestId('confirm-workflow-import-url-button');
}
async clickCancelImportURL(): Promise<void> {
await this.clickByTestId('cancel-workflow-import-url-button');
}
async clickOutsideModal(): Promise<void> {
await this.page.locator('body').click({ position: { x: 0, y: 0 } });
}
async publishWorkflow(): Promise<void> {
const responsePromise = this.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/activate') &&
response.request().method() === 'POST',
);
await this.getOpenPublishModalButton().click();
await this.getPublishButton().click();
await responsePromise;
}
async openShareModal(): Promise<void> {
await this.clickByTestId('workflow-menu');
await this.clickByTestId('workflow-menu-item-share');
await this.page.getByTestId('workflowShare-modal').waitFor({ state: 'visible' });
}
async clickZoomToFitButton(): Promise<void> {
await this.clickByTestId('zoom-to-fit');
}
/**
* Get node issues for a specific node
*/
getNodeIssuesByName(nodeName: string) {
return this.nodeByName(nodeName).getByTestId('node-issues');
}
async clickCreateTagButton(): Promise<void> {
await this.page.getByTestId('new-tag-link').click();
}
async clickNthTagPill(index: number): Promise<void> {
await this.page.getByTestId('workflow-tags-container').locator('.el-tag').nth(index).click();
}
async clickWorkflowTagsArea(): Promise<void> {
await this.page.getByTestId('workflow-tags').click();
}
async clickWorkflowTagsContainer(): Promise<void> {
await this.page.getByTestId('workflow-tags-dropdown').click();
}
getTagPills(): Locator {
return this.page
.getByTestId('workflow-tags-container')
.locator('.el-tag:not(.count-container)');
}
getSavedWorkflowTagPills(): Locator {
return this.page.getByTestId('workflow-tags').locator('.n8n-tag:not(.count-container)');
}
getWorkflowTagsElement(): Locator {
return this.page.getByTestId('workflow-tags');
}
getWorkflowTagsDropdown(): Locator {
return this.page.getByTestId('workflow-tags-dropdown');
}
getTagCloseButton(): Locator {
return this.getWorkflowTagsDropdown().locator('.el-tag__close');
}
async typeInTagInput(text: string): Promise<void> {
const input = this.page.getByTestId('workflow-tags-container').locator('input').first();
await input.fill(text);
}
async openTagManagerModal(): Promise<void> {
await this.clickCreateTagButton();
await this.page.getByTestId('tags-dropdown').click();
await this.page.locator('.manage-tags').click();
}
async pressEnterToCreateTag(): Promise<void> {
const responsePromise = this.waitForRestResponse('/rest/tags', 'POST');
await this.page.keyboard.press('Enter');
await responsePromise;
}
// Tag dropdown getters
getVisibleDropdown(): Locator {
return this.page.locator('.el-select-dropdown:visible');
}
getTagItemsInDropdown(): Locator {
return this.getVisibleDropdown().locator('[data-test-id="tag"].tag');
}
getTagItemInDropdownByName(name: string): Locator {
return this.getVisibleDropdown().locator(`[data-test-id="tag"].tag:has-text("${name}")`);
}
getSelectedTagItems(): Locator {
return this.getVisibleDropdown().locator('[data-test-id="tag"].tag.selected');
}
getOpenPublishModalButton(): Locator {
return this.page.getByTestId('workflow-open-publish-modal-button');
}
getPublishButton(): Locator {
return this.page.getByTestId('workflow-publish-button');
}
getPublishedIndicator(): Locator {
return this.page.getByRole('button', { name: 'Published' });
}
getLoadingMask(): Locator {
return this.page.locator('.el-loading-mask');
}
getNodeViewLoader(): Locator {
return this.page.getByTestId('node-view-loader');
}
getWorkflowIdFromUrl(): string {
const url = new URL(this.page.url());
const workflowId = url.pathname.split('/workflow/')[1]?.split('/')[0];
if (!workflowId) throw new Error('Workflow ID not found in URL');
return workflowId;
}
/**
* Get the "Set up template" button that appears when credential setup is incomplete
* @returns Locator for the setup workflow credentials button
*/
getSetupWorkflowCredentialsButton(): Locator {
return this.page.getByRole('button', { name: 'Set up template' });
}
// Production Checklist methods
getProductionChecklistButton(): Locator {
return this.page.getByTestId('suggested-action-count');
}
getProductionChecklistPopover(): Locator {
return this.page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
}
getProductionChecklistActionItem(text?: string): Locator {
const items = this.page.getByTestId('suggested-action-item');
if (text) {
return items.getByText(text);
}
return items;
}
getProductionChecklistIgnoreAllButton(): Locator {
return this.page.getByTestId('suggested-action-ignore-all');
}
getErrorActionItem(): Locator {
return this.getProductionChecklistActionItem('Set up error notifications');
}
getTimeSavedActionItem(): Locator {
return this.getProductionChecklistActionItem('Track time saved');
}
getEvaluationsActionItem(): Locator {
return this.getProductionChecklistActionItem('Test reliability of AI steps');
}
async clickProductionChecklistButton(): Promise<void> {
await this.getProductionChecklistButton().click();
}
async clickProductionChecklistIgnoreAll(): Promise<void> {
await this.getProductionChecklistIgnoreAllButton().click();
}
async duplicateNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Duplicate').click();
}
nodeConnections(): Locator {
return this.page.locator('[data-test-id="edge"]');
}
canvasNodePlusEndpointByName(nodeName: string): Locator {
return this.page
.locator(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
)
.first();
}
nodeCreatorSearchBar(): Locator {
return this.page.getByTestId('node-creator-search-bar');
}
nodeCreatorNodeItems(): Locator {
return this.page.getByTestId('node-creator-item-name');
}
nodeCreatorActionItems(): Locator {
return this.page.getByTestId('node-creator-action-item');
}
nodeCreatorCategoryItems(): Locator {
return this.page.getByTestId('node-creator-category-item');
}
getFirstAction(): Locator {
return this.page.locator('[data-keyboard-nav-type="action"]').first();
}
selectedNodes(): Locator {
return this.page
.locator('[data-test-id="canvas-node"]')
.locator('xpath=..')
.locator('.selected');
}
disabledNodes(): Locator {
return this.page.locator('[data-canvas-node-render-type][class*="disabled"]');
}
nodeExecuteButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('execute-node-button');
}
getArchivedTag(): Locator {
return this.page.getByTestId('workflow-archived-tag');
}
getNodeCreatorPlusButton(): Locator {
return this.page.getByTestId('node-creator-plus-button');
}
canvasPane(): Locator {
return this.page.getByTestId('canvas-wrapper');
}
canvasBody(): Locator {
return this.page.getByTestId('canvas');
}
toggleFocusPanelButton(): Locator {
return this.page.getByTestId('toggle-focus-panel-button');
}
stopExecutionButton(): Locator {
return this.page.getByTestId('stop-execution-button');
}
// Actions
async addInitialNodeToCanvas(nodeName: string): Promise<void> {
await this.clickCanvasPlusButton();
await this.fillNodeCreatorSearchBar(nodeName);
await this.clickNodeCreatorItemName(nodeName);
}
async clickNodePlusEndpoint(nodeName: string): Promise<void> {
await this.canvasNodePlusEndpointByName(nodeName).click();
}
async executeNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).hover();
await this.nodeExecuteButton(nodeName).click();
}
async selectAll(): Promise<void> {
// Establish proper selection context first
await this.getCanvasNodes().first().click();
await this.page.keyboard.press('ControlOrMeta+a');
}
async copyNodes(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+c');
}
async deselectAll(): Promise<void> {
await this.canvasPane().click({ position: { x: 10, y: 10 } });
}
async openCanvasContextMenu(): Promise<void> {
await this.canvasPane().click({ button: 'right', position: { x: 10, y: 10 } });
}
// Connection helpers
connectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
return this.page.locator(
`[data-test-id="edge"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
);
}
connectionToolbarBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
return this.page.locator(
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
);
}
// Canvas action helpers
async addNodeBetweenNodes(
sourceNodeName: string,
targetNodeName: string,
newNodeName: string,
): Promise<void> {
const specificConnection = this.connectionBetweenNodes(sourceNodeName, targetNodeName);
// eslint-disable-next-line playwright/no-force-option
await specificConnection.hover({ force: true });
const addNodeButton = this.connectionToolbarBetweenNodes(
sourceNodeName,
targetNodeName,
).getByTestId('add-connection-button');
await addNodeButton.click();
await this.fillNodeCreatorSearchBar(newNodeName);
await this.clickNodeCreatorItemName(newNodeName);
await this.page.keyboard.press('Escape');
}
async deleteConnectionBetweenNodes(
sourceNodeName: string,
targetNodeName: string,
): Promise<void> {
const specificConnection = this.connectionBetweenNodes(sourceNodeName, targetNodeName);
// eslint-disable-next-line playwright/no-force-option
await specificConnection.hover({ force: true });
const deleteButton = this.connectionToolbarBetweenNodes(
sourceNodeName,
targetNodeName,
).getByTestId('delete-connection-button');
await deleteButton.click();
}
async navigateNodesWithArrows(direction: 'left' | 'right' | 'up' | 'down'): Promise<void> {
const keyMap = {
left: 'ArrowLeft',
right: 'ArrowRight',
up: 'ArrowUp',
down: 'ArrowDown',
};
await this.canvasPane().focus();
await this.page.keyboard.press(keyMap[direction]);
}
async extendSelectionWithArrows(direction: 'left' | 'right' | 'up' | 'down'): Promise<void> {
const keyMap = {
left: 'Shift+ArrowLeft',
right: 'Shift+ArrowRight',
up: 'Shift+ArrowUp',
down: 'Shift+ArrowDown',
};
await this.canvasPane().focus();
await this.page.keyboard.press(keyMap[direction]);
}
/**
* Visit the workflow page with a specific timestamp for NPS survey testing.
* Uses Playwright's clock API to set a fixed time.
*/
async visitWithTimestamp(timestamp: number): Promise<void> {
// Set fixed time using Playwright's clock API
await this.page.clock.setFixedTime(timestamp);
await this.openNewWorkflow();
}
async openNewWorkflow() {
await this.page.goto(ROUTES.NEW_WORKFLOW_PAGE);
}
getRagCalloutTip(): Locator {
return this.page.getByText('Tip: Get a feel for vector stores in n8n with our');
}
getRagTemplateLink(): Locator {
return this.page.getByText('RAG starter template');
}
async clickRagTemplateLink(): Promise<void> {
await this.getRagTemplateLink().click();
}
async rightClickNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
}
async rightClickCanvas(): Promise<void> {
await this.canvasBody().click({ button: 'right' });
}
getContextMenuItem(itemId: string): Locator {
return this.page.getByTestId(`context-menu-item-${itemId}`);
}
async clickContextMenuAction(actionText: string): Promise<void> {
await this.page.getByTestId('context-menu').getByText(actionText).click();
}
async executeNodeFromContextMenu(nodeName: string): Promise<void> {
await this.rightClickNode(nodeName);
await this.clickContextMenuAction('execute');
}
clearExecutionDataButton(): Locator {
return this.page.getByTestId('clear-execution-data-button');
}
async clearExecutionData(): Promise<void> {
await this.clearExecutionDataButton().click();
}
getManualChatModal(): Locator {
return this.page.getByTestId('canvas-chat');
}
getManualChatInput(): Locator {
return this.getManualChatModal().locator('.chat-inputs textarea');
}
getManualChatMessages(): Locator {
return this.getManualChatModal().locator('.chat-messages-list .chat-message');
}
getManualChatLatestBotMessage(): Locator {
return this.getManualChatModal()
.locator('.chat-messages-list .chat-message.chat-message-from-bot')
.last();
}
getWaitingNodes(): Locator {
return this.page.locator('[data-test-id="canvas-node"].waiting');
}
/**
* Get all currently selected nodes on the canvas
*/
getSelectedNodes() {
return this.page.locator('[data-test-id="canvas-node"].selected');
}
// Disable node via context menu
async disableNodeFromContextMenu(nodeName: string): Promise<void> {
await this.rightClickNode(nodeName);
await this.page
.getByTestId('context-menu')
.getByTestId('context-menu-item-toggle_activation')
.click();
}
/**
* Toggle node enabled/disabled state using keyboard shortcut
* @param nodeName - The name of the node to toggle
*/
async toggleNodeEnabled(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click();
await this.page.keyboard.press('d');
}
// Chat open/close buttons (manual chat)
async clickManualChatButton(): Promise<void> {
await this.page.getByTestId('workflow-chat-button').click();
await this.getManualChatModal().waitFor({ state: 'visible' });
}
async closeManualChatModal(): Promise<void> {
// Same toggle button closes the chat
await this.page.getByTestId('workflow-chat-button').click();
}
// Input plus endpoints (to add supplemental nodes to parent inputs)
getInputPlusEndpointByType(nodeName: string, endpointType: string) {
return this.page
.locator(
`[data-test-id="canvas-node-input-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
)
.first();
}
// Generic supplemental node addition, then wrappers for specific types
async addSupplementalNodeToParent(
childNodeName: string,
endpointType:
| 'main'
| 'ai_chain'
| 'ai_document'
| 'ai_embedding'
| 'ai_languageModel'
| 'ai_memory'
| 'ai_outputParser'
| 'ai_tool'
| 'ai_retriever'
| 'ai_textSplitter'
| 'ai_vectorRetriever'
| 'ai_vectorStore',
parentNodeName: string,
{
closeNDV = false,
exactMatch = false,
subcategory,
}: { closeNDV?: boolean; exactMatch?: boolean; subcategory?: string } = {},
): Promise<void> {
await this.getInputPlusEndpointByType(parentNodeName, endpointType).click();
if (subcategory) {
await this.nodeCreator.navigateToSubcategory(subcategory);
}
if (exactMatch) {
await this.nodeCreatorNodeItems().getByText(childNodeName, { exact: true }).click();
} else {
await this.nodeCreatorNodeItems().filter({ hasText: childNodeName }).first().click();
}
if (closeNDV) {
await this.page.keyboard.press('Escape');
}
}
async openExecutions() {
await this.page.getByTestId('radio-button-executions').click();
}
getZoomInButton(): Locator {
return this.page.getByTestId('zoom-in-button');
}
getResetZoomButton(): Locator {
return this.page.getByTestId('reset-zoom-button');
}
async clickZoomInButton(): Promise<void> {
await this.clickByTestId('zoom-in-button');
}
async clickZoomOutButton(): Promise<void> {
await this.clickByTestId('zoom-out-button');
}
/**
* Get the current zoom level of the canvas
* @returns The current zoom/scale factor as a number
*/
async getCanvasZoomLevel(): Promise<number> {
return await this.page.evaluate(() => {
const canvasViewport = document.querySelector(
'.vue-flow__transformationpane.vue-flow__container',
);
if (canvasViewport) {
const transform = window.getComputedStyle(canvasViewport).transform;
if (transform && transform !== 'none') {
const matrix = transform.match(/matrix\(([^)]+)\)/);
if (matrix) {
const values = matrix[1].split(',').map((v) => v.trim());
return parseFloat(values[0]); // First value is scaleX
}
}
}
// Fallback: return default zoom level
return 1.0;
});
}
waitingForTriggerEvent() {
return this.getExecuteWorkflowButton().getByText('Waiting for trigger event');
}
getNodeSuccessStatusIndicator(nodeName: string): Locator {
return this.nodeByName(nodeName).getByTestId('canvas-node-status-success');
}
getNodeWarningStatusIndicator(nodeName: string): Locator {
return this.nodeByName(nodeName).getByTestId('canvas-node-status-warning');
}
getNodeRunningStatusIndicator(nodeName: string): Locator {
return this.page.locator(
`[data-test-id="canvas-node"][data-node-name="${nodeName}"].running, [data-test-id="canvas-node"][data-node-name="${nodeName}"].waiting`,
);
}
getSuccessEdges(): Locator {
return this.page.locator('[data-edge-status="success"]');
}
getAllNodeSuccessIndicators(): Locator {
return this.page.getByTestId('canvas-node-status-success');
}
getCanvasHandlePlusWrapperByName(nodeName: string): Locator {
return this.page
.locator(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus-wrapper"]`,
)
.first();
}
stopExecutionWaitingForWebhookButton(): Locator {
return this.page.getByTestId('stop-execution-waiting-for-webhook-button');
}
getExecuteWorkflowButtonSpinner(): Locator {
return this.getExecuteWorkflowButton().locator('.n8n-spinner');
}
getCanvasPlusButton(): Locator {
return this.page.getByTestId('canvas-plus-button');
}
async hitUndo(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+z');
// Wait for canvas to redraw after undo
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(100);
}
async hitRedo(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+Shift+z');
// Wait for canvas to redraw after redo
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(100);
}
async hitExecuteWorkflow(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+Enter');
}
async getNodePosition(nodeName: string): Promise<{ x: number; y: number }> {
const node = this.nodeByName(nodeName);
const boundingBox = await node.boundingBox();
if (!boundingBox) throw new Error(`Node ${nodeName} not found or not visible`);
return { x: boundingBox.x, y: boundingBox.y };
}
async dragNodeToRelativePosition(
nodeName: string,
deltaX: number,
deltaY: number,
): Promise<void> {
const node = this.nodeByName(nodeName);
const currentBox = await node.boundingBox();
if (!currentBox) throw new Error(`Node ${nodeName} not found`);
// Calculate center of node for drag start
const startX = currentBox.x + currentBox.width / 2;
const startY = currentBox.y + currentBox.height / 2;
// Use mouse events for precise control
await this.page.mouse.move(startX, startY);
await this.page.mouse.down();
await this.page.mouse.move(startX + deltaX, startY + deltaY, { steps: 10 });
await this.page.mouse.up();
}
async deleteNodeFromContextMenu(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Delete').click();
}
async hitDeleteAllNodes(): Promise<void> {
await this.selectAll();
await this.page.keyboard.press('Backspace');
}
getNodeInputHandles(nodeName: string): Locator {
return this.page.locator(
`[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"]`,
);
}
getNodeOutputHandle(nodeName: string, outputIndex = 0): Locator {
return this.page.locator(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-index="${outputIndex}"]`,
);
}
getNodeInputHandle(nodeName: string, inputIndex = 0): Locator {
return this.page.locator(
`[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"][data-index="${inputIndex}"]`,
);
}
async connectNodesByDrag(
sourceNode: string,
targetNode: string,
sourceIndex = 0,
targetIndex = 0,
): Promise<void> {
const outputHandle = this.getNodeOutputHandle(sourceNode, sourceIndex);
const inputHandle = this.getNodeInputHandle(targetNode, targetIndex);
await outputHandle.dragTo(inputHandle);
}
getConnectionLabelBetweenNodes(sourceNode: string, targetNode: string): Locator {
return this.page.locator(
`[data-test-id="edge-label"][data-source-node-name="${sourceNode}"][data-target-node-name="${targetNode}"]`,
);
}
getWorkflowName(): Locator {
return this.page.getByTestId('workflow-name-input');
}
// Workflow History methods
getWorkflowHistoryButton(): Locator {
return this.page.getByTestId('workflow-history-button');
}
getWorkflowHistoryCloseButton(): Locator {
return this.page.getByTestId('workflow-history-close-button');
}
async openWorkflowHistory(): Promise<void> {
await this.getWorkflowHistoryButton().click();
}
async closeWorkflowHistory(): Promise<void> {
await this.getWorkflowHistoryCloseButton().click();
}
}
@@ -0,0 +1,160 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { ChatHubCredentialModal } from './components/ChatHubCredentialModal';
import { ChatHubPersonalAgentModal } from './components/ChatHubPersonalAgentModal';
import { ChatHubSidebar } from './components/ChatHubSidebar';
import { ChatHubToolsModal } from './components/ChatHubToolsModal';
export class ChatHubChatPage extends BasePage {
readonly sidebar = new ChatHubSidebar(this.page.locator('#sidebar'));
readonly toolsModal = new ChatHubToolsModal(
this.page.getByRole('dialog').filter({ has: this.page.locator('[data-tools-manager-modal]') }),
);
readonly credModal = new ChatHubCredentialModal(
this.page.getByTestId('chatCredentialSelectorModal-modal'),
);
readonly personalAgentModal = new ChatHubPersonalAgentModal(this.page.getByRole('dialog'));
constructor(page: Page) {
super(page);
}
getGreetingMessage(): Locator {
return this.page.getByRole('heading', { level: 2 });
}
getWelcomeStartNewChatButton(): Locator {
return this.page.getByTestId('welcome-start-new-chat');
}
async dismissWelcomeScreen(): Promise<void> {
// Wait for sessions to load - either the welcome screen or the model selector will appear
const welcomeButton = this.getWelcomeStartNewChatButton();
const modelSelector = this.getModelSelectorButton();
// Wait for either element to be visible (indicates sessions are loaded)
await expect(welcomeButton.or(modelSelector)).toBeVisible();
// If welcome screen is shown, click to dismiss it
if (await welcomeButton.isVisible()) {
await welcomeButton.click();
await welcomeButton.waitFor({ state: 'hidden' });
}
}
getModelSelectorButton(): Locator {
return this.page.getByTestId('chat-model-selector');
}
getSelectedCredentialName(): Locator {
return this.getModelSelectorButton().locator('span.n8n-text').first();
}
getChatInput(): Locator {
return this.page.locator('form').getByRole('textbox');
}
getSendButton(): Locator {
return this.page.getByTitle('Send');
}
getChatMessages(): Locator {
return this.page.locator('[data-message-id]');
}
getEditButtonAt(index: number): Locator {
return this.getChatMessages().nth(index).getByTestId('chat-message-edit');
}
getEditorAt(index: number): Locator {
return this.getChatMessages().nth(index).getByRole('textbox');
}
getSendButtonAt(index: number): Locator {
return this.getChatMessages().nth(index).getByText('Send');
}
getRegenerateButtonAt(index: number): Locator {
return this.getChatMessages().nth(index).getByTestId('chat-message-regenerate');
}
getPrevAlternativeButtonAt(index: number): Locator {
return this.getChatMessages().nth(index).getByTestId('chat-message-prev-alternative');
}
async clickEditButtonAt(index: number): Promise<void> {
await this.hoverMessageActionsAt(index);
const editButton = this.getEditButtonAt(index);
// Wait for streaming to complete - the button is disabled during streaming
await editButton.waitFor({ state: 'visible' });
await expect(editButton).toBeEnabled();
await editButton.click({ force: true });
}
async clickRegenerateButtonAt(index: number): Promise<void> {
await this.hoverMessageActionsAt(index);
const regenerateButton = this.getRegenerateButtonAt(index);
// Wait for streaming to complete - the button is disabled during streaming
await regenerateButton.waitFor({ state: 'visible' });
await expect(regenerateButton).toBeEnabled();
await regenerateButton.click({ force: true });
}
async clickPrevAlternativeButtonAt(index: number): Promise<void> {
await this.hoverMessageActionsAt(index);
const prevButton = this.getPrevAlternativeButtonAt(index);
// Wait for streaming to complete - the button is disabled during streaming
await prevButton.waitFor({ state: 'visible' });
await expect(prevButton).toBeEnabled();
await prevButton.click({ force: true });
}
/**
* Hovers over the message content area to reveal hidden action buttons.
* The action buttons are hidden by CSS until the content area is hovered.
*/
private async hoverMessageActionsAt(index: number): Promise<void> {
const message = this.getChatMessages().nth(index);
await message.hover();
await message.getByTestId('chat-message-actions').waitFor({ state: 'visible' });
}
getFileInput(): Locator {
return this.page.locator('input[type="file"]');
}
getAttachmentsAt(messageIndex: number): Locator {
return this.getChatMessages().nth(messageIndex).locator('.chat-file');
}
getToolsButton(): Locator {
return this.page.getByTestId('chat-tools-button');
}
getOpenWorkflowButton(): Locator {
return this.page.getByRole('button', { name: /open workflow/i });
}
async clickOpenWorkflowButton(): Promise<Page> {
const newPagePromise = this.page.context().waitForEvent('page');
await this.getOpenWorkflowButton().click();
const workflowPage = await newPagePromise;
await workflowPage.waitForLoadState();
return workflowPage;
}
async openAttachmentAt(messageIndex: number, attachmentIndex: number): Promise<Page> {
const [newPage] = await Promise.all([
this.page.context().waitForEvent('page'),
this.getAttachmentsAt(messageIndex).nth(attachmentIndex).click(),
]);
await newPage.waitForLoadState('load');
return newPage;
}
}
@@ -0,0 +1,28 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { ChatHubPersonalAgentModal } from './components/ChatHubPersonalAgentModal';
export class ChatHubPersonalAgentsPage extends BasePage {
readonly editModal = new ChatHubPersonalAgentModal(this.page.getByRole('dialog'));
constructor(page: Page) {
super(page);
}
getNewAgentButton(): Locator {
return this.page.getByText('New Agent');
}
getAgentCards(): Locator {
return this.page.getByTestId('chat-agent-card');
}
getEditButtonAt(index: number): Locator {
return this.page.getByTestId('chat-agent-card').nth(index).getByTitle('Edit');
}
getMenuAt(index: number): Locator {
return this.page.getByTestId('chat-agent-card').nth(index).getByTitle('More options');
}
}
@@ -0,0 +1,28 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { ChatHubProviderSettingsModal } from './components/ChatHubProviderSettingsModal';
import { CredentialModal } from './components/CredentialModal';
export class ChatHubSettingsPage extends BasePage {
readonly providerModal = new ChatHubProviderSettingsModal(
this.page.getByTestId('chatProviderSettingsModal-modal'),
);
readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal'));
constructor(page: Page) {
super(page);
}
getProvidersTable(): Locator {
return this.page.getByTestId('chat-providers-table');
}
getProviderRow(providerName: string): Locator {
return this.getProvidersTable().getByRole('row').filter({ hasText: providerName });
}
getProviderActionToggle(providerName: string): Locator {
return this.getProviderRow(providerName).getByTestId('action-toggle');
}
}
@@ -0,0 +1,17 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
export class ChatHubWorkflowAgentsPage extends BasePage {
constructor(page: Page) {
super(page);
}
getAgentCards(): Locator {
return this.page.getByTestId('chat-agent-card');
}
getEmptyText(): Locator {
return this.page.getByText('No workflow agents available.');
}
}
@@ -0,0 +1,121 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class CommunityNodesPage extends BasePage {
// Element getters
getCommunityCards(): Locator {
return this.page.getByTestId('community-package-card');
}
getActionBox(): Locator {
return this.page.getByTestId('action-box');
}
getInstallButton(): Locator {
// Try action box first (empty state), fallback to header install button
const actionBoxButton = this.getActionBox().locator('button');
const headerInstallButton = this.page.getByRole('button', { name: 'Install' });
return actionBoxButton.or(headerInstallButton);
}
getInstallModal(): Locator {
return this.page.getByTestId('communityPackageInstall-modal');
}
getConfirmModal(): Locator {
return this.page.getByTestId('communityPackageManageConfirm-modal');
}
getPackageNameInput(): Locator {
return this.getInstallModal().locator('input').first();
}
getUserAgreementCheckbox(): Locator {
return this.page.getByTestId('user-agreement-checkbox');
}
getInstallPackageButton(): Locator {
return this.page.getByTestId('install-community-package-button');
}
getActionToggle(): Locator {
return this.page.getByTestId('action-toggle');
}
getUninstallAction(): Locator {
return this.page.getByTestId('action-uninstall');
}
getUpdateButton(): Locator {
return this.getCommunityCards().first().locator('button');
}
getConfirmUpdateButton(): Locator {
return this.getConfirmModal().getByRole('button', { name: 'Confirm update' });
}
getConfirmUninstallButton(): Locator {
return this.getConfirmModal().getByRole('button', { name: 'Confirm uninstall' });
}
// Simple actions
async clickInstallButton(): Promise<void> {
await this.getInstallButton().click();
}
async fillPackageName(packageName: string): Promise<void> {
await this.getPackageNameInput().fill(packageName);
}
async clickUserAgreementCheckbox(): Promise<void> {
await this.getUserAgreementCheckbox().click();
}
async clickInstallPackageButton(): Promise<void> {
await this.getInstallPackageButton().click();
}
async clickActionToggle(): Promise<void> {
await this.getActionToggle().click();
}
async clickUninstallAction(): Promise<void> {
await this.getUninstallAction().click();
}
async clickUpdateButton(): Promise<void> {
await this.getUpdateButton().click();
}
async clickConfirmUpdate(): Promise<void> {
await this.getConfirmUpdateButton().click();
}
async clickConfirmUninstall(): Promise<void> {
await this.getConfirmUninstallButton().click();
}
// Helper methods for common workflows
async installPackage(packageName: string): Promise<void> {
await this.clickInstallButton();
await this.fillPackageName(packageName);
await this.clickUserAgreementCheckbox();
await this.clickInstallPackageButton();
// Wait for install modal to close
await this.getInstallModal().waitFor({ state: 'hidden' });
}
async updatePackage(): Promise<void> {
await this.clickUpdateButton();
await this.clickConfirmUpdate();
}
async uninstallPackage(): Promise<void> {
await this.clickActionToggle();
await this.clickUninstallAction();
await this.clickConfirmUninstall();
}
}
@@ -0,0 +1,67 @@
import { BasePage } from './BasePage';
import { AddResource } from './components/AddResource';
import { CredentialModal } from './components/CredentialModal';
import { ResourceCards } from './components/ResourceCards';
export class CredentialsPage extends BasePage {
readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal'));
readonly addResource = new AddResource(this.page);
readonly cards = new ResourceCards(this.page);
get emptyListCreateCredentialButton() {
return this.page.getByRole('button', { name: 'Add first credential' });
}
get createCredentialButton() {
return this.page.getByTestId('create-credential-button');
}
/**
* Create a credential from the credentials list, fill fields, save, and close the modal.
* @param credentialType - The type of credential to create (e.g. 'Notion API')
* @param fields - Key-value pairs for credential fields to fill
*/
async createCredentialFromCredentialPicker(
credentialType: string,
fields: Record<string, string>,
options?: { closeDialog?: boolean; skipSave?: boolean; name?: string },
): Promise<void> {
await this.page.getByRole('combobox', { name: 'Search for app...' }).fill(credentialType);
await this.page
.getByTestId('new-credential-type-select-option')
.filter({ hasText: credentialType })
.click();
await this.page.getByTestId('new-credential-type-button').click();
await this.credentialModal.addCredential(fields, {
name: options?.name,
closeDialog: options?.closeDialog,
skipSave: options?.skipSave,
});
}
async clearSearch() {
await this.page.getByTestId('resources-list-search').clear();
}
async sortByNameDescending() {
await this.page.getByTestId('resources-list-sort').click();
await this.page.getByText('Name (Z-A)').click();
}
async sortByNameAscending() {
await this.page.getByTestId('resources-list-sort').click();
await this.page.getByText('Name (A-Z)').click();
}
/**
* Select credential type without auto-saving (for tests that need to handle save manually)
*/
async selectCredentialType(credentialType: string): Promise<void> {
await this.page.getByRole('combobox', { name: 'Search for app...' }).fill(credentialType);
await this.page
.getByTestId('new-credential-type-select-option')
.filter({ hasText: credentialType })
.click();
await this.page.getByTestId('new-credential-type-button').click();
}
}
@@ -0,0 +1,399 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DataTableDetails extends BasePage {
getPageWrapper() {
return this.page.getByTestId('data-table-details-view');
}
getDataTableProjectBreadcrumb() {
return this.page.getByTestId('home-project');
}
getDataTableBreadcrumb() {
return this.page.getByTestId('data-table-header-name-input');
}
async renameDataTable(newName: string) {
const nameInput = this.getDataTableBreadcrumb();
await nameInput.click();
const input = nameInput.locator('input');
await input.fill('');
await input.fill(newName);
await input.press('Enter');
}
getVisibleColumns() {
return this.page.getByTestId('data-table-column-header');
}
getNoRowsMessage() {
return this.page.getByTestId('data-table-no-rows-overlay');
}
getAddColumnHeaderButton() {
return this.page.getByTestId('data-table-add-column-trigger-button').first();
}
getAddColumnTableButton() {
return this.page.getByTestId('data-table-add-column-trigger-button').last();
}
getAddColumnPopoverContent() {
return this.page.getByTestId('add-column-popover-content');
}
getAddColumnSubmitButton() {
return this.page.getByTestId('data-table-add-column-submit-button');
}
getColumnHeaderByName(name: string) {
return this.page
.getByTestId('data-table-column-header')
.filter({ has: this.page.getByTestId('data-table-column-header-text').getByText(name) });
}
async getColumnIdByName(name: string): Promise<string> {
const columnHeader = this.page
.getByRole('columnheader')
.filter({ has: this.page.getByTestId('data-table-column-header-text').getByText(name) });
const colId = await columnHeader.getAttribute('col-id');
if (!colId) {
throw new Error(`Could not find col-id for column with name: ${name}`);
}
return colId;
}
async addColumn(
name: string,
type: 'string' | 'number' | 'boolean' | 'date',
source: 'header' | 'table',
) {
if (source === 'header') {
await this.getAddColumnHeaderButton().click();
} else {
await this.getAddColumnTableButton().click();
}
await this.getAddColumnPopoverContent().waitFor({ state: 'visible' });
const nameInput = this.page
.getByTestId('add-column-popover-content')
.locator('input[type="text"]')
.first();
await nameInput.fill(name);
const typeSelect = this.page
.getByTestId('add-column-popover-content')
.locator('.n8n-select')
.first();
await typeSelect.click();
const typeLabel = type === 'date' ? 'datetime' : type;
await this.page.getByRole('option', { name: typeLabel, exact: true }).click();
await this.getAddColumnSubmitButton().click();
await this.getAddColumnPopoverContent().waitFor({ state: 'hidden' });
}
getAddRowHeaderButton() {
return this.page.getByTestId('data-table-header-add-row-button');
}
getAddRowTableButton() {
return this.page.locator('[data-test-id="data-table-grid"] .add-row-cell button');
}
async addRow() {
await this.getAddRowHeaderButton().click();
}
async addRowFromTable() {
await this.getAddRowTableButton().click();
}
getDataRows() {
return this.page.locator(
'[data-test-id="data-table-grid"] .ag-center-cols-container [row-index]',
);
}
getRowSelectionCheckbox(rowIndex: number) {
return this.page.locator(
`[data-test-id="data-table-grid"] .ag-center-cols-container [row-index="${rowIndex}"] [col-id="ag-Grid-SelectionColumn"] input[type="checkbox"]`,
);
}
async selectRow(rowIndex: number) {
const checkbox = this.getRowSelectionCheckbox(rowIndex);
await checkbox.check();
}
getSelectedItemsInfo() {
return this.page.getByTestId('selected-items-info');
}
getDeleteSelectedButton() {
return this.page.getByTestId('delete-selected-button');
}
getClearSelectionButton() {
return this.page.getByTestId('clear-selection-button');
}
async deleteSelectedRows() {
await this.getDeleteSelectedButton().click();
const confirmButton = this.page.locator('.btn--confirm');
await confirmButton.click();
}
async clearSelection() {
await this.getClearSelectionButton().click();
}
getColumnHeaderActions(columnName: string) {
const columnHeader = this.page.getByRole('columnheader').filter({
has: this.page.getByTestId('data-table-column-header-text').getByText(columnName),
});
return columnHeader.getByTestId('data-table-column-header-actions');
}
async deleteColumn(columnName: string) {
const columnHeader = this.page.getByTestId('data-table-column-header').filter({
has: this.page.getByTestId('data-table-column-header-text').getByText(columnName),
});
await columnHeader.hover();
const actionsButton = this.getColumnHeaderActions(columnName);
await actionsButton.click();
await this.page
.getByTestId('data-table-column-header-actions-item-delete')
.filter({ visible: true })
.click();
const confirmButton = this.page.locator('.btn--confirm');
await confirmButton.click();
}
async openColumnFilter(columnName: string) {
const columnHeader = this.page.getByRole('columnheader').filter({
has: this.page.getByTestId('data-table-column-header-text').getByText(columnName),
});
await columnHeader.hover();
const filterButton = columnHeader.getByTestId('data-table-column-header-filter-button');
await filterButton.click();
}
private async openFilterPanelAndWait(columnName: string) {
await this.openColumnFilter(columnName);
const filterPanel = this.page.locator('.ag-filter');
await filterPanel.waitFor({ state: 'visible' });
return filterPanel;
}
private async selectPickerOption(label: string) {
await this.page.locator('.ag-picker-field-icon').click();
await this.page.locator('.ag-select-list-item').filter({ hasText: label }).first().click();
}
private async selectFilterOperator(condition: 'equals' | 'greaterThan' | 'lessThan') {
if (condition === 'equals') return;
const label = condition === 'greaterThan' ? 'Greater than' : 'Less than';
await this.selectPickerOption(label);
}
private async fillFilterValue(
filterPanel: Locator,
value: string,
type: 'text' | 'number' | 'date',
) {
let input: Locator;
if (type === 'number') {
input = filterPanel.locator('input[type="number"]').first();
} else if (type === 'text') {
input = filterPanel.locator('input[type="text"]').first();
} else {
input = filterPanel.locator('input').first();
}
await input.fill(value);
return input;
}
async clearColumnFilter(columnName: string) {
await this.openColumnFilter(columnName);
const filterPanel = this.page.locator('.ag-filter');
await filterPanel.locator('[data-ref="resetFilterButton"]').click();
}
async setTextFilter(columnName: string, value: string) {
await this.openColumnFilter(columnName);
const filterPanel = this.page.locator('.ag-filter');
await filterPanel.waitFor({ state: 'visible' });
const filterInput = filterPanel.locator('input[type="text"]').first();
await filterInput.fill(value);
await this.page.keyboard.press('Enter');
}
async setNumberFilter(
columnName: string,
value: string,
condition: 'equals' | 'greaterThan' | 'lessThan' = 'equals',
) {
const filterPanel = await this.openFilterPanelAndWait(columnName);
await this.selectFilterOperator(condition);
const filterInput = await this.fillFilterValue(filterPanel, value, 'number');
// Wait for the value to be set before pressing Enter
await expect(filterInput).toHaveValue(value);
await this.page.keyboard.press('Enter');
}
async setBooleanFilter(columnName: string, value: boolean) {
await this.openFilterPanelAndWait(columnName);
await this.selectPickerOption(value ? 'True' : 'False');
}
async setDateFilter(
columnName: string,
value: string,
condition: 'equals' | 'greaterThan' | 'lessThan' = 'equals',
) {
const filterPanel = await this.openFilterPanelAndWait(columnName);
await this.selectFilterOperator(condition);
await this.fillFilterValue(filterPanel, value, 'date');
await this.page.keyboard.press('Enter');
}
getPagination() {
return this.page.getByTestId('data-table-content-pagination');
}
async setPageSize(size: '10' | '20' | '50') {
const pagination = this.getPagination();
const selectTrigger = pagination.locator('.el-pagination__sizes .el-select');
await selectTrigger.click();
await this.page.getByRole('option').getByText(`${size}/page`).click();
}
getCell(rowIndex: number, columnId: string) {
const cell = this.page.locator(
`[data-test-id="data-table-grid"] .ag-center-cols-container [row-index="${rowIndex}"] [col-id="${columnId}"]`,
);
return cell;
}
async getCellValue(
rowIndex: number,
columnId: string,
type: 'string' | 'number' | 'boolean' | 'date',
): Promise<string> {
const cell = this.getCell(rowIndex, columnId);
if (type === 'boolean') {
const checkbox = cell.locator('input[type="checkbox"]');
const isChecked = await checkbox.isChecked();
return isChecked ? 'true' : 'false';
} else {
const text = await cell.textContent();
return (text ?? '').trim();
}
}
async setCellValue(
rowIndex: number,
columnId: string,
value: string,
type: 'string' | 'number' | 'boolean' | 'date',
options?: { skipDoubleClick?: boolean },
) {
const cell = this.getCell(rowIndex, columnId);
if (!options?.skipDoubleClick) {
await cell.dblclick();
}
if (type === 'string') {
const input = this.page.locator('.ag-text-area-input');
await input.fill(value);
await input.press('Enter');
} else if (type === 'date') {
const input = this.page.locator('#data-table-datepicker');
await input.fill(value);
await input.press('Enter');
} else if (type === 'boolean') {
const checkbox = cell.locator('input[type="checkbox"]');
const shouldBeChecked = value === 'true' || value === '1';
if (shouldBeChecked) {
await checkbox.check();
} else {
await checkbox.uncheck();
}
await this.page.keyboard.press('Enter');
} else if (type === 'number') {
const input = cell.locator('input');
await input.fill(value);
await input.press('Enter');
}
}
async getColumnOrder(): Promise<string[]> {
const headers = this.page.getByRole('columnheader');
const count = await headers.count();
const columnData: Array<{ index: number; name: string }> = [];
for (let i = 0; i < count; i++) {
const header = headers.nth(i);
const ariaColIndex = await header.getAttribute('aria-colindex');
const textElement = header.getByTestId('data-table-column-header-text');
const textCount = await textElement.count();
if (textCount > 0 && ariaColIndex) {
// Use innerText instead of textContent to avoid getting hidden input values
const text = await textElement.innerText();
if (text) {
columnData.push({
index: parseInt(ariaColIndex, 10),
name: text.trim(),
});
}
}
}
columnData.sort((a, b) => a.index - b.index);
return columnData.map((col) => col.name);
}
async dragColumnToPosition(sourceColumnName: string, targetColumnName: string) {
const sourceColumn = this.getColumnHeaderByName(sourceColumnName);
const targetColumn = this.getColumnHeaderByName(targetColumnName);
await sourceColumn.dragTo(targetColumn);
}
getSearchInput() {
return this.page.getByTestId('data-table-search-input');
}
async search(query: string) {
const searchInput = this.getSearchInput();
await searchInput.fill(query);
// Wait for debounce
await this.page.waitForTimeout(300);
await this.page.getByText('Loading...').waitFor({ state: 'hidden' });
}
async clearSearch() {
await this.search('');
}
}
@@ -0,0 +1,91 @@
import { BasePage } from './BasePage';
import { AddResource } from './components/AddResource';
export class DataTableView extends BasePage {
readonly addResource = new AddResource(this.page);
getEmptyStateActionBox() {
return this.page.getByTestId('empty-data-table-action-box');
}
getEmptyStateActionBoxButton() {
return this.getEmptyStateActionBox().getByRole('button');
}
getNewDataTableModal() {
return this.page.getByTestId('addDataTableModal-modal');
}
getNewDataTableNameInput() {
return this.page.getByTestId('data-table-name-input-select');
}
getFromScratchOption() {
return this.page.getByTestId('create-from-scratch-option');
}
getProceedFromSelectButton() {
return this.page.getByTestId('proceed-from-select-button');
}
getDataTableCards() {
return this.page.getByTestId('data-table-card');
}
getDataTableCardByName(name: string) {
return this.getDataTableCards().filter({ hasText: name });
}
getDataTableCardActionsButton(dataTableName: string) {
return this.getDataTableCardByName(dataTableName).getByTestId('data-table-card-actions');
}
getDataTableCardAction(actionName: string) {
return this.page.getByTestId('action-toggle-dropdown').getByTestId(`action-${actionName}`);
}
getDeleteDataTableModal() {
return this.page.locator('.el-message-box').filter({ hasText: 'Delete Data table' });
}
getDeleteDataTableConfirmButton() {
return this.getDeleteDataTableModal().locator('.btn--confirm');
}
getDataTablePageSizeSelect() {
return this.page.getByTestId('resources-list-pagination').locator('.el-pagination__sizes');
}
getDataTablePageOption(pageSize: string) {
return this.page.locator('.el-select-dropdown__item').filter({ hasText: `${pageSize}/page` });
}
getPaginationNextButton() {
return this.page.getByTestId('resources-list-pagination').locator('button.btn-next');
}
async clickDataTableProjectTab() {
await this.clickByTestId('tab-project-data-tables');
}
async clickEmptyStateButton() {
await this.getEmptyStateActionBoxButton().click();
}
async clickAddDataTableAction(fromDataTableTab: boolean = true) {
await this.addResource.dataTable(fromDataTableTab);
}
async clickDataTableCardActionsButton(dataTableName: string) {
await this.getDataTableCardActionsButton(dataTableName).click();
}
async clickDeleteDataTableConfirmButton() {
await this.getDeleteDataTableConfirmButton().click();
}
async selectDataTablePageSize(pageSize: string) {
await this.getDataTablePageSizeSelect().click();
await this.getDataTablePageOption(pageSize).click();
}
}
@@ -0,0 +1,25 @@
import { BasePage } from './BasePage';
export class DemoPage extends BasePage {
async goto(theme?: 'dark' | 'light') {
const query = theme ? `?theme=${theme}` : '';
await this.page.goto('/workflows/demo' + query);
await this.page.getByTestId('canvas-background').waitFor({ state: 'visible' });
}
/**
* Import a workflow into the demo page
* @param workflow - The workflow to import
*/
async importWorkflow(workflow: object) {
const OPEN_WORKFLOW = { command: 'openWorkflow', workflow };
await this.page.evaluate((message) => {
console.log('Posting message:', JSON.stringify(message));
window.postMessage(JSON.stringify(message), '*');
}, OPEN_WORKFLOW);
}
getBody() {
return this.page.locator('body');
}
}
@@ -0,0 +1,123 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { LogsPanel } from './components/LogsPanel';
export class ExecutionsPage extends BasePage {
readonly logsPanel = new LogsPanel(this.getPreviewIframe().getByTestId('logs-panel'));
async clickDebugInEditorButton(): Promise<void> {
await this.clickButtonByName('Debug in editor');
}
async clickCopyToEditorButton(): Promise<void> {
await this.clickButtonByName('Copy to editor');
}
getExecutionItems(): Locator {
return this.page.locator('div.execution-card');
}
getLastExecutionItem(): Locator {
const executionItems = this.getExecutionItems();
return executionItems.nth(0);
}
getAutoRefreshButton() {
return this.page.getByTestId('auto-refresh-checkbox');
}
getPreviewIframe() {
return this.page.getByTestId('workflow-preview-iframe').contentFrame();
}
async clickLastExecutionItem(): Promise<void> {
const executionItem = this.getLastExecutionItem();
await executionItem.click();
}
/**
* Handle the pinned nodes confirmation dialog.
* @param action - The action to take.
*/
async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise<void> {
await this.page.getByRole('button', { name: action }).click();
}
getExecutionsList(): Locator {
return this.page.getByTestId('current-executions-list');
}
getExecutionsSidebar(): Locator {
return this.page.getByTestId('executions-sidebar');
}
getExecutionsEmptyList(): Locator {
return this.page.getByTestId('execution-list-empty');
}
getSuccessfulExecutionItems(): Locator {
return this.page.locator('[data-test-execution-status="success"]');
}
getFailedExecutionItems(): Locator {
return this.page.locator('[data-test-execution-status="error"]');
}
/**
* Scroll the executions list to the bottom to trigger lazy loading
*/
async scrollExecutionsListToBottom(): Promise<void> {
await this.getExecutionsList().evaluate((el) => el.scrollTo(0, el.scrollHeight));
}
/**
* Get error notifications in the preview iframe
*/
getErrorNotificationsInPreview(): Locator {
return this.getPreviewIframe().locator('.el-notification:has(.el-notification--error)');
}
getFirstExecutionItem(): Locator {
return this.getExecutionItems().first();
}
async deleteExecutionInPreview(): Promise<void> {
await this.page.getByTestId('execution-preview-delete-button').click();
await this.page.locator('button.btn--confirm').click();
}
// Filter methods
getFilterButton(): Locator {
return this.page.getByTestId('executions-filter-button');
}
getFilterForm(): Locator {
return this.page.getByTestId('execution-filter-form');
}
getStatusSelect(): Locator {
return this.page.getByTestId('executions-filter-status-select');
}
async openFilter(): Promise<void> {
await this.getFilterButton().click();
}
getFilterBadge(): Locator {
return this.page.getByTestId('execution-filter-badge');
}
getFilterResetButton(): Locator {
return this.page.getByTestId('executions-filter-reset-button');
}
async resetFilter(): Promise<void> {
await this.getFilterResetButton().click();
}
async selectFilterStatus(status: string): Promise<void> {
await this.getStatusSelect().getByRole('combobox').click();
await this.page.getByRole('option', { name: status }).click();
}
}
@@ -0,0 +1,48 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class InteractionsPage extends BasePage {
constructor(page: Page) {
super(page);
}
async precisionDragToTarget(
sourceLocator: Locator,
targetLocator: Locator,
position: 'top' | 'center' | 'bottom' = 'bottom',
): Promise<void> {
await expect(sourceLocator).toBeVisible();
await expect(targetLocator).toBeVisible();
const targetBox = await targetLocator.boundingBox();
if (!targetBox) {
throw new Error('Could not get bounding box for target element');
}
let dropPosition: { x: number; y: number };
switch (position) {
case 'top':
dropPosition = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + 2 };
break;
case 'center':
dropPosition = {
x: targetBox.x + targetBox.width / 2,
y: targetBox.y + targetBox.height / 2,
};
break;
case 'bottom':
dropPosition = {
x: targetBox.x + targetBox.width / 2,
y: targetBox.y + targetBox.height - 2,
};
break;
}
await sourceLocator.hover();
await this.page.mouse.down();
await this.page.mouse.move(dropPosition.x, dropPosition.y);
await this.page.mouse.up();
}
}
@@ -0,0 +1,45 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for the Keycloak login page.
* Used when testing OIDC authentication flows.
*/
export class KeycloakLoginPage extends BasePage {
getUsernameField(): Locator {
return this.page.locator('#username');
}
getPasswordField(): Locator {
return this.page.locator('#password');
}
getLoginButton(): Locator {
return this.page.locator('#kc-login');
}
async fillUsername(username: string): Promise<void> {
await this.getUsernameField().fill(username);
}
async fillPassword(password: string): Promise<void> {
await this.getPasswordField().fill(password);
}
async clickLogin(): Promise<void> {
await this.getLoginButton().click();
}
/**
* Complete Keycloak login flow
* @param email - User email/username
* @param password - User password
*/
async login(email: string, password: string): Promise<void> {
await this.getUsernameField().waitFor({ state: 'visible', timeout: 10000 });
await this.fillUsername(email);
await this.fillPassword(password);
await this.clickLogin();
}
}
@@ -0,0 +1,51 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for the MFA login page that appears after entering email/password when MFA is enabled.
*/
export class MfaLoginPage extends BasePage {
getForm(): Locator {
return this.page.getByTestId('mfa-login-form');
}
getMfaCodeField(): Locator {
return this.getForm().locator('input[name="mfaCode"]');
}
getMfaRecoveryCodeField(): Locator {
return this.getForm().locator('input[name="mfaRecoveryCode"]');
}
async fillMfaCode(code: string): Promise<void> {
await this.getMfaCodeField().fill(code);
}
async fillMfaRecoveryCode(recoveryCode: string): Promise<void> {
await this.getMfaRecoveryCodeField().fill(recoveryCode);
}
async clickEnterRecoveryCode(): Promise<void> {
await this.clickByTestId('mfa-enter-recovery-code-button');
}
/**
* Fill MFA code and submit the form
* @param code - The MFA token to submit
*/
async submitMfaCode(code: string): Promise<void> {
await this.fillMfaCode(code);
// Form auto-submits
}
/**
* Switch to recovery code mode, fill recovery code and submit
* @param recoveryCode - The recovery code to submit
*/
async submitMfaRecoveryCode(recoveryCode: string): Promise<void> {
await this.clickEnterRecoveryCode();
await this.fillMfaRecoveryCode(recoveryCode);
// Form auto-submits
}
}
@@ -0,0 +1,44 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for the MFA setup modal that appears when enabling two-factor authentication.
*/
export class MfaSetupModal extends BasePage {
getModalContainer(): Locator {
return this.page.getByTestId('mfaSetup-modal');
}
getTokenInput(): Locator {
return this.page.getByTestId('mfa-token-input');
}
getDownloadRecoveryCodesButton(): Locator {
return this.page.getByTestId('mfa-recovery-codes-button');
}
async fillToken(token: string): Promise<void> {
await this.getTokenInput().fill(token);
}
async clickCopySecretToClipboard(): Promise<void> {
await this.clickByTestId('mfa-secret-button');
}
async clickDownloadRecoveryCodes(): Promise<void> {
await this.clickByTestId('mfa-recovery-codes-button');
}
async clickSave(): Promise<void> {
await this.getModalContainer().getByTestId('mfa-save-button').click();
}
/**
* Wait for the MFA setup modal to be hidden from view
*/
async waitForHidden(): Promise<void> {
await expect(this.getModalContainer()).toBeHidden();
}
}
@@ -0,0 +1,879 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { RunDataPanel } from './components/RunDataPanel';
import { ClipboardHelper } from '../helpers/ClipboardHelper';
import { NodeParameterHelper } from '../helpers/NodeParameterHelper';
import { EditFieldsNode } from './nodes/EditFieldsNode';
import { locatorByIndex } from '../utils/index-helper';
export class NodeDetailsViewPage extends BasePage {
readonly setupHelper: NodeParameterHelper;
readonly editFields: EditFieldsNode;
readonly clipboard: ClipboardHelper;
readonly inputPanel = new RunDataPanel(this.page.getByTestId('ndv-input-panel'));
readonly outputPanel = new RunDataPanel(this.page.getByTestId('output-panel'));
constructor(page: Page) {
super(page);
this.setupHelper = new NodeParameterHelper(this);
this.editFields = new EditFieldsNode(page);
this.clipboard = new ClipboardHelper(page);
}
getNodeCredentialsSelect() {
return this.page.getByTestId('node-credentials-select');
}
credentialDropdownCreateNewCredential() {
return this.page.getByText('Create new credential');
}
getCredentialOptionByText(text: string) {
return this.page.getByText(text);
}
getCredentialDropdownOptions() {
return this.page.getByRole('option');
}
getCredentialSelect() {
return this.page.getByRole('combobox', { name: 'Select Credential' });
}
getCredentialSelectInput() {
return this.getNodeCredentialsSelect().locator('input');
}
async clickBackToCanvasButton() {
await this.clickByTestId('ndv-close-button');
}
getParameterByLabel(labelName: string) {
return this.getContainer().locator('.parameter-item').filter({ hasText: labelName });
}
async fillParameterInput(labelName: string, value: string, index?: number) {
await locatorByIndex(this.getParameterByLabel(labelName), index)
.getByTestId('parameter-input-field')
.fill(value);
}
async selectWorkflowResource(createItemText: string, searchText: string = '') {
await this.clickByTestId('rlc-input');
if (searchText) {
await this.fillByTestId('rlc-search', searchText);
}
await this.clickByText(createItemText);
}
async togglePinData() {
await this.clickByTestId('ndv-pin-data');
}
async close() {
await this.clickBackToCanvasButton();
}
async addFixedCollectionItem() {
await this.clickByTestId('fixed-collection-add');
}
async execute() {
await this.clickByTestId('node-execute-button');
}
getOutputPanel() {
return this.page.getByTestId('output-panel');
}
getContainer() {
return this.page.getByTestId('ndv');
}
getInputPanel() {
return this.page.getByTestId('ndv-input-panel');
}
getParameterExpressionPreviewValue() {
return this.page.getByTestId('parameter-expression-preview-value');
}
getParameterExpressionPreviewOutput() {
return this.page.getByTestId('parameter-expression-preview-output');
}
getInlineExpressionEditorPreview() {
return this.page.getByTestId('inline-expression-editor-output');
}
async activateParameterExpressionEditor(parameterName: string) {
const parameterInput = this.getParameterInput(parameterName);
await parameterInput.click();
await this.page
.getByTestId(`${parameterName}-parameter-input-options-container`)
.getByTestId('radio-button-expression')
.click();
}
getEditPinnedDataButton() {
return this.page.getByTestId('ndv-edit-pinned-data');
}
getRunDataPaneHeader() {
return this.page.getByTestId('run-data-pane-header');
}
getOutputDataContainer() {
return this.getOutputPanel().getByTestId('ndv-data-container');
}
async setPinnedData(data: object | string) {
const pinnedData = typeof data === 'string' ? data : JSON.stringify(data);
await this.getEditPinnedDataButton().click();
const editor = this.outputPanel.get().locator('[contenteditable="true"]');
await editor.waitFor();
await editor.click();
await editor.fill(pinnedData);
await this.savePinnedData();
}
async savePinnedData() {
await this.getRunDataPaneHeader().locator('button:visible').filter({ hasText: 'Save' }).click();
}
getAssignmentCollectionAdd(paramName: string) {
return this.page
.getByTestId(`assignment-collection-${paramName}`)
.getByTestId('assignment-collection-drop-area');
}
getAssignmentCollectionDropArea() {
return this.page.getByTestId('assignment-collection-drop-area');
}
async clickAssignmentCollectionDropArea() {
await this.getAssignmentCollectionDropArea().click();
}
getAssignmentValue(paramName: string) {
return this.page
.getByTestId(`assignment-collection-${paramName}`)
.getByTestId('assignment-value');
}
/**
* Get the inline expression editor input
* @param parameterName - The name of the parameter to get the inline expression editor input for. If not set, gets the first inline expression editor input on page
* @returns The inline expression editor input
*/
getInlineExpressionEditorInput(parameterName?: string) {
if (parameterName) {
const parameterInput = this.getParameterInput(parameterName);
return parameterInput.getByTestId('inline-expression-editor-input');
}
return this.page.getByTestId('inline-expression-editor-input');
}
getNodeParameters() {
return this.page.getByTestId('node-parameters');
}
getParameterInputHint() {
return this.page.getByTestId('parameter-input-hint');
}
getInputLabel() {
return this.page.getByTestId('input-label');
}
getNthParameter(index: number) {
return this.getNodeParameters().locator('.parameter-item').nth(index);
}
getCredentialsLabel() {
return this.page.getByTestId('credentials-label');
}
async makeWebhookRequest(path: string) {
return await this.page.request.get(path);
}
async clearExpressionEditor(parameterName?: string) {
const editor = this.getInlineExpressionEditorInput(parameterName);
await editor.click();
await this.page.keyboard.press('ControlOrMeta+A');
await this.page.keyboard.press('Delete');
}
async typeInExpressionEditor(text: string, parameterName?: string) {
const editor = this.getInlineExpressionEditorInput(parameterName);
await editor.click();
await editor.type(text);
}
getParameterInput(parameterName: string, index?: number) {
return locatorByIndex(this.page.getByTestId(`parameter-input-${parameterName}`), index);
}
getParameterInputField(parameterName: string, index?: number) {
return this.getParameterInput(parameterName, index).locator('input');
}
getParameterEditor(parameterName: string, index?: number) {
// CodeMirror editor
return this.getParameterInput(parameterName, index).locator('.cm-content');
}
async selectOptionInParameterDropdown(parameterName: string, optionText: string, index = 0) {
await this.clickParameterDropdown(parameterName, index);
await this.selectFromVisibleDropdown(optionText);
}
async clickParameterDropdown(parameterName: string, index = 0): Promise<void> {
await locatorByIndex(this.page.getByTestId(`parameter-input-${parameterName}`), index).click();
}
async selectFromVisibleDropdown(optionText: string): Promise<void> {
await this.page.getByRole('option', { name: optionText }).click();
}
async fillParameterInputByName(parameterName: string, value: string, index = 0): Promise<void> {
const input = this.getParameterInputField(parameterName, index);
await input.click();
await input.fill(value);
}
async clickParameterOptions(): Promise<void> {
await this.page.getByTestId('collection-parameter-add').click();
}
async addParameterOptionByName(optionName: string): Promise<void> {
await this.clickParameterOptions();
await this.selectFromVisibleDropdown(optionName);
}
async clickFloatingNode(nodeName: string) {
await this.page.locator(`[data-test-id="floating-node"][data-node-name="${nodeName}"]`).click();
}
async executePrevious() {
await this.clickByTestId('execute-previous-node');
}
async clickAskAiTab() {
await this.page.locator('#tab-ask-ai').click();
}
getAskAiTabPanel() {
return this.page.getByTestId('code-node-tab-ai');
}
getAskAiCtaButton() {
return this.page.getByTestId('ask-ai-cta');
}
getAskAiPromptInput() {
return this.page.getByTestId('ask-ai-prompt-input');
}
getAskAiPromptCounter() {
return this.page.getByTestId('ask-ai-prompt-counter');
}
getAskAiCtaTooltipNoInputData() {
return this.page.getByTestId('ask-ai-cta-tooltip-no-input-data');
}
getAskAiCtaTooltipNoPrompt() {
return this.page.getByTestId('ask-ai-cta-tooltip-no-prompt');
}
getAskAiCtaTooltipPromptTooShort() {
return this.page.getByTestId('ask-ai-cta-tooltip-prompt-too-short');
}
getCodeTabPanel() {
return this.page.getByTestId('code-node-tab-code');
}
getCodeTab() {
return this.page.locator('#tab-code');
}
getCodeEditor() {
return this.getParameterInput('jsCode').locator('.cm-content');
}
getLintErrors() {
return this.getParameterInput('jsCode').locator('.cm-lintRange-error');
}
getLintTooltip() {
return this.page.locator('.cm-tooltip-lint');
}
getPlaceholderText(text: string) {
return this.page.getByText(text);
}
getHeyAiText() {
return this.page.locator('text=Hey AI, generate JavaScript');
}
getCodeGenerationCompletedText() {
return this.page.locator('text=Code generation completed');
}
getErrorMessageText(message: string) {
return this.page.locator(`text=${message}`);
}
async setParameterDropdown(parameterName: string, optionText: string): Promise<void> {
await this.getParameterInput(parameterName).click();
await this.page.getByRole('option', { name: optionText }).click();
}
async changeNodeOperation(operationName: string): Promise<void> {
await this.setParameterDropdown('operation', operationName);
}
async setParameterInput(parameterName: string, value: string): Promise<void> {
await this.fillParameterInputByName(parameterName, value);
}
async setParameterSwitch(parameterName: string, enabled: boolean): Promise<void> {
const switchElement = this.getParameterInput(parameterName).locator('.el-switch');
const isCurrentlyEnabled = (await switchElement.getAttribute('aria-checked')) === 'true';
if (isCurrentlyEnabled !== enabled) {
await switchElement.click();
}
}
getAssignmentCollectionContainer(paramName: string) {
return this.page.getByTestId(`assignment-collection-${paramName}`);
}
async selectInputNode(nodeName: string) {
const inputSelect = this.inputPanel.getNodeInputOptions();
await inputSelect.click();
await this.page.getByRole('option', { name: nodeName }).click();
}
getAssignmentName(paramName: string, index = 0) {
return this.getAssignmentCollectionContainer(paramName)
.getByTestId('assignment')
.nth(index)
.getByTestId('assignment-name');
}
getResourceMapperFieldsContainer() {
return this.page.getByTestId('mapping-fields-container');
}
getResourceMapperParameterInputs() {
return this.getResourceMapperFieldsContainer().getByTestId('parameter-input');
}
getResourceMapperSelectColumn() {
return this.page.getByTestId('matching-column-select');
}
getResourceMapperColumnsOptionsButton() {
return this.page.getByTestId('columns-parameter-input-options-container');
}
getResourceMapperRemoveFieldButton(fieldName: string) {
return this.page.getByTestId(`remove-field-button-${fieldName}`);
}
getResourceMapperRemoveAllFieldsOption() {
return this.page.getByTestId('action-removeAllFields');
}
async refreshResourceMapperColumns() {
const selectColumn = this.getResourceMapperSelectColumn();
await selectColumn.hover();
await selectColumn.getByTestId('action-toggle').click();
await expect(this.getVisiblePopper().getByTestId('action-refreshFieldList')).toBeVisible();
await this.getVisiblePopper().getByTestId('action-refreshFieldList').click();
}
getAddValueButton() {
return this.getNodeParameters().locator('input[placeholder*="Add Value"]');
}
getParameterSwitch(parameterName: string) {
return this.getParameterInput(parameterName).locator('.el-switch');
}
getParameterTextInput(parameterName: string) {
return this.getParameterInput(parameterName).locator('input[type="text"]');
}
/**
* Get the N8nInput container element for a parameter.
* Use this for checking border styles since N8nInput has border on container, not input.
*/
getParameterInputContainer(parameterName: string) {
return this.getParameterInput(parameterName).locator('input[type="text"]').locator('..');
}
getInlineExpressionEditorContent() {
return this.getInlineExpressionEditorInput().locator('.cm-content');
}
getInlineExpressionEditorOutput() {
return this.page.getByTestId('inline-expression-editor-output');
}
getInlineExpressionEditorItemInput() {
return this.page.getByTestId('inline-expression-editor-item-input').locator('input');
}
getInlineExpressionEditorItemPrevButton() {
return this.page.getByTestId('inline-expression-editor-item-prev');
}
getInlineExpressionEditorItemNextButton() {
return this.page.getByTestId('inline-expression-editor-item-next');
}
async expressionSelectNextItem() {
await this.getInlineExpressionEditorItemNextButton().click();
}
async expressionSelectPrevItem() {
await this.getInlineExpressionEditorItemPrevButton().click();
}
async openExpressionEditorModal(parameterName: string) {
await this.activateParameterExpressionEditor(parameterName);
const parameter = this.getParameterInput(parameterName);
await parameter.click();
const expander = parameter.getByTestId('expander');
await expander.click();
await this.page.getByTestId('expression-modal-input').waitFor({ state: 'visible' });
}
getExpressionEditorModalInput() {
return this.page.getByTestId('expression-modal-input').getByRole('textbox');
}
async fillExpressionEditorModalInput(text: string) {
const input = this.getExpressionEditorModalInput();
await input.clear();
await input.click();
await input.fill(text);
}
getExpressionEditorModalOutput() {
return this.page.getByTestId('expression-modal-output');
}
getAddFieldToSortByButton() {
return this.getNodeParameters().getByText('Add Field To Sort By');
}
async toggleCodeMode(switchTo: 'Run Once for Each Item' | 'Run Once for All Items') {
await this.getParameterInput('mode').click();
await this.page.getByRole('option', { name: switchTo }).click();
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(2500);
}
getOutputPagination() {
return this.outputPanel.get().getByTestId('ndv-data-pagination');
}
getOutputPaginationPages() {
return this.getOutputPagination().locator('.el-pager li.number');
}
async navigateToOutputPage(pageNumber: number): Promise<void> {
const pages = this.getOutputPaginationPages();
await pages.nth(pageNumber - 1).click();
}
async setParameterInputValue(parameterName: string, value: string): Promise<void> {
const input = this.getParameterInput(parameterName).locator('input');
await input.clear();
await input.fill(value);
}
/** Waits for parameter input debounce (100ms) to flush. */
async waitForDebounce(): Promise<void> {
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(150);
}
getRunDataInfoCallout() {
return this.page.getByTestId('run-data-callout');
}
async checkParameterCheckboxInputByName(name: string): Promise<void> {
const checkbox = this.getParameterInput(name).locator('.el-switch.switch-input');
await checkbox.click();
}
// Credentials modal helpers
async clickCreateNewCredential(eq: number = 0): Promise<void> {
await this.page.getByTestId('node-credentials-select').nth(eq).click();
await this.page.getByTestId('node-credentials-select-item-new').nth(eq).click();
}
// Run selector and linking helpers
getInputRunSelector() {
return this.page.locator('[data-test-id="ndv-input-panel"] [data-test-id="run-selector"]');
}
getOutputRunSelector() {
return this.page.locator('[data-test-id="output-panel"] [data-test-id="run-selector"]');
}
getInputRunSelectorInput() {
return this.getInputRunSelector().locator('input');
}
async toggleInputRunLinking(): Promise<void> {
await this.getInputPanel().getByTestId('link-run').click();
}
getNodeRunErrorMessage() {
return this.page.getByTestId('node-error-message');
}
getNodeRunErrorDescription() {
return this.page.getByTestId('node-error-description');
}
async isOutputRunLinkingEnabled() {
const linkButton = this.outputPanel.getLinkRun();
const classList = await linkButton.getAttribute('class');
return classList?.includes('linked') ?? false;
}
async ensureOutputRunLinking(shouldBeLinked: boolean = true) {
const isLinked = await this.isOutputRunLinkingEnabled();
if (isLinked !== shouldBeLinked) {
await this.outputPanel.getLinkRun().click();
}
}
async changeInputRunSelector(value: string) {
const selector = this.inputPanel.getRunSelector();
await selector.click();
await this.page.getByRole('option', { name: value }).click();
}
async changeOutputRunSelector(value: string) {
const selector = this.outputPanel.getRunSelector();
await selector.click();
await this.page.getByRole('option', { name: value }).click();
}
async getInputRunSelectorValue() {
return await this.inputPanel.getRunSelectorInput().inputValue();
}
async getOutputRunSelectorValue() {
return await this.outputPanel.getRunSelectorInput().inputValue();
}
getExecuteNodeButton() {
return this.page.getByTestId('node-execute-button');
}
getTriggerPanelExecuteButton() {
return this.page.getByTestId('trigger-execute-button');
}
async openCodeEditorFullscreen() {
await this.page.getByTestId('code-editor-fullscreen-button').click();
}
getCodeEditorFullscreen() {
return this.page.getByTestId('code-editor-fullscreen').locator('.cm-content');
}
getCodeEditorDialog() {
return this.page.locator('.el-dialog');
}
async closeCodeEditorDialog() {
await this.getCodeEditorDialog().locator('.el-dialog__close').click();
}
getNodeRunSuccessIndicator() {
return this.page.getByTestId('node-run-status-success');
}
getNodeRunErrorIndicator() {
return this.page.getByTestId('node-run-status-danger');
}
getNodeRunTooltipIndicator() {
return this.page.getByTestId('node-run-info');
}
getStaleNodeIndicator() {
return this.page.getByTestId('node-run-info-stale');
}
getExecuteStepButton() {
return this.page.getByTestId('node-execute-button');
}
async clickExecuteStep() {
await this.getExecuteStepButton().click();
}
async openSettings() {
await this.page.getByTestId('tab-settings').click();
}
getNodeVersion() {
return this.page.getByTestId('node-version');
}
async searchOutputData(searchTerm: string) {
// Focus the search input to expand it (it has opacity:0 when collapsed)
const searchInput = this.outputPanel.getSearchInput();
await searchInput.focus();
// Wait for the search input to become visible after focus triggers expansion
await searchInput.waitFor({ state: 'visible' });
await searchInput.fill(searchTerm);
}
/**
* Type multiple values into the first available text parameter field
* Useful for testing multiple parameter changes
*/
async fillFirstAvailableTextParameterMultipleTimes(values: string[]) {
const firstTextField = this.getNodeParameters().locator('input[type="text"]').first();
await firstTextField.click();
for (const value of values) {
await firstTextField.fill(value);
}
}
getFloatingNodeByPosition(position: 'inputMain' | 'outputMain' | 'inputSub' | 'outputSub') {
return this.page.locator(`[data-node-placement="${position}"]`);
}
getNodeNameContainer() {
return this.getContainer().getByTestId('node-title-container');
}
async clickFloatingNodeByPosition(
position: 'inputMain' | 'outputMain' | 'inputSub' | 'outputSub',
) {
// eslint-disable-next-line playwright/no-force-option
await this.getFloatingNodeByPosition(position).click({ force: true });
}
async navigateToNextFloatingNodeWithKeyboard() {
await this.page.keyboard.press('Shift+Meta+Alt+ArrowRight');
}
async navigateToPreviousFloatingNodeWithKeyboard() {
await this.page.keyboard.press('Shift+Meta+Alt+ArrowLeft');
}
async verifyFloatingNodeName(
position: 'inputMain' | 'outputMain' | 'inputSub' | 'outputSub',
nodeName: string,
index: number = 0,
) {
const floatingNode = this.getFloatingNodeByPosition(position).nth(index);
await expect(floatingNode).toHaveAttribute('data-node-name', nodeName);
}
async getFloatingNodeCount(position: 'inputMain' | 'outputMain' | 'inputSub' | 'outputSub') {
return await this.getFloatingNodeByPosition(position).count();
}
getAddSubNodeButton(connectionType: string, index: number = 0) {
return this.page.getByTestId(`add-subnode-${connectionType}-${index}`);
}
getNodesWithIssues() {
return this.page.locator('[class*="hasIssues"]');
}
async connectAISubNode(connectionType: string, nodeName: string, index: number = 0) {
await this.getAddSubNodeButton(connectionType, index).click();
await this.page.getByText(nodeName).click();
await this.getFloatingNode().click();
}
getFloatingNode() {
return this.page.getByTestId('floating-node');
}
async addItemToFixedCollection(collectionName: string) {
await this.page.getByTestId(`fixed-collection-${collectionName}`).click();
}
getFixedCollectionPropertyPicker(index?: number) {
const pickers = this.getNodeParameters().getByTestId('fixed-collection-add-property');
return index !== undefined ? pickers.nth(index) : pickers.first();
}
async addFixedCollectionProperty(propertyName: string, index?: number) {
const picker = this.getFixedCollectionPropertyPicker(index);
await picker.locator('input').click();
await this.page.getByRole('option', { name: propertyName, exact: true }).click();
}
getParameterItemWithText(text: string) {
return this.page.getByTestId('parameter-item').getByText(text);
}
getParameterInputWithIssues(parameterPath: string) {
return this.page.locator(
`[data-test-id="parameter-input-field"][title*="${parameterPath}"][title*="has issues"]`,
);
}
getResourceLocator(paramName: string) {
return this.page.getByTestId(`resource-locator-${paramName}`);
}
getResourceLocatorInput(paramName: string) {
return this.getResourceLocator(paramName).getByTestId('rlc-input-container');
}
getResourceLocatorModeSelector(paramName: string) {
return this.getResourceLocator(paramName).getByTestId('rlc-mode-selector');
}
getResourceLocatorModeSelectorInput(paramName: string) {
return this.getResourceLocatorModeSelector(paramName).locator('input');
}
getResourceLocatorErrorMessage(paramName: string) {
return this.getResourceLocator(paramName).getByTestId('rlc-error-container');
}
getResourceLocatorAddCredentials(paramName: string) {
return this.getResourceLocatorErrorMessage(paramName).locator('a');
}
getResourceLocatorSearch(paramName: string) {
return this.getResourceLocator(paramName).getByTestId('rlc-search');
}
getParameterInputIssues() {
return this.page.getByTestId('parameter-issues');
}
getResourceLocatorItems() {
return this.page.getByTestId('rlc-item');
}
getAddResourceItem() {
return this.page.getByTestId('rlc-item-add-resource');
}
getExpressionModeToggle(index: number = 1) {
return this.page.getByTestId('radio-button-expression').nth(index);
}
async setRLCValue(paramName: string, value: string, index = 0): Promise<void> {
await this.getResourceLocatorModeSelector(paramName).click();
await this.page.getByTestId('mode-id').nth(index).click();
const input = this.getResourceLocatorInput(paramName).locator('input');
await input.fill(value);
}
async clickNodeCreatorInsertOneButton() {
await this.page.getByText('Insert one').click();
}
getInputSelect() {
return this.page.getByTestId('ndv-input-select').locator('input');
}
getOutputRunSelectorInput() {
return this.getOutputPanel().locator('[data-test-id="run-selector"] input');
}
getAiOutputModeToggle() {
return this.page.getByTestId('ai-output-mode-select');
}
getCredentialLabel(credentialType: string) {
return this.page.getByText(credentialType);
}
getFilterComponent(paramName: string) {
return this.page.getByTestId(`filter-${paramName}`);
}
getFilterConditions(paramName: string) {
return this.getFilterComponent(paramName).getByTestId('filter-condition');
}
getFilterConditionLeft(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-condition-left').nth(index);
}
getFilterConditionOperator(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-operator-select').nth(index);
}
getFilterConditionRemove(paramName: string, index: number = 0) {
return this.getFilterComponent(paramName).getByTestId('filter-remove-condition').nth(index);
}
getFilterConditionAdd(paramName: string) {
return this.getFilterComponent(paramName).getByTestId('filter-add-condition');
}
async addFilterCondition(paramName: string) {
await this.getFilterConditionAdd(paramName).click();
}
async removeFilterCondition(paramName: string, index: number) {
await this.getFilterConditionRemove(paramName, index).click();
}
getWebhookTestEvent() {
return this.page.getByText('Listening for test event');
}
getAddOptionDropdown() {
return this.page.getByRole('combobox', { name: 'Add option' });
}
async setInvalidExpression({
fieldName,
invalidExpression,
}: {
fieldName: string;
invalidExpression?: string;
}): Promise<void> {
await this.activateParameterExpressionEditor(fieldName);
const editor = this.getInlineExpressionEditorInput(fieldName);
await editor.click();
await this.page.keyboard.type(invalidExpression ?? '{{ =()');
}
/**
* Opens a resource locator dropdown for a given parameter
* @param paramName - The parameter name for the resource locator
*/
async openResourceLocator(paramName: string): Promise<void> {
await this.getResourceLocator(paramName).waitFor({ state: 'visible' });
await this.getResourceLocatorInput(paramName).click();
}
}
@@ -0,0 +1,151 @@
import type { Locator, Page } from '@playwright/test';
export class NotificationsPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the main container locator for a notification by searching in its title text.
* @param text The text or a regular expression to find within the notification's title.
* @returns A Locator for the notification container element.
*/
getNotificationByTitle(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({
has: this.page.locator('.el-notification__title').filter({ hasText: text }),
});
}
/**
* Gets the main container locator for a notification by searching in its content/body text.
* This is useful for finding notifications where the detailed message is in the content
* rather than the title (e.g., error messages with detailed descriptions).
* @param text The text or a regular expression to find within the notification's content.
* @returns A Locator for the notification container element.
*/
getNotificationByContent(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({
has: this.page.locator('.el-notification__content').filter({ hasText: text }),
});
}
/**
* Gets the main container locator for a notification by searching in both title and content.
* This is the most flexible method as it will find notifications regardless of whether
* the text appears in the title or content section.
* @param text The text or a regular expression to find within the notification's title or content.
* @returns A Locator for the notification container element.
*/
getNotificationByTitleOrContent(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({ hasText: text });
}
/**
* Clicks the close button on the FIRST notification matching the text.
* Fast execution with short timeouts for snappy notifications.
* @param text The text of the notification to close.
* @param options Optional configuration
*/
async closeNotificationByText(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 2000 } = options;
try {
const notification = this.getNotificationByTitle(text).first();
await notification.waitFor({ state: 'visible', timeout });
const closeBtn = notification.locator('.el-notification__closeBtn');
await closeBtn.click({ timeout: 500 });
// Quick check that it's gone - don't wait long
await notification.waitFor({ state: 'hidden', timeout: 1000 });
return true;
} catch (error) {
return false;
}
}
/**
* Wait for a notification to appear with specific text.
* Reasonable timeout for waiting, but still faster than before.
* @param text The text to search for in notification title.
* @param options Optional configuration
*/
async waitForNotification(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 5000 } = options;
try {
const notification = this.getNotificationByTitle(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
// Wait for notification and then close it
async waitForNotificationAndClose(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 3000 } = options;
const isVisible = await this.waitForNotification(text, { timeout });
if (!isVisible) {
return false;
}
return await this.closeNotificationByText(text, { timeout });
}
/**
* Get all visible notification texts.
* @returns Array of notification title texts
*/
async getAllNotificationTexts(): Promise<string[]> {
try {
const titles = this.page.getByRole('alert').locator('.el-notification__title');
return await titles.allTextContents();
} catch {
return [];
}
}
/**
* Nuclear option: Close everything as fast as possible.
* No waiting, no error handling, just close and move on.
*/
async quickCloseAll(): Promise<void> {
try {
const closeButtons = this.page.locator('.el-notification__closeBtn');
const count = await closeButtons.count();
for (let i = 0; i < count; i++) {
try {
await closeButtons.nth(i).click({ timeout: 100 });
} catch {
// Continue silently
}
}
} catch {
// Silent fail
}
}
getErrorNotifications(): Locator {
return this.page.locator('.el-notification:has(.el-notification--error)');
}
getSuccessNotifications(): Locator {
return this.page.locator('.el-notification:has(.el-notification--success)');
}
getWarningNotifications(): Locator {
return this.page.locator('.el-notification:has(.el-notification--warning)');
}
}
@@ -0,0 +1,57 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
export class NpsSurveyPage extends BasePage {
constructor(page: Page) {
super(page);
}
getNpsSurveyModal(): Locator {
return this.page.getByTestId('nps-survey-modal');
}
getNpsSurveyRatings(): Locator {
return this.page.getByTestId('nps-survey-ratings');
}
getNpsSurveyFeedback(): Locator {
return this.page.getByTestId('nps-survey-feedback');
}
getNpsSurveySubmitButton(): Locator {
return this.page.getByTestId('nps-survey-feedback-button');
}
getNpsSurveyCloseButton(): Locator {
return this.getNpsSurveyModal().locator('button.el-drawer__close-btn');
}
getRatingButton(rating: number): Locator {
return this.getNpsSurveyRatings().locator('button').nth(rating);
}
getFeedbackTextarea(): Locator {
return this.getNpsSurveyFeedback().locator('textarea');
}
async clickRating(rating: number): Promise<void> {
await this.getRatingButton(rating).click();
}
async fillFeedback(feedback: string): Promise<void> {
await this.getFeedbackTextarea().fill(feedback);
}
async clickSubmitButton(): Promise<void> {
await this.getNpsSurveySubmitButton().click();
}
async closeSurvey(): Promise<void> {
await this.getNpsSurveyCloseButton().click();
}
async getRatingButtonCount(): Promise<number> {
return await this.getNpsSurveyRatings().locator('button').count();
}
}
@@ -0,0 +1,113 @@
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class ProjectSettingsPage extends BasePage {
async fillProjectName(name: string) {
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
}
async fillProjectDescription(description: string) {
await this.page
.getByTestId('project-settings-description-input')
.locator('textarea')
.fill(description);
}
async clickSaveButton() {
await Promise.all([
this.waitForRestResponse(/\/rest\/projects\/[^/]+$/, 'PATCH'),
this.clickButtonByName('Save'),
]);
}
async clickCancelButton() {
await this.page.getByTestId('project-settings-cancel-button').click();
}
getSaveButton() {
return this.page.getByTestId('project-settings-save-button');
}
getCancelButton() {
return this.page.getByTestId('project-settings-cancel-button');
}
getDeleteButton() {
return this.page.getByTestId('project-settings-delete-button');
}
getMembersSearchInput() {
return this.page.getByPlaceholder('Add users...');
}
getRoleDropdownFor(email: string) {
return this.getMembersTable()
.locator('tr')
.filter({ hasText: email })
.getByTestId('project-member-role-dropdown');
}
getMembersTable() {
return this.page.getByTestId('project-members-table');
}
async getMemberRowCount() {
const table = this.getMembersTable();
const rows = table.locator('tbody tr');
return await rows.count();
}
async expectTableHasMemberCount(expectedCount: number) {
const actualCount = await this.getMemberRowCount();
expect(actualCount).toBe(expectedCount);
}
getTitle() {
return this.page.getByTestId('project-name');
}
// Robust value assertions on inner form controls
getNameInput() {
return this.page.locator('#projectName input');
}
getDescriptionTextarea() {
return this.page.locator('#projectDescription textarea');
}
async expectProjectNameValue(value: string) {
await expect(this.getNameInput()).toHaveValue(value);
}
async expectProjectDescriptionValue(value: string) {
await expect(this.getDescriptionTextarea()).toHaveValue(value);
}
async expectTableIsVisible() {
const table = this.getMembersTable();
await expect(table).toBeVisible();
}
async expectMembersSelectIsVisible() {
const select = this.page.getByTestId('project-members-select');
await expect(select).toBeVisible();
}
// Icon picker methods
getIconPickerButton() {
return this.page.getByTestId('icon-picker-button');
}
async clickIconPickerButton() {
await this.getIconPickerButton().click();
}
async selectIconTab(tabName: string) {
await this.page.getByTestId('icon-picker-tabs').getByText(tabName).click();
}
async selectFirstEmoji() {
await this.page.getByTestId('icon-picker-emoji').first().click();
}
}
@@ -0,0 +1,54 @@
import { expect, type Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SettingsEnvironmentPage extends BasePage {
getConnectButton(): Locator {
return this.page.getByTestId('source-control-connect-button');
}
getDisconnectButton(): Locator {
return this.page.getByTestId('source-control-disconnect-button');
}
getRepoUrlInput(): Locator {
return this.page.getByPlaceholder('git@github.com:user/repository.git');
}
getBranchSelect(): Locator {
return this.page.getByTestId('source-control-branch-select');
}
getSaveButton(): Locator {
return this.page.getByTestId('source-control-save-settings-button');
}
fillRepoUrl(url: string): Promise<void> {
return this.getRepoUrlInput().fill(url);
}
async selectBranch(branchName: string): Promise<void> {
await this.getBranchSelect().click();
await this.page.getByRole('option', { name: branchName }).click();
}
async enableReadOnlyMode(): Promise<void> {
const checkbox = this.page.getByTestId('source-control-read-only-checkbox');
await checkbox.check();
}
async disableReadOnlyMode(): Promise<void> {
const checkbox = this.page.getByTestId('source-control-read-only-checkbox');
await checkbox.uncheck();
}
async disconnect(): Promise<void> {
await this.getDisconnectButton().click();
const confirmModal = this.page
.getByRole('dialog')
.filter({ hasText: 'Disconnect Git repository' });
await expect(confirmModal).toBeVisible();
await confirmModal.locator('.btn--confirm').click();
}
}
@@ -0,0 +1,223 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SettingsLogStreamingPage extends BasePage {
getActionBoxUnlicensed(): Locator {
return this.page.getByTestId('action-box-unlicensed');
}
getActionBoxLicensed(): Locator {
return this.page.getByTestId('action-box-licensed');
}
getContactUsButton(): Locator {
return this.getActionBoxUnlicensed().locator('button');
}
getAddFirstDestinationButton(): Locator {
return this.getActionBoxLicensed().locator('button');
}
getAddNewDestinationButton(): Locator {
return this.page.getByRole('button', { name: 'Add new destination' });
}
getDestinationModal(): Locator {
return this.page.getByTestId('destination-modal');
}
getSelectDestinationType(): Locator {
return this.page.getByTestId('select-destination-type');
}
getSelectDestinationTypeItems(): Locator {
return this.page.locator('.el-select-dropdown__item');
}
getSelectDestinationButton(): Locator {
return this.page.getByTestId('select-destination-button');
}
getDestinationNameInput(): Locator {
return this.page.getByTestId('subtitle-showing-type');
}
getDestinationSaveButton(): Locator {
return this.page.getByTestId('destination-save-button').locator('button');
}
getDestinationDeleteButton(): Locator {
return this.page.getByTestId('destination-delete-button');
}
getDestinationCards(): Locator {
return this.page.getByTestId('destination-card');
}
getDropdownMenuItem(index: number): Locator {
return this.page.locator('.el-dropdown-menu__item').nth(index);
}
getConfirmationDialog(): Locator {
return this.page.locator('.el-message-box');
}
getCancelButton(): Locator {
return this.page.locator('.btn--cancel');
}
getConfirmButton(): Locator {
return this.page.locator('.btn--confirm');
}
async addDestination(): Promise<void> {
const addFirstButton = this.getAddFirstDestinationButton();
const addNewButton = this.getAddNewDestinationButton();
await addFirstButton.or(addNewButton).click();
}
async clickSelectDestinationType(): Promise<void> {
await this.clickByTestId('select-destination-type');
}
async selectDestinationType(index: number): Promise<void> {
await this.getSelectDestinationTypeItems().nth(index).click();
}
async clickSelectDestinationButton(): Promise<void> {
await this.clickByTestId('select-destination-button');
}
async clickDestinationNameInput(): Promise<void> {
await this.clickByTestId('subtitle-showing-type');
}
async writeUrlToDestinationUrlInput(url: string): Promise<void> {
await this.page.getByTestId('parameter-input-field').fill(url);
}
async clickInlineEditPreview(): Promise<void> {
// First click on the destination name input to activate it
await this.getDestinationNameInput().click();
const inlineEditPreview = this.getDestinationNameInput().locator(
'span[data-test-id="inline-edit-preview"]',
);
// eslint-disable-next-line playwright/no-force-option
await inlineEditPreview.click({ force: true });
}
async typeDestinationName(name: string): Promise<void> {
await this.fillByTestId('inline-edit-input', name);
}
async saveDestination(): Promise<void> {
const responsePromise = this.page.waitForResponse(
(res) => res.url().includes('/eventbus/destination') && res.request().method() === 'POST',
);
await this.getDestinationSaveButton().click();
await responsePromise;
}
async deleteDestination(): Promise<void> {
await this.clickByTestId('destination-delete-button');
}
async clickDestinationCard(index: number): Promise<void> {
await this.getDestinationCards().nth(index).click();
}
async clickDestinationCardDropdown(index: number): Promise<void> {
await this.getDestinationCards().nth(index).locator('.el-dropdown').click();
}
async clickDropdownMenuItem(index: number): Promise<void> {
await this.getDropdownMenuItem(index).click();
}
async closeModalByClickingOverlay(): Promise<void> {
await this.page
.locator('.el-overlay')
.filter({ has: this.getDestinationModal() })
.click({ position: { x: 1, y: 1 } });
}
async confirmDialog(): Promise<void> {
await this.getConfirmButton().click();
}
async cancelDialog(): Promise<void> {
await this.getCancelButton().click();
}
/**
* Creates a new webhook log streaming destination with the specified name.
* Handles the full flow: modal opening, type selection, naming, and saving.
* @param destinationName - The name to give the new destination
*/
async createDestination(destinationName: string): Promise<void> {
await this.addDestination();
await this.getDestinationModal().waitFor({ state: 'visible' });
await this.clickSelectDestinationType();
await this.selectDestinationType(0); // Webhook
await this.clickSelectDestinationButton();
await this.clickDestinationNameInput();
await this.clickInlineEditPreview();
await this.typeDestinationName(destinationName);
await this.writeUrlToDestinationUrlInput('https://www.example.com');
await this.saveDestination();
await this.closeModalByClickingOverlay();
}
/**
* Creates a new syslog log streaming destination.
* @param config - Syslog configuration
*/
async createSyslogDestination(config: {
name: string;
host: string;
port: number;
}): Promise<void> {
await this.addDestination();
await this.getDestinationModal().waitFor({ state: 'visible' });
await this.clickSelectDestinationType();
await this.selectDestinationType(2); // Syslog (0=Webhook, 1=Sentry, 2=Syslog)
await this.clickSelectDestinationButton();
// Set destination name
await this.clickDestinationNameInput();
await this.clickInlineEditPreview();
await this.typeDestinationName(config.name);
// Fill syslog config - host and port fields
const hostInput = this.page.getByTestId('parameter-input-host').locator('input');
const portInput = this.page.getByTestId('parameter-input-port').locator('input');
await hostInput.clear();
await hostInput.fill(config.host);
await this.page.waitForTimeout(300);
await portInput.clear();
await portInput.fill(config.port.toString());
// Wait for debounced input update (200ms debounce in ParameterInput.vue)
await this.page.waitForTimeout(200);
await this.saveDestination();
}
/**
* Gets the send test event button
*/
getSendTestEventButton(): Locator {
return this.page.getByTestId('destination-test-button');
}
/**
* Sends a test event to the destination.
* Must be called while the destination modal is open and the destination has been saved.
*/
async sendTestEvent(): Promise<void> {
const testButton = this.getSendTestEventButton();
await testButton.waitFor({ state: 'visible' });
await testButton.click();
}
}
@@ -0,0 +1,117 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for Settings including Personal Settings where users can update their profile and manage MFA.
*/
export class SettingsPersonalPage extends BasePage {
getMenuItems() {
return this.page.getByTestId('menu-item');
}
async gotoSettings() {
await this.page.goto('/settings');
}
getUserRole(): Locator {
return this.page.getByTestId('current-user-role');
}
async goto(): Promise<void> {
await this.page.goto('/settings/personal');
}
getPersonalDataForm(): Locator {
return this.page.getByTestId('personal-data-form');
}
getFirstNameField(): Locator {
return this.getPersonalDataForm().locator('input[name="firstName"]');
}
getLastNameField(): Locator {
return this.getPersonalDataForm().locator('input[name="lastName"]');
}
getEmailField(): Locator {
return this.getPersonalDataForm().locator('input[name="email"]');
}
getSaveSettingsButton(): Locator {
return this.page.getByTestId('save-settings-button');
}
async fillPersonalData(firstName: string, lastName: string): Promise<void> {
await this.getFirstNameField().fill(firstName);
await this.getLastNameField().fill(lastName);
}
async fillEmail(email: string): Promise<void> {
await this.getEmailField().fill(email);
}
async pressEnterOnEmail(): Promise<void> {
await this.getEmailField().press('Enter');
}
async saveSettings(): Promise<void> {
await this.getSaveSettingsButton().click();
}
/**
* Complete workflow to update user's first and last name
* @param firstName - The new first name
* @param lastName - The new last name
*/
async updateFirstAndLastName(firstName: string, lastName: string): Promise<void> {
await this.goto();
await this.fillPersonalData(firstName, lastName);
await this.saveSettings();
}
getEnableMfaButton(): Locator {
return this.page.getByTestId('enable-mfa-button');
}
getDisableMfaButton(): Locator {
return this.page.getByTestId('disable-mfa-button');
}
getMfaCodeOrRecoveryCodeInput(): Locator {
return this.page.locator('input[name="mfaCodeOrMfaRecoveryCode"]');
}
getMfaSaveButton(): Locator {
return this.page.getByTestId('mfa-save-button');
}
async clickEnableMfa(): Promise<void> {
await this.clickByTestId('enable-mfa-button');
}
async clickDisableMfa(): Promise<void> {
await this.getDisableMfaButton().click();
}
/**
* Navigate to personal settings and initiate MFA disable workflow
*/
async triggerDisableMfa(): Promise<void> {
await this.goto();
await this.clickDisableMfa();
}
/**
* Fill in MFA code or recovery code and save the form
* @param code - MFA token or recovery code
*/
async fillMfaCodeAndSave(code: string): Promise<void> {
await this.getMfaCodeOrRecoveryCodeInput().fill(code);
await this.getMfaSaveButton().click();
}
getUpgradeCta(): Locator {
return this.page.getByTestId('public-api-upgrade-cta');
}
}
@@ -0,0 +1,82 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SettingsSsoPage extends BasePage {
async goto(): Promise<void> {
await this.page.goto('/settings/sso');
}
getProtocolSelect(): Locator {
return this.page.getByTestId('sso-auth-protocol-select');
}
async selectOidcProtocol(): Promise<void> {
await this.getProtocolSelect().locator('.el-select').click();
await this.page.locator('.el-select-dropdown__item').filter({ hasText: 'OIDC' }).click();
}
getOidcDiscoveryEndpointInput(): Locator {
return this.page.getByTestId('oidc-discovery-endpoint');
}
getOidcClientIdInput(): Locator {
return this.page.getByTestId('oidc-client-id');
}
getOidcClientSecretInput(): Locator {
return this.page.getByTestId('oidc-client-secret');
}
getOidcLoginToggle(): Locator {
return this.page.getByTestId('sso-oidc-toggle');
}
getOidcSaveButton(): Locator {
return this.page.getByTestId('sso-oidc-save');
}
async isOidcLoginEnabled(): Promise<boolean> {
return await this.getOidcLoginToggle().isChecked();
}
async enableOidcLogin(): Promise<void> {
const isEnabled = await this.isOidcLoginEnabled();
if (!isEnabled) {
await this.getOidcLoginToggle().click();
}
}
/** Fill the OIDC form. Discovery endpoint default value is cleared first. */
async fillOidcForm(config: {
discoveryEndpoint: string;
clientId: string;
clientSecret: string;
enableLogin?: boolean;
}): Promise<void> {
const discoveryInput = this.getOidcDiscoveryEndpointInput();
await discoveryInput.click();
await this.page.keyboard.press('ControlOrMeta+a');
await discoveryInput.pressSequentially(config.discoveryEndpoint, { delay: 10 });
await this.getOidcClientIdInput().fill(config.clientId);
await this.getOidcClientSecretInput().fill(config.clientSecret);
if (config.enableLogin !== false) {
await this.enableOidcLogin();
}
}
/** Save the OIDC configuration and wait for API response. */
async saveOidcConfig(): Promise<void> {
const responsePromise = this.page.waitForResponse(
(res) => res.url().includes('/rest/sso/oidc') && res.request().method() === 'POST',
);
await this.getOidcSaveButton().click();
const response = await responsePromise;
if (!response.ok()) {
const body = await response.text();
throw new Error(`OIDC config save failed: ${response.status()} - ${body}`);
}
}
}
@@ -0,0 +1,79 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SettingsUsersPage extends BasePage {
getSearchInput(): Locator {
return this.page.getByTestId('users-list-search');
}
getRow(email: string): Locator {
return this.page.getByRole('row', { name: email });
}
getAccountType(email: string) {
return this.getRow(email).getByTestId('user-role-dropdown');
}
clickAccountType(email: string) {
return this.getRow(email).getByTestId('user-role-dropdown').getByRole('button').click();
}
async search(email: string) {
const searchInput = this.getSearchInput();
await searchInput.click();
await searchInput.fill(email);
}
async transferData(emailOrName: string) {
await this.page
.getByRole('radio', {
name: 'Transfer their workflows and credentials to another user or project',
})
// This doesn't work without force: true
// eslint-disable-next-line playwright/no-force-option
.click({ force: true });
await this.page.getByPlaceholder('Select project or user').click();
const projectSharingInfo = this.page.getByTestId('project-sharing-info');
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const byEmail = projectSharingInfo.filter({ hasText: emailOrName });
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 projectSharingInfo
.filter({ hasText: new RegExp(namePart, 'i') })
.first()
.click();
}
await this.page.getByRole('button', { name: 'Delete' }).click();
}
async deleteData() {
await this.page
.getByRole('radio', {
name: 'Delete their workflows and credentials',
})
// This doesn't work without force: true
// eslint-disable-next-line playwright/no-force-option
.check({ force: true });
await this.page.getByPlaceholder('delete all data').fill('delete all data');
await this.page.getByRole('button', { name: 'Delete' }).click();
}
async selectAccountType(email: string, type: 'Admin' | 'Member') {
await this.clickAccountType(email);
await this.page.getByRole('menuitem', { name: type }).click();
}
async openActions(email: string) {
await this.getRow(email).getByTestId('action-toggle').click();
}
async clickDeleteUser(email: string) {
await this.openActions(email);
await this.page.getByTestId('action-delete').filter({ visible: true }).click();
}
}
@@ -0,0 +1,148 @@
import { expect, type Locator, type Page } from '@playwright/test';
export class SidebarPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async clickHomeButton() {
await this.page.getByTestId('project-home-menu-item').click();
}
async universalAdd() {
await this.page.getByTestId('universal-add').click();
}
async clickHomeMenuItem() {
await this.page.getByTestId('project-home-menu-item').click();
}
async clickPersonalMenuItem() {
await this.page.getByTestId('project-personal-menu-item').click();
}
async clickWorkflowsLink(): Promise<void> {
await this.page.getByRole('link', { name: 'Workflows' }).click();
}
async clickCredentialsLink(): Promise<void> {
await this.page.getByRole('link', { name: 'Credentials' }).click();
}
getProjectButtonInUniversalAdd(): Locator {
return this.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' });
}
async addWorkflowFromUniversalAdd(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Workflow').click();
await this.page.getByTestId('universal-add').getByRole('link', { name: projectName }).click();
}
async openNewCredentialDialogForProject(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Credential', { exact: true }).click();
await this.page.getByTestId('universal-add').getByRole('link', { name: projectName }).click();
}
getProjectMenuItems(): Locator {
return this.page.getByTestId('project-menu-item');
}
async clickProjectMenuItem(projectName: string) {
await this.expand();
await this.getProjectMenuItems().filter({ hasText: projectName }).click();
}
getSettings(): Locator {
return this.page.getByTestId('main-sidebar-settings');
}
getLogoutMenuItem(): Locator {
return this.page.getByTestId('main-sidebar-log-out');
}
getAboutModal(): Locator {
return this.page.getByTestId('about-modal');
}
getHelp(): Locator {
return this.page.getByTestId('main-sidebar-help');
}
async clickHelpMenuItem(): Promise<void> {
await this.getHelp().click();
}
async clickAboutMenuItem(): Promise<void> {
await this.getHelp().click();
await this.page.getByTestId('about').click();
}
async openAboutModalViaShortcut(): Promise<void> {
await this.page.keyboard.press('Alt+Meta+o');
}
async closeAboutModal(): Promise<void> {
await this.page.getByTestId('close-about-modal-button').click();
}
getAdminPanel(): Locator {
return this.page.getByTestId('main-sidebar-cloud-admin');
}
getTrialBanner(): Locator {
return this.page.getByTestId('banners-TRIAL');
}
getTemplatesLink(): Locator {
return this.page.getByTestId('main-sidebar-templates').locator('a');
}
getVersionUpdateItem(): Locator {
return this.page.getByTestId('version-update-cta-button');
}
getSourceControlPushButton(): Locator {
return this.page.getByTestId('main-sidebar-source-control-push');
}
getSourceControlPullButton(): Locator {
return this.page.getByTestId('main-sidebar-source-control-pull');
}
getSourceControlConnectedIndicator(): Locator {
return this.page.getByTestId('main-sidebar-source-control-connected');
}
async openSettings(): Promise<void> {
await this.getSettings().click();
}
async clickSignout(): Promise<void> {
await this.expand();
await this.openSettings();
await this.getLogoutMenuItem().click();
}
async signOutFromWorkflows(): Promise<void> {
await this.page.goto('/workflows');
await this.clickSignout();
}
async expand() {
// First ensure the sidebar is visible before checking if it is expanded
await expect(this.getSettings()).toBeVisible();
const logo = this.page.getByTestId('n8n-logo');
const isExpanded = await logo.isVisible();
if (!isExpanded) {
const collapseButton = this.page.locator('#toggle-sidebar-button');
await expect(collapseButton).toBeVisible();
await collapseButton.click();
}
}
}
@@ -0,0 +1,58 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SignInPage extends BasePage {
getEmailField(): Locator {
return this.page.getByRole('textbox', { name: 'Email' });
}
getPasswordField(): Locator {
return this.page.getByRole('textbox', { name: 'Password' });
}
getSubmitButton(): Locator {
return this.page.getByRole('button', { name: 'Sign in' });
}
getSsoButton(): Locator {
return this.page.getByRole('button', { name: /continue with sso/i });
}
async goto(): Promise<void> {
await this.page.goto('/signin');
}
async fillEmail(email: string): Promise<void> {
await this.getEmailField().fill(email);
}
async fillPassword(password: string): Promise<void> {
await this.getPasswordField().fill(password);
}
async clickSubmit(): Promise<void> {
await this.getSubmitButton().click();
}
/**
* Complete login flow with email and password
* @param email - User email
* @param password - User password
* @param waitForWorkflow - Whether to wait for redirect to workflow page after login
*/
async loginWithEmailAndPassword(
email: string,
password: string,
waitForWorkflow = false,
): Promise<void> {
await this.goto();
await this.fillEmail(email);
await this.fillPassword(password);
await this.clickSubmit();
if (waitForWorkflow) {
await this.page.waitForURL(/workflows/);
}
}
}
@@ -0,0 +1,29 @@
import type { Locator, Page } from '@playwright/test';
export class SourceControlPullModal {
constructor(private readonly page: Page) {}
getModal() {
return this.page.getByTestId('sourceControlPull-modal');
}
getPullAndOverrideButton(): Locator {
return this.page.getByTestId('force-pull');
}
getWorkflowsTab(): Locator {
return this.page.getByTestId('source-control-pull-modal-tab-workflow');
}
async selectWorkflowsTab(): Promise<void> {
await this.getWorkflowsTab().click();
}
getFileInModal(fileName: string): Locator {
return this.page.getByTestId('pull-modal-item').filter({ hasText: fileName }).first();
}
getStatusBadge(fileName: string, status: 'New' | 'Modified' | 'Deleted' | 'Conflict'): Locator {
return this.getFileInModal(fileName).getByText(status, { exact: true });
}
}
@@ -0,0 +1,94 @@
import type { GitCommitInfo, SourceControlledFile } from '@n8n/api-types';
import type { Locator, Page } from '@playwright/test';
export interface PushResult {
files: SourceControlledFile[];
commit: GitCommitInfo | null;
}
export class SourceControlPushModal {
constructor(private readonly page: Page) {}
getModal() {
return this.page.getByTestId('sourceControlPush-modal');
}
getSubmitButton(): Locator {
return this.page.getByTestId('source-control-push-modal-submit');
}
async push(commitMessage: string): Promise<PushResult> {
await this.page.getByTestId('source-control-push-modal-commit').fill(commitMessage);
const responsePromise = this.page.waitForResponse(
(response) =>
response.url().includes('/rest/source-control/push-workfolder') &&
response.status() === 200,
);
await this.getSubmitButton().click();
const response = await responsePromise;
const json = await response.json();
return json.data as PushResult;
}
// Tabs
getWorkflowsTab(): Locator {
return this.page.getByTestId('source-control-push-modal-tab-workflow');
}
getCredentialsTab(): Locator {
return this.page.getByTestId('source-control-push-modal-tab-credential');
}
async selectWorkflowsTab(): Promise<void> {
await this.getWorkflowsTab().click();
}
async selectCredentialsTab(): Promise<void> {
await this.getCredentialsTab().click();
}
isWorkflowsTabSelected(): Promise<boolean> {
return this.getWorkflowsTab()
.getAttribute('class')
.then((classList) => classList?.includes('tabActive') ?? false);
}
// File items
getFileInModal(fileName: string): Locator {
return this.getModal().getByTestId('push-modal-item').filter({ hasText: fileName }).first();
}
getFileCheckboxByName(fileName: string): Locator {
// Find the checkbox that is associated with the file name
return this.getModal()
.locator('[data-test-id="source-control-push-modal-file-checkbox"]')
.filter({ has: this.page.getByText(fileName, { exact: true }) });
}
async selectAllFilesInModal(): Promise<void> {
const toggleAll = this.getModal().getByTestId('source-control-push-modal-toggle-all');
const isChecked = await toggleAll.isChecked();
if (!isChecked) {
await toggleAll.click();
}
}
getNotice(): Locator {
return this.page.locator('#source-control-push-modal-notice.notice[role="alert"]');
}
getStatusBadge(fileName: string, status: 'New' | 'Modified' | 'Deleted'): Locator {
return this.getFileCheckboxByName(fileName).getByText(status);
}
async selectFile(fileName: string): Promise<void> {
const checkbox = this.getFileCheckboxByName(fileName);
const isChecked = await checkbox.isChecked();
if (!isChecked) {
await checkbox.click();
}
}
}
@@ -0,0 +1,72 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { CredentialModal } from './components/CredentialModal';
export class TemplateCredentialSetupPage extends BasePage {
readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal'));
getTitle(titleText: string): Locator {
return this.page.getByRole('heading', { name: titleText, level: 1 });
}
getInfoCallout(): Locator {
return this.page.getByTestId('info-callout');
}
getFormSteps(): Locator {
return this.page.getByTestId('setup-credentials-form-step');
}
getStepHeading(step: Locator): Locator {
return step.getByTestId('credential-step-heading');
}
getStepDescription(step: Locator): Locator {
return step.getByTestId('credential-step-description');
}
getSkipLink(): Locator {
return this.page.getByRole('link', { name: 'Skip' });
}
getContinueButton(): Locator {
return this.page.getByTestId('continue-button');
}
getCanvasSetupButton(): Locator {
return this.page.getByTestId('setup-credentials-button');
}
getCanvasCredentialModal(): Locator {
return this.page.getByTestId('setup-workflow-credentials-modal');
}
getSetupCredentialModalSteps(): Locator {
return this.page
.getByTestId('setup-workflow-credentials-modal')
.getByTestId('setup-credentials-form-step');
}
getCreateCredentialButton(appName: string): Locator {
return this.page.getByRole('button', { name: `Create new ${appName} credential` });
}
getMessageBox(): Locator {
// Using class selector as Element UI message box doesn't have semantic attributes
return this.page.locator('.el-message-box');
}
/** Opens credential creation modal and waits for it to be visible */
async openCredentialCreation(appName: string): Promise<void> {
await this.getCreateCredentialButton(appName).click();
await this.credentialModal.waitForModal();
}
/** Waits for the message box to appear and clicks the cancel button to dismiss it */
async dismissMessageBox(): Promise<void> {
const messageBox = this.getMessageBox();
await messageBox.waitFor({ state: 'visible' });
await messageBox.locator('.btn--cancel').click();
}
}
@@ -0,0 +1,79 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class TemplatesPage extends BasePage {
getPageHeading(): Locator {
return this.page.getByRole('heading', { name: /workflow.*templates/i });
}
getTemplateCards(): Locator {
return this.page.getByTestId('template-card');
}
getFirstTemplateCard(): Locator {
return this.getTemplateCards().first();
}
getUseTemplateButton(): Locator {
return this.page.getByTestId('use-template-button');
}
getTemplatesLoadingContainer(): Locator {
return this.page.getByTestId('templates-loading-container');
}
getDescription(): Locator {
return this.page.getByTestId('template-description');
}
getSearchInput(): Locator {
return this.page.getByTestId('template-search-input');
}
getAllCategoriesFilter(): Locator {
return this.page.getByTestId('template-filter-all-categories');
}
getCategoryFilters(): Locator {
return this.page.locator('[data-test-id^=template-filter]');
}
async clickFirstTemplateCard(): Promise<void> {
await this.getFirstTemplateCard().click();
}
getCategoryFilter(category: string): Locator {
return this.page.getByTestId(`template-filter-${category}`).locator('[role="checkbox"]');
}
getTemplateCountLabel(): Locator {
return this.page.getByTestId('template-count-label');
}
getCollectionCountLabel(): Locator {
return this.page.getByTestId('collection-count-label');
}
getSkeletonLoader(): Locator {
return this.page.locator('.el-skeleton.n8n-loading');
}
async clickUseTemplateButton(): Promise<void> {
await this.getUseTemplateButton().click();
}
async clickCategoryFilter(category: string): Promise<void> {
await this.getCategoryFilter(category).click();
}
/**
* Click the "Use workflow" button on a specific template card by workflow title
* @param workflowTitle - The title of the workflow to find on the template card
*/
async clickUseWorkflowButton(workflowTitle: string): Promise<void> {
const templateCard = this.page.getByTestId('template-card').filter({ hasText: workflowTitle });
await templateCard.hover();
await templateCard.getByTestId('use-workflow-button').click();
}
}
@@ -0,0 +1,89 @@
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { VariableModal } from './components/VariableModal';
export class VariablesPage extends BasePage {
readonly variableModal = new VariableModal(this.page.getByTestId('variableModal-modal'));
getUnavailableResourcesList() {
return this.page.getByTestId('unavailable-resources-list');
}
getResourcesList() {
return this.page.getByTestId('resources-list');
}
getEmptyResourcesListNewVariableButton() {
return this.page.getByRole('button', { name: 'Add first variable' });
}
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
getCreateVariableButton() {
return this.page.getByTestId('add-resource-variable');
}
getVariablesRows() {
return this.page.getByTestId('variables-row');
}
getNoVariablesFoundMessage() {
return this.page.getByText('No variables found');
}
getVariableRow(key: string) {
return this.getVariablesRows().filter({ hasText: key });
}
/**
* Create a variable with the key,
* @param key - The key of the variable
* @param value - The value of the variable
*/
async createVariableFromModal(
key: string,
value: string,
{ shouldSave }: { shouldSave: boolean } = { shouldSave: true },
) {
await this.variableModal.waitForModal();
await this.variableModal.addVariable(key, value, { shouldSave });
}
async createVariableFromEmptyState(key: string, value: string) {
await this.getEmptyResourcesListNewVariableButton().click();
await this.createVariableFromModal(key, value);
}
async createVariable(
key: string,
value: string,
{ shouldSave }: { shouldSave: boolean } = { shouldSave: true },
) {
await this.getCreateVariableButton().click();
await this.createVariableFromModal(key, value, { shouldSave });
}
async deleteVariable(key: string) {
const row = this.getVariableRow(key);
await row.getByTestId('variable-row-delete-button').click();
// Use a more specific selector to avoid strict mode violation with other dialogs
const modal = this.page.getByRole('dialog').filter({ hasText: 'Delete variable' });
await expect(modal).toBeVisible();
await modal.locator('.btn--confirm').click();
}
async editVariable(
key: string,
newValue: string,
{ shouldSave }: { shouldSave: boolean } = { shouldSave: true },
) {
const row = this.getVariableRow(key);
await row.getByTestId('variable-row-edit-button').click();
await this.createVariableFromModal(key, newValue, { shouldSave });
}
}
@@ -0,0 +1,11 @@
import { BasePage } from './BasePage';
export class VersionsPage extends BasePage {
getVersionUpdatesPanel() {
return this.page.getByTestId('version-updates-panel');
}
getVersionCard() {
return this.page.getByTestId('version-card');
}
}
@@ -0,0 +1,19 @@
import { BasePage } from './BasePage';
export class WorkerViewPage extends BasePage {
getWorkerViewLicensed() {
return this.page.getByTestId('worker-view-licensed');
}
getWorkerViewUnlicensed() {
return this.page.getByTestId('worker-view-unlicensed');
}
getWorkerMenuItem() {
return this.page.getByTestId('menu-item').getByText('Workers', { exact: true });
}
async goto() {
await this.page.goto('/settings/workers');
}
}
@@ -0,0 +1,23 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowActivationModal extends BasePage {
getModal(): Locator {
return this.page.getByTestId('activation-modal');
}
getDontShowAgainCheckbox(): Locator {
return this.getModal().getByText("Don't show again");
}
getGotItButton(): Locator {
return this.getModal().getByRole('button', { name: 'Got it' });
}
async close(): Promise<void> {
await this.getDontShowAgainCheckbox().click();
await this.getGotItButton().click();
}
}
@@ -0,0 +1,33 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for the Workflow Credential Setup Modal
* This modal appears in the workflow editor when users need to complete credential setup
* after skipping or partially completing it during template setup
*/
export class WorkflowCredentialSetupModal extends BasePage {
/**
* Get the workflow credential setup modal
* @returns Locator for the modal element
*/
getModal(): Locator {
return this.page.getByTestId('setup-workflow-credentials-modal');
}
/**
* Get the continue button in the modal
* @returns Locator for the continue button
*/
getContinueButton(): Locator {
return this.page.getByTestId('continue-button');
}
/**
* Click the continue button to close the modal
*/
async clickContinue(): Promise<void> {
await this.getContinueButton().click();
}
}
@@ -0,0 +1,139 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowSettingsModal extends BasePage {
getModal(): Locator {
return this.page.getByTestId('workflow-settings-dialog');
}
getWorkflowMenu(): Locator {
return this.page.getByTestId('workflow-menu');
}
getSettingsMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-settings');
}
getErrorWorkflowField(): Locator {
return this.page.getByTestId('workflow-settings-error-workflow');
}
getTimezoneField(): Locator {
return this.page.getByTestId('workflow-settings-timezone');
}
getSaveFailedExecutionsField(): Locator {
return this.page.getByTestId('workflow-settings-save-failed-executions');
}
getSaveSuccessExecutionsField(): Locator {
return this.page.getByTestId('workflow-settings-save-success-executions');
}
getSaveManualExecutionsField(): Locator {
return this.page.getByTestId('workflow-settings-save-manual-executions');
}
getSaveExecutionProgressField(): Locator {
return this.page.getByTestId('workflow-settings-save-execution-progress');
}
getTimeoutSwitch(): Locator {
return this.page.getByTestId('workflow-settings-timeout-workflow');
}
getTimeoutInput(): Locator {
return this.page.getByTestId('workflow-settings-timeout-form').locator('input').first();
}
getDuplicateMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-duplicate');
}
getDeleteMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-delete');
}
getArchiveMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-archive');
}
getUnarchiveMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-unarchive');
}
getPushToGitMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-push');
}
getUnpublishMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-unpublish');
}
getUnpublishModal(): Locator {
return this.page.getByTestId('workflow-history-version-unpublish-modal');
}
async clickUnpublishMenuItem(): Promise<void> {
await this.getUnpublishMenuItem().click();
}
async confirmUnpublishModal(): Promise<void> {
await this.getUnpublishModal().getByRole('button', { name: 'Unpublish' }).click();
}
getSaveButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
getDuplicateModal(): Locator {
return this.page.getByTestId('duplicate-modal');
}
getDuplicateNameInput(): Locator {
return this.getDuplicateModal().locator('input').first();
}
getDuplicateTagsInput(): Locator {
return this.getDuplicateModal().locator('.el-select__tags input');
}
getDuplicateSaveButton(): Locator {
return this.getDuplicateModal().getByRole('button', { name: /duplicate|save/i });
}
async open(): Promise<void> {
await this.getWorkflowMenu().click();
await this.getSettingsMenuItem().click();
}
async clickSave(): Promise<void> {
await this.getSaveButton().click();
}
async selectErrorWorkflow(workflowName: string): Promise<void> {
await this.getErrorWorkflowField().click();
await this.page.getByRole('option', { name: workflowName }).first().click();
}
async clickArchiveMenuItem(): Promise<void> {
await this.getArchiveMenuItem().click();
}
async clickUnarchiveMenuItem(): Promise<void> {
await this.getUnarchiveMenuItem().click();
}
async clickDeleteMenuItem(): Promise<void> {
await this.getDeleteMenuItem().click();
}
async confirmDeleteModal(): Promise<void> {
await this.page.getByRole('button', { name: 'delete' }).click();
}
async confirmArchiveModal(): Promise<void> {
await this.page.locator('.btn--confirm').click();
}
}
@@ -0,0 +1,33 @@
import { BasePage } from './BasePage';
export class WorkflowSharingModal extends BasePage {
getModal() {
return this.page.getByTestId('workflowShare-modal');
}
getUsersSelect() {
return this.page.getByTestId('project-sharing-select').filter({ visible: true });
}
async addUser(emailOrName: string) {
await this.clickByTestId('project-sharing-select');
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const dropdown = this.page.locator('.el-select-dropdown__item');
const byEmail = dropdown.filter({ hasText: emailOrName.toLowerCase() });
if ((await byEmail.count()) > 0) {
await byEmail.click();
} else {
// For personal projects, the email is not shown, so try matching by name part of email
const namePart = emailOrName.split('@')[0].replace(/[.-]/g, ' ');
await dropdown
.filter({ hasText: new RegExp(namePart, 'i') })
.first()
.click();
}
}
async save() {
await this.clickByTestId('workflow-sharing-modal-save-button');
await this.getModal().waitFor({ state: 'hidden' });
}
}
@@ -0,0 +1,183 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { AddResource } from './components/AddResource';
import { ResourceCards } from './components/ResourceCards';
export class WorkflowsPage extends BasePage {
readonly addResource = new AddResource(this.page);
readonly cards = new ResourceCards(this.page);
/**
* This is the new workflow button on the workflows page, visible when there are no workflows.
*/
async clickNewWorkflowButtonFromOverview() {
await this.clickByTestId('new-workflow-card');
}
async clickNewWorkflowButtonFromProject() {
await this.clickByTestId('add-resource-workflow');
}
async clearSearch() {
await this.clickByTestId('resources-list-search');
await this.page.getByTestId('resources-list-search').clear();
}
getProjectName() {
return this.page.getByTestId('project-name');
}
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
async unarchiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Unarchive' }).click();
}
async deleteWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByTestId('action-delete').click();
await this.page.getByRole('button', { name: 'delete' }).click();
}
async search(searchTerm: string) {
await this.clickByTestId('resources-list-search');
await this.fillByTestId('resources-list-search', searchTerm);
}
getNoWorkflowsFoundMessage() {
return this.page.getByText('No workflows found');
}
async shareWorkflow(workflowName: string) {
const workflow = this.cards.getWorkflow(workflowName);
await workflow.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Share...' }).click();
}
getArchiveMenuItem() {
return this.page.getByRole('menuitem', { name: 'Archive' });
}
async archiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.getArchiveMenuItem().click();
}
async unpublishWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Unpublish' }).click();
await this.page.getByRole('button', { name: 'Unpublish' }).click();
}
async openFilters() {
await this.clickByTestId('resources-list-filters-trigger');
}
async closeFilters() {
await this.clickByTestId('resources-list-filters-trigger');
}
getShowArchivedCheckbox() {
return this.page.getByTestId('show-archived-checkbox');
}
async toggleShowArchived() {
await this.openFilters();
await this.getShowArchivedCheckbox().click();
await this.closeFilters();
}
async filterByTags(tags: string[]) {
await this.openFilters();
await this.clickByTestId('tags-dropdown');
for (const tag of tags) {
await this.page.getByRole('option', { name: tag }).locator('span').click();
}
await this.closeFilters();
}
async filterByTag(tag: string) {
await this.filterByTags([tag]);
}
getFolderBreadcrumbsActions() {
return this.page.getByTestId('folder-breadcrumbs-actions');
}
getFolderBreadcrumbsActionToggle() {
return this.page.getByTestId('action-toggle-dropdown');
}
getFolderBreadcrumbsAction(actionName: string) {
return this.getFolderBreadcrumbsActionToggle().getByTestId(`action-${actionName}`);
}
addFolderButton() {
return this.page.getByTestId('add-folder-button');
}
// Add region for actions
/**
* Add a folder from the add resource dropdown
* @returns The name of the folder
*/
async addFolder() {
const folderName = 'My Test Folder';
await this.addResource.folder();
await this.fillFolderModal(folderName);
return folderName;
}
/**
* Fill the folder modal
* @param folderName - The name of the folder
* @param buttonText - The text of the button to click (default: 'Create')
*/
async fillFolderModal(folderName: string, buttonText: string = 'Create') {
await this.baseModal.fillInput(folderName);
await this.baseModal.clickButton(buttonText);
}
deleteFolderModal() {
return this.page.getByTestId('deleteFolder-modal');
}
deleteModalTransferRadioButton() {
return this.deleteFolderModal().getByTestId('transfer-content-radio');
}
deleteModalConfirmButton() {
return this.deleteFolderModal().getByTestId('confirm-delete-folder-button');
}
transferFolderDropdown() {
return this.deleteFolderModal().getByRole('combobox', { name: 'Select a folder' });
}
transferFolderOption(folderName: string) {
return this.page.getByTestId('move-to-folder-option').filter({ hasText: folderName });
}
// Move folder modal methods
moveFolderModal() {
return this.page.getByTestId('moveFolder-modal');
}
moveFolderDropdown() {
return this.moveFolderModal().getByTestId('move-to-folder-dropdown').getByRole('combobox');
}
moveFolderOption(folderName: string) {
return this.page.getByTestId('move-to-folder-option').filter({ hasText: folderName });
}
moveFolderConfirmButton() {
return this.moveFolderModal().getByTestId('confirm-move-folder-button');
}
}
@@ -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();
}
}
}
@@ -0,0 +1,202 @@
import type { Page } from '@playwright/test';
import { AIAssistantPage } from './AIAssistantPage';
import { AIBuilderPage } from './AIBuilderPage';
import { CanvasPage } from './CanvasPage';
import { CommunityNodesPage } from './CommunityNodesPage';
import { BaseModal } from './components/BaseModal';
import { Breadcrumbs } from './components/Breadcrumbs';
import { ProjectTabsComponent } from './components/ProjectTabsComponent';
import { ResourceMoveModal } from './components/ResourceMoveModal';
import { CredentialsPage } from './CredentialsPage';
import { DataTableDetails } from './DataTableDetails';
import { DataTableView } from './DataTableView';
import { DemoPage } from './DemoPage';
import { ExecutionsPage } from './ExecutionsPage';
import { InteractionsPage } from './InteractionsPage';
import { KeycloakLoginPage } from './KeycloakLoginPage';
import { MfaLoginPage } from './MfaLoginPage';
import { MfaSetupModal } from './MfaSetupModal';
import { NodeDetailsViewPage } from './NodeDetailsViewPage';
import { NotificationsPage } from './NotificationsPage';
import { NpsSurveyPage } from './NpsSurveyPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SettingsEnvironmentPage } from './SettingsEnvironmentPage';
import { SettingsLogStreamingPage } from './SettingsLogStreamingPage';
import { SettingsPersonalPage } from './SettingsPersonalPage';
import { SettingsSsoPage } from './SettingsSsoPage';
import { SettingsUsersPage } from './SettingsUsersPage';
import { SidebarPage } from './SidebarPage';
import { SignInPage } from './SignInPage';
import { SourceControlPullModal } from './SourceControlPullModal';
import { SourceControlPushModal } from './SourceControlPushModal';
import { TemplateCredentialSetupPage } from './TemplateCredentialSetupPage';
import { TemplatesPage } from './TemplatesPage';
import { VariablesPage } from './VariablesPage';
import { VersionsPage } from './VersionsPage';
import { WorkerViewPage } from './WorkerViewPage';
import { WorkflowActivationModal } from './WorkflowActivationModal';
import { WorkflowCredentialSetupModal } from './WorkflowCredentialSetupModal';
import { WorkflowSettingsModal } from './WorkflowSettingsModal';
import { WorkflowSharingModal } from './WorkflowSharingModal';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
import { CredentialsComposer } from '../composables/CredentialsComposer';
import { DataTableComposer } from '../composables/DataTablesComposer';
import { ExecutionsComposer } from '../composables/ExecutionsComposer';
import { MfaComposer } from '../composables/MfaComposer';
import { NodeDetailsViewComposer } from '../composables/NodeDetailsViewComposer';
import { OidcComposer } from '../composables/OidcComposer';
import { PartialExecutionComposer } from '../composables/PartialExecutionComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
import { TemplatesComposer } from '../composables/TemplatesComposer';
import { TestEntryComposer } from '../composables/TestEntryComposer';
import { WorkflowComposer } from '../composables/WorkflowComposer';
import { ClipboardHelper } from '../helpers/ClipboardHelper';
import { NavigationHelper } from '../helpers/NavigationHelper';
import { ApiHelpers } from '../services/api-helper';
// eslint-disable-next-line @typescript-eslint/naming-convention
export class n8nPage {
readonly page: Page;
readonly api: ApiHelpers;
// Pages
readonly aiAssistant: AIAssistantPage;
readonly aiBuilder: AIBuilderPage;
readonly canvas: CanvasPage;
readonly communityNodes: CommunityNodesPage;
readonly demo: DemoPage;
readonly interactions: InteractionsPage;
readonly keycloakLogin: KeycloakLoginPage;
readonly mfaLogin: MfaLoginPage;
readonly ndv: NodeDetailsViewPage;
readonly npsSurvey: NpsSurveyPage;
readonly projectSettings: ProjectSettingsPage;
readonly settingsPersonal: SettingsPersonalPage;
readonly settingsLogStreaming: SettingsLogStreamingPage;
readonly templateCredentialSetup: TemplateCredentialSetupPage;
readonly templates: TemplatesPage;
readonly variables: VariablesPage;
readonly versions: VersionsPage;
readonly workerView: WorkerViewPage;
readonly workflows: WorkflowsPage;
readonly notifications: NotificationsPage;
readonly credentials: CredentialsPage;
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
readonly dataTable: DataTableView;
readonly dataTableDetails: DataTableDetails;
readonly signIn: SignInPage;
readonly settingsUsers: SettingsUsersPage;
readonly settingsSso: SettingsSsoPage;
// Components
readonly projectTabs: ProjectTabsComponent;
readonly settingsEnvironment: SettingsEnvironmentPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
readonly workflowCredentialSetupModal: WorkflowCredentialSetupModal;
readonly workflowSettingsModal: WorkflowSettingsModal;
readonly workflowSharingModal: WorkflowSharingModal;
readonly sourceControlPushModal: SourceControlPushModal;
readonly sourceControlPullModal: SourceControlPullModal;
readonly mfaSetupModal: MfaSetupModal;
readonly modal: BaseModal;
readonly resourceMoveModal: ResourceMoveModal;
// Composables
readonly workflowComposer: WorkflowComposer;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
readonly credentialsComposer: CredentialsComposer;
readonly executionsComposer: ExecutionsComposer;
readonly mfaComposer: MfaComposer;
readonly oidcComposer: OidcComposer;
readonly partialExecutionComposer: PartialExecutionComposer;
readonly ndvComposer: NodeDetailsViewComposer;
readonly templatesComposer: TemplatesComposer;
readonly start: TestEntryComposer;
readonly dataTableComposer: DataTableComposer;
// Helpers
readonly navigate: NavigationHelper;
readonly breadcrumbs: Breadcrumbs;
readonly clipboard: ClipboardHelper;
constructor(page: Page, api?: ApiHelpers) {
this.page = page;
this.api = api ?? new ApiHelpers(page.context().request);
// Pages
this.aiAssistant = new AIAssistantPage(page);
this.aiBuilder = new AIBuilderPage(page);
this.canvas = new CanvasPage(page);
this.communityNodes = new CommunityNodesPage(page);
this.demo = new DemoPage(page);
this.interactions = new InteractionsPage(page);
this.keycloakLogin = new KeycloakLoginPage(page);
this.mfaLogin = new MfaLoginPage(page);
this.ndv = new NodeDetailsViewPage(page);
this.npsSurvey = new NpsSurveyPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.settingsPersonal = new SettingsPersonalPage(page);
this.settingsLogStreaming = new SettingsLogStreamingPage(page);
this.templateCredentialSetup = new TemplateCredentialSetupPage(page);
this.templates = new TemplatesPage(page);
this.variables = new VariablesPage(page);
this.versions = new VersionsPage(page);
this.workerView = new WorkerViewPage(page);
this.workflows = new WorkflowsPage(page);
this.notifications = new NotificationsPage(page);
this.credentials = new CredentialsPage(page);
this.executions = new ExecutionsPage(page);
this.sideBar = new SidebarPage(page);
this.signIn = new SignInPage(page);
this.workflowSharingModal = new WorkflowSharingModal(page);
this.dataTable = new DataTableView(page);
this.dataTableDetails = new DataTableDetails(page);
this.settingsEnvironment = new SettingsEnvironmentPage(page);
this.settingsUsers = new SettingsUsersPage(page);
this.settingsSso = new SettingsSsoPage(page);
// Components
this.projectTabs = new ProjectTabsComponent(page);
// Modals
this.workflowActivationModal = new WorkflowActivationModal(page);
this.workflowCredentialSetupModal = new WorkflowCredentialSetupModal(page);
this.workflowSettingsModal = new WorkflowSettingsModal(page);
this.sourceControlPushModal = new SourceControlPushModal(page);
this.sourceControlPullModal = new SourceControlPullModal(page);
this.mfaSetupModal = new MfaSetupModal(page);
this.modal = new BaseModal(page);
this.resourceMoveModal = new ResourceMoveModal(page);
// Composables
this.workflowComposer = new WorkflowComposer(this);
this.projectComposer = new ProjectComposer(this);
this.canvasComposer = new CanvasComposer(this);
this.credentialsComposer = new CredentialsComposer(this);
this.executionsComposer = new ExecutionsComposer(this);
this.mfaComposer = new MfaComposer(this);
this.oidcComposer = new OidcComposer(this);
this.partialExecutionComposer = new PartialExecutionComposer(this);
this.ndvComposer = new NodeDetailsViewComposer(this);
this.templatesComposer = new TemplatesComposer(this);
this.start = new TestEntryComposer(this);
this.dataTableComposer = new DataTableComposer(this);
// Helpers
this.navigate = new NavigationHelper(page);
this.breadcrumbs = new Breadcrumbs(page);
this.clipboard = new ClipboardHelper(page);
}
async goHome() {
await this.page.goto('/');
}
}
@@ -0,0 +1,111 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from '../BasePage';
export class EditFieldsNode extends BasePage {
constructor(page: Page) {
super(page);
}
async setFieldsValues(
fields: Array<{
name: string;
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
value: string | number | boolean;
}>,
paramName = 'assignments',
): Promise<void> {
const container = this.page.getByTestId(`assignment-collection-${paramName}`);
for (let i = 0; i < fields.length; i++) {
await this.ensureFieldExists(container, i);
const assignment = container.getByTestId('assignment').nth(i);
await this.setFieldName(assignment, fields[i].name);
await this.setFieldType(assignment, fields[i].type);
await this.setFieldValue(assignment, fields[i].type, fields[i].value);
}
}
async setSingleFieldValue(
name: string,
type: 'string' | 'number' | 'boolean' | 'array' | 'object',
value: string | number | boolean,
paramName = 'assignments',
): Promise<void> {
await this.setFieldsValues([{ name, type, value }], paramName);
}
private async ensureFieldExists(container: Locator, index: number): Promise<void> {
if (index > 0) {
await container.getByTestId('assignment-collection-drop-area').click();
await container.getByTestId('assignment').nth(index).waitFor({ state: 'visible' });
} else {
const existingFields = await container.getByTestId('assignment').count();
if (existingFields === 0) {
await container.getByTestId('assignment-collection-drop-area').click();
await container.getByTestId('assignment').first().waitFor({ state: 'visible' });
}
}
}
private async setFieldName(assignment: Locator, name: string): Promise<void> {
const nameInput = assignment.getByTestId('assignment-name').getByRole('textbox');
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(name);
await nameInput.blur();
}
private async setFieldType(assignment: Locator, type: string): Promise<void> {
const typeSelect = assignment.getByTestId('assignment-type-select');
await typeSelect.waitFor({ state: 'visible' });
await typeSelect.click();
const typeOptionText = this.getTypeOptionText(type);
const option = this.page.getByRole('menuitem', { name: typeOptionText });
await option.waitFor({ state: 'visible' });
await option.click();
}
private async setFieldValue(
assignment: Locator,
type: string,
value: string | number | boolean,
): Promise<void> {
const valueContainer = assignment.getByTestId('assignment-value');
await valueContainer.waitFor({ state: 'visible' });
if (type === 'boolean') {
await this.setBooleanValue(valueContainer, value as boolean);
} else {
await this.setTextValue(valueContainer, String(value));
}
}
private getTypeOptionText(type: string): string {
const typeMap = new Map([
['string', 'String'],
['number', 'Number'],
['boolean', 'Boolean'],
['array', 'Array'],
['object', 'Object'],
]);
return typeMap.get(type) ?? 'String';
}
private async setTextValue(valueContainer: Locator, value: string): Promise<void> {
const input = valueContainer
.getByRole('textbox')
.or(valueContainer.locator('input, textarea, [contenteditable]').first());
await input.waitFor({ state: 'visible' });
await input.fill(value);
}
private async setBooleanValue(valueContainer: Locator, value: boolean): Promise<void> {
await valueContainer.click();
const booleanValue = value ? 'True' : 'False';
const option = this.page.getByRole('option', { name: booleanValue });
await option.waitFor({ state: 'visible' });
await option.click();
}
}