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": {