test: Add core entry points to allow easier test setup (#18597)

This commit is contained in:
shortstacked
2025-08-20 16:17:57 +01:00
committed by GitHub
parent cf76165457
commit 413b14b286
15 changed files with 311 additions and 197 deletions

View File

@@ -0,0 +1,54 @@
import type { n8nPage } from '../pages/n8nPage';
/**
* Composer for UI test entry points. All methods in this class navigate to or verify UI state.
* For API-only testing, use the standalone `api` fixture directly instead.
*/
export class TestEntryComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Start UI test from the home page and navigate to canvas
*/
async fromHome() {
await this.n8n.goHome();
await this.n8n.page.waitForURL('/home/workflows');
}
/**
* Start UI test from a blank canvas (assumes already on canvas)
*/
async fromBlankCanvas() {
await this.n8n.goHome();
await this.n8n.workflows.clickAddWorkflowButton();
// Verify we're on canvas
await this.n8n.canvas.canvasPane().isVisible();
}
/**
* Start UI test from a workflow in a new project
*/
async fromNewProject() {
// Enable features to allow us to create a new project
await this.n8n.api.enableFeature('projectRole:admin');
await this.n8n.api.enableFeature('projectRole:editor');
await this.n8n.api.setMaxTeamProjectsQuota(-1);
// Create a project using the API
const response = await this.n8n.api.projectApi.createProject();
const projectId = response.id;
await this.n8n.page.goto(`workflow/new?projectId=${projectId}`);
await this.n8n.canvas.canvasPane().isVisible();
}
/**
* Start UI test from the canvas of an imported workflow
* Returns the workflow import result for use in the test
*/
async fromImportedWorkflow(workflowFile: string) {
const workflowImportResult = await this.n8n.api.workflowApi.importWorkflow(workflowFile);
await this.n8n.page.goto(`workflow/${workflowImportResult.workflowId}`);
return workflowImportResult;
}
}

View File

@@ -61,7 +61,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const envBaseURL = process.env.N8N_BASE_URL;
if (envBaseURL) {
console.log(`Using external N8N_BASE_URL: ${envBaseURL}`);
await use(null as unknown as N8NStack);
return;
}
@@ -141,8 +140,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await page.close();
},
n8n: async ({ page }, use) => {
const n8nInstance = new n8nPage(page);
n8n: async ({ page, api }, use) => {
const n8nInstance = new n8nPage(page, api);
await use(n8nInstance);
},

View File

@@ -24,13 +24,16 @@
},
"devDependencies": {
"@currents/playwright": "^1.15.3",
"@n8n/api-types": "workspace:^",
"@playwright/test": "1.54.2",
"@types/lodash": "catalog:",
"eslint-plugin-playwright": "2.2.2",
"generate-schema": "2.6.0",
"n8n": "workspace:*",
"n8n-containers": "workspace:*",
"n8n-core": "workspace:*",
"n8n-workflow": "workspace:*",
"nanoid": "catalog:",
"tsx": "catalog:",
"@n8n/api-types": "workspace:^"
"tsx": "catalog:"
}
}

View File

@@ -3,10 +3,6 @@ import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowsPage extends BasePage {
async clickNewWorkflowCard() {
await this.clickByTestId('new-workflow-card');
}
async clickAddFirstProjectButton() {
await this.clickByTestId('add-first-project-button');
}
@@ -15,10 +11,20 @@ export class WorkflowsPage extends BasePage {
await this.clickByTestId('project-plus-button');
}
/**
* This is the add workflow button on the workflows page, visible when there are already workflows.
*/
async clickAddWorkflowButton() {
await this.clickByTestId('add-resource-workflow');
}
/**
* This is the new workflow button on the workflows page, visible when there are no workflows.
*/
async clickNewWorkflowCard() {
await this.clickByTestId('new-workflow-card');
}
getNewWorkflowCard() {
return this.page.getByTestId('new-workflow-card');
}

View File

@@ -19,11 +19,14 @@ import { WorkflowSharingModal } from './WorkflowSharingModal';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
import { TestEntryComposer } from '../composables/TestEntryComposer';
import { WorkflowComposer } from '../composables/WorkflowComposer';
import type { ApiHelpers } from '../services/api-helper';
// eslint-disable-next-line @typescript-eslint/naming-convention
export class n8nPage {
readonly page: Page;
readonly api: ApiHelpers;
// Pages
readonly aiAssistant: AIAssistantPage;
@@ -51,9 +54,11 @@ export class n8nPage {
readonly workflowComposer: WorkflowComposer;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
readonly start: TestEntryComposer;
constructor(page: Page) {
constructor(page: Page, api: ApiHelpers) {
this.page = page;
this.api = api;
// Pages
this.aiAssistant = new AIAssistantPage(page);
@@ -81,6 +86,7 @@ export class n8nPage {
this.workflowComposer = new WorkflowComposer(this);
this.projectComposer = new ProjectComposer(this);
this.canvasComposer = new CanvasComposer(this);
this.start = new TestEntryComposer(this);
}
async goHome() {

View File

@@ -27,7 +27,9 @@ export default defineConfig({
retries: IS_CI ? 2 : 0,
workers: WORKERS,
timeout: 60000,
expect: {
timeout: 10000,
},
projects: getProjects(),
// We use this if an n8n url is passed in. If the server is already running, we reuse it.

View File

@@ -9,6 +9,7 @@ import {
INSTANCE_ADMIN_CREDENTIALS,
} from '../config/test-users';
import { TestError } from '../Types';
import { ProjectApiHelper } from './project-api-helper';
import { WorkflowApiHelper } from './workflow-api-helper';
export interface LoginResponseData {
@@ -33,10 +34,12 @@ const DB_TAGS = {
export class ApiHelpers {
request: APIRequestContext;
workflowApi: WorkflowApiHelper;
projectApi: ProjectApiHelper;
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
this.workflowApi = new WorkflowApiHelper(this);
this.projectApi = new ProjectApiHelper(this);
}
// ===== MAIN SETUP METHODS =====

View File

@@ -0,0 +1,30 @@
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) {
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;
}
}

View File

@@ -1,160 +0,0 @@
import { readFileSync } from 'fs';
import { nanoid } from 'nanoid';
import { setTimeout } from 'timers/promises';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
import { resolveFromRoot } from '../utils/path-helper';
type WorkflowDefinition = {
name?: string;
active?: boolean;
nodes: Array<{
id?: string;
name?: string;
type: string;
typeVersion?: number;
position?: [number, number];
webhookId?: string;
parameters: { [key: string]: unknown } & { path?: string };
}>;
connections?: Record<string, unknown>;
};
/**
* Generate and assign a unique webhook id and path to the first Webhook node in a workflow.
*
* - Uniqueness: Uses nanoid to ensure both the internal `webhookId` and external `parameters.path`
* are unique per call, avoiding collisions across parallel tests and instances.
* - Path format: `${prefix}-${nanoid}` (default prefix: `test-webhook`).
* - Mutation: Updates the passed-in `workflow` object in-place.
*/
export function applyUniqueWebhookIds(
workflow: WorkflowDefinition,
options?: { prefix?: string; idLength?: number },
) {
const idLength = options?.idLength ?? 12;
const prefix = options?.prefix ?? 'test-webhook';
const generatedId = nanoid(idLength);
const generatedPath = `${prefix}-${generatedId}`;
for (const node of workflow.nodes) {
if (node.type === 'n8n-nodes-base.webhook') {
node.webhookId = generatedId;
node.parameters.path = generatedPath;
}
}
return { webhookId: generatedId, webhookPath: generatedPath, workflow };
}
/**
* Create a webhook workflow from an in-memory definition after assigning unique webhook id/path.
*
* Returns the externally callable `webhookPath` (what to pass to triggerWebhook)
* and the `workflowId` created by the API.
*/
export async function createWebhookWorkflow(
api: ApiHelpers,
workflow: WorkflowDefinition,
options?: { prefix?: string; idLength?: number },
) {
const { webhookPath } = applyUniqueWebhookIds(workflow, options);
const createdWorkflow = await api.workflowApi.createWorkflow(workflow as object);
const workflowId = createdWorkflow.id as string;
return { webhookPath, workflowId, createdWorkflow };
}
/**
* Import a webhook workflow from `packages/testing/playwright/workflows/{fileName}` and create it
* with a unique webhook id/path.
*/
export async function importWebhookWorkflow(
api: ApiHelpers,
fileName: string,
options?: { prefix?: string; idLength?: number },
) {
const workflowDefinition = JSON.parse(
readFileSync(resolveFromRoot('workflows', fileName), 'utf8'),
) as WorkflowDefinition;
return await createWebhookWorkflow(api, workflowDefinition, options);
}
/**
* Convenience: import a webhook workflow from file, ensure unique webhook id/path, and activate it.
*
* Returns the `webhookPath` to call and the `workflowId` for follow-up assertions.
*/
export async function importAndActivateWebhookWorkflow(
api: ApiHelpers,
fileName: string,
options?: { prefix?: string; idLength?: number },
) {
const { webhookPath, workflowId, createdWorkflow } = await importWebhookWorkflow(
api,
fileName,
options,
);
await setTimeout(500);
await api.workflowApi.setActive(workflowId, true);
return { webhookPath, workflowId, createdWorkflow };
}
/**
* Convenience: create a webhook workflow from an in-memory definition and activate it.
*/
export async function createAndActivateWebhookWorkflow(
api: ApiHelpers,
workflow: WorkflowDefinition,
options?: { prefix?: string; idLength?: number },
) {
const { webhookPath, workflowId, createdWorkflow } = await createWebhookWorkflow(
api,
workflow,
options,
);
// Timing issue between workflow creation and activation
await setTimeout(500);
await api.workflowApi.setActive(workflowId, true);
return { webhookPath, workflowId, createdWorkflow };
}
/**
* Trigger a webhook endpoint with optional data and parameters.
*
* @param api - The API helpers instance
* @param path - The webhook path (without /webhook/ prefix)
* @param options - Configuration for the webhook request
*/
export async function triggerWebhook(
api: ApiHelpers,
path: string,
options: { method?: 'GET' | 'POST'; data?: object; params?: Record<string, string> } = {},
) {
const { method = 'POST', data, params } = options;
let url = `/webhook/${path}`;
if (params && Object.keys(params).length > 0) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const requestOptions: Record<string, unknown> = {
headers: { 'Content-Type': 'application/json' },
};
if (data && method === 'POST') {
requestOptions.data = data;
}
const response =
method === 'GET' ? await api.request.get(url) : await api.request.post(url, requestOptions);
if (!response.ok()) {
throw new TestError(`Webhook trigger failed: ${await response.text()}`);
}
return response;
}

View File

@@ -1,10 +1,30 @@
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;
};
export class WorkflowApiHelper {
constructor(private api: ApiHelpers) {}
async createWorkflow(workflow: object) {
async createWorkflow(workflow: IWorkflowBase) {
const response = await this.api.request.post('/rest/workflows', { data: workflow });
if (!response.ok()) {
@@ -27,7 +47,83 @@ export class WorkflowApiHelper {
}
}
async getExecutions(workflowId?: string, limit = 20) {
/**
* Make workflow unique by updating name, IDs, and webhook paths if present.
* This ensures no conflicts when importing workflows for testing.
*/
private makeWorkflowUnique(
workflow: IWorkflowBase,
options?: { webhookPrefix?: string; idLength?: number },
) {
const idLength = options?.idLength ?? 12;
const webhookPrefix = options?.webhookPrefix ?? 'test-webhook';
const uniqueSuffix = nanoid(idLength);
// Make workflow name unique
if (workflow.name) {
workflow.name = `${workflow.name} (Test ${uniqueSuffix})`;
}
// Check if workflow has webhook nodes and process them
let webhookId: string | undefined;
let webhookPath: string | undefined;
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;
}
}
return { webhookId, webhookPath, workflow };
}
/**
* Create a workflow from an in-memory definition, making it unique for testing.
* Returns detailed information about what was created.
*/
async createWorkflowFromDefinition(
workflow: IWorkflowBase,
options?: { webhookPrefix?: string; idLength?: number },
): Promise<WorkflowImportResult> {
const { webhookPath, webhookId } = this.makeWorkflowUnique(workflow, options);
const createdWorkflow = await this.createWorkflow(workflow);
const workflowId: string = String(createdWorkflow.id);
return {
workflowId,
createdWorkflow,
webhookPath,
webhookId,
};
}
/**
* Import a workflow from file and make it unique for testing.
* The workflow will be created with its original active state from the JSON file.
* Returns detailed information about what was imported, including webhook info if present.
*/
async importWorkflow(
fileName: string,
options?: { webhookPrefix?: string; idLength?: number },
): Promise<WorkflowImportResult> {
const workflowDefinition: IWorkflowBase = JSON.parse(
readFileSync(resolveFromRoot('workflows', fileName), 'utf8'),
);
const result = await this.createWorkflowFromDefinition(workflowDefinition, options);
// Ensure the workflow is in the correct active state as specified in the JSON
if (workflowDefinition.active) {
await this.setActive(result.workflowId, workflowDefinition.active);
}
return result;
}
async getExecutions(workflowId?: string, limit = 20): Promise<ExecutionListResponse[]> {
const params = new URLSearchParams();
if (workflowId) params.set('workflowId', workflowId);
params.set('limit', limit.toString());
@@ -47,7 +143,7 @@ export class WorkflowApiHelper {
return [];
}
async getExecution(executionId: string) {
async getExecution(executionId: string): Promise<ExecutionListResponse> {
const response = await this.api.request.get(`/rest/executions/${executionId}`);
if (!response.ok()) {
@@ -58,7 +154,7 @@ export class WorkflowApiHelper {
return result.data ?? result;
}
async waitForExecution(workflowId: string, timeoutMs = 10000) {
async waitForExecution(workflowId: string, timeoutMs = 10000): Promise<ExecutionListResponse> {
const initialExecutions = await this.getExecutions(workflowId, 50);
const initialCount = initialExecutions.length;
const startTime = Date.now();
@@ -77,7 +173,9 @@ export class WorkflowApiHelper {
for (const execution of executions) {
const isCompleted = execution.status === 'success' || execution.status === 'error';
if (isCompleted && execution.mode === 'webhook') {
const executionTime = new Date(execution.startedAt ?? execution.createdAt).getTime();
const executionTime = new Date(
execution.startedAt ?? execution.createdAt ?? Date.now(),
).getTime();
if (executionTime >= startTime - 5000) {
return execution;
}

View File

@@ -0,0 +1,48 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Core UI Patterns - Building Blocks', () => {
test.describe('Entry Point: Home Page', () => {
test('should navigate from home', async ({ n8n }) => {
await n8n.start.fromHome();
expect(n8n.page.url()).toContain('/home/workflows');
});
});
test.describe('Entry Point: Blank Canvas', () => {
test('should navigate from blank canvas', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
test.describe('Entry Point: Basic Workflow Creation', () => {
test('should create a new project and workflow', async ({ n8n }) => {
await n8n.start.fromNewProject();
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
test.describe('Entry Point: Imported Workflow', () => {
test('should import a webhook workflow', async ({ n8n }) => {
const workflowImportResult = await n8n.start.fromImportedWorkflow('simple-webhook-test.json');
const { webhookPath } = workflowImportResult;
const testPayload = { message: 'Hello from Playwright test' };
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
const webhookResponse = await n8n.page.request.post(`/webhook-test/${webhookPath}`, {
data: testPayload,
});
expect(webhookResponse.ok()).toBe(true);
});
test('should import a workflow', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('manual.json');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Success');
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
});

View File

@@ -1,23 +1,22 @@
import { test, expect } from '../../fixtures/base';
import { importAndActivateWebhookWorkflow, triggerWebhook } from '../../services/webhook-helper';
test.describe('External Webhook Triggering @auth:owner', () => {
test.describe('External Webhook Triggering', () => {
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
api,
}) => {
const { webhookPath, workflowId } = await importAndActivateWebhookWorkflow(
api,
const { webhookPath, workflowId } = await api.workflowApi.importWorkflow(
'simple-webhook-test.json',
);
const testPayload = { message: 'Hello from Playwright test' };
const webhookResponse = await triggerWebhook(api, webhookPath, {
const webhookResponse = await api.request.post(`/webhook/${webhookPath}`, {
data: testPayload,
});
expect(webhookResponse.ok()).toBe(true);
const execution = await api.workflowApi.waitForExecution(workflowId, 10000);
const execution = await api.workflowApi.waitForExecution(workflowId, 5000);
expect(execution.status).toBe('success');
const executionDetails = await api.workflowApi.getExecution(execution.id);

View File

@@ -0,0 +1,22 @@
{
"name": "Manual Trigger Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "0567e1a2-e1cb-480f-9a17-8f37b88bbe7f",
"name": "When clicking Execute workflow"
}
],
"pinData": {},
"connections": {},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "2071c8bd-6e92-4eb6-81cd-3d693f683955",
"id": "YaCQ6nNLk4xJabWT",
"tags": []
}

View File

@@ -1,6 +1,6 @@
{
"name": "Simple Webhook Test",
"active": false,
"active": true,
"nodes": [
{
"parameters": {

32
pnpm-lock.yaml generated
View File

@@ -3129,9 +3129,18 @@ importers:
generate-schema:
specifier: 2.6.0
version: 2.6.0
n8n:
specifier: workspace:*
version: link:../../cli
n8n-containers:
specifier: workspace:*
version: link:../containers
n8n-core:
specifier: workspace:*
version: link:../../core
n8n-workflow:
specifier: workspace:*
version: link:../../workflow
nanoid:
specifier: 'catalog:'
version: 3.3.8
@@ -18795,7 +18804,7 @@ snapshots:
'@currents/commit-info': 1.0.1-beta.0
async-retry: 1.3.3
axios: 1.11.0(debug@4.4.1)
axios-retry: 4.5.0(axios@1.11.0(debug@4.4.1))
axios-retry: 4.5.0(axios@1.11.0)
c12: 1.11.2(magicast@0.3.5)
chalk: 4.1.2
commander: 12.1.0
@@ -23479,14 +23488,9 @@ snapshots:
axe-core@4.7.2: {}
axios-retry@4.5.0(axios@1.11.0(debug@4.4.1)):
dependencies:
axios: 1.11.0(debug@4.4.1)
is-retry-allowed: 2.2.0
axios-retry@4.5.0(axios@1.11.0):
dependencies:
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
is-retry-allowed: 2.2.0
axios-retry@4.5.0(axios@1.8.3):
@@ -23851,7 +23855,7 @@ snapshots:
bundlemon@3.1.0(typescript@5.9.2):
dependencies:
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
axios-retry: 4.5.0(axios@1.11.0)
brotli-size: 4.0.0
bundlemon-utils: 2.0.1
@@ -27000,7 +27004,7 @@ snapshots:
infisical-node@1.3.0:
dependencies:
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
dotenv: 16.3.1
tweetnacl: 1.0.3
tweetnacl-util: 0.15.1
@@ -28207,7 +28211,7 @@ snapshots:
'@langchain/groq': 0.2.3(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)
'@langchain/mistralai': 0.2.1(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(zod@3.25.67)
'@langchain/ollama': 0.2.3(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
cheerio: 1.0.0
handlebars: 4.7.8
transitivePeerDependencies:
@@ -30259,7 +30263,7 @@ snapshots:
posthog-node@3.2.1:
dependencies:
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
rusha: 0.8.14
transitivePeerDependencies:
- debug
@@ -30938,7 +30942,7 @@ snapshots:
retry-axios@2.6.0(axios@1.11.0):
dependencies:
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
retry-request@7.0.2(encoding@0.1.13):
dependencies:
@@ -31443,7 +31447,7 @@ snapshots:
asn1.js: 5.4.1
asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1)
asn1.js-rfc5280: 3.0.0
axios: 1.11.0(debug@4.3.6)
axios: 1.11.0(debug@4.4.1)
big-integer: 1.6.52
bignumber.js: 9.1.2
binascii: 0.0.2
@@ -33587,4 +33591,4 @@ snapshots:
zx@8.1.4:
optionalDependencies:
'@types/fs-extra': 11.0.4
'@types/node': 20.17.57
'@types/node': 20.17.57