first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,496 @@
// services/api-helper.ts
import { request, type APIRequestContext } from '@playwright/test';
import { setTimeout as wait } from 'node:timers/promises';
import type { UserCredentials } from '../config/test-users';
import {
INSTANCE_OWNER_CREDENTIALS,
INSTANCE_MEMBER_CREDENTIALS,
INSTANCE_ADMIN_CREDENTIALS,
INSTANCE_CHAT_CREDENTIALS,
} from '../config/test-users';
import { TestError } from '../Types';
import { CredentialApiHelper } from './credential-api-helper';
import { DynamicCredentialApiHelper } from './dynamic-credential-api-helper';
import { ExternalSecretsApiHelper } from './external-secrets-api-helper';
import { McpApiHelper } from './mcp-api-helper';
import { ProjectApiHelper } from './project-api-helper';
import { PublicApiHelper } from './public-api-helper';
import { RoleApiHelper } from './role-api-helper';
import { SourceControlApiHelper } from './source-control-api-helper';
import { TagApiHelper } from './tag-api-helper';
import { UserApiHelper, type TestUser } from './user-api-helper';
import { VariablesApiHelper } from './variables-api-helper';
import { WebhookApiHelper } from './webhook-api-helper';
import { WorkflowApiHelper } from './workflow-api-helper';
export interface LoginResponseData {
id: string;
[key: string]: unknown;
}
export type UserRole = 'owner' | 'admin' | 'member' | 'chat';
export type TestState = 'fresh' | 'reset' | 'signin-only';
const AUTH_TAGS = {
ADMIN: '@auth:admin',
OWNER: '@auth:owner',
MEMBER: '@auth:member',
CHAT: '@auth:chat',
NONE: '@auth:none',
} as const;
const DB_TAGS = {
RESET: '@db:reset',
} as const;
export class ApiHelpers {
request: APIRequestContext;
workflows: WorkflowApiHelper;
webhooks: WebhookApiHelper;
mcp: McpApiHelper;
projects: ProjectApiHelper;
credentials: CredentialApiHelper;
dynamicCredentials: DynamicCredentialApiHelper;
variables: VariablesApiHelper;
externalSecrets: ExternalSecretsApiHelper;
users: UserApiHelper;
tags: TagApiHelper;
roles: RoleApiHelper;
sourceControl: SourceControlApiHelper;
publicApi: PublicApiHelper;
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
this.workflows = new WorkflowApiHelper(this);
this.webhooks = new WebhookApiHelper(this);
this.mcp = new McpApiHelper(this);
this.projects = new ProjectApiHelper(this);
this.credentials = new CredentialApiHelper(this);
this.dynamicCredentials = new DynamicCredentialApiHelper(this);
this.variables = new VariablesApiHelper(this);
this.externalSecrets = new ExternalSecretsApiHelper(this);
this.users = new UserApiHelper(this);
this.tags = new TagApiHelper(this);
this.roles = new RoleApiHelper(this);
this.sourceControl = new SourceControlApiHelper(this);
this.publicApi = new PublicApiHelper(this);
}
// ===== MAIN SETUP METHODS =====
/**
* Setup test environment based on test tags
* @param tags - Array of test tags (e.g., ['@db:reset', '@auth:owner'])
* @param memberIndex - Which member to use (if auth role is 'member')
*
* Examples:
* - ['@db:reset', '@auth:owner'] = reset DB + signin as owner
* - ['@auth:admin'] = signin as admin (no reset)
* - ['@auth:none'] = no signin (unauthenticated)
*/
async setupFromTags(tags: string[], memberIndex: number = 0): Promise<LoginResponseData | null> {
const shouldReset = this.shouldResetDatabase(tags);
const role = this.getRoleFromTags(tags);
if (shouldReset && role) {
// Reset + signin
await this.resetDatabase();
return await this.signin(role, memberIndex);
} else if (shouldReset) {
// Reset only, manual signin required
await this.resetDatabase();
return null;
} else if (role) {
// Signin only
return await this.signin(role, memberIndex);
}
// No setup required
return null;
}
/**
* Check if database should be reset based on tags
*/
private shouldResetDatabase(tags: string[]): boolean {
const lowerTags = tags.map((tag) => tag.toLowerCase());
return lowerTags.includes(DB_TAGS.RESET.toLowerCase());
}
/**
* Setup test environment based on desired state (programmatic approach)
* @param state - 'fresh': new container, 'reset': reset DB + signin, 'signin-only': just signin
* @param role - User role to sign in as
* @param memberIndex - Which member to use (if role is 'member')
*/
async setupTest(
state: TestState,
role: UserRole = 'owner',
memberIndex: number = 0,
): Promise<LoginResponseData | null> {
switch (state) {
case 'fresh':
// For fresh docker container - just reset, no signin needed yet
await this.resetDatabase();
return null;
case 'reset':
// Reset database then sign in
await this.resetDatabase();
return await this.signin(role, memberIndex);
case 'signin-only':
// Just sign in without reset
return await this.signin(role, memberIndex);
default:
throw new TestError('Unknown test state');
}
}
// ===== CORE METHODS =====
async resetDatabase(): Promise<void> {
const response = await this.request.post('/rest/e2e/reset', {
data: {
owner: INSTANCE_OWNER_CREDENTIALS,
members: INSTANCE_MEMBER_CREDENTIALS,
admin: INSTANCE_ADMIN_CREDENTIALS,
chat: INSTANCE_CHAT_CREDENTIALS,
},
});
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(errorText);
}
// Adding small delay to ensure database is reset
await wait(1000);
}
async signin(role: UserRole, memberIndex: number = 0): Promise<LoginResponseData> {
const credentials = this.getCredentials(role, memberIndex);
return await this.loginAndSetCookies(credentials);
}
async login(credentials: { email: string; password: string }): Promise<LoginResponseData> {
return await this.loginAndSetCookies(credentials);
}
// ===== CONFIGURATION METHODS =====
async setFeature(feature: string, enabled: boolean): Promise<void> {
await this.request.patch('/rest/e2e/feature', {
data: { feature: `feat:${feature}`, enabled },
});
}
async setQuota(quotaName: string, value: number | string): Promise<void> {
await this.request.patch('/rest/e2e/quota', {
data: { feature: `quota:${quotaName}`, value },
});
}
async setQueueMode(enabled: boolean): Promise<void> {
await this.request.patch('/rest/e2e/queue-mode', {
data: { enabled },
});
}
// ===== FEATURE FLAG METHODS =====
async setEnvFeatureFlags(flags: Record<string, string>): Promise<{
data: {
success: boolean;
message: string;
flags: Record<string, string>;
};
}> {
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
data: { flags },
});
return await response.json();
}
async clearEnvFeatureFlags(): Promise<{
data: {
success: boolean;
message: string;
flags: Record<string, string>;
};
}> {
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
data: { flags: {} },
});
return await response.json();
}
async getEnvFeatureFlags(): Promise<{
data: Record<string, string>;
}> {
const response = await this.request.get('/rest/e2e/env-feature-flags');
return await response.json();
}
// ===== CONVENIENCE METHODS =====
async enableFeature(feature: string): Promise<void> {
await this.setFeature(feature, true);
}
/**
* Enable all project features (sharing, folders, advancedPermissions, projectRoles)
* Use this in API-only tests - the n8n fixture enables these via withProjectFeatures()
*/
async enableProjectFeatures(): Promise<void> {
await this.enableFeature('sharing');
await this.enableFeature('folders');
await this.enableFeature('advancedPermissions');
await this.enableFeature('projectRole:admin');
await this.enableFeature('projectRole:editor');
}
async disableFeature(feature: string): Promise<void> {
await this.setFeature(feature, false);
}
async setMaxTeamProjectsQuota(value: number | string): Promise<void> {
await this.setQuota('maxTeamProjects', value);
}
/**
* Create an isolated API context for a specific user.
* Returns an ApiHelpers instance logged in as the specified user.
* Use this for API-only operations without needing a browser context.
*/
async createApiForUser(user: Pick<TestUser, 'email' | 'password'>): Promise<ApiHelpers> {
const userContext = await request.newContext();
const userApi = new ApiHelpers(userContext);
await userApi.login({ email: user.email, password: user.password });
return userApi;
}
/**
* Check if n8n is healthy
* @returns True if n8n is healthy, false otherwise
*/
async isHealthy(probe: 'liveness' | 'readiness' = 'liveness'): Promise<boolean> {
const url = probe === 'liveness' ? '/healthz' : '/healthz/readiness';
const response = await this.request.get(url);
const data = await response.json();
return data.status === 'ok';
}
// ===== LOG STREAMING METHODS =====
/**
* Create a syslog destination for log streaming.
* Requires the logStreaming feature to be enabled.
*
* @param config - Syslog destination configuration
* @returns Created destination data
*/
async createSyslogDestination(config: {
host: string;
port: number;
protocol?: 'tcp' | 'udp';
facility?: number;
app_name?: string;
label?: string;
subscribedEvents?: string[];
}): Promise<{ id: string }> {
const response = await this.request.post('/rest/eventbus/destination', {
data: {
__type: '$$MessageEventBusDestinationSyslog',
host: config.host,
port: config.port,
protocol: config.protocol ?? 'tcp',
facility: config.facility ?? 16, // Local0
app_name: config.app_name ?? 'n8n',
label: config.label ?? 'VictoriaLogs Syslog',
subscribedEvents: config.subscribedEvents ?? ['*'], // All events
},
});
if (!response.ok()) {
throw new TestError(
`Failed to create syslog destination: ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
// Handle both direct response and {data: ...} wrapped response
return result.data ?? result;
}
/**
* Delete a log streaming destination.
*
* @param id - Destination ID to delete
*/
async deleteLogStreamingDestination(id: string): Promise<void> {
const response = await this.request.delete(`/rest/eventbus/destination?id=${id}`);
if (!response.ok()) {
throw new TestError(
`Failed to delete log streaming destination: ${response.status()} ${await response.text()}`,
);
}
}
/**
* Get all log streaming destinations.
*
* @returns Array of destination configurations
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
async getLogStreamingDestinations(): Promise<Array<{ id: string; __type: string }>> {
const response = await this.request.get('/rest/eventbus/destination');
if (!response.ok()) {
throw new TestError(
`Failed to get log streaming destinations: ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
// Handle both direct response and {data: ...} wrapped response
return result.data ?? result;
}
/**
* Send a test message to a log streaming destination.
*
* @param id - Destination ID to test
* @returns True if test was successful
*/
async testLogStreamingDestination(id: string): Promise<boolean> {
const response = await this.request.get(`/rest/eventbus/testmessage?id=${id}`);
if (!response.ok()) {
return false;
}
const result = await response.json();
// Handle both direct response and {data: ...} wrapped response
return result.data ?? result;
}
/**
* Delete all log streaming destinations.
*/
async deleteAllLogStreamingDestinations(): Promise<void> {
const destinations = await this.getLogStreamingDestinations();
for (const destination of destinations) {
await this.deleteLogStreamingDestination(destination.id);
}
}
// ===== MCP API KEY METHODS =====
/**
* Rotate the MCP API key for the authenticated user.
* Creates a new API key and invalidates the old one.
*
* @returns The new MCP API key data
*/
async rotateMcpApiKey(): Promise<{ id: string; apiKey: string; userId: string }> {
const response = await this.request.post('/rest/mcp/api-key/rotate');
if (!response.ok()) {
throw new TestError(
`Failed to rotate MCP API key: ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Enable or disable MCP access for the instance.
* Uses the MCP settings endpoint to toggle access.
*
* @param enabled - Whether MCP access should be enabled
*/
async setMcpAccess(enabled: boolean): Promise<void> {
const response = await this.request.patch('/rest/mcp/settings', {
data: { mcpAccessEnabled: enabled },
});
if (!response.ok()) {
throw new TestError(
`Failed to set MCP access: ${response.status()} ${await response.text()}`,
);
}
}
// ===== PRIVATE METHODS =====
private async loginAndSetCookies(
credentials: Pick<UserCredentials, 'email' | 'password'>,
): Promise<LoginResponseData> {
const response = await this.request.post('/rest/login', {
data: {
emailOrLdapLoginId: credentials.email,
password: credentials.password,
},
maxRetries: 3,
});
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(errorText);
}
let responseData: unknown;
try {
responseData = await response.json();
} catch (error: unknown) {
const errorText = await response.text();
throw new TestError(errorText);
}
const loginData: LoginResponseData = (responseData as { data: LoginResponseData }).data;
if (!loginData?.id) {
throw new TestError('Login did not return expected user data (missing user ID)');
}
return loginData;
}
private getCredentials(role: UserRole, memberIndex: number = 0): UserCredentials {
switch (role) {
case 'owner':
return INSTANCE_OWNER_CREDENTIALS;
case 'admin':
return INSTANCE_ADMIN_CREDENTIALS;
case 'member':
if (!INSTANCE_MEMBER_CREDENTIALS || memberIndex >= INSTANCE_MEMBER_CREDENTIALS.length) {
throw new TestError(`No member credentials found for index ${memberIndex}`);
}
return INSTANCE_MEMBER_CREDENTIALS[memberIndex];
case 'chat':
return INSTANCE_CHAT_CREDENTIALS;
default:
throw new TestError(`Unknown role: ${role as string}`);
}
}
// ===== TAG PARSING METHODS =====
/**
* Get the role from the tags
* @param tags - Array of test tags (e.g., ['@auth:owner'])
* @returns The role from the tags, or 'owner' if no role is found
*/
getRoleFromTags(tags: string[]): UserRole | null {
const lowerTags = tags.map((tag) => tag.toLowerCase());
if (lowerTags.includes(AUTH_TAGS.ADMIN.toLowerCase())) return 'admin';
if (lowerTags.includes(AUTH_TAGS.OWNER.toLowerCase())) return 'owner';
if (lowerTags.includes(AUTH_TAGS.MEMBER.toLowerCase())) return 'member';
if (lowerTags.includes(AUTH_TAGS.CHAT.toLowerCase())) return 'chat';
if (lowerTags.includes(AUTH_TAGS.NONE.toLowerCase())) return null;
return 'owner';
}
}
@@ -0,0 +1,235 @@
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';
export interface CredentialResponse {
id: string;
name: string;
type: string;
data?: ICredentialDataDecryptedObject;
scopes?: string[];
shared?: Array<{
id: string;
projectId: string;
role: string;
}>;
isResolvable?: boolean;
createdAt: string;
updatedAt: string;
}
type CredentialImportResult = {
credentialId: string;
createdCredential: CredentialResponse;
};
export class CredentialApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create a new credential
*
* Notes:
* - The `type` field is the credential type ID (e.g., 'notionApi'), which differs from the UI display name (e.g., 'Notion API').
* - You can find available credential type IDs in the codebase under `packages/nodes-base/credentials/*.credentials.ts` and by inspecting node credential references (e.g., Notion nodes use `type: 'notionApi'`).
*/
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 credentials filtered by project ID
*/
async getCredentialsByProject(
projectId: string,
options?: { includeScopes?: boolean; includeData?: boolean },
): Promise<CredentialResponse[]> {
const params = new URLSearchParams();
params.set('includeScopes', String(options?.includeScopes ?? true));
params.set('includeData', String(options?.includeData ?? true));
params.set('filter', JSON.stringify({ projectId }));
const response = await this.api.request.get('/rest/credentials', { params });
if (!response.ok()) {
throw new TestError(`Failed to get credentials by project: ${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 ?? []);
}
/**
* Share a credential with other projects/users
*/
async shareCredential(credentialId: string, shareWithIds: string[]): Promise<void> {
const response = await this.api.request.put(`/rest/credentials/${credentialId}/share`, {
data: { shareWithIds },
});
if (!response.ok()) {
throw new TestError(`Failed to share credential: ${await response.text()}`);
}
}
/**
* 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,
};
}
}
@@ -0,0 +1,189 @@
import type { APIResponse } from '@playwright/test';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
export interface WorkflowExecutionStatus {
workflowId: string;
readyToExecute: boolean;
credentials?: Array<{
credentialId: string;
credentialName: string;
credentialType: string;
credentialStatus: 'missing' | 'configured';
authorizationUrl?: string;
revokeUrl?: string;
}>;
}
/**
* Static endpoint auth token used in e2e tests.
* Must match N8N_DYNAMIC_CREDENTIALS_ENDPOINT_AUTH_TOKEN in the 'dynamic-credentials' capability.
*/
export const DYNAMIC_CRED_ENDPOINT_TOKEN = 'e2e-test-endpoint-token';
export interface CredentialResolver {
id: string;
name: string;
type: string;
config: string;
createdAt: string;
updatedAt: string;
}
export interface CreateResolverOptions {
name: string;
type: string;
config: Record<string, unknown>;
}
export interface ExecutionStatusOptions {
/** Bearer token for credential context (identifies the external user). */
bearerToken?: string;
/** Override auth source. Defaults to bearer if bearerToken is set, else cookie. */
authSource?: 'bearer' | 'cookie';
/**
* Static endpoint auth token for unauthenticated requests.
* Sent as X-Authorization header.
* Required when the caller has no n8n session (e.g. external users).
*/
endpointToken?: string;
}
export class DynamicCredentialApiHelper {
constructor(private readonly api: ApiHelpers) {}
// ===== Resolver CRUD =====
async createResolver(options: CreateResolverOptions): Promise<CredentialResolver> {
const response = await this.api.request.post('/rest/credential-resolvers', {
data: options,
});
if (!response.ok()) {
throw new TestError(`Failed to create credential resolver: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
// ===== Execution status =====
/**
* GET /rest/workflows/:workflowId/execution-status
*
* Returns the execution status, asserting a 2xx response.
* For external (unauthenticated) callers, provide both `bearerToken` and `endpointToken`.
* For authenticated n8n users, the session cookie is used automatically.
*/
async getExecutionStatus(
workflowId: string,
options?: ExecutionStatusOptions,
): Promise<WorkflowExecutionStatus> {
const response = await this.getExecutionStatusRaw(workflowId, options);
if (!response.ok()) {
throw new TestError(
`Failed to get execution status: ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
return result.data ?? result;
}
/**
* GET /rest/workflows/:workflowId/execution-status
*
* Returns the raw Playwright APIResponse for status-code assertions.
*/
async getExecutionStatusRaw(
workflowId: string,
options?: ExecutionStatusOptions,
): Promise<APIResponse> {
const params = new URLSearchParams();
if (options?.authSource) {
params.set('authSource', options.authSource);
}
const headers: Record<string, string> = {};
if (options?.bearerToken) {
headers['Authorization'] = `Bearer ${options.bearerToken}`;
}
if (options?.endpointToken) {
headers['X-Authorization'] = options.endpointToken;
}
const query = params.toString();
const url = `/rest/workflows/${workflowId}/execution-status${query ? `?${query}` : ''}`;
return await this.api.request.get(url, { headers });
}
// ===== Authorization =====
/**
* POST /rest/credentials/:id/authorize?resolverId=:resolverId
*
* Starts the OAuth2 authorization flow for a credential.
* Returns the OAuth2 provider authorization URL (e.g. Keycloak login page).
* The caller should then follow this URL to complete the login and obtain the
* n8n callback URL (with code + state), then GET the callback URL using the
* n8n API context to store the tokens.
*/
async getAuthorizationUrl(
credentialId: string,
resolverId: string,
bearerToken: string,
): Promise<string> {
const response = await this.api.request.post(
`/rest/credentials/${credentialId}/authorize?resolverId=${encodeURIComponent(resolverId)}`,
{
data: {},
headers: { Authorization: `Bearer ${bearerToken}` },
},
);
if (!response.ok()) {
throw new TestError(
`Failed to get credential authorization URL: ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
return result.data ?? result; // The OAuth2 provider authorization URL
}
/**
* POSTs to the `authorizationUrl` returned by the execution-status endpoint.
*
* The execution-status response includes a full `authorizationUrl` for each
* missing credential (e.g. `https://n8n:5678/rest/credentials/:id/authorize?resolverId=...`).
* This helper extracts the path+query from that URL and posts to it using the
* api.request context, so that the session cookie is included automatically.
*
* Returns the OAuth2 provider authorization URL (e.g. Keycloak login page).
*/
async startAuthorizationFromStatusUrl(
statusAuthorizationUrl: string,
bearerToken: string,
): Promise<string> {
// Extract path+query to use with api.request (which has its own baseURL).
// This handles the case where the URL hostname differs from the test context baseURL.
const parsed = new URL(statusAuthorizationUrl);
const path = parsed.pathname + parsed.search;
const response = await this.api.request.post(path, {
data: {},
headers: { Authorization: `Bearer ${bearerToken}` },
});
if (!response.ok()) {
throw new TestError(
`Failed to start authorization: ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
return result.data ?? result; // The OAuth2 provider authorization URL
}
// ===== Revoke =====
}
@@ -0,0 +1,105 @@
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
interface ExternalSecretsProviderSettings {
region: string;
authMethod: string;
accessKeyId: string;
secretAccessKey: string;
}
interface SecretProviderConnectionDto {
providerKey: string;
type: string;
projectIds?: string[];
settings: ExternalSecretsProviderSettings;
}
export class ExternalSecretsApiHelper {
constructor(private api: ApiHelpers) {}
async getSecrets(providerName: string): Promise<string[]> {
const response = await this.api.request.get('/rest/external-secrets/secrets');
if (!response.ok()) {
throw new TestError(`Failed to get secrets: ${await response.text()}`);
}
const { data } = await response.json();
return data[providerName] ?? [];
}
async saveProviderSettings(
providerName: string,
settings: ExternalSecretsProviderSettings,
): Promise<void> {
const response = await this.api.request.post(
`/rest/external-secrets/providers/${providerName}`,
{ data: settings },
);
if (!response.ok()) {
throw new TestError(`Failed to save provider settings: ${await response.text()}`);
}
}
async testProvider(
providerName: string,
settings: ExternalSecretsProviderSettings,
): Promise<void> {
const response = await this.api.request.post(
`/rest/external-secrets/providers/${providerName}/test`,
{ data: settings },
);
if (!response.ok()) {
throw new TestError(`Failed to test provider: ${await response.text()}`);
}
}
async connectProvider(providerName: string): Promise<void> {
const response = await this.api.request.post(
`/rest/external-secrets/providers/${providerName}/connect`,
{ data: { connected: true } },
);
if (!response.ok()) {
throw new TestError(`Failed to connect provider: ${await response.text()}`);
}
}
async updateProvider(providerName: string): Promise<void> {
const response = await this.api.request.post(
`/rest/external-secrets/providers/${providerName}/update`,
);
if (!response.ok()) {
throw new TestError(`Failed to update provider: ${await response.text()}`);
}
}
async createConnection(
connection: SecretProviderConnectionDto,
): Promise<Record<string, unknown>> {
const response = await this.api.request.post('/rest/secret-providers/connections', {
data: connection,
});
if (!response.ok()) {
throw new TestError(`Failed to create connection: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async deleteConnection(providerKey: string): Promise<void> {
const response = await this.api.request.delete(
`/rest/secret-providers/connections/${providerKey}`,
);
if (!response.ok()) {
throw new TestError(`Failed to delete connection: ${await response.text()}`);
}
}
}
@@ -0,0 +1,977 @@
import type { APIResponse } from '@playwright/test';
import * as http from 'http';
import * as https from 'https';
import { nanoid } from 'nanoid';
import type { ApiHelpers } from './api-helper';
import { N8N_AUTH_COOKIE } from '../config/constants';
type HttpMethod = 'GET' | 'POST' | 'DELETE';
interface SseConnection {
sessionId: string;
postUrl: string;
response: http.IncomingMessage | null;
pendingMessages: Map<
string,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>;
onMessage: (data: string) => void;
}
export interface McpSession {
sessionId: string;
transport: 'sse' | 'streamableHttp';
postUrl?: string; // For SSE transport - the URL to POST messages to
}
export interface McpToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface McpToolCallResponse {
content: Array<{
type: string;
text: string;
}>;
isError?: boolean;
}
interface TriggerOptions {
method?: HttpMethod;
headers?: Record<string, string>;
data?: unknown;
maxNotFoundRetries?: number;
notFoundRetryDelayMs?: number;
}
interface McpJsonRpcRequest {
jsonrpc: '2.0';
id: string;
method: string;
params?: unknown;
}
interface McpJsonRpcResponse {
jsonrpc: '2.0';
id: string;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
/** Internal MCP session for the /mcp-server/http endpoint */
export interface InternalMcpSession {
apiKey: string;
}
/** Response from the internal MCP tools/list */
export interface InternalMcpToolsListResult {
tools: McpToolDefinition[];
}
/** Response from search_workflows tool */
export interface SearchWorkflowsResult {
data: Array<{
id: string;
name: string | null;
description?: string | null;
active: boolean | null;
createdAt: string | null;
updatedAt: string | null;
triggerCount: number | null;
scopes: string[];
canExecute: boolean;
availableInMCP: boolean;
}>;
count: number;
}
/** Response from get_workflow_details tool */
export interface WorkflowDetailsResult {
workflow: {
id: string;
name: string;
active: boolean;
isArchived: boolean;
versionId: string;
triggerCount: number;
createdAt: string;
updatedAt: string;
settings: Record<string, unknown> | null;
connections: Record<string, unknown>;
nodes: Array<Record<string, unknown>>;
tags: Array<{ id: string; name: string }>;
meta: Record<string, unknown> | null;
parentFolderId: string | null;
description?: string;
scopes: string[];
canExecute: boolean;
};
triggerInfo: unknown;
}
/** Response from execute_workflow tool */
export interface ExecuteWorkflowResult {
success: boolean;
executionId: string | null;
result?: unknown;
error?: unknown;
}
/**
* Helper class for interacting with MCP Server endpoints.
* Supports both SSE and Streamable HTTP transports.
*/
export class McpApiHelper {
private sseConnections = new Map<string, SseConnection>();
constructor(private readonly api: ApiHelpers) {}
// ===== SSE Transport Methods =====
/**
* Establishes an SSE connection with the MCP server using Node.js native http.
* This implementation uses streaming to handle the persistent SSE connection
* without blocking on the full response.
*
* @param path - The webhook path (e.g., 'webhook/mcp-basic')
* @param options - Optional headers for authentication
* @returns McpSession with sessionId and postUrl for sending messages
*/
async sseSetup(
path: string,
options?: { headers?: Record<string, string> },
): Promise<McpSession> {
// Get base URL and auth cookie from Playwright context
const storageState = await this.api.request.storageState();
const authCookie = storageState.cookies.find((c) => c.name === N8N_AUTH_COOKIE);
// Construct full URL - handle both absolute and relative paths
let fullUrl: string;
if (path.startsWith('http://') || path.startsWith('https://')) {
fullUrl = path;
} else {
// Get base URL from a test request
const testResponse = await this.api.request.get('/healthz');
const testUrl = testResponse.url();
const baseUrl = new URL(testUrl).origin;
fullUrl = `${baseUrl}/${path.replace(/^\//, '')}`;
}
const url = new URL(fullUrl);
const httpModule = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
...options?.headers,
};
if (authCookie) {
headers.Cookie = `${authCookie.name}=${authCookie.value}`;
}
return await new Promise((resolve, reject) => {
const req = httpModule.request(
url,
{
method: 'GET',
headers,
},
(res) => {
let buffer = '';
let resolved = false;
const sseConn: SseConnection = {
sessionId: '',
postUrl: '',
response: res,
pendingMessages: new Map(),
onMessage: () => {},
};
// Handler for processing incoming SSE messages
sseConn.onMessage = (data: string) => {
this.handleSseMessage(sseConn, data);
};
res.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
// Parse complete SSE events (separated by \n\n)
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';
for (const event of events) {
if (!event.trim()) continue;
const parsed = this.parseSseEvent(event);
if (parsed.type === 'endpoint' && !resolved) {
const match = parsed.data.match(/sessionId=([a-f0-9-]+)/);
if (match) {
sseConn.sessionId = match[1];
sseConn.postUrl = `${path}?sessionId=${match[1]}`;
// Store connection and resolve
this.sseConnections.set(match[1], sseConn);
resolved = true;
clearTimeout(timeout);
resolve({
sessionId: match[1],
transport: 'sse',
postUrl: sseConn.postUrl,
});
}
} else if (parsed.type === 'message' && parsed.data) {
// Handle response messages
sseConn.onMessage(parsed.data);
}
}
});
res.on('error', (error) => {
if (!resolved) {
reject(error);
}
// Reject any pending messages
for (const [, pending] of sseConn.pendingMessages) {
pending.reject(error);
}
sseConn.pendingMessages.clear();
});
res.on('end', () => {
if (!resolved) {
reject(new Error('SSE connection closed before receiving session ID'));
}
// Reject any pending messages
for (const [, pending] of sseConn.pendingMessages) {
pending.reject(new Error('SSE connection closed'));
}
sseConn.pendingMessages.clear();
});
},
);
req.on('error', reject);
// Timeout after 10 seconds
const timeout = setTimeout(() => {
if (!req.destroyed) {
req.destroy();
reject(new Error('SSE setup timeout'));
}
}, 10000);
// Clear timeout on success (handled in resolve)
req.on('close', () => clearTimeout(timeout));
req.end();
});
}
/**
* Handles incoming SSE messages and resolves pending requests.
*/
private handleSseMessage(conn: SseConnection, data: string): void {
try {
const message = JSON.parse(data) as McpJsonRpcResponse;
const msgId = message.id;
if (msgId && conn.pendingMessages.has(msgId)) {
const pending = conn.pendingMessages.get(msgId)!;
conn.pendingMessages.delete(msgId);
if (message.error) {
pending.reject(new Error(`MCP Error ${message.error.code}: ${message.error.message}`));
} else {
pending.resolve(message.result);
}
}
} catch {
// Ignore parse errors for non-JSON messages
}
}
/**
* Parses an SSE event string into type and data components.
*/
private parseSseEvent(event: string): { type: string; data: string } {
let type = 'message';
let data = '';
for (const line of event.split('\n')) {
if (line.startsWith('event:')) {
type = line.slice(6).trim();
} else if (line.startsWith('data:')) {
data = line.slice(5).trim();
}
}
return { type, data };
}
/**
* Closes an SSE connection.
*
* @param session - The MCP session to close
*/
sseClose(session: McpSession): void {
const conn = this.sseConnections.get(session.sessionId);
if (conn?.response) {
conn.response.destroy();
this.sseConnections.delete(session.sessionId);
}
}
/**
* Sends a JSON-RPC message via SSE transport (POST to the message endpoint).
* The actual response comes back via the SSE stream, not the POST response.
*
* @param session - The MCP session from sseSetup
* @param message - The JSON-RPC message to send
* @returns The API response (note: will be 202 Accepted, actual result comes via stream)
*/
async sseSendMessage(session: McpSession, message: unknown): Promise<APIResponse> {
if (session.transport !== 'sse' || !session.postUrl) {
throw new Error('Invalid SSE session: missing postUrl');
}
return await this.trigger(session.postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: message,
});
}
/**
* Sends a JSON-RPC message via SSE and waits for the response on the SSE stream.
*
* @param session - The MCP session from sseSetup
* @param message - The JSON-RPC message to send (must have an id field)
* @returns The result from the JSON-RPC response
*/
async sseSendAndWait<T>(session: McpSession, message: McpJsonRpcRequest): Promise<T> {
const conn = this.sseConnections.get(session.sessionId);
if (!conn) {
throw new Error('SSE connection not found for session');
}
// Set up promise to wait for response
const responsePromise = new Promise<T>((resolve, reject) => {
conn.pendingMessages.set(message.id, {
resolve: resolve as (value: unknown) => void,
reject,
});
// Timeout after 30 seconds
setTimeout(() => {
if (conn.pendingMessages.has(message.id)) {
conn.pendingMessages.delete(message.id);
reject(new Error('SSE response timeout'));
}
}, 30000);
});
// Send the message
await this.sseSendMessage(session, message);
// Wait for response via SSE stream
return await responsePromise;
}
/**
* Sends a JSON-RPC message to a specific path (different main) and waits for
* the response on this helper's SSE stream. Used for cross-main testing.
*
* @param session - The MCP session from sseSetup
* @param targetPath - The path to POST to (can be on a different main)
* @param message - The JSON-RPC message to send (must have an id field)
* @returns The result from the JSON-RPC response
*/
async sseSendAndWaitCrossMain<T>(
session: McpSession,
targetPath: string,
message: McpJsonRpcRequest,
): Promise<T> {
const conn = this.sseConnections.get(session.sessionId);
if (!conn) {
throw new Error(
'SSE connection not found for session. For cross-main testing, ' +
'ensure sseSetup was called on THIS API helper instance.',
);
}
// Set up promise to wait for response
const responsePromise = new Promise<T>((resolve, reject) => {
conn.pendingMessages.set(message.id, {
resolve: resolve as (value: unknown) => void,
reject,
});
// Timeout after 30 seconds
setTimeout(() => {
if (conn.pendingMessages.has(message.id)) {
conn.pendingMessages.delete(message.id);
reject(new Error('SSE response timeout'));
}
}, 30000);
});
// Send the message to the target path (different main) with session ID
const pathWithSession = `${targetPath}?sessionId=${session.sessionId}`;
await this.trigger(pathWithSession, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: message,
});
// Wait for response via SSE stream (on this helper's connection)
return await responsePromise;
}
// ===== Streamable HTTP Transport Methods =====
/**
* Initializes a Streamable HTTP session with the MCP server.
*
* @param path - The webhook path (e.g., 'webhook/mcp-basic')
* @param options - Optional headers for authentication
* @returns McpSession with sessionId
*/
async streamableHttpInitialize(
path: string,
options?: { headers?: Record<string, string> },
): Promise<McpSession> {
const initMessage = this.createMessage('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'n8n-e2e-test', version: '1.0.0' },
});
const response = await this.trigger(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...options?.headers,
},
data: initMessage,
});
const sessionId = response.headers()['mcp-session-id'];
if (!sessionId) {
const body = await response.text();
throw new Error(`Streamable HTTP init failed: No mcp-session-id header returned: ${body}`);
}
return {
sessionId,
transport: 'streamableHttp',
};
}
/**
* Sends a JSON-RPC message via Streamable HTTP transport.
*
* @param session - The MCP session from streamableHttpInitialize
* @param path - The webhook path
* @param message - The JSON-RPC message to send
* @returns The API response
*/
async streamableHttpSendMessage(
session: McpSession,
path: string,
message: unknown,
): Promise<APIResponse> {
if (session.transport !== 'streamableHttp') {
throw new Error('Invalid Streamable HTTP session');
}
return await this.trigger(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': session.sessionId,
},
data: message,
});
}
/**
* Closes a Streamable HTTP session via DELETE request.
*
* @param session - The MCP session to close
* @param path - The webhook path
* @returns The API response
*/
async streamableHttpDelete(session: McpSession, path: string): Promise<APIResponse> {
if (session.transport !== 'streamableHttp') {
throw new Error('Invalid Streamable HTTP session');
}
return await this.trigger(path, {
method: 'DELETE',
headers: {
'mcp-session-id': session.sessionId,
},
});
}
// ===== High-level MCP Protocol Methods =====
/**
* Lists all available tools from the MCP server.
*
* @param session - The MCP session
* @param path - The webhook path (required for Streamable HTTP)
* @returns Array of tool definitions
*/
async listTools(session: McpSession, path: string): Promise<McpToolDefinition[]> {
const message = this.createMessage('tools/list');
if (session.transport === 'sse') {
// For SSE, response comes via the stream
const result = await this.sseSendAndWait<{ tools: McpToolDefinition[] }>(session, message);
return result.tools;
} else {
const response = await this.streamableHttpSendMessage(session, path, message);
const result = await this.parseResponse<{ tools: McpToolDefinition[] }>(response);
return result.tools;
}
}
/**
* Calls a tool on the MCP server.
*
* @param session - The MCP session
* @param path - The webhook path (required for Streamable HTTP)
* @param toolName - The name of the tool to call
* @param args - The arguments to pass to the tool
* @returns The tool call response
*/
async callTool(
session: McpSession,
path: string,
toolName: string,
args: Record<string, unknown>,
): Promise<McpToolCallResponse> {
const message = this.createMessage('tools/call', {
name: toolName,
arguments: args,
});
if (session.transport === 'sse') {
// For SSE, response comes via the stream
return await this.sseSendAndWait<McpToolCallResponse>(session, message);
} else {
const response = await this.streamableHttpSendMessage(session, path, message);
return await this.parseResponse<McpToolCallResponse>(response);
}
}
/**
* Calls a tool via cross-main SSE transport.
* Use this when testing multi-main setups where:
* - This API helper holds the SSE connection (established via sseSetup)
* - The POST request should go to a different main's endpoint
* - The response comes back via this helper's SSE stream
*
* @param session - The MCP session (with SSE transport)
* @param targetPath - The target path to POST to (e.g., 'webhook/mcp-basic')
* @param toolName - The name of the tool to call
* @param args - The arguments to pass to the tool
* @returns The tool call response
*/
async callToolCrossMain(
session: McpSession,
targetPath: string,
toolName: string,
args: Record<string, unknown>,
): Promise<McpToolCallResponse> {
const message = this.createMessage('tools/call', {
name: toolName,
arguments: args,
});
return await this.sseSendAndWaitCrossMain<McpToolCallResponse>(session, targetPath, message);
}
/**
* Lists tools via cross-main SSE transport.
* Use this when testing multi-main setups where:
* - This API helper holds the SSE connection (established via sseSetup)
* - The POST request should go to a different main's endpoint
* - The response comes back via this helper's SSE stream
*
* @param session - The MCP session (with SSE transport)
* @param targetPath - The target path to POST to
* @returns Array of tool definitions
*/
async listToolsCrossMain(session: McpSession, targetPath: string): Promise<McpToolDefinition[]> {
const message = this.createMessage('tools/list');
const result = await this.sseSendAndWaitCrossMain<{ tools: McpToolDefinition[] }>(
session,
targetPath,
message,
);
return result.tools;
}
// ===== Helper Methods =====
/**
* Creates a JSON-RPC 2.0 message.
*
* @param method - The method name (e.g., 'tools/list', 'tools/call')
* @param params - Optional parameters for the method
* @param id - Optional message ID (auto-generated if not provided)
* @returns The JSON-RPC message object
*/
createMessage(method: string, params?: unknown, id?: string): McpJsonRpcRequest {
return {
jsonrpc: '2.0',
id: id ?? nanoid(),
method,
...(params !== undefined && { params }),
};
}
/**
* Parses a JSON-RPC response from an API response.
* Handles both direct JSON responses and SSE event streams.
*
* @param response - The API response to parse
* @returns The parsed result
*/
async parseResponse<T>(response: APIResponse): Promise<T> {
const contentType = response.headers()['content-type'] ?? '';
const body = await response.text();
// Handle SSE event stream responses
if (contentType.includes('text/event-stream')) {
return this.parseSSEResponse<T>(body);
}
// Handle JSON responses
const parsed = JSON.parse(body) as McpJsonRpcResponse;
if (parsed.error) {
throw new Error(`MCP Error ${parsed.error.code}: ${parsed.error.message}`);
}
return parsed.result as T;
}
/**
* Parses an SSE event stream to extract the JSON-RPC response.
*
* @param body - The SSE event stream body
* @returns The parsed result
*/
private parseSSEResponse<T>(body: string): T {
// SSE format: event: message\ndata: {...}\n\n
const lines = body.split('\n');
let jsonData = '';
for (const line of lines) {
if (line.startsWith('data:')) {
jsonData = line.slice(5).trim();
break;
}
}
if (!jsonData) {
throw new Error(`Could not extract data from SSE response: ${body}`);
}
const parsed = JSON.parse(jsonData) as McpJsonRpcResponse;
if (parsed.error) {
throw new Error(`MCP Error ${parsed.error.code}: ${parsed.error.message}`);
}
return parsed.result as T;
}
/**
* Parses an SSE event stream for tool call responses.
* Extracts the McpToolCallResponse from the SSE body.
*/
private parseSSEToolResponse(body: string): McpToolCallResponse {
const lines = body.split('\n');
let jsonData = '';
for (const line of lines) {
if (line.startsWith('data:')) {
jsonData = line.slice(5).trim();
break;
}
}
if (!jsonData) {
throw new Error(`Could not extract data from SSE response: ${body}`);
}
const parsed = JSON.parse(jsonData) as McpJsonRpcResponse;
if (parsed.error) {
throw new Error(`MCP Error ${parsed.error.code}: ${parsed.error.message}`);
}
return parsed.result as McpToolCallResponse;
}
/**
* Triggers an HTTP request to the MCP endpoint with retry logic for 404s.
* Based on WebhookApiHelper.trigger().
*/
private async trigger(path: string, options?: TriggerOptions): Promise<APIResponse> {
const maxNotFoundRetries = options?.maxNotFoundRetries ?? 5;
const notFoundRetryDelayMs = options?.notFoundRetryDelayMs ?? 500;
let lastResponse: APIResponse | undefined;
for (let attempt = 0; attempt <= maxNotFoundRetries; attempt++) {
lastResponse = await this.api.request.fetch(path, {
method: options?.method ?? 'GET',
headers: options?.headers,
data: options?.data,
maxRetries: 3,
});
if (lastResponse.status() !== 404 || attempt === maxNotFoundRetries) {
return lastResponse;
}
await new Promise((resolve) => setTimeout(resolve, notFoundRetryDelayMs));
}
return lastResponse!;
}
// ===== Internal MCP Service Methods (/mcp-server/http) =====
/**
* Sends a JSON-RPC message to the internal MCP service endpoint.
* This endpoint uses Bearer token authentication (API key).
*
* @param apiKey - The MCP API key for authentication
* @param message - The JSON-RPC message to send
* @returns The API response
*/
async internalMcpSendMessage(apiKey: string, message: unknown): Promise<APIResponse> {
return await this.api.request.fetch('/mcp-server/http', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${apiKey}`,
},
data: message,
});
}
/**
* Sends a raw request to the internal MCP service without authentication.
* Useful for testing authentication rejection.
*
* @param message - The JSON-RPC message to send
* @param headers - Optional custom headers
* @returns The API response
*/
async internalMcpSendMessageNoAuth(
message: unknown,
headers?: Record<string, string>,
): Promise<APIResponse> {
return await this.api.request.fetch('/mcp-server/http', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...headers,
},
data: message,
});
}
/**
* Lists all available tools from the internal MCP service.
*
* @param apiKey - The MCP API key for authentication
* @returns Array of tool definitions
*/
async internalMcpListTools(apiKey: string): Promise<McpToolDefinition[]> {
const message = this.createMessage('tools/list');
const response = await this.internalMcpSendMessage(apiKey, message);
const result = await this.parseResponse<InternalMcpToolsListResult>(response);
return result.tools;
}
/**
* Calls search_workflows tool on the internal MCP service.
*
* @param apiKey - The MCP API key for authentication
* @param args - Search arguments (limit, query, projectId)
* @returns Search results with workflow data
*/
async internalMcpSearchWorkflows(
apiKey: string,
args: { limit?: number; query?: string; projectId?: string } = {},
): Promise<SearchWorkflowsResult> {
const message = this.createMessage('tools/call', {
name: 'search_workflows',
arguments: args,
});
const response = await this.internalMcpSendMessage(apiKey, message);
const contentType = response.headers()['content-type'] ?? '';
const body = await response.text();
// Parse the response (handles both SSE and JSON)
let result: McpToolCallResponse;
if (contentType.includes('text/event-stream')) {
result = this.parseSSEToolResponse(body);
} else {
const parsed = JSON.parse(body) as { result?: McpToolCallResponse; error?: unknown };
if (parsed.error) {
throw new Error(`MCP Error: ${JSON.stringify(parsed.error)}`);
}
result = parsed.result as McpToolCallResponse;
}
// The tool returns structuredContent with the data, or text content with JSON
if (result?.content?.[0]?.text) {
const text = result.content[0].text;
try {
return JSON.parse(text) as SearchWorkflowsResult;
} catch {
if (result.isError) {
throw new Error(text);
}
throw new Error(`Invalid JSON response from search_workflows: ${text}`);
}
}
throw new Error(
`Unexpected response format from search_workflows: ${JSON.stringify(result ?? body)}`,
);
}
/**
* Calls get_workflow_details tool on the internal MCP service.
*
* @param apiKey - The MCP API key for authentication
* @param workflowId - The workflow ID to get details for
* @returns Workflow details
*/
async internalMcpGetWorkflowDetails(
apiKey: string,
workflowId: string,
): Promise<WorkflowDetailsResult> {
const message = this.createMessage('tools/call', {
name: 'get_workflow_details',
arguments: { workflowId },
});
const response = await this.internalMcpSendMessage(apiKey, message);
const contentType = response.headers()['content-type'] ?? '';
const body = await response.text();
// Parse the response (handles both SSE and JSON)
let result: McpToolCallResponse;
if (contentType.includes('text/event-stream')) {
result = this.parseSSEToolResponse(body);
} else {
const parsed = JSON.parse(body) as { result?: McpToolCallResponse; error?: unknown };
if (parsed.error) {
throw new Error(`MCP Error: ${JSON.stringify(parsed.error)}`);
}
result = parsed.result as McpToolCallResponse;
}
if (result?.content?.[0]?.text) {
const text = result.content[0].text;
try {
return JSON.parse(text) as WorkflowDetailsResult;
} catch {
if (result.isError) {
throw new Error(text);
}
throw new Error(`Invalid JSON response from get_workflow_details: ${text}`);
}
}
throw new Error(
`Unexpected response format from get_workflow_details: ${JSON.stringify(result ?? body)}`,
);
}
/**
* Calls execute_workflow tool on the internal MCP service.
*
* @param apiKey - The MCP API key for authentication
* @param workflowId - The workflow ID to execute
* @param inputs - Optional inputs for the workflow
* @returns Execution result
*/
async internalMcpExecuteWorkflow(
apiKey: string,
workflowId: string,
inputs?: Record<string, unknown>,
): Promise<ExecuteWorkflowResult> {
const args: Record<string, unknown> = { workflowId };
if (inputs) {
args.inputs = inputs;
}
const message = this.createMessage('tools/call', {
name: 'execute_workflow',
arguments: args,
});
const response = await this.internalMcpSendMessage(apiKey, message);
const contentType = response.headers()['content-type'] ?? '';
const body = await response.text();
// Parse the response (handles both SSE and JSON)
let result: McpToolCallResponse;
if (contentType.includes('text/event-stream')) {
result = this.parseSSEToolResponse(body);
} else {
const parsed = JSON.parse(body) as { result?: McpToolCallResponse; error?: unknown };
if (parsed.error) {
throw new Error(`MCP Error: ${JSON.stringify(parsed.error)}`);
}
result = parsed.result as McpToolCallResponse;
}
if (result?.content?.[0]?.text) {
const text = result.content[0].text;
try {
return JSON.parse(text) as ExecuteWorkflowResult;
} catch {
if (result.isError) {
return {
success: false,
executionId: null,
error: text,
};
}
throw new Error(`Invalid JSON response from execute_workflow: ${text}`);
}
}
if (result?.isError) {
return {
success: false,
executionId: null,
error: JSON.stringify(result),
};
}
throw new Error(
`Unexpected response format from execute_workflow: ${JSON.stringify(result ?? body)}`,
);
}
}
@@ -0,0 +1,138 @@
import type { Folder, Project } from '@n8n/db';
import { nanoid } from 'nanoid';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
export class ProjectApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create a new project with a unique name
* @param projectName Optional base name for the project. If not provided, generates a default name.
* @returns The created project data
*/
async createProject(projectName?: string): Promise<Project> {
const uniqueName = projectName ? `${projectName} (${nanoid(8)})` : `Test Project ${nanoid(8)}`;
const response = await this.api.request.post('/rest/projects', {
data: {
name: uniqueName,
},
});
if (!response.ok()) {
throw new TestError(`Failed to create project: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Get the current logged-in user's personal project.
* Uses the dedicated /rest/projects/personal endpoint which returns
* only the authenticated user's personal project, not all visible personal projects.
* @returns The current user's personal project
*/
async getMyPersonalProject(): Promise<Project> {
const response = await this.api.request.get('/rest/projects/personal');
if (!response.ok()) {
throw new TestError(`Failed to get personal project: ${await response.text()}`);
}
const result = await response.json();
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;
}
/**
* Create a new folder in a project
* @param projectId The ID of the project to create the folder in
* @param folderName The name of the folder to create
* @param parentFolderId Optional parent folder ID for nested folders
* @returns The created folder data
*/
async createFolder(
projectId: string,
folderName?: string,
parentFolderId?: string,
): Promise<Folder> {
const uniqueName = folderName ? `${folderName} (${nanoid(8)})` : `Test Folder ${nanoid(8)}`;
const response = await this.api.request.post(`/rest/projects/${projectId}/folders`, {
data: {
name: uniqueName,
...(parentFolderId && { parentFolderId }),
},
});
if (!response.ok()) {
throw new TestError(`Failed to create folder: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Private helper: Add multiple users to a project
* @param projectId The ID of the project
* @param relations Array of userId and role pairs
* @returns True if users were added successfully
*/
private async addUsersToProject(
projectId: string,
relations: Array<{ userId: string; role: string }>,
): Promise<boolean> {
const response = await this.api.request.post(`/rest/projects/${projectId}/users`, {
data: { relations },
});
if (!response.ok()) {
throw new TestError(`Failed to add users to project: ${await response.text()}`);
}
return true;
}
/**
* Add a user to a project
* @param projectId The ID of the project
* @param userId The ID of the user to add
* @param role The role to assign to the user (e.g., 'project:editor', 'project:viewer', 'project:admin')
* @returns True if user was added successfully
*/
async addUserToProject(projectId: string, userId: string, role: string): Promise<boolean> {
return await this.addUsersToProject(projectId, [{ userId, role }]);
}
/**
* Add a user to a project by email
* @param projectId The ID of the project
* @param email The email of the user to add
* @param role The role to assign to the user (e.g., 'project:editor', 'project:viewer', 'project:admin')
* @returns True if user was added successfully
*/
async addUserToProjectByEmail(projectId: string, email: string, role: string): Promise<boolean> {
const user = await this.api.users.getUserByEmail(email);
if (!user) {
throw new TestError(`User with email ${email} not found`);
}
return await this.addUserToProject(projectId, user.id, role);
}
}
@@ -0,0 +1,177 @@
import type { ApiKeyScope } from '@n8n/permissions';
import { request } from '@playwright/test';
import { nanoid } from 'nanoid';
import type { ApiHelpers } from './api-helper';
import type { TestUser } from './user-api-helper';
import { TestError } from '../Types';
export interface ApiKey {
id: string;
label: string;
apiKey: string;
rawApiKey: string;
createdAt: string;
expiresAt: string | null;
}
/** Default scopes for test API keys - covers most common operations */
const DEFAULT_API_KEY_SCOPES: ApiKeyScope[] = [
'user:read',
'user:list',
'user:create',
'user:delete',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'credential:create',
'credential:update',
'credential:delete',
'project:create',
'project:update',
'project:delete',
'project:list',
];
/** Helper for working with n8n's Public API using API key authentication. */
export class PublicApiHelper {
private apiKey: string | null = null;
constructor(private readonly api: ApiHelpers) {}
async createApiKey(
label?: string,
scopes: ApiKeyScope[] = DEFAULT_API_KEY_SCOPES,
): Promise<ApiKey> {
const keyLabel = label ?? `E2E Test API Key ${nanoid()}`;
const response = await this.api.request.post('/rest/api-keys', {
data: { label: keyLabel, scopes, expiresAt: null },
});
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(
`Failed to create API key "${keyLabel}": ${response.status()} ${errorText}`,
);
}
const result = await response.json();
const apiKeyData = result.data ?? result;
this.apiKey = apiKeyData.rawApiKey;
return apiKeyData;
}
private async ensureApiKey(): Promise<string> {
if (!this.apiKey) {
await this.createApiKey();
}
return this.apiKey!;
}
private async getApiHeaders(): Promise<Record<string, string>> {
return { 'X-N8N-API-KEY': await this.ensureApiKey() };
}
/** Invite a user and return the invite accept URL for completing registration. */
async inviteUser(
email: string,
role: 'global:member' | 'global:admin' = 'global:member',
): Promise<{ id: string; email: string; inviteAcceptUrl: string; emailSent: boolean }> {
const headers = await this.getApiHeaders();
const response = await this.api.request.post('/api/v1/users', {
headers,
data: [{ email, role }],
});
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(`Failed to invite user: ${response.status()} ${errorText}`);
}
const result = await response.json();
const userResult = result[0];
if (!userResult) {
throw new TestError('Failed to invite user: empty response from API');
}
if (userResult.error) {
throw new TestError(`Failed to invite user: ${userResult.error}`);
}
return userResult.user;
}
/**
* Create a fully activated user by inviting them via the Public API and accepting the invitation.
*
* n8n's Public API doesn't have a direct "create user" endpoint. Users must be invited first,
* then accept the invitation to complete registration. The invitation acceptance endpoint
* (`/rest/invitations/:id/accept`) automatically logs in the new user by setting session cookies.
*
* To prevent this from hijacking the current browser session (which would log out the owner),
* we use an isolated request context for the acceptance step. This ensures the owner's session
* remains intact and multiple users can be created consecutively without session interference.
*/
async createUser(
options: {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
role?: 'global:member' | 'global:admin';
} = {},
): Promise<TestUser> {
const email = options.email ?? `testuser-${nanoid()}@test.com`;
const password = options.password ?? 'PlaywrightTest123';
const firstName = options.firstName ?? 'Test';
const lastName = options.lastName ?? `User${nanoid()}`;
const role = options.role ?? 'global:member';
const invited = await this.inviteUser(email, role);
const url = new URL(invited.inviteAcceptUrl);
const inviterId = url.searchParams.get('inviterId');
const inviteeId = url.searchParams.get('inviteeId');
// Use an isolated request context to prevent session cookie contamination.
// The accept endpoint sets cookies that would otherwise override the current user's session.
const isolatedContext = await request.newContext({ baseURL: url.origin });
try {
const acceptResponse = await isolatedContext.post(`/rest/invitations/${inviteeId}/accept`, {
data: { inviterId, firstName, lastName, password },
});
if (!acceptResponse.ok()) {
const errorText = await acceptResponse.text();
throw new TestError(`Failed to accept invitation: ${acceptResponse.status()} ${errorText}`);
}
} finally {
await isolatedContext.dispose();
}
return { id: invited.id, email, password, firstName, lastName, role: role as TestUser['role'] };
}
async getUsers(options?: { includeRole?: boolean; limit?: number }): Promise<
Array<{ id: string; email: string; firstName: string; lastName: string; role?: string }>
> {
const headers = await this.getApiHeaders();
const params = new URLSearchParams();
if (options?.includeRole) params.set('includeRole', 'true');
if (options?.limit) params.set('limit', options.limit.toString());
const response = await this.api.request.get('/api/v1/users', { headers, params });
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(`Failed to get users: ${response.status()} ${errorText}`);
}
const result = await response.json();
return result.data;
}
}
@@ -0,0 +1,33 @@
import { nanoid } from 'nanoid';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
export class RoleApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create a custom role with unique name via REST API
* @param scopes Array of scope strings (e.g., ['project:read', 'workflow:read'])
* @param displayName Base display name for the role (will be made unique with nanoid)
* @returns The created role data including slug
*/
async createCustomRole(scopes: string[], displayName: string): Promise<{ slug: string }> {
const uniqueName = `${displayName} (${nanoid(8)})`;
const response = await this.api.request.post('/rest/roles', {
data: {
displayName: uniqueName,
description: `Custom role with scopes: ${scopes.join(', ')}`,
roleType: 'project',
scopes,
},
});
if (!response.ok()) {
throw new TestError(`Failed to create custom role: ${await response.text()}`);
}
const result = await response.json();
return result.data;
}
}
@@ -0,0 +1,63 @@
import { TestError } from '../Types';
import type { ApiHelpers } from './api-helper';
export class SourceControlApiHelper {
constructor(private api: ApiHelpers) {}
async disconnect({ keepKeyPair = true }: { keepKeyPair?: boolean } = {}) {
const response = await this.api.request.post('/rest/source-control/disconnect', {
data: {
keepKeyPair,
},
});
if (!response.ok()) {
throw new TestError(`Failed to disconnect from source control: ${await response.text()}`);
}
const result = await response.json();
return result.data;
}
async connect(preferences: {
repositoryUrl: string;
}) {
const response = await this.api.request.post('/rest/source-control/preferences', {
data: {
connectionType: 'ssh',
...preferences,
},
});
if (!response.ok()) {
throw new TestError(`Failed to connect to source control: ${await response.text()}`);
}
const result = await response.json();
return result.data;
}
/**
* This will push all the changes
* OPTIMIZE: add a fileNames to select what specific changes to push
* @returns
*/
async pushWorkFolder({
commitMessage,
force = false,
}: {
commitMessage: string;
force?: boolean;
}) {
const response = await this.api.request.post('/rest/source-control/push-workfolder', {
data: {
commitMessage,
force,
fileNames: [],
},
});
if (!response.ok()) {
throw new TestError(`Failed to push work folder: ${await response.text()}`);
}
const result = await response.json();
return result.data;
}
}
@@ -0,0 +1,74 @@
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
export interface Tag {
id: string;
name: string;
createdAt?: string;
updatedAt?: string;
}
/**
* Helper class for managing tags via the n8n API
*/
export class TagApiHelper {
constructor(private readonly api: ApiHelpers) {}
/**
* Create a new tag
* @param name - The name of the tag to create
* @returns The created tag with its ID
*/
async create(name: string): Promise<Tag> {
const response = await this.api.request.post('/rest/tags', {
data: { name },
});
if (!response.ok()) {
throw new TestError(
`Failed to create tag "${name}": ${response.status()} ${await response.text()}`,
);
}
const result = await response.json();
// Unwrap the data property if it exists
return result.data ?? result;
}
/**
* Delete a tag by ID
* @param id - The ID of the tag to delete
*/
async delete(id: string): Promise<void> {
const response = await this.api.request.delete(`/rest/tags/${id}`);
if (!response.ok()) {
throw new TestError(`Failed to delete tag ${id}: ${response.status()}`);
}
}
/**
* Get all tags
* @returns Array of all tags
*/
async getAll(): Promise<Tag[]> {
const response = await this.api.request.get('/rest/tags');
if (!response.ok()) {
throw new TestError(`Failed to get tags: ${response.status()}`);
}
const result = await response.json();
return Array.isArray(result) ? result : (result.data ?? []);
}
/**
* Delete all tags, optionally filtered by prefix
* @param prefix - If provided, only delete tags whose names start with this prefix
*/
async deleteAll(prefix?: string): Promise<void> {
const tags = await this.getAll();
const toDelete = prefix ? tags.filter((tag) => tag.name.startsWith(prefix)) : tags;
await Promise.all(toDelete.map((tag) => this.delete(tag.id)));
}
}
@@ -0,0 +1,96 @@
import type { User } from '@n8n/api-types';
import { customAlphabet } from 'nanoid';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
export interface TestUser {
id: string;
email: string;
password: string;
firstName: string;
lastName: string;
role: 'global:owner' | 'global:admin' | 'global:member';
}
/**
* Creates test users via n8n's invitation API.
* Note: Using this with n8n.api will affect browser cookies. Use with the isolated api fixture instead unless you want to overwrite the existing user
*/
export class UserApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create and activate a test user
*/
async create(options: Partial<TestUser> = {}): Promise<TestUser> {
const user = {
email: options.email?.toLowerCase() ?? `testuser${nanoid()}@test.com`,
password: options.password ?? 'PlaywrightTest123',
firstName: options.firstName ?? 'Test',
lastName: options.lastName ?? `User${nanoid()}`,
role: options.role ?? 'global:member',
};
// Invite user
const inviteResponse = await this.api.request.post('/rest/invitations', {
data: [{ email: user.email, role: user.role }],
});
if (!inviteResponse.ok()) {
throw new TestError(`Failed to invite user: ${inviteResponse.status()}`);
}
const inviteData = await inviteResponse.json();
const { id, inviteAcceptUrl } = inviteData.data[0].user;
// Accept invitation
const url = new URL(inviteAcceptUrl);
const inviterId = url.searchParams.get('inviterId');
const inviteeId = url.searchParams.get('inviteeId');
const acceptResponse = await this.api.request.post(`/rest/invitations/${inviteeId}/accept`, {
data: {
inviterId,
firstName: user.firstName,
lastName: user.lastName,
password: user.password,
},
});
if (!acceptResponse.ok()) {
throw new TestError(`Failed to accept invitation: ${acceptResponse.status()}`);
}
return { id, ...user };
}
/**
* Get all users, with optional filtering by email, firstName, lastName, or fullText search
*/
async getUsers(options?: {
filter?: { email?: string; firstName?: string; lastName?: string; fullText?: string };
}): Promise<User[]> {
const params = new URLSearchParams();
if (options?.filter) {
params.set('filter', JSON.stringify(options.filter));
}
const response = await this.api.request.get('/rest/users', { params });
if (!response.ok()) {
throw new TestError(`Failed to get users: ${response.status()}`);
}
const json = await response.json();
// API returns { data: { count, items: [...users] } }
return json.data?.items ?? [];
}
/**
* Get a single user by email address
* @param email - The email address to search for
* @returns User object if found, null if no user exists with that email
*/
async getUserByEmail(email: string): Promise<User | null> {
const users = await this.getUsers({ filter: { email } });
return users[0] ?? null;
}
}
@@ -0,0 +1,79 @@
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
interface VariableResponse {
id: string;
key: string;
value: string;
}
interface CreateVariableDto {
key: string;
value: string;
projectId?: string;
}
export class VariablesApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create a new variable
*/
async createVariable(variable: CreateVariableDto): Promise<VariableResponse> {
const response = await this.api.request.post('/rest/variables', { data: variable });
if (!response.ok()) {
throw new TestError(`Failed to create variable: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Get all variables
*/
async getAllVariables(): Promise<VariableResponse[]> {
const response = await this.api.request.get('/rest/variables');
if (!response.ok()) {
throw new TestError(`Failed to get variables: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/**
* Delete a variable by ID
*/
async deleteVariable(id: string): Promise<void> {
const response = await this.api.request.delete(`/rest/variables/${id}`);
if (!response.ok()) {
throw new TestError(`Failed to delete variable: ${await response.text()}`);
}
}
/**
* Delete all variables (useful for test cleanup)
*/
async deleteAllVariables(): Promise<void> {
const variables = await this.getAllVariables();
// Delete variables in parallel for better performance
await Promise.all(variables.map((variable) => this.deleteVariable(variable.id)));
}
/**
* Create a test variable with a unique key
*/
async createTestVariable(
keyPrefix: string = 'TEST_VAR',
value: string = 'test_value',
projectId?: string,
): Promise<VariableResponse> {
const key = `${keyPrefix}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
return await this.createVariable({ key, value, projectId });
}
}
@@ -0,0 +1,44 @@
import type { APIResponse } from '@playwright/test';
import type { ApiHelpers } from './api-helper';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
interface TriggerOptions {
method?: HttpMethod;
headers?: Record<string, string>;
data?: unknown;
/** Max retries for 404 (webhook not yet registered). Default: 3 */
maxNotFoundRetries?: number;
/** Delay between 404 retries in ms. Default: 250 */
notFoundRetryDelayMs?: number;
}
/** Triggers webhooks with retry for 404s (async registration) and connection errors. */
export class WebhookApiHelper {
constructor(private readonly api: ApiHelpers) {}
async trigger(path: string, options?: TriggerOptions): Promise<APIResponse> {
const maxNotFoundRetries = options?.maxNotFoundRetries ?? 3;
const notFoundRetryDelayMs = options?.notFoundRetryDelayMs ?? 250;
let lastResponse: APIResponse | undefined;
for (let attempt = 0; attempt <= maxNotFoundRetries; attempt++) {
lastResponse = await this.api.request.fetch(path, {
method: options?.method ?? 'GET',
headers: options?.headers,
data: options?.data,
maxRetries: 3, // Playwright retry for connection errors
});
if (lastResponse.status() !== 404 || attempt === maxNotFoundRetries) {
return lastResponse;
}
await new Promise((resolve) => setTimeout(resolve, notFoundRetryDelayMs));
}
return lastResponse!;
}
}
@@ -0,0 +1,406 @@
import { readFileSync } from 'fs';
import type { IWorkflowBase, ExecutionSummary } from 'n8n-workflow';
import { nanoid } from 'nanoid';
// Type for execution responses from the n8n API
// Couldn't find the exact type so I put these ones together
interface ExecutionListResponse extends ExecutionSummary {
data: string;
workflowData: IWorkflowBase;
}
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
import { resolveFromRoot } from '../utils/path-helper';
type WorkflowImportResult = {
workflowId: string;
createdWorkflow: IWorkflowBase;
webhookPath?: string;
webhookId?: string;
webhookMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
};
export class WorkflowApiHelper {
constructor(private api: ApiHelpers) {}
async createWorkflow(workflow: Partial<IWorkflowBase>) {
const response = await this.api.request.post('/rest/workflows', { data: workflow });
if (!response.ok()) {
throw new TestError(`Failed to create workflow: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/** Creates a workflow in a project with optional folder placement. */
async createInProject(
project: string,
options?: {
folder?: string;
name?: string;
},
): Promise<{ name: string; id: string; versionId: string }> {
const workflowName = options?.name ?? `Test Workflow ${nanoid(8)}`;
const workflow = {
name: workflowName,
nodes: [],
connections: {},
settings: {},
active: false,
projectId: project,
...(options?.folder && { parentFolderId: options.folder }),
};
const response = await this.api.request.post('/rest/workflows', { data: workflow });
if (!response.ok()) {
throw new TestError(`Failed to create workflow: ${await response.text()}`);
}
const result = await response.json();
const workflowData = result.data ?? result;
return {
name: workflowName,
id: workflowData.id,
versionId: workflowData.versionId,
};
}
async activate(workflowId: string, versionId: string) {
const response = await this.api.request.post(`/rest/workflows/${workflowId}/activate`, {
data: { versionId },
});
if (!response.ok()) {
throw new TestError(`Failed to activate workflow: ${await response.text()}`);
}
}
async update(
workflowId: string,
versionId: string,
data: Partial<IWorkflowBase>,
): Promise<IWorkflowBase> {
const response = await this.api.request.patch(`/rest/workflows/${workflowId}`, {
data: {
...data,
versionId,
},
});
if (!response.ok()) {
throw new TestError(`Failed to update workflow: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
/** Triggers a manual workflow execution from a specific trigger node. */
async runManually(workflowId: string, triggerNodeName: string): Promise<{ executionId: string }> {
const response = await this.api.request.post(`/rest/workflows/${workflowId}/run`, {
data: {
triggerToStartFrom: { name: triggerNodeName },
},
});
if (!response.ok()) {
throw new TestError(`Failed to run workflow: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async deactivate(workflowId: string) {
const response = await this.api.request.post(`/rest/workflows/${workflowId}/deactivate`);
if (!response.ok()) {
throw new TestError(`Failed to deactivate workflow: ${await response.text()}`);
}
}
async archive(workflowId: string) {
const response = await this.api.request.post(`/rest/workflows/${workflowId}/archive`);
if (!response.ok()) {
throw new TestError(`Failed to archive workflow: ${await response.text()}`);
}
}
async delete(workflowId: string) {
const response = await this.api.request.delete(`/rest/workflows/${workflowId}`);
if (!response.ok()) {
throw new TestError(`Failed to delete workflow: ${await response.text()}`);
}
}
async shareWorkflow(workflowId: string, shareWithIds: string[]) {
const response = await this.api.request.put(`/rest/workflows/${workflowId}/share`, {
data: { shareWithIds },
});
if (!response.ok()) {
throw new TestError(`Failed to share workflow: ${await response.text()}`);
}
}
async getWorkflows() {
const response = await this.api.request.get('/rest/workflows');
if (!response.ok()) {
throw new TestError(`Failed to get workflows: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async transfer(workflowId: string, destinationProjectId: string) {
const response = await this.api.request.put(`/rest/workflows/${workflowId}/transfer`, {
data: { destinationProjectId },
});
if (!response.ok()) {
throw new TestError(`Failed to transfer workflow: ${await response.text()}`);
}
}
/**
* Set tags on a workflow via API
* @param workflowId - The workflow ID
* @param tagIds - Array of tag IDs to assign to the workflow
*/
async setTags(workflowId: string, tagIds: string[]): Promise<void> {
const getResponse = await this.api.request.get(`/rest/workflows/${workflowId}`);
if (!getResponse.ok()) {
throw new TestError(`Failed to get workflow: ${await getResponse.text()}`);
}
const workflowData = await getResponse.json();
const workflow = workflowData.data ?? workflowData;
const response = await this.api.request.patch(`/rest/workflows/${workflowId}`, {
data: {
versionId: workflow.versionId,
tags: tagIds,
},
});
if (!response.ok()) {
throw new TestError(`Failed to set workflow tags: ${await response.text()}`);
}
}
/** Makes workflow unique by updating name, IDs, and webhook paths. */
private makeWorkflowUnique(
workflow: Partial<IWorkflowBase>,
options?: { webhookPrefix?: string; idLength?: number },
) {
delete workflow.id;
const idLength = options?.idLength ?? 12;
const webhookPrefix = options?.webhookPrefix ?? 'test-webhook';
const uniqueSuffix = nanoid(idLength);
// Make workflow name unique; add a default if missing
if (workflow.name && workflow.name.trim().length > 0) {
workflow.name = `${workflow.name} (Test ${uniqueSuffix})`;
} else {
workflow.name = `Test Workflow ${uniqueSuffix}`;
}
// Ensure workflow is inactive by default when not specified
workflow.active ??= false;
// Check if workflow has webhook nodes and process them
let webhookId: string | undefined;
let webhookPath: string | undefined;
let webhookMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | undefined;
if (workflow.nodes) {
for (const node of workflow.nodes) {
if (node.type === 'n8n-nodes-base.webhook') {
webhookId = nanoid(idLength);
webhookPath = `${webhookPrefix}-${webhookId}`;
node.webhookId = webhookId;
node.parameters.path = webhookPath;
// Extract HTTP method from webhook node, default to GET
webhookMethod = (node.parameters.httpMethod as typeof webhookMethod) ?? 'GET';
}
// Handle MCP Trigger nodes - make their paths unique
// Note: webhookId is required for isFullPath: true webhooks to work correctly.
// Without it, the webhook path becomes workflowId/nodeName/path instead of just path.
if (node.type === '@n8n/n8n-nodes-langchain.mcpTrigger') {
const mcpId = nanoid(idLength);
const currentPath = (node.parameters.path as string) ?? 'mcp';
node.parameters.path = `${currentPath}-${mcpId}`;
node.webhookId = mcpId;
}
}
}
return { webhookId, webhookPath, webhookMethod, workflow };
}
/** Creates a workflow from definition, making it unique for testing. */
async createWorkflowFromDefinition(
workflow: Partial<IWorkflowBase>,
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
): Promise<WorkflowImportResult> {
const { makeUnique = true, ...rest } = options ?? {};
const { webhookPath, webhookId, webhookMethod } = makeUnique
? this.makeWorkflowUnique(workflow, rest)
: { webhookPath: undefined, webhookId: undefined, webhookMethod: undefined };
const createdWorkflow = await this.createWorkflow(workflow);
const workflowId: string = String(createdWorkflow.id);
return {
workflowId,
createdWorkflow,
webhookPath,
webhookId,
webhookMethod,
};
}
/** Imports a workflow from file, making it unique for testing. */
async importWorkflowFromFile(
fileName: string,
options?: {
webhookPrefix?: string;
idLength?: number;
makeUnique?: boolean;
transform?: (workflow: Partial<IWorkflowBase>) => Partial<IWorkflowBase>;
},
): Promise<WorkflowImportResult> {
const filePath = resolveFromRoot('workflows', fileName);
const fileContent = readFileSync(filePath, 'utf8');
let workflowDefinition = JSON.parse(fileContent) as IWorkflowBase;
// Apply transform if provided
if (options?.transform) {
workflowDefinition = options.transform(workflowDefinition) as IWorkflowBase;
}
return await this.importWorkflowFromDefinition(workflowDefinition, options);
}
async importWorkflowFromDefinition(
workflowDefinition: Partial<IWorkflowBase>,
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
): Promise<WorkflowImportResult> {
const result = await this.createWorkflowFromDefinition(workflowDefinition, options);
if (workflowDefinition.active) {
await this.activate(result.workflowId, result.createdWorkflow.versionId!);
}
return result;
}
async getExecutions(workflowId?: string, limit = 20): Promise<ExecutionListResponse[]> {
const params = new URLSearchParams();
if (workflowId) {
params.set('filter', JSON.stringify({ workflowId }));
}
params.set('limit', limit.toString());
const response = await this.api.request.get('/rest/executions', { params });
if (!response.ok()) {
throw new TestError(`Failed to get executions: ${await response.text()}`);
}
const result = await response.json();
if (Array.isArray(result)) return result;
if (result.data?.results) return result.data.results;
if (result.data) return result.data;
return [];
}
async getExecution(executionId: string): Promise<ExecutionListResponse> {
const response = await this.api.request.get(`/rest/executions/${executionId}`);
if (!response.ok()) {
throw new TestError(`Failed to get execution: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async waitForExecution(
workflowId: string,
timeoutMs = 10000,
mode: 'manual' | 'webhook' | 'trigger' | 'integrated' = 'webhook',
): Promise<ExecutionListResponse> {
const initialExecutions = await this.getExecutions(workflowId, 50);
const initialCount = initialExecutions.length;
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const executions = await this.getExecutions(workflowId, 50);
if (executions.length > initialCount) {
for (const execution of executions.slice(0, executions.length - initialCount)) {
const isCompleted = execution.status === 'success' || execution.status === 'error';
const isCorrectWorkflow = execution.workflowId === workflowId;
const isCorrectMode = execution.mode === mode;
if (isCompleted && isCorrectWorkflow && isCorrectMode) {
return execution;
}
}
}
for (const execution of executions) {
const isCompleted = execution.status === 'success' || execution.status === 'error';
const isCorrectWorkflow = execution.workflowId === workflowId;
const isCorrectMode = execution.mode === mode;
if (isCompleted && isCorrectWorkflow && isCorrectMode) {
const executionTime = new Date(
execution.startedAt ?? execution.createdAt ?? Date.now(),
).getTime();
if (executionTime >= startTime - 5000) {
return execution;
}
}
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new TestError(`Execution did not complete within ${timeoutMs}ms`);
}
/** Waits for a workflow execution to reach a specific status. */
async waitForWorkflowStatus(
workflowId: string,
expectedStatus: string,
timeoutMs = 5000,
): Promise<ExecutionListResponse> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const executions = await this.getExecutions(workflowId);
const execution = executions.find((e) => e.workflowId === workflowId);
if (execution && execution.status === expectedStatus) {
return execution;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new TestError(
`Workflow ${workflowId} did not reach status '${expectedStatus}' within ${timeoutMs}ms`,
);
}
}