mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
chore: Add mockserver for e2e testing (#19104)
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
|||||||
setupRedis,
|
setupRedis,
|
||||||
setupCaddyLoadBalancer,
|
setupCaddyLoadBalancer,
|
||||||
pollContainerHttpEndpoint,
|
pollContainerHttpEndpoint,
|
||||||
|
setupProxyServer,
|
||||||
} from './n8n-test-container-dependencies';
|
} from './n8n-test-container-dependencies';
|
||||||
import { createSilentLogConsumer } from './n8n-test-container-utils';
|
import { createSilentLogConsumer } from './n8n-test-container-utils';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const POSTGRES_IMAGE = 'postgres:16-alpine';
|
|||||||
const REDIS_IMAGE = 'redis:7-alpine';
|
const REDIS_IMAGE = 'redis:7-alpine';
|
||||||
const CADDY_IMAGE = 'caddy:2-alpine';
|
const CADDY_IMAGE = 'caddy:2-alpine';
|
||||||
const N8N_E2E_IMAGE = 'n8nio/n8n:local';
|
const N8N_E2E_IMAGE = 'n8nio/n8n:local';
|
||||||
|
const MOCKSERVER_IMAGE = 'mockserver/mockserver:5.15.0';
|
||||||
|
|
||||||
// Default n8n image (can be overridden via N8N_DOCKER_IMAGE env var)
|
// Default n8n image (can be overridden via N8N_DOCKER_IMAGE env var)
|
||||||
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE ?? N8N_E2E_IMAGE;
|
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE ?? N8N_E2E_IMAGE;
|
||||||
@@ -78,6 +80,7 @@ export interface N8NConfig {
|
|||||||
memory?: number; // in GB
|
memory?: number; // in GB
|
||||||
cpu?: number; // in cores
|
cpu?: number; // in cores
|
||||||
};
|
};
|
||||||
|
proxyServerEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface N8NStack {
|
export interface N8NStack {
|
||||||
@@ -109,7 +112,14 @@ export interface N8NStack {
|
|||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack> {
|
export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack> {
|
||||||
const { postgres = false, queueMode = false, env = {}, projectName, resourceQuota } = config;
|
const {
|
||||||
|
postgres = false,
|
||||||
|
queueMode = false,
|
||||||
|
env = {},
|
||||||
|
proxyServerEnabled = false,
|
||||||
|
projectName,
|
||||||
|
resourceQuota,
|
||||||
|
} = config;
|
||||||
const queueConfig = normalizeQueueConfig(queueMode);
|
const queueConfig = normalizeQueueConfig(queueMode);
|
||||||
const usePostgres = postgres || !!queueConfig;
|
const usePostgres = postgres || !!queueConfig;
|
||||||
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
|
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
|
||||||
@@ -117,7 +127,7 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
|
|||||||
|
|
||||||
const mainCount = queueConfig?.mains ?? 1;
|
const mainCount = queueConfig?.mains ?? 1;
|
||||||
const needsLoadBalancer = mainCount > 1;
|
const needsLoadBalancer = mainCount > 1;
|
||||||
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer;
|
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled;
|
||||||
|
|
||||||
let network: StartedNetwork | undefined;
|
let network: StartedNetwork | undefined;
|
||||||
if (needsNetwork) {
|
if (needsNetwork) {
|
||||||
@@ -182,6 +192,31 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (proxyServerEnabled) {
|
||||||
|
assert(network, 'Network should be created for ProxyServer');
|
||||||
|
const hostname = 'proxyserver';
|
||||||
|
const port = 1080;
|
||||||
|
const url = `http://${hostname}:${port}`;
|
||||||
|
const proxyServerContainer: StartedTestContainer = await setupProxyServer({
|
||||||
|
proxyServerImage: MOCKSERVER_IMAGE,
|
||||||
|
projectName: uniqueProjectName,
|
||||||
|
network,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
|
||||||
|
containers.push(proxyServerContainer);
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
...environment,
|
||||||
|
// Configure n8n to proxy all HTTP requests through ProxyServer
|
||||||
|
HTTP_PROXY: url,
|
||||||
|
HTTPS_PROXY: url,
|
||||||
|
// Ensure https requests can be proxied without SSL issues
|
||||||
|
...(proxyServerEnabled ? { NODE_TLS_REJECT_UNAUTHORIZED: '0' } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
|
|
||||||
if (needsLoadBalancer) {
|
if (needsLoadBalancer) {
|
||||||
|
|||||||
@@ -316,6 +316,40 @@ export async function pollContainerHttpEndpoint(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setupProxyServer({
|
||||||
|
proxyServerImage,
|
||||||
|
projectName,
|
||||||
|
network,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
}: {
|
||||||
|
proxyServerImage: string;
|
||||||
|
projectName: string;
|
||||||
|
network: StartedNetwork;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
}): Promise<StartedTestContainer> {
|
||||||
|
const { consumer, throwWithLogs } = createSilentLogConsumer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await new GenericContainer(proxyServerImage)
|
||||||
|
.withNetwork(network)
|
||||||
|
.withNetworkAliases(hostname)
|
||||||
|
.withExposedPorts(port)
|
||||||
|
// Wait.forListeningPorts strategy did not work here for some reason
|
||||||
|
.withWaitStrategy(Wait.forLogMessage(`INFO ${port} started on port: ${port}`))
|
||||||
|
.withLabels({
|
||||||
|
'com.docker.compose.project': projectName,
|
||||||
|
'com.docker.compose.service': 'proxyserver',
|
||||||
|
})
|
||||||
|
.withName(`${projectName}-proxyserver`)
|
||||||
|
.withReuse()
|
||||||
|
.withLogConsumer(consumer)
|
||||||
|
.start();
|
||||||
|
} catch (error) {
|
||||||
|
return throwWithLogs(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Look at Ollama container?
|
// TODO: Look at Ollama container?
|
||||||
// TODO: Look at MariaDB container?
|
// TODO: Look at MariaDB container?
|
||||||
// TODO: Look at MockServer container, could we use this for mocking out external services?
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ test('postgres only @mode:postgres', ...) // Mode-specific
|
|||||||
test('needs clean db @db:reset', ...) // Sequential per worker
|
test('needs clean db @db:reset', ...) // Sequential per worker
|
||||||
test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
|
test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
|
||||||
test('cloud resource test @cloud:trial', ...) // Cloud resource constraints
|
test('cloud resource test @cloud:trial', ...) // Cloud resource constraints
|
||||||
|
test('proxy test @capability:proxy', ...) // Requires proxy server capability
|
||||||
```
|
```
|
||||||
|
|
||||||
## Fixture Selection
|
## Fixture Selection
|
||||||
@@ -73,5 +74,48 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => {
|
|||||||
- **utils**: Utility functions (string manipulation, helpers, etc.)
|
- **utils**: Utility functions (string manipulation, helpers, etc.)
|
||||||
- **workflows**: Test workflow JSON files for import/reuse
|
- **workflows**: Test workflow JSON files for import/reuse
|
||||||
|
|
||||||
|
## Writing Tests with Proxy
|
||||||
|
|
||||||
|
You can use ProxyServer to mock API requests.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '../fixtures/base';
|
||||||
|
|
||||||
|
// The `@capability:proxy` tag ensures tests only run when proxy infrastructure is available.
|
||||||
|
test.describe('Proxy tests @capability:proxy', () => {
|
||||||
|
test('should mock HTTP requests', async ({ proxyServer, n8n }) => {
|
||||||
|
// Create mock expectations
|
||||||
|
await proxyServer.createGetExpectation('/api/data', { result: 'mocked' });
|
||||||
|
|
||||||
|
// Execute workflow that makes HTTP requests
|
||||||
|
await n8n.canvas.openNewWorkflow();
|
||||||
|
// ... test implementation
|
||||||
|
|
||||||
|
// Verify requests were proxied
|
||||||
|
expect(await proxyServer.wasGetRequestMade('/api/data')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recording and replaying requests
|
||||||
|
|
||||||
|
The ProxyServer service supports recording HTTP requests for test mocking and replay. All proxied requests are automatically recorded by the mock server as described in the [Mock Server documentation](https://www.mock-server.com/proxy/record_and_replay.html).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Record all requests
|
||||||
|
await proxyServer.recordExpectations();
|
||||||
|
|
||||||
|
// Record requests with matching criteria
|
||||||
|
await proxyServer.recordExpectations({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/workflows',
|
||||||
|
queryStringParameters: {
|
||||||
|
'userId': ['123']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Recorded expectations are saved as JSON files in the `expectations/` directory with unique names based on the request details. When the ProxyServer fixture initializes, all saved expectations are automatically loaded and mocked for subsequent test runs.
|
||||||
|
|
||||||
## Writing Tests
|
## Writing Tests
|
||||||
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"httpRequest": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/mock-endpoint"
|
||||||
|
},
|
||||||
|
"httpResponse": {
|
||||||
|
"statusCode": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": ["application/json"]
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"userId": 1,
|
||||||
|
"id": 1,
|
||||||
|
"title": "delectus aut autem",
|
||||||
|
"completed": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "511d9c87-9ee3-4af8-8729-ca13b0a70e89",
|
||||||
|
"priority": 0,
|
||||||
|
"timeToLive": {
|
||||||
|
"unlimited": true
|
||||||
|
},
|
||||||
|
"times": {
|
||||||
|
"remainingTimes": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { ContainerTestHelpers } from 'n8n-containers/n8n-test-container-helpers'
|
|||||||
import { setupDefaultInterceptors } from '../config/intercepts';
|
import { setupDefaultInterceptors } from '../config/intercepts';
|
||||||
import { n8nPage } from '../pages/n8nPage';
|
import { n8nPage } from '../pages/n8nPage';
|
||||||
import { ApiHelpers } from '../services/api-helper';
|
import { ApiHelpers } from '../services/api-helper';
|
||||||
|
import { ProxyServer } from '../services/proxy-server';
|
||||||
import { TestError, type TestRequirements } from '../Types';
|
import { TestError, type TestRequirements } from '../Types';
|
||||||
import { setupTestRequirements } from '../utils/requirements';
|
import { setupTestRequirements } from '../utils/requirements';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ type TestFixtures = {
|
|||||||
api: ApiHelpers;
|
api: ApiHelpers;
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
setupRequirements: (requirements: TestRequirements) => Promise<void>;
|
setupRequirements: (requirements: TestRequirements) => Promise<void>;
|
||||||
|
proxyServer: ProxyServer;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@@ -31,6 +33,7 @@ interface ContainerConfig {
|
|||||||
workers: number;
|
workers: number;
|
||||||
};
|
};
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
proxyServerEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +161,31 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
await use(setupFunction);
|
await use(setupFunction);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
proxyServer: async ({ n8nContainer }, use) => {
|
||||||
|
// n8nContainer is "null" if running tests in "local" mode
|
||||||
|
if (!n8nContainer) {
|
||||||
|
throw new TestError(
|
||||||
|
'Testing with Proxy server is not supported when using N8N_BASE_URL environment variable. Remove N8N_BASE_URL to use containerized testing.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyServerContainer = n8nContainer.containers.find((container) =>
|
||||||
|
container.getName().endsWith('proxyserver'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// proxy server is not initialized in local mode (it be only supported in container modes)
|
||||||
|
// tests that require proxy server should have "@capability:proxy" so that they are skipped in local mode
|
||||||
|
if (!proxyServerContainer) {
|
||||||
|
throw new TestError('Proxy server container not initialized. Cannot initialize client.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = `http://${proxyServerContainer?.getHost()}:${proxyServerContainer?.getFirstMappedPort()}`;
|
||||||
|
const proxyServer = new ProxyServer(serverUrl);
|
||||||
|
await proxyServer.loadExpectations();
|
||||||
|
|
||||||
|
await use(proxyServer);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { expect };
|
export { expect };
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"n8n-workflow": "workspace:*",
|
"n8n-workflow": "workspace:*",
|
||||||
"nanoid": "catalog:",
|
"nanoid": "catalog:",
|
||||||
"tsx": "catalog:",
|
"tsx": "catalog:",
|
||||||
|
"mockserver-client": "^5.15.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Locator } from '@playwright/test';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import { BasePage } from './BasePage';
|
import { BasePage } from './BasePage';
|
||||||
|
import { ROUTES } from '../config/constants';
|
||||||
import { resolveFromRoot } from '../utils/path-helper';
|
import { resolveFromRoot } from '../utils/path-helper';
|
||||||
|
|
||||||
export class CanvasPage extends BasePage {
|
export class CanvasPage extends BasePage {
|
||||||
@@ -494,7 +495,7 @@ export class CanvasPage extends BasePage {
|
|||||||
// Set fixed time using Playwright's clock API
|
// Set fixed time using Playwright's clock API
|
||||||
await this.page.clock.setFixedTime(timestamp);
|
await this.page.clock.setFixedTime(timestamp);
|
||||||
|
|
||||||
await this.page.goto('/workflow/new');
|
await this.openNewWorkflow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
|
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
|
||||||
@@ -502,6 +503,10 @@ export class CanvasPage extends BasePage {
|
|||||||
await this.nodeCreatorSubItem(subItemText).click();
|
await this.nodeCreatorSubItem(subItemText).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openNewWorkflow() {
|
||||||
|
await this.page.goto(ROUTES.NEW_WORKFLOW_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
getRagCalloutTip(): Locator {
|
getRagCalloutTip(): Locator {
|
||||||
return this.page.getByText('Tip: Get a feel for vector stores in n8n with our');
|
return this.page.getByText('Tip: Get a feel for vector stores in n8n with our');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')}
|
|||||||
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
|
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
|
||||||
const SERIAL_EXECUTION = /@db:reset/;
|
const SERIAL_EXECUTION = /@db:reset/;
|
||||||
|
|
||||||
|
// Tags that require proxy server
|
||||||
|
const REQUIRES_PROXY_SERVER = /@capability:proxy/;
|
||||||
|
|
||||||
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
|
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
|
||||||
{ name: 'standard', config: {} },
|
{ name: 'standard', config: { proxyServerEnabled: true } },
|
||||||
{ name: 'postgres', config: { postgres: true } },
|
{ name: 'postgres', config: { postgres: true } },
|
||||||
{ name: 'queue', config: { queueMode: true } },
|
{ name: 'queue', config: { queueMode: true } },
|
||||||
{ name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
|
{ name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
|
||||||
@@ -35,17 +38,23 @@ export function getProjects(): Project[] {
|
|||||||
name: 'ui:isolated',
|
name: 'ui:isolated',
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
grep: SERIAL_EXECUTION,
|
grep: SERIAL_EXECUTION,
|
||||||
|
grepInvert: REQUIRES_PROXY_SERVER,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
use: { baseURL: process.env.N8N_BASE_URL },
|
use: { baseURL: process.env.N8N_BASE_URL },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
for (const { name, config } of CONTAINER_CONFIGS) {
|
for (const { name, config } of CONTAINER_CONFIGS) {
|
||||||
|
const grepInvertPatterns = [SERIAL_EXECUTION.source];
|
||||||
|
if (!config.proxyServerEnabled) {
|
||||||
|
grepInvertPatterns.push(REQUIRES_PROXY_SERVER.source);
|
||||||
|
}
|
||||||
|
|
||||||
projects.push(
|
projects.push(
|
||||||
{
|
{
|
||||||
name: `${name}:ui`,
|
name: `${name}:ui`,
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
grepInvert: SERIAL_EXECUTION,
|
grepInvert: new RegExp(grepInvertPatterns.join('|')),
|
||||||
timeout: name === 'standard' ? 60000 : 180000, // 60 seconds for standard container test, 180 for containers to allow startup etc
|
timeout: name === 'standard' ? 60000 : 180000, // 60 seconds for standard container test, 180 for containers to allow startup etc
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
use: { containerConfig: config },
|
use: { containerConfig: config },
|
||||||
@@ -54,6 +63,7 @@ export function getProjects(): Project[] {
|
|||||||
name: `${name}:ui:isolated`,
|
name: `${name}:ui:isolated`,
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
grep: SERIAL_EXECUTION,
|
grep: SERIAL_EXECUTION,
|
||||||
|
grepInvert: !config.proxyServerEnabled ? REQUIRES_PROXY_SERVER : undefined,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
use: { containerConfig: config },
|
use: { containerConfig: config },
|
||||||
},
|
},
|
||||||
|
|||||||
239
packages/testing/playwright/services/proxy-server.ts
Normal file
239
packages/testing/playwright/services/proxy-server.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* ProxyServer service helper functions for Playwright tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import type { Expectation, HttpRequest } from 'mockserver-client';
|
||||||
|
import { mockServerClient as proxyServerClient } from 'mockserver-client';
|
||||||
|
import type { MockServerClient, RequestResponse } from 'mockserver-client/mockServerClient';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export interface ProxyServerRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
queryStringParameters?: Record<string, string[]>;
|
||||||
|
headers?: Record<string, string[]>;
|
||||||
|
body?: string | { type?: string; [key: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyServerResponse {
|
||||||
|
statusCode: number;
|
||||||
|
headers?: Record<string, string[]>;
|
||||||
|
body?: string;
|
||||||
|
delay?: {
|
||||||
|
timeUnit: 'MICROSECONDS' | 'MILLISECONDS' | 'SECONDS' | 'MINUTES';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyServerExpectation {
|
||||||
|
httpRequest: ProxyServerRequest;
|
||||||
|
httpResponse: ProxyServerResponse;
|
||||||
|
times?: {
|
||||||
|
remainingTimes?: number;
|
||||||
|
unlimited?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestLog {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
headers: Record<string, string[]>;
|
||||||
|
queryStringParameters?: Record<string, string[]>;
|
||||||
|
body?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxyServer {
|
||||||
|
private client: MockServerClient;
|
||||||
|
url: string;
|
||||||
|
private expectationsDir = './expectations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ProxyServer client instance from a URL
|
||||||
|
*/
|
||||||
|
constructor(proxyServerUrl: string) {
|
||||||
|
this.url = proxyServerUrl;
|
||||||
|
const parsedURL = new URL(proxyServerUrl);
|
||||||
|
this.client = proxyServerClient(parsedURL.hostname, parseInt(parsedURL.port, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all expectations from the expectations directory and mock them
|
||||||
|
*/
|
||||||
|
async loadExpectations(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(this.expectationsDir);
|
||||||
|
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||||
|
const expectations: Expectation[] = [];
|
||||||
|
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
try {
|
||||||
|
const filePath = join(this.expectationsDir, file);
|
||||||
|
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||||
|
const expectation = JSON.parse(fileContent);
|
||||||
|
expectations.push(expectation);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log(`Error parsing expectation from ${file}:`, parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectations.length > 0) {
|
||||||
|
console.log('Loading expectations:', expectations.length);
|
||||||
|
await this.client.mockAnyResponse(expectations);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error loading expectations:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an expectation in ProxyServer
|
||||||
|
*/
|
||||||
|
async createExpectation(expectation: ProxyServerExpectation): Promise<RequestResponse> {
|
||||||
|
try {
|
||||||
|
return await this.client.mockAnyResponse({
|
||||||
|
httpRequest: expectation.httpRequest,
|
||||||
|
httpResponse: expectation.httpResponse,
|
||||||
|
times: expectation.times,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create expectation: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that a request was received by ProxyServer
|
||||||
|
*/
|
||||||
|
async verifyRequest(request: ProxyServerRequest, numberOfRequests: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.client.verify(request, numberOfRequests);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all expectations and logs from ProxyServer
|
||||||
|
*/
|
||||||
|
async clearProxyServer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client.clear(null, 'ALL');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple GET request expectation with JSON response
|
||||||
|
*/
|
||||||
|
async createGetExpectation(
|
||||||
|
path: string,
|
||||||
|
responseBody: unknown,
|
||||||
|
queryParams?: Record<string, string>,
|
||||||
|
statusCode: number = 200,
|
||||||
|
): Promise<RequestResponse> {
|
||||||
|
const queryStringParameters = queryParams
|
||||||
|
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
|
||||||
|
acc[key] = [value];
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return await this.createExpectation({
|
||||||
|
httpRequest: {
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
...(queryStringParameters && { queryStringParameters }),
|
||||||
|
},
|
||||||
|
httpResponse: {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': ['application/json'],
|
||||||
|
},
|
||||||
|
body: JSON.stringify(responseBody),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a GET request was made to ProxyServer
|
||||||
|
*/
|
||||||
|
async wasGetRequestMade(
|
||||||
|
path: string,
|
||||||
|
queryParams?: Record<string, string>,
|
||||||
|
numberOfRequests = 1,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const queryStringParameters = queryParams
|
||||||
|
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
|
||||||
|
acc[key] = [value];
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return await this.verifyRequest(
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
...(queryStringParameters && { queryStringParameters }),
|
||||||
|
},
|
||||||
|
numberOfRequests,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve recorded expectations and write to files
|
||||||
|
*/
|
||||||
|
async recordExpectations(request?: HttpRequest): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Retrieve recorded expectations from the mock server
|
||||||
|
const recordedExpectations = await this.client.retrieveRecordedExpectations(request);
|
||||||
|
|
||||||
|
// Ensure expectations directory exists
|
||||||
|
await fs.mkdir(this.expectationsDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const expectation of recordedExpectations) {
|
||||||
|
if (
|
||||||
|
!expectation.httpRequest ||
|
||||||
|
!(
|
||||||
|
'method' in expectation.httpRequest &&
|
||||||
|
typeof expectation.httpRequest.method === 'string' &&
|
||||||
|
typeof expectation.httpRequest.path === 'string'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename based on request details
|
||||||
|
const requestData = {
|
||||||
|
method: expectation.httpRequest?.method,
|
||||||
|
path: expectation.httpRequest?.path,
|
||||||
|
queryStringParameters: expectation.httpRequest?.queryStringParameters,
|
||||||
|
headers: expectation.httpRequest?.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(requestData))
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, 8);
|
||||||
|
|
||||||
|
const filename = `${expectation.httpRequest?.method?.toString()}-${expectation.httpRequest?.path?.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
|
||||||
|
const filePath = join(this.expectationsDir, filename);
|
||||||
|
|
||||||
|
// Write expectation to JSON file
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(expectation, null, 2));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveExpectations() {
|
||||||
|
return await this.client.retrieveActiveExpectations({ method: 'GET' });
|
||||||
|
}
|
||||||
|
}
|
||||||
67
packages/testing/playwright/tests/ui/env-mock-server.spec.ts
Normal file
67
packages/testing/playwright/tests/ui/env-mock-server.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
import { test, expect } from '../../fixtures/base';
|
||||||
|
|
||||||
|
// @capability:proxy tag ensures that test suite is only run when proxy is available
|
||||||
|
test.describe('Proxy server @capability:proxy', () => {
|
||||||
|
test('should verify ProxyServer container is running', async ({ proxyServer }) => {
|
||||||
|
const mockResponse = await proxyServer.createGetExpectation('/health', {
|
||||||
|
status: 'healthy',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(typeof mockResponse !== 'string');
|
||||||
|
expect(mockResponse.statusCode).toBe(201);
|
||||||
|
|
||||||
|
expect(await proxyServer.wasGetRequestMade('/health')).toBe(false);
|
||||||
|
|
||||||
|
// Verify the mock endpoint works
|
||||||
|
const healthResponse = await fetch(`${proxyServer.url}/health`);
|
||||||
|
expect(healthResponse.ok).toBe(true);
|
||||||
|
const healthData = await healthResponse.json();
|
||||||
|
expect(healthData.status).toBe('healthy');
|
||||||
|
|
||||||
|
expect(await proxyServer.wasGetRequestMade('/health')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should run a simple workflow calling http endpoint', async ({ n8n, proxyServer }) => {
|
||||||
|
const mockResponse = { data: 'Hello from ProxyServer!', test: '1' };
|
||||||
|
|
||||||
|
// Create expectation in mockserver to handle the request
|
||||||
|
await proxyServer.createGetExpectation('/data', mockResponse, { test: '1' });
|
||||||
|
|
||||||
|
await n8n.canvas.openNewWorkflow();
|
||||||
|
|
||||||
|
// This is calling a random endpoint http://mock-api.com
|
||||||
|
await n8n.canvas.importWorkflow('Simple_workflow_with_http_node.json', 'Test');
|
||||||
|
|
||||||
|
// Execute workflow - this should now proxy through mockserver
|
||||||
|
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
|
||||||
|
await n8n.canvas.openNode('HTTP Request');
|
||||||
|
await expect(n8n.ndv.getOutputTbodyCell(0, 0)).toContainText('Hello from ProxyServer!');
|
||||||
|
|
||||||
|
// Verify the request was handled by mockserver
|
||||||
|
expect(
|
||||||
|
await proxyServer.wasGetRequestMade('/data', {
|
||||||
|
test: '1',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use stored expectations respond to api request', async ({ proxyServer }) => {
|
||||||
|
const response = await fetch(`${proxyServer.url}/mock-endpoint`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.title).toBe('delectus aut autem');
|
||||||
|
expect(await proxyServer.wasGetRequestMade('/mock-endpoint')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should run a simple workflow proxying HTTPS request', async ({ n8n }) => {
|
||||||
|
await n8n.canvas.openNewWorkflow();
|
||||||
|
await n8n.canvas.importWorkflow('Simple_workflow_with_http_node.json', 'Test');
|
||||||
|
|
||||||
|
await n8n.canvas.openNode('HTTP Request');
|
||||||
|
await n8n.ndv.setParameterInput('url', 'https://jsonplaceholder.typicode.com/todos/1');
|
||||||
|
await n8n.ndv.execute();
|
||||||
|
await expect(n8n.ndv.getOutputTbodyCell(0, 0)).toContainText('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"type": "n8n-nodes-base.manualTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [-688, 272],
|
||||||
|
"id": "2edde9d2-09a3-46dd-8d56-19a0c92654fc",
|
||||||
|
"name": "When clicking ‘Execute workflow’"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://mock-api.com/data",
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"allowUnauthorizedCerts": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [-480, 272],
|
||||||
|
"id": "99de0e7f-20d1-441e-8e8b-2f3cabcacb0c",
|
||||||
|
"name": "HTTP Request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"When clicking ‘Execute workflow’": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"meta": {
|
||||||
|
"instanceId": "cb484ba7b742928a2048bf8829668bed5b5ad9787579adea888f05980292a4a7"
|
||||||
|
}
|
||||||
|
}
|
||||||
885
pnpm-lock.yaml
generated
885
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user