first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user