mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
test: Add credential helper and ndv helper (#18636)
This commit is contained in:
106
packages/testing/playwright/helpers/NodeParameterHelper.ts
Normal file
106
packages/testing/playwright/helpers/NodeParameterHelper.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { NodeDetailsViewPage } from '../pages/NodeDetailsViewPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class for setting node parameters in the NDV
|
||||||
|
*/
|
||||||
|
export class NodeParameterHelper {
|
||||||
|
constructor(private ndv: NodeDetailsViewPage) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects parameter type by checking DOM structure
|
||||||
|
* Supports dropdown, text, and switch parameters
|
||||||
|
* @param parameterName - The parameter name to check
|
||||||
|
* @returns The detected parameter type
|
||||||
|
*/
|
||||||
|
async detectParameterType(parameterName: string): Promise<'dropdown' | 'text' | 'switch'> {
|
||||||
|
const parameterContainer = this.ndv.getParameterInput(parameterName);
|
||||||
|
const [hasSwitch, hasSelect, hasSelectCaret] = await Promise.all([
|
||||||
|
parameterContainer
|
||||||
|
.locator('.el-switch')
|
||||||
|
.count()
|
||||||
|
.then((count) => count > 0),
|
||||||
|
parameterContainer
|
||||||
|
.locator('.el-select')
|
||||||
|
.count()
|
||||||
|
.then((count) => count > 0),
|
||||||
|
parameterContainer
|
||||||
|
.locator('.el-select__caret')
|
||||||
|
.count()
|
||||||
|
.then((count) => count > 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (hasSwitch) return 'switch';
|
||||||
|
if (hasSelect && hasSelectCaret) return 'dropdown';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a parameter value with automatic type detection or explicit type
|
||||||
|
* Supports dropdown, text, and switch parameters
|
||||||
|
* @param parameterName - Name of the parameter to set
|
||||||
|
* @param value - Value to set (string or boolean)
|
||||||
|
* @param type - Optional explicit type to skip detection for better performance
|
||||||
|
*/
|
||||||
|
async setParameter(
|
||||||
|
parameterName: string,
|
||||||
|
value: string | boolean,
|
||||||
|
type?: 'dropdown' | 'text' | 'switch',
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
await this.ndv.setParameterSwitch(parameterName, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameterType = type ?? (await this.detectParameterType(parameterName));
|
||||||
|
switch (parameterType) {
|
||||||
|
case 'dropdown':
|
||||||
|
await this.ndv.setParameterDropdown(parameterName, value);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
await this.ndv.setParameterInput(parameterName, value);
|
||||||
|
break;
|
||||||
|
case 'switch':
|
||||||
|
await this.ndv.setParameterSwitch(parameterName, value === 'true');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async webhook(config: {
|
||||||
|
httpMethod?: string;
|
||||||
|
path?: string;
|
||||||
|
authentication?: string;
|
||||||
|
responseMode?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (config.httpMethod !== undefined)
|
||||||
|
await this.setParameter('httpMethod', config.httpMethod, 'dropdown');
|
||||||
|
if (config.path !== undefined) await this.setParameter('path', config.path, 'text');
|
||||||
|
if (config.authentication !== undefined)
|
||||||
|
await this.setParameter('authentication', config.authentication, 'dropdown');
|
||||||
|
if (config.responseMode !== undefined)
|
||||||
|
await this.setParameter('responseMode', config.responseMode, 'dropdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified HTTP Request node parameter configuration
|
||||||
|
* @param config - Configuration object with parameter values
|
||||||
|
*/
|
||||||
|
async httpRequest(config: {
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
authentication?: string;
|
||||||
|
sendQuery?: boolean;
|
||||||
|
sendHeaders?: boolean;
|
||||||
|
sendBody?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (config.method !== undefined) await this.setParameter('method', config.method, 'dropdown');
|
||||||
|
if (config.url !== undefined) await this.setParameter('url', config.url, 'text');
|
||||||
|
if (config.authentication !== undefined)
|
||||||
|
await this.setParameter('authentication', config.authentication, 'dropdown');
|
||||||
|
if (config.sendQuery !== undefined)
|
||||||
|
await this.setParameter('sendQuery', config.sendQuery, 'switch');
|
||||||
|
if (config.sendHeaders !== undefined)
|
||||||
|
await this.setParameter('sendHeaders', config.sendHeaders, 'switch');
|
||||||
|
if (config.sendBody !== undefined)
|
||||||
|
await this.setParameter('sendBody', config.sendBody, 'switch');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,18 @@ export class CanvasPage extends BasePage {
|
|||||||
await this.nodeCreatorSubItem(subItemText).click();
|
await this.nodeCreatorSubItem(subItemText).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addActionNode(searchText: string, subItemText: string): Promise<void> {
|
||||||
|
await this.addNode(searchText);
|
||||||
|
await this.page.getByText('Actions').click();
|
||||||
|
await this.nodeCreatorSubItem(subItemText).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTriggerNode(searchText: string, subItemText: string): Promise<void> {
|
||||||
|
await this.addNode(searchText);
|
||||||
|
await this.page.getByText('Triggers').click();
|
||||||
|
await this.nodeCreatorSubItem(subItemText).click();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteNodeByName(nodeName: string): Promise<void> {
|
async deleteNodeByName(nodeName: string): Promise<void> {
|
||||||
await this.nodeDeleteButton(nodeName).click();
|
await this.nodeDeleteButton(nodeName).click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { BasePage } from './BasePage';
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { BasePage } from './BasePage';
|
||||||
|
import { NodeParameterHelper } from '../helpers/NodeParameterHelper';
|
||||||
|
|
||||||
|
export class NodeDetailsViewPage extends BasePage {
|
||||||
|
readonly setupHelper: NodeParameterHelper;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page);
|
||||||
|
this.setupHelper = new NodeParameterHelper(this);
|
||||||
|
}
|
||||||
|
|
||||||
export class NodeDisplayViewPage extends BasePage {
|
|
||||||
async clickBackToCanvasButton() {
|
async clickBackToCanvasButton() {
|
||||||
await this.clickByTestId('back-to-canvas');
|
await this.clickByTestId('back-to-canvas');
|
||||||
}
|
}
|
||||||
@@ -187,16 +198,82 @@ export class NodeDisplayViewPage extends BasePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select option in parameter dropdown
|
* Get parameter input field
|
||||||
|
* @param parameterName - The name of the parameter
|
||||||
|
*/
|
||||||
|
getParameterInputField(parameterName: string) {
|
||||||
|
return this.getParameterInput(parameterName).getByTestId('parameter-input-field');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select option in parameter dropdown (improved with Playwright best practices)
|
||||||
* @param parameterName - The parameter name
|
* @param parameterName - The parameter name
|
||||||
* @param optionText - The text of the option to select
|
* @param optionText - The text of the option to select
|
||||||
*/
|
*/
|
||||||
async selectOptionInParameterDropdown(parameterName: string, optionText: string) {
|
async selectOptionInParameterDropdown(parameterName: string, optionText: string) {
|
||||||
const dropdown = this.getParameterInput(parameterName);
|
const dropdown = this.getParameterInput(parameterName);
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
|
|
||||||
|
// Wait for dropdown to be visible and select option - following Playwright best practices
|
||||||
await this.page.getByRole('option', { name: optionText }).click();
|
await this.page.getByRole('option', { name: optionText }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click parameter dropdown by name (test-id based selector)
|
||||||
|
* @param parameterName - The parameter name e.g 'httpMethod', 'authentication'
|
||||||
|
*/
|
||||||
|
async clickParameterDropdown(parameterName: string): Promise<void> {
|
||||||
|
await this.clickByTestId(`parameter-input-${parameterName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select option from visible dropdown using Playwright role-based selectors
|
||||||
|
* This follows the pattern used in working n8n tests
|
||||||
|
* @param optionText - The text of the option to select
|
||||||
|
*/
|
||||||
|
async selectFromVisibleDropdown(optionText: string): Promise<void> {
|
||||||
|
// Use Playwright's role-based selector - this is more reliable than CSS selectors
|
||||||
|
await this.page.getByRole('option', { name: optionText }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill parameter input field by parameter name
|
||||||
|
* @param parameterName - The parameter name e.g 'path', 'url'
|
||||||
|
* @param value - The value to fill
|
||||||
|
*/
|
||||||
|
async fillParameterInputByName(parameterName: string, value: string): Promise<void> {
|
||||||
|
const input = this.getParameterInputField(parameterName);
|
||||||
|
await input.click();
|
||||||
|
await input.fill(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click parameter options expansion (e.g. for Response Code)
|
||||||
|
*/
|
||||||
|
async clickParameterOptions(): Promise<void> {
|
||||||
|
await this.page.locator('.param-options').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get visible Element UI popper (dropdown/popover)
|
||||||
|
* Ported from Cypress pattern with Playwright selectors
|
||||||
|
*/
|
||||||
|
getVisiblePopper() {
|
||||||
|
return this.page
|
||||||
|
.locator('.el-popper')
|
||||||
|
.filter({ hasNot: this.page.locator('[aria-hidden="true"]') });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for parameter dropdown to be visible and ready for interaction
|
||||||
|
* @param parameterName - The parameter name
|
||||||
|
*/
|
||||||
|
async waitForParameterDropdown(parameterName: string): Promise<void> {
|
||||||
|
const dropdown = this.getParameterInput(parameterName);
|
||||||
|
await dropdown.waitFor({ state: 'visible' });
|
||||||
|
await expect(dropdown).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click on a floating node in the NDV (for switching between connected nodes)
|
* Click on a floating node in the NDV (for switching between connected nodes)
|
||||||
* @param nodeName - The name of the node to click
|
* @param nodeName - The name of the node to click
|
||||||
@@ -279,4 +356,91 @@ export class NodeDisplayViewPage extends BasePage {
|
|||||||
getErrorMessageText(message: string) {
|
getErrorMessageText(message: string) {
|
||||||
return this.page.locator(`text=${message}`);
|
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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMultipleParameters(
|
||||||
|
parameters: Record<string, string | number | boolean>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [parameterName, value] of Object.entries(parameters)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parameterType = await this.setupHelper.detectParameterType(parameterName);
|
||||||
|
if (parameterType === 'dropdown') {
|
||||||
|
await this.setParameterDropdown(parameterName, value);
|
||||||
|
} else {
|
||||||
|
await this.setParameterInput(parameterName, value);
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
await this.setParameterSwitch(parameterName, value);
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
await this.setParameterInput(parameterName, value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParameterValue(parameterName: string): Promise<string> {
|
||||||
|
const parameterType = await this.setupHelper.detectParameterType(parameterName);
|
||||||
|
|
||||||
|
switch (parameterType) {
|
||||||
|
case 'text':
|
||||||
|
return await this.getTextParameterValue(parameterName);
|
||||||
|
case 'dropdown':
|
||||||
|
return await this.getDropdownParameterValue(parameterName);
|
||||||
|
case 'switch':
|
||||||
|
return await this.getSwitchParameterValue(parameterName);
|
||||||
|
default:
|
||||||
|
// Fallback for unknown types
|
||||||
|
return (await this.getParameterInput(parameterName).textContent()) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value from a text parameter - simplified approach
|
||||||
|
*/
|
||||||
|
private async getTextParameterValue(parameterName: string): Promise<string> {
|
||||||
|
const parameterContainer = this.getParameterInput(parameterName);
|
||||||
|
const input = parameterContainer.locator('input').first();
|
||||||
|
return await input.inputValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value from a dropdown parameter
|
||||||
|
*/
|
||||||
|
private async getDropdownParameterValue(parameterName: string): Promise<string> {
|
||||||
|
const selectedOption = this.getParameterInput(parameterName).locator('.el-select__tags-text');
|
||||||
|
return (await selectedOption.textContent()) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value from a switch parameter
|
||||||
|
*/
|
||||||
|
private async getSwitchParameterValue(parameterName: string): Promise<string> {
|
||||||
|
const switchElement = this.getParameterInput(parameterName).locator('.el-switch');
|
||||||
|
const isEnabled = (await switchElement.getAttribute('aria-checked')) === 'true';
|
||||||
|
return isEnabled ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateParameter(parameterName: string, expectedValue: string): Promise<void> {
|
||||||
|
const actualValue = await this.getParameterValue(parameterName);
|
||||||
|
if (actualValue !== expectedValue) {
|
||||||
|
throw new Error(
|
||||||
|
`Parameter ${parameterName} has value "${actualValue}", expected "${expectedValue}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { CanvasPage } from './CanvasPage';
|
|||||||
import { CredentialsPage } from './CredentialsPage';
|
import { CredentialsPage } from './CredentialsPage';
|
||||||
import { ExecutionsPage } from './ExecutionsPage';
|
import { ExecutionsPage } from './ExecutionsPage';
|
||||||
import { IframePage } from './IframePage';
|
import { IframePage } from './IframePage';
|
||||||
import { NodeDisplayViewPage } from './NodeDisplayViewPage';
|
import { NodeDetailsViewPage } from './NodeDetailsViewPage';
|
||||||
import { NotificationsPage } from './NotificationsPage';
|
import { NotificationsPage } from './NotificationsPage';
|
||||||
import { NpsSurveyPage } from './NpsSurveyPage';
|
import { NpsSurveyPage } from './NpsSurveyPage';
|
||||||
import { ProjectSettingsPage } from './ProjectSettingsPage';
|
import { ProjectSettingsPage } from './ProjectSettingsPage';
|
||||||
@@ -34,7 +34,7 @@ export class n8nPage {
|
|||||||
readonly canvas: CanvasPage;
|
readonly canvas: CanvasPage;
|
||||||
|
|
||||||
readonly iframe: IframePage;
|
readonly iframe: IframePage;
|
||||||
readonly ndv: NodeDisplayViewPage;
|
readonly ndv: NodeDetailsViewPage;
|
||||||
readonly npsSurvey: NpsSurveyPage;
|
readonly npsSurvey: NpsSurveyPage;
|
||||||
readonly projectSettings: ProjectSettingsPage;
|
readonly projectSettings: ProjectSettingsPage;
|
||||||
readonly settings: SettingsPage;
|
readonly settings: SettingsPage;
|
||||||
@@ -66,7 +66,7 @@ export class n8nPage {
|
|||||||
this.canvas = new CanvasPage(page);
|
this.canvas = new CanvasPage(page);
|
||||||
|
|
||||||
this.iframe = new IframePage(page);
|
this.iframe = new IframePage(page);
|
||||||
this.ndv = new NodeDisplayViewPage(page);
|
this.ndv = new NodeDetailsViewPage(page);
|
||||||
this.npsSurvey = new NpsSurveyPage(page);
|
this.npsSurvey = new NpsSurveyPage(page);
|
||||||
this.projectSettings = new ProjectSettingsPage(page);
|
this.projectSettings = new ProjectSettingsPage(page);
|
||||||
this.settings = new SettingsPage(page);
|
this.settings = new SettingsPage(page);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
INSTANCE_ADMIN_CREDENTIALS,
|
INSTANCE_ADMIN_CREDENTIALS,
|
||||||
} from '../config/test-users';
|
} from '../config/test-users';
|
||||||
import { TestError } from '../Types';
|
import { TestError } from '../Types';
|
||||||
|
import { CredentialApiHelper } from './credential-api-helper';
|
||||||
import { ProjectApiHelper } from './project-api-helper';
|
import { ProjectApiHelper } from './project-api-helper';
|
||||||
import { WorkflowApiHelper } from './workflow-api-helper';
|
import { WorkflowApiHelper } from './workflow-api-helper';
|
||||||
|
|
||||||
@@ -35,11 +36,13 @@ export class ApiHelpers {
|
|||||||
request: APIRequestContext;
|
request: APIRequestContext;
|
||||||
workflowApi: WorkflowApiHelper;
|
workflowApi: WorkflowApiHelper;
|
||||||
projectApi: ProjectApiHelper;
|
projectApi: ProjectApiHelper;
|
||||||
|
credentialApi: CredentialApiHelper;
|
||||||
|
|
||||||
constructor(requestContext: APIRequestContext) {
|
constructor(requestContext: APIRequestContext) {
|
||||||
this.request = requestContext;
|
this.request = requestContext;
|
||||||
this.workflowApi = new WorkflowApiHelper(this);
|
this.workflowApi = new WorkflowApiHelper(this);
|
||||||
this.projectApi = new ProjectApiHelper(this);
|
this.projectApi = new ProjectApiHelper(this);
|
||||||
|
this.credentialApi = new CredentialApiHelper(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== MAIN SETUP METHODS =====
|
// ===== MAIN SETUP METHODS =====
|
||||||
|
|||||||
195
packages/testing/playwright/services/credential-api-helper.ts
Normal file
195
packages/testing/playwright/services/credential-api-helper.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type {
|
||||||
|
CreateCredentialDto,
|
||||||
|
CredentialsGetManyRequestQuery,
|
||||||
|
CredentialsGetOneRequestQuery,
|
||||||
|
} from '@n8n/api-types';
|
||||||
|
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import type { ApiHelpers } from './api-helper';
|
||||||
|
import { TestError } from '../Types';
|
||||||
|
|
||||||
|
interface CredentialResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
data?: ICredentialDataDecryptedObject;
|
||||||
|
scopes?: string[];
|
||||||
|
shared?: Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
role: string;
|
||||||
|
}>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialImportResult = {
|
||||||
|
credentialId: string;
|
||||||
|
createdCredential: CredentialResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CredentialApiHelper {
|
||||||
|
constructor(private api: ApiHelpers) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new credential
|
||||||
|
*/
|
||||||
|
async createCredential(credential: CreateCredentialDto): Promise<CredentialResponse> {
|
||||||
|
const response = await this.api.request.post('/rest/credentials', { data: credential });
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to create credential: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.data ?? result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all credentials with optional query parameters
|
||||||
|
*/
|
||||||
|
async getCredentials(query?: CredentialsGetManyRequestQuery): Promise<CredentialResponse[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query?.includeScopes) params.set('includeScopes', String(query.includeScopes));
|
||||||
|
if (query?.includeData) params.set('includeData', String(query.includeData));
|
||||||
|
if (query?.onlySharedWithMe) params.set('onlySharedWithMe', String(query.onlySharedWithMe));
|
||||||
|
|
||||||
|
const response = await this.api.request.get('/rest/credentials', { params });
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to get credentials: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return Array.isArray(result) ? result : (result.data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific credential by ID
|
||||||
|
*/
|
||||||
|
async getCredential(
|
||||||
|
credentialId: string,
|
||||||
|
query?: CredentialsGetOneRequestQuery,
|
||||||
|
): Promise<CredentialResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query?.includeData) params.set('includeData', String(query.includeData));
|
||||||
|
|
||||||
|
const response = await this.api.request.get(`/rest/credentials/${credentialId}`, { params });
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to get credential: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.data ?? result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing credential
|
||||||
|
*/
|
||||||
|
async updateCredential(
|
||||||
|
credentialId: string,
|
||||||
|
updates: Partial<CreateCredentialDto>,
|
||||||
|
): Promise<CredentialResponse> {
|
||||||
|
const existingCredential = await this.getCredential(credentialId);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
name: existingCredential.name,
|
||||||
|
type: existingCredential.type,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.api.request.patch(`/rest/credentials/${credentialId}`, {
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to update credential: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.data ?? result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a credential
|
||||||
|
*/
|
||||||
|
async deleteCredential(credentialId: string): Promise<boolean> {
|
||||||
|
const response = await this.api.request.delete(`/rest/credentials/${credentialId}`);
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to delete credential: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credentials available for a specific workflow or project
|
||||||
|
*/
|
||||||
|
async getCredentialsForWorkflow(options: {
|
||||||
|
workflowId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): Promise<CredentialResponse[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.workflowId) params.set('workflowId', options.workflowId);
|
||||||
|
if (options.projectId) params.set('projectId', options.projectId);
|
||||||
|
|
||||||
|
const response = await this.api.request.get('/rest/credentials/for-workflow', { params });
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to get credentials for workflow: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return Array.isArray(result) ? result : (result.data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer a credential to another project
|
||||||
|
*/
|
||||||
|
async transferCredential(credentialId: string, destinationProjectId: string): Promise<void> {
|
||||||
|
const response = await this.api.request.put(`/rest/credentials/${credentialId}/transfer`, {
|
||||||
|
data: { destinationProjectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to transfer credential: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make credential unique by adding a unique suffix to avoid naming conflicts in tests.
|
||||||
|
*/
|
||||||
|
private makeCredentialUnique(
|
||||||
|
credential: CreateCredentialDto,
|
||||||
|
options?: { idLength?: number },
|
||||||
|
): CreateCredentialDto {
|
||||||
|
const idLength = options?.idLength ?? 8;
|
||||||
|
const uniqueSuffix = nanoid(idLength);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...credential,
|
||||||
|
name: `${credential.name} (Test ${uniqueSuffix})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a credential from definition with automatic unique naming for testing.
|
||||||
|
* Returns detailed information about what was created.
|
||||||
|
*/
|
||||||
|
async createCredentialFromDefinition(
|
||||||
|
credential: CreateCredentialDto,
|
||||||
|
options?: { idLength?: number },
|
||||||
|
): Promise<CredentialImportResult> {
|
||||||
|
const uniqueCredential = this.makeCredentialUnique(credential, options);
|
||||||
|
const createdCredential = await this.createCredential(uniqueCredential);
|
||||||
|
const credentialId = createdCredential.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
credentialId,
|
||||||
|
createdCredential,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,19 @@ export class ProjectApiHelper {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.data ?? result;
|
return result.data ?? result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a project
|
||||||
|
* @param projectId The ID of the project to delete
|
||||||
|
* @returns True if deletion was successful
|
||||||
|
*/
|
||||||
|
async deleteProject(projectId: string): Promise<boolean> {
|
||||||
|
const response = await this.api.request.delete(`/rest/projects/${projectId}`);
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to delete project: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from '../../../fixtures/base';
|
import { test, expect } from '../../../fixtures/base';
|
||||||
|
|
||||||
test.describe('Core UI Patterns - Building Blocks', () => {
|
test.describe('01 - UI Test Entry Points', () => {
|
||||||
test.describe('Entry Point: Home Page', () => {
|
test.describe('Entry Point: Home Page', () => {
|
||||||
test('should navigate from home', async ({ n8n }) => {
|
test('should navigate from home', async ({ n8n }) => {
|
||||||
await n8n.start.fromHome();
|
await n8n.start.fromHome();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from '../../../fixtures/base';
|
||||||
|
|
||||||
|
test.describe('03 - Node Details Configuration', () => {
|
||||||
|
test.beforeEach(async ({ n8n }) => {
|
||||||
|
await n8n.start.fromBlankCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should configure webhook node', async ({ n8n }) => {
|
||||||
|
await n8n.canvas.addNode('Webhook');
|
||||||
|
|
||||||
|
await n8n.ndv.setupHelper.webhook({
|
||||||
|
httpMethod: 'POST',
|
||||||
|
path: 'test-webhook',
|
||||||
|
authentication: 'Basic Auth',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('test-webhook');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should configure HTTP Request node', async ({ n8n }) => {
|
||||||
|
await n8n.canvas.addNode('HTTP Request');
|
||||||
|
|
||||||
|
await n8n.ndv.setupHelper.httpRequest({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://api.example.com/test',
|
||||||
|
sendQuery: true,
|
||||||
|
sendHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(n8n.ndv.getParameterInputField('url')).toHaveValue('https://api.example.com/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should auto-detect parameter types', async ({ n8n }) => {
|
||||||
|
await n8n.canvas.addNode('Webhook');
|
||||||
|
|
||||||
|
await n8n.ndv.setupHelper.setParameter('httpMethod', 'PUT');
|
||||||
|
await n8n.ndv.setupHelper.setParameter('path', 'auto-detect-test');
|
||||||
|
|
||||||
|
await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('auto-detect-test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use explicit types for better performance', async ({ n8n }) => {
|
||||||
|
await n8n.canvas.addNode('Webhook');
|
||||||
|
|
||||||
|
await n8n.ndv.setupHelper.setParameter('httpMethod', 'PATCH', 'dropdown');
|
||||||
|
await n8n.ndv.setupHelper.setParameter('path', 'explicit-types', 'text');
|
||||||
|
|
||||||
|
await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('explicit-types');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { CreateCredentialDto } from '@n8n/api-types';
|
||||||
|
|
||||||
|
import { test, expect } from '../../fixtures/base';
|
||||||
|
|
||||||
|
test.describe('Credential API Operations', () => {
|
||||||
|
test.describe('Basic CRUD Operations', () => {
|
||||||
|
test('should create, retrieve, update, and delete credential', async ({ api }) => {
|
||||||
|
const credentialData: CreateCredentialDto = {
|
||||||
|
name: 'Test HTTP Basic Auth',
|
||||||
|
type: 'httpBasicAuth',
|
||||||
|
data: {
|
||||||
|
user: 'test_user',
|
||||||
|
password: 'test_password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { credentialId, createdCredential } =
|
||||||
|
await api.credentialApi.createCredentialFromDefinition(credentialData);
|
||||||
|
|
||||||
|
expect(credentialId).toBeTruthy();
|
||||||
|
expect(createdCredential.type).toBe('httpBasicAuth');
|
||||||
|
expect(createdCredential.name).toContain('Test HTTP Basic Auth (Test');
|
||||||
|
|
||||||
|
const retrievedCredential = await api.credentialApi.getCredential(credentialId);
|
||||||
|
expect(retrievedCredential.id).toBe(credentialId);
|
||||||
|
expect(retrievedCredential.type).toBe('httpBasicAuth');
|
||||||
|
expect(retrievedCredential.name).toBe(createdCredential.name);
|
||||||
|
|
||||||
|
const credentialWithData = await api.credentialApi.getCredential(credentialId, {
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
expect(credentialWithData.data).toBeDefined();
|
||||||
|
expect(credentialWithData.data?.user).toBe('test_user');
|
||||||
|
|
||||||
|
const updatedName = 'Updated HTTP Basic Auth';
|
||||||
|
const updatedCredential = await api.credentialApi.updateCredential(credentialId, {
|
||||||
|
name: updatedName,
|
||||||
|
data: {
|
||||||
|
user: 'updated_user',
|
||||||
|
password: 'updated_password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updatedCredential.name).toBe(updatedName);
|
||||||
|
|
||||||
|
const verifyUpdated = await api.credentialApi.getCredential(credentialId, {
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
expect(verifyUpdated.name).toBe(updatedName);
|
||||||
|
expect(verifyUpdated.data?.user).toBe('updated_user');
|
||||||
|
|
||||||
|
const deleteResult = await api.credentialApi.deleteCredential(credentialId);
|
||||||
|
expect(deleteResult).toBe(true);
|
||||||
|
|
||||||
|
await expect(api.credentialApi.getCredential(credentialId)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Credential Listing', () => {
|
||||||
|
test('should list credentials with different query options', async ({ api }) => {
|
||||||
|
const credential1 = await api.credentialApi.createCredentialFromDefinition({
|
||||||
|
name: 'First Test Credential',
|
||||||
|
type: 'httpBasicAuth',
|
||||||
|
data: { user: 'user1', password: 'pass1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const credential2 = await api.credentialApi.createCredentialFromDefinition({
|
||||||
|
name: 'Second Test Credential',
|
||||||
|
type: 'httpHeaderAuth',
|
||||||
|
data: { name: 'Authorization', value: 'Bearer token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCredentials = await api.credentialApi.getCredentials();
|
||||||
|
expect(allCredentials.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const createdIds = [credential1.credentialId, credential2.credentialId];
|
||||||
|
const foundCredentials = allCredentials.filter((c) => createdIds.includes(c.id));
|
||||||
|
expect(foundCredentials).toHaveLength(2);
|
||||||
|
|
||||||
|
const credentialsWithScopes = await api.credentialApi.getCredentials({
|
||||||
|
includeScopes: true,
|
||||||
|
});
|
||||||
|
expect(credentialsWithScopes[0].scopes).toBeDefined();
|
||||||
|
expect(Array.isArray(credentialsWithScopes[0].scopes)).toBe(true);
|
||||||
|
|
||||||
|
const credentialsWithData = await api.credentialApi.getCredentials({
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
const foundWithData = credentialsWithData.filter((c) => createdIds.includes(c.id));
|
||||||
|
expect(foundWithData.some((c) => c.data)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Project Integration', () => {
|
||||||
|
test('should handle credential-project associations', async ({ api }) => {
|
||||||
|
await api.enableFeature('projectRole:admin');
|
||||||
|
await api.enableFeature('projectRole:editor');
|
||||||
|
await api.setMaxTeamProjectsQuota(-1);
|
||||||
|
|
||||||
|
const project = await api.projectApi.createProject('Test Project for Credentials');
|
||||||
|
|
||||||
|
const credential = await api.credentialApi.createCredentialFromDefinition({
|
||||||
|
name: 'Project Credential',
|
||||||
|
type: 'httpBasicAuth',
|
||||||
|
data: { user: 'user', password: 'pass' },
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectCredentials = await api.credentialApi.getCredentialsForWorkflow({
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projectCredentials).toBeDefined();
|
||||||
|
expect(Array.isArray(projectCredentials)).toBe(true);
|
||||||
|
|
||||||
|
const foundCredential = projectCredentials.find((c) => c.id === credential.credentialId);
|
||||||
|
expect(foundCredential).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should transfer credential between projects', async ({ api }) => {
|
||||||
|
await api.enableFeature('projectRole:admin');
|
||||||
|
await api.enableFeature('projectRole:editor');
|
||||||
|
await api.setMaxTeamProjectsQuota(-1);
|
||||||
|
|
||||||
|
const sourceProject = await api.projectApi.createProject('Source Project');
|
||||||
|
const destinationProject = await api.projectApi.createProject('Destination Project');
|
||||||
|
|
||||||
|
const credential = await api.credentialApi.createCredentialFromDefinition({
|
||||||
|
name: 'Transfer Test Credential',
|
||||||
|
type: 'httpBasicAuth',
|
||||||
|
data: { user: 'user', password: 'pass' },
|
||||||
|
projectId: sourceProject.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceCredentials = await api.credentialApi.getCredentialsForWorkflow({
|
||||||
|
projectId: sourceProject.id,
|
||||||
|
});
|
||||||
|
const foundInSource = sourceCredentials.find((c) => c.id === credential.credentialId);
|
||||||
|
expect(foundInSource).toBeDefined();
|
||||||
|
|
||||||
|
await api.credentialApi.transferCredential(credential.credentialId, destinationProject.id);
|
||||||
|
|
||||||
|
const destinationCredentials = await api.credentialApi.getCredentialsForWorkflow({
|
||||||
|
projectId: destinationProject.id,
|
||||||
|
});
|
||||||
|
const foundInDestination = destinationCredentials.find(
|
||||||
|
(c) => c.id === credential.credentialId,
|
||||||
|
);
|
||||||
|
expect(foundInDestination).toBeDefined();
|
||||||
|
|
||||||
|
const sourceCredentialsAfter = await api.credentialApi.getCredentialsForWorkflow({
|
||||||
|
projectId: sourceProject.id,
|
||||||
|
});
|
||||||
|
const stillInSource = sourceCredentialsAfter.find((c) => c.id === credential.credentialId);
|
||||||
|
expect(stillInSource).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Data Persistence', () => {
|
||||||
|
test('should maintain credential data across operations', async ({ api }) => {
|
||||||
|
const originalData: CreateCredentialDto = {
|
||||||
|
name: 'Persistence Test Credential',
|
||||||
|
type: 'httpBasicAuth',
|
||||||
|
data: {
|
||||||
|
user: 'persistent_user',
|
||||||
|
password: 'persistent_password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { credentialId } = await api.credentialApi.createCredentialFromDefinition(originalData);
|
||||||
|
|
||||||
|
const afterCreate = await api.credentialApi.getCredential(credentialId, {
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
expect(afterCreate.data?.user).toBe('persistent_user');
|
||||||
|
|
||||||
|
await api.credentialApi.updateCredential(credentialId, {
|
||||||
|
data: {
|
||||||
|
user: 'updated_persistent_user',
|
||||||
|
password: 'updated_persistent_password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterUpdate = await api.credentialApi.getCredential(credentialId, {
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
expect(afterUpdate.data?.user).toBe('updated_persistent_user');
|
||||||
|
expect(afterUpdate.data?.password).toBeDefined();
|
||||||
|
|
||||||
|
const allCredentials = await api.credentialApi.getCredentials();
|
||||||
|
const foundCredential = allCredentials.find((c) => c.id === credentialId);
|
||||||
|
expect(foundCredential).toBeDefined();
|
||||||
|
expect(foundCredential!.type).toBe('httpBasicAuth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user