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