From f769d5588e2a3d2c7f4e994068b2576094f2ffb3 Mon Sep 17 00:00:00 2001 From: shortstacked Date: Mon, 18 Aug 2025 12:41:13 +0100 Subject: [PATCH] test: Add workflow api testing for webhooks (#18400) --- packages/testing/playwright/CONTRIBUTING.md | 37 +++++- packages/testing/playwright/README.md | 2 +- packages/testing/playwright/fixtures/base.ts | 3 +- .../testing/playwright/services/api-helper.ts | 5 +- .../services/workflow-api-helper.ts | 124 ++++++++++++++++++ .../tests/ui/webhook-external-trigger.spec.ts | 33 +++++ .../workflows/simple-webhook-test.json | 67 ++++++++++ 7 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 packages/testing/playwright/services/workflow-api-helper.ts create mode 100644 packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts create mode 100644 packages/testing/playwright/workflows/simple-webhook-test.json diff --git a/packages/testing/playwright/CONTRIBUTING.md b/packages/testing/playwright/CONTRIBUTING.md index 110f824357..31cefb088e 100644 --- a/packages/testing/playwright/CONTRIBUTING.md +++ b/packages/testing/playwright/CONTRIBUTING.md @@ -35,8 +35,9 @@ Troubleshooting: ## 🏗️ Architecture Overview -Our test architecture follows a strict four-layer approach: +Our test architecture supports both UI-driven and API-driven testing: +### UI Testing (Four-Layer Approach) ``` Tests (*.spec.ts) ↓ uses @@ -47,11 +48,19 @@ Page Objects (*Page.ts) - UI interactions BasePage - Common utilities ``` +### API Testing (Two-Layer Approach) +``` +Tests (*.spec.ts) + ↓ uses +API Services (ApiHelpers + specialized helpers) +``` + ### Core Principle: Separation of Concerns - **BasePage**: Generic interaction methods - **Page Objects**: Element locators and simple actions - **Composables**: Complex business workflows -- **Tests**: Readable scenarios using composables +- **API Services**: REST API interactions, workflow management +- **Tests**: Readable scenarios using composables or API services --- @@ -293,6 +302,7 @@ export class ProjectComposer { ### When Writing Tests +#### UI Tests ```typescript // ✅ GOOD: From 1-workflows.spec.ts test('should create a new workflow using add workflow button', async ({ n8n }) => { @@ -326,6 +336,29 @@ test('should enter debug mode for failed executions', async ({ n8n }) => { }); ``` +#### API Tests +```typescript +// ✅ GOOD: API-driven workflow testing +test('should create workflow via API, activate it, trigger webhook externally @auth:owner', async ({ api }) => { + const workflowDefinition = JSON.parse( + readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'), + ); + + const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition); + await api.workflowApi.setActive(createdWorkflow.id, true); + + const testPayload = { message: 'Hello from Playwright test' }; + const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', { data: testPayload }); + expect(webhookResponse.ok()).toBe(true); + + const execution = await api.workflowApi.waitForExecution(createdWorkflow.id, 10000); + expect(execution.status).toBe('success'); + + const executionDetails = await api.workflowApi.getExecution(execution.id); + expect(executionDetails.data).toContain('Hello from Playwright test'); +}); +``` + --- ## 🎯 Best Practices diff --git a/packages/testing/playwright/README.md b/packages/testing/playwright/README.md index 031e288fee..6a71253b60 100644 --- a/packages/testing/playwright/README.md +++ b/packages/testing/playwright/README.md @@ -69,7 +69,7 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => { - `base.ts`: Standard fixtures with worker-scoped containers - `cloud-only.ts`: Cloud resource testing with test-scoped containers only - **pages**: Page Object Models for UI interactions -- **services**: API helpers for E2E controller, REST calls, etc. +- **services**: API helpers for E2E controller, REST calls, workflow management, etc. - **utils**: Utility functions (string manipulation, helpers, etc.) - **workflows**: Test workflow JSON files for import/reuse diff --git a/packages/testing/playwright/fixtures/base.ts b/packages/testing/playwright/fixtures/base.ts index 1e71e86fa0..81510ad089 100644 --- a/packages/testing/playwright/fixtures/base.ts +++ b/packages/testing/playwright/fixtures/base.ts @@ -146,8 +146,9 @@ export const test = base.extend({ await use(n8nInstance); }, - api: async ({ context }, use) => { + api: async ({ context }, use, testInfo) => { const api = new ApiHelpers(context.request); + await api.setupFromTags(testInfo.tags); await use(api); }, diff --git a/packages/testing/playwright/services/api-helper.ts b/packages/testing/playwright/services/api-helper.ts index 13c55f9778..0810c65a2c 100644 --- a/packages/testing/playwright/services/api-helper.ts +++ b/packages/testing/playwright/services/api-helper.ts @@ -9,6 +9,7 @@ import { INSTANCE_ADMIN_CREDENTIALS, } from '../config/test-users'; import { TestError } from '../Types'; +import { WorkflowApiHelper } from './workflow-api-helper'; export interface LoginResponseData { id: string; @@ -30,10 +31,12 @@ const DB_TAGS = { } as const; export class ApiHelpers { - private request: APIRequestContext; + request: APIRequestContext; + workflowApi: WorkflowApiHelper; constructor(requestContext: APIRequestContext) { this.request = requestContext; + this.workflowApi = new WorkflowApiHelper(this); } // ===== MAIN SETUP METHODS ===== diff --git a/packages/testing/playwright/services/workflow-api-helper.ts b/packages/testing/playwright/services/workflow-api-helper.ts new file mode 100644 index 0000000000..e231b26b74 --- /dev/null +++ b/packages/testing/playwright/services/workflow-api-helper.ts @@ -0,0 +1,124 @@ +import type { ApiHelpers } from './api-helper'; +import { TestError } from '../Types'; + +export class WorkflowApiHelper { + constructor(private api: ApiHelpers) {} + + async createWorkflow(workflow: object) { + 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; + } + + async setActive(workflowId: string, active: boolean) { + const response = await this.api.request.patch(`/rest/workflows/${workflowId}`, { + data: { active }, + }); + + if (!response.ok()) { + throw new TestError( + `Failed to ${active ? 'activate' : 'deactivate'} workflow: ${await response.text()}`, + ); + } + } + + async getExecutions(workflowId?: string, limit = 20) { + const params = new URLSearchParams(); + if (workflowId) params.set('workflowId', 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) { + 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) { + 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)) { + if (execution.status === 'success' || execution.status === 'error') { + return execution; + } + } + } + + 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(); + if (executionTime >= startTime - 5000) { + return execution; + } + } + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + throw new TestError(`Execution did not complete within ${timeoutMs}ms`); + } + + async triggerWebhook( + path: string, + options: { method?: 'GET' | 'POST'; data?: object; params?: Record } = {}, + ) { + 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 = { + headers: { 'Content-Type': 'application/json' }, + }; + + if (data && method === 'POST') { + requestOptions.data = data; + } + + const response = + method === 'GET' + ? await this.api.request.get(url) + : await this.api.request.post(url, requestOptions); + + if (!response.ok()) { + throw new TestError(`Webhook trigger failed: ${await response.text()}`); + } + + return response; + } +} diff --git a/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts b/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts new file mode 100644 index 0000000000..07d2d1a962 --- /dev/null +++ b/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'fs'; + +import { test, expect } from '../../fixtures/base'; +import { resolveFromRoot } from '../../utils/path-helper'; + +test.describe('External Webhook Triggering @auth:owner', () => { + test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({ + api, + }) => { + const workflowDefinition = JSON.parse( + readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'), + ); + + const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition); + expect(createdWorkflow.id).toBeDefined(); + + const workflowId = createdWorkflow.id; + await api.workflowApi.setActive(workflowId, true); + + const testPayload = { message: 'Hello from Playwright test' }; + + const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', { + data: testPayload, + }); + expect(webhookResponse.ok()).toBe(true); + + const execution = await api.workflowApi.waitForExecution(workflowId, 10000); + expect(execution.status).toBe('success'); + + const executionDetails = await api.workflowApi.getExecution(execution.id); + expect(executionDetails.data).toContain('Hello from Playwright test'); + }); +}); diff --git a/packages/testing/playwright/workflows/simple-webhook-test.json b/packages/testing/playwright/workflows/simple-webhook-test.json new file mode 100644 index 0000000000..86e5ff5f88 --- /dev/null +++ b/packages/testing/playwright/workflows/simple-webhook-test.json @@ -0,0 +1,67 @@ +{ + "name": "Simple Webhook Test", + "active": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "test-webhook", + "options": {} + }, + "id": "webhook-trigger", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [300, 300], + "webhookId": "test-webhook-id" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "result-field", + "name": "result", + "value": "=Webhook received: {{ $json.body }}", + "type": "string" + }, + { + "id": "timestamp-field", + "name": "timestamp", + "value": "={{ new Date().toISOString() }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "set-response", + "name": "Set Response", + "type": "n8n-nodes-base.set", + "typeVersion": 3.3, + "position": [500, 300] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Set Response", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "meta": null, + "pinData": {}, + "versionId": null, + "triggerCount": 0, + "tags": [] +}