mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 04:37:50 +08:00
test: Align proxy service helper and add local dev service stack (no-changelog) (#25467)
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
import { DockerImageNotFoundError } from './docker-image-not-found-error';
|
||||
import { BASE_PERFORMANCE_PLANS, isValidPerformancePlan } from './performance-plans';
|
||||
import { createServiceStack } from './service-stack';
|
||||
import type { CloudflaredResult } from './services/cloudflared';
|
||||
import type { KeycloakResult } from './services/keycloak';
|
||||
import type { MailpitResult } from './services/mailpit';
|
||||
import type { NgrokResult } from './services/ngrok';
|
||||
import { services as SERVICE_REGISTRY } from './services/registry';
|
||||
import type { TracingResult } from './services/tracing';
|
||||
import type { ServiceName } from './services/types';
|
||||
import type { VictoriaLogsResult } from './services/victoria-logs';
|
||||
@@ -44,6 +48,8 @@ ${colors.yellow}Usage:${colors.reset}
|
||||
npm run stack [options]
|
||||
|
||||
${colors.yellow}Options:${colors.reset}
|
||||
--services-only Start services only (no n8n containers), write .env for local dev
|
||||
--services <list> Comma-separated services (e.g. postgres,redis,mailpit,proxy,kafka)
|
||||
--postgres Use PostgreSQL instead of SQLite
|
||||
--queue Enable queue mode (requires PostgreSQL)
|
||||
--source-control Enable source control (Git) container for testing
|
||||
@@ -108,6 +114,11 @@ ${Object.keys(BASE_PERFORMANCE_PLANS)
|
||||
.map((name) => ` npm run stack --plan ${name}`)
|
||||
.join('\n')}
|
||||
|
||||
${colors.bright}# Services only (local dev — writes .env for pnpm start)${colors.reset}
|
||||
pnpm services --services postgres
|
||||
pnpm services --services postgres,redis
|
||||
pnpm services --services postgres,mailpit,proxy
|
||||
|
||||
${colors.bright}# Parallel instances${colors.reset}
|
||||
npm run stack --name test-1
|
||||
npm run stack --name test-2
|
||||
@@ -127,8 +138,10 @@ async function main() {
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
'services-only': { type: 'boolean' },
|
||||
postgres: { type: 'boolean' },
|
||||
queue: { type: 'boolean' },
|
||||
services: { type: 'string' },
|
||||
'source-control': { type: 'boolean' },
|
||||
oidc: { type: 'boolean' },
|
||||
observability: { type: 'boolean' },
|
||||
@@ -152,8 +165,20 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const servicesOnly = values['services-only'] ?? false;
|
||||
|
||||
// Build services array from CLI flags
|
||||
const validServiceNames = new Set(Object.keys(SERVICE_REGISTRY));
|
||||
const services: ServiceName[] = [];
|
||||
if (values.services) {
|
||||
for (const name of values.services.split(',').map((s) => s.trim())) {
|
||||
if (!validServiceNames.has(name)) {
|
||||
log.error(`Unknown service: '${name}'. Available: ${[...validServiceNames].join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
services.push(name as ServiceName);
|
||||
}
|
||||
}
|
||||
if (values['source-control']) services.push('gitea');
|
||||
if (values.oidc) services.push('keycloak');
|
||||
if (values.observability) services.push('victoriaLogs', 'victoriaMetrics', 'vector');
|
||||
@@ -167,7 +192,11 @@ async function main() {
|
||||
const config: N8NConfig = {
|
||||
postgres: values.postgres ?? false,
|
||||
services,
|
||||
projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`,
|
||||
projectName:
|
||||
values.name ??
|
||||
(servicesOnly
|
||||
? `n8n-svc-${Math.random().toString(36).substring(7)}`
|
||||
: `n8n-stack-${Math.random().toString(36).substring(7)}`),
|
||||
};
|
||||
|
||||
// Handle queue mode (mains > 1 or workers > 0)
|
||||
@@ -227,6 +256,94 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Services-only mode: start containers, write .env, no n8n
|
||||
if (servicesOnly) {
|
||||
if (services.length === 0) {
|
||||
log.error('No services specified. Use flags like --postgres, --redis, --mailpit, etc.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.header('Starting service containers');
|
||||
log.info(`Project: ${config.projectName}`);
|
||||
log.info(`Services: ${services.join(', ')}`);
|
||||
|
||||
try {
|
||||
const stack = await createServiceStack({
|
||||
services,
|
||||
projectName: config.projectName,
|
||||
});
|
||||
|
||||
// Collect host-compatible env vars from each service
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const name of services) {
|
||||
const result = stack.serviceResults[name];
|
||||
if (!result) continue;
|
||||
|
||||
const service = SERVICE_REGISTRY[name];
|
||||
Object.assign(
|
||||
envVars,
|
||||
service.env?.(result, true) ?? {},
|
||||
service.extraEnv?.(result, true) ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
// Write .env to packages/cli/bin/ because `pnpm start` runs os-normalize.mjs
|
||||
// which does `cd packages/cli/bin` before launching n8n, and dotenv loads from cwd.
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
const repoRoot = resolve(__dirname, '../../..');
|
||||
const envPath = resolve(repoRoot, 'packages/cli/bin/.env');
|
||||
const lines = [
|
||||
'# Generated by pnpm services — do not edit',
|
||||
`# Project: ${stack.projectName}`,
|
||||
'# Stop with: pnpm --filter n8n-containers services:clean',
|
||||
'',
|
||||
...Object.entries(envVars).map(([key, value]) => `${key}=${value}`),
|
||||
'',
|
||||
];
|
||||
writeFileSync(envPath, lines.join('\n'));
|
||||
log.success(`Wrote ${Object.keys(envVars).length} env vars to packages/cli/bin/.env`);
|
||||
}
|
||||
|
||||
// Print summary
|
||||
log.header('Services running');
|
||||
for (const name of services) {
|
||||
const result = stack.serviceResults[name];
|
||||
if (!result) continue;
|
||||
|
||||
const service = SERVICE_REGISTRY[name];
|
||||
const vars = {
|
||||
...(service.env?.(result, true) ?? {}),
|
||||
...(service.extraEnv?.(result, true) ?? {}),
|
||||
};
|
||||
const varSummary = Object.entries(vars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(', ');
|
||||
log.success(`${name}${varSummary ? `: ${varSummary}` : ''}`);
|
||||
}
|
||||
|
||||
// Print mailpit UI URL if running
|
||||
const mailpitResult = stack.serviceResults.mailpit as MailpitResult | undefined;
|
||||
if (mailpitResult) {
|
||||
console.log('');
|
||||
log.info(`Mailpit UI: ${colors.cyan}${mailpitResult.meta.apiBaseUrl}${colors.reset}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
log.info('Containers are running in the background');
|
||||
log.info(`Run ${colors.bright}pnpm dev${colors.reset} in another terminal to start n8n`);
|
||||
log.info(
|
||||
`Cleanup: ${colors.bright}pnpm --filter n8n-containers services:clean${colors.reset}`,
|
||||
);
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Failed to start services: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log.header('Starting n8n Stack');
|
||||
log.info(`Project name: ${config.projectName}`);
|
||||
displayConfig(config);
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"stack:clean:containers": "docker ps -aq --filter 'name=n8n-stack-*' | xargs -r docker rm -f 2>/dev/null",
|
||||
"stack:clean:networks": "docker network ls --filter 'label=org.testcontainers=true' -q | xargs -r docker network rm 2>/dev/null",
|
||||
"stack:clean:all": "pnpm run stack:clean:containers && pnpm run stack:clean:networks",
|
||||
"services": "tsx ./n8n-start-stack.ts --services-only",
|
||||
"services:clean": "docker ps -aq --filter 'name=n8n-svc-*' | xargs docker rm -f 2>/dev/null; rm -f ../../../packages/cli/bin/.env",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
@@ -31,6 +33,7 @@
|
||||
"@testcontainers/postgresql": "^11.0.3",
|
||||
"@testcontainers/redis": "^11.0.3",
|
||||
"get-port": "^7.1.0",
|
||||
"mockserver-client": "^5.15.0",
|
||||
"kafkajs": "catalog:",
|
||||
"testcontainers": "^11.11.0"
|
||||
}
|
||||
|
||||
@@ -76,9 +76,9 @@ export const gitea: Service<GiteaResult> = {
|
||||
}
|
||||
},
|
||||
|
||||
env(): Record<string, string> {
|
||||
env(result: GiteaResult, external?: boolean): Record<string, string> {
|
||||
return {
|
||||
N8N_SOURCECONTROL_HOST: `http://${HOSTNAME}:${HTTP_PORT}`,
|
||||
N8N_SOURCECONTROL_HOST: external ? result.meta.apiUrl : `http://${HOSTNAME}:${HTTP_PORT}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,8 +41,11 @@ export const kafka: Service<KafkaResult> = {
|
||||
};
|
||||
},
|
||||
|
||||
env(): Record<string, string> {
|
||||
return {};
|
||||
env(result: KafkaResult, external?: boolean): Record<string, string> {
|
||||
if (!external) return {};
|
||||
return {
|
||||
KAFKA_BROKER: result.meta.externalBroker,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -306,7 +306,14 @@ export const keycloak: Service<KeycloakResult> = {
|
||||
}
|
||||
},
|
||||
|
||||
env(): Record<string, string> {
|
||||
env(result: KeycloakResult, external?: boolean): Record<string, string> {
|
||||
if (external) {
|
||||
return {
|
||||
N8N_OIDC_DISCOVERY_URL: result.meta.discoveryUrl,
|
||||
N8N_OIDC_CLIENT_ID: result.meta.clientId,
|
||||
N8N_OIDC_CLIENT_SECRET: result.meta.clientSecret,
|
||||
};
|
||||
}
|
||||
return {
|
||||
NODE_EXTRA_CA_CERTS: N8N_KEYCLOAK_CERT_PATH,
|
||||
NO_PROXY: `localhost,127.0.0.1,${HOSTNAME},host.docker.internal`,
|
||||
|
||||
@@ -84,11 +84,9 @@ export const localstack: Service<LocalStackResult> = {
|
||||
}
|
||||
},
|
||||
|
||||
env(result: LocalStackResult): Record<string, string> {
|
||||
env(result: LocalStackResult, external?: boolean): Record<string, string> {
|
||||
return {
|
||||
// AWS SDK v3 standard endpoint override
|
||||
AWS_ENDPOINT_URL: result.meta.internalEndpoint,
|
||||
// Dummy credentials (LocalStack doesn't validate by default)
|
||||
AWS_ENDPOINT_URL: external ? result.meta.endpoint : result.meta.internalEndpoint,
|
||||
AWS_ACCESS_KEY_ID: 'test',
|
||||
AWS_SECRET_ACCESS_KEY: 'test',
|
||||
AWS_DEFAULT_REGION: DEFAULT_REGION,
|
||||
|
||||
@@ -114,11 +114,13 @@ export const mailpit: Service<MailpitResult> = {
|
||||
}
|
||||
},
|
||||
|
||||
env(): Record<string, string> {
|
||||
env(result: MailpitResult, external?: boolean): Record<string, string> {
|
||||
return {
|
||||
N8N_EMAIL_MODE: 'smtp',
|
||||
N8N_SMTP_HOST: HOSTNAME,
|
||||
N8N_SMTP_PORT: String(SMTP_PORT),
|
||||
N8N_SMTP_HOST: external ? result.container.getHost() : HOSTNAME,
|
||||
N8N_SMTP_PORT: external
|
||||
? String(result.container.getMappedPort(SMTP_PORT))
|
||||
: String(SMTP_PORT),
|
||||
N8N_SMTP_SSL: 'false',
|
||||
N8N_SMTP_SENDER: 'test@n8n.local',
|
||||
};
|
||||
@@ -128,8 +130,16 @@ export const mailpit: Service<MailpitResult> = {
|
||||
export class MailpitHelper {
|
||||
private readonly apiBaseUrl: string;
|
||||
|
||||
constructor(apiBaseUrl: string) {
|
||||
/** SMTP host that n8n should use to send email (internal hostname in container mode, localhost in local mode) */
|
||||
readonly smtpHost: string;
|
||||
|
||||
/** SMTP port that n8n should use to send email (1025 in container mode, mapped port in local mode) */
|
||||
readonly smtpPort: number;
|
||||
|
||||
constructor(apiBaseUrl: string, smtpHost = HOSTNAME, smtpPort = SMTP_PORT) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.smtpHost = smtpHost;
|
||||
this.smtpPort = smtpPort;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
|
||||
@@ -54,7 +54,15 @@ export const mysqlService: Service<MySqlResult> = {
|
||||
};
|
||||
},
|
||||
|
||||
env(): Record<string, string> {
|
||||
return {};
|
||||
env(result: MySqlResult, external?: boolean): Record<string, string> {
|
||||
if (!external) return {};
|
||||
return {
|
||||
DB_TYPE: 'mysqldb',
|
||||
DB_MYSQLDB_HOST: result.meta.externalHost,
|
||||
DB_MYSQLDB_PORT: String(result.meta.externalPort),
|
||||
DB_MYSQLDB_DATABASE: result.meta.database,
|
||||
DB_MYSQLDB_USER: result.meta.username,
|
||||
DB_MYSQLDB_PASSWORD: result.meta.password,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,11 +45,11 @@ export const postgres: Service<PostgresResult> = {
|
||||
};
|
||||
},
|
||||
|
||||
env(result: PostgresResult): Record<string, string> {
|
||||
env(result: PostgresResult, external?: boolean): Record<string, string> {
|
||||
return {
|
||||
DB_TYPE: 'postgresdb',
|
||||
DB_POSTGRESDB_HOST: HOSTNAME,
|
||||
DB_POSTGRESDB_PORT: '5432',
|
||||
DB_POSTGRESDB_HOST: external ? result.container.getHost() : HOSTNAME,
|
||||
DB_POSTGRESDB_PORT: external ? String(result.container.getMappedPort(5432)) : '5432',
|
||||
DB_POSTGRESDB_DATABASE: result.meta.database,
|
||||
DB_POSTGRESDB_USER: result.meta.username,
|
||||
DB_POSTGRESDB_PASSWORD: result.meta.password,
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import type { Expectation, RequestDefinition } from 'mockserver-client';
|
||||
import { mockServerClient } from 'mockserver-client';
|
||||
import type { HttpRequest, HttpResponse } from 'mockserver-client/mockServer';
|
||||
import type {
|
||||
MockServerClient,
|
||||
PathOrRequestDefinition,
|
||||
RequestResponse,
|
||||
} from 'mockserver-client/mockServerClient';
|
||||
import { join } from 'path';
|
||||
import { GenericContainer, Wait } from 'testcontainers';
|
||||
|
||||
import { createSilentLogConsumer } from '../helpers/utils';
|
||||
import { TEST_CONTAINER_IMAGES } from '../test-containers';
|
||||
import type { Service, ServiceResult } from './types';
|
||||
import type { HelperContext, Service, ServiceResult } from './types';
|
||||
|
||||
const HOSTNAME = 'proxyserver';
|
||||
const PORT = 1080;
|
||||
@@ -18,10 +29,13 @@ export type ProxyResult = ServiceResult<ProxyMeta>;
|
||||
export const proxy: Service<ProxyResult> = {
|
||||
description: 'HTTP proxy server',
|
||||
|
||||
extraEnv(result): Record<string, string> {
|
||||
extraEnv(result: ProxyResult, external?: boolean): Record<string, string> {
|
||||
const url = external
|
||||
? `http://${result.container.getHost()}:${result.container.getMappedPort(PORT)}`
|
||||
: result.meta.internalUrl;
|
||||
return {
|
||||
HTTP_PROXY: result.meta.internalUrl,
|
||||
HTTPS_PROXY: result.meta.internalUrl,
|
||||
HTTP_PROXY: url,
|
||||
HTTPS_PROXY: url,
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0',
|
||||
};
|
||||
},
|
||||
@@ -57,10 +71,302 @@ export const proxy: Service<ProxyResult> = {
|
||||
}
|
||||
},
|
||||
|
||||
env(result): Record<string, string> {
|
||||
env(result: ProxyResult, external?: boolean): Record<string, string> {
|
||||
return {
|
||||
N8N_PROXY_HOST: result.meta.host,
|
||||
N8N_PROXY_PORT: String(result.meta.port),
|
||||
N8N_PROXY_HOST: external ? result.container.getHost() : result.meta.host,
|
||||
N8N_PROXY_PORT: external
|
||||
? String(result.container.getMappedPort(PORT))
|
||||
: String(result.meta.port),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// --- ProxyServer helper (MockServer API client) ---
|
||||
|
||||
export type RequestMade = {
|
||||
httpRequest?: HttpRequest;
|
||||
httpResponse?: HttpResponse;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
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: string;
|
||||
|
||||
constructor(proxyServerUrl: string, expectationsDir = './expectations') {
|
||||
this.url = proxyServerUrl;
|
||||
this.expectationsDir = expectationsDir;
|
||||
const parsedURL = new URL(proxyServerUrl);
|
||||
this.client = mockServerClient(parsedURL.hostname, parseInt(parsedURL.port, 10));
|
||||
}
|
||||
|
||||
async loadExpectations(
|
||||
folderName: string,
|
||||
options: { strictBodyMatching?: boolean } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const targetDir = join(this.expectationsDir, folderName);
|
||||
const files = await fs.readdir(targetDir);
|
||||
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||
const expectations: Expectation[] = [];
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const filePath = join(targetDir, file);
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const expectation = JSON.parse(fileContent) as Expectation;
|
||||
|
||||
if (
|
||||
options.strictBodyMatching &&
|
||||
expectation.httpRequest &&
|
||||
'body' in expectation.httpRequest
|
||||
) {
|
||||
(expectation.httpRequest as { body: { matchType: string } }).body.matchType = 'STRICT';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyRequest(request: RequestDefinition, numberOfRequests: number): Promise<boolean> {
|
||||
try {
|
||||
await this.client.verify(request, numberOfRequests, numberOfRequests);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async clearAllExpectations(): Promise<void> {
|
||||
try {
|
||||
await this.client.clear('', 'ALL');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async wasRequestMade(request: RequestDefinition, numberOfRequests = 1): Promise<boolean> {
|
||||
return await this.verifyRequest(request, numberOfRequests);
|
||||
}
|
||||
|
||||
async getAllRequestsMade(): Promise<RequestMade[]> {
|
||||
// @ts-expect-error mockserver types seem to be messed up
|
||||
return await this.client.retrieveRecordedRequestsAndResponses('');
|
||||
}
|
||||
|
||||
async recordExpectations(
|
||||
folderName: string,
|
||||
options?: {
|
||||
pathOrRequestDefinition?: PathOrRequestDefinition;
|
||||
host?: string;
|
||||
dedupe?: boolean;
|
||||
raw?: boolean;
|
||||
transform?: (expectation: Expectation) => Expectation;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const recordedExpectations = await this.client.retrieveRecordedExpectations(
|
||||
options?.pathOrRequestDefinition,
|
||||
);
|
||||
|
||||
const targetDir = join(this.expectationsDir, folderName);
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const seenRequests = new Set<string>();
|
||||
|
||||
for (const expectation of recordedExpectations) {
|
||||
if (
|
||||
!expectation.httpRequest ||
|
||||
!(
|
||||
'method' in expectation.httpRequest &&
|
||||
typeof expectation.httpRequest.method === 'string' &&
|
||||
typeof expectation.httpRequest.path === 'string'
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headers = (expectation.httpRequest.headers ?? {}) as Record<string, unknown>;
|
||||
const hostHeader = 'Host' in headers ? (headers.Host as string | string[]) : undefined;
|
||||
const hostName = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? 'unknown-host');
|
||||
|
||||
if (options?.host && typeof hostName === 'string' && !hostName.includes(options.host)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const method = expectation.httpRequest.method;
|
||||
let requestForProcessing: Record<string, unknown> | HttpRequest;
|
||||
|
||||
if (options?.raw) {
|
||||
requestForProcessing = expectation.httpRequest;
|
||||
} else {
|
||||
const cleanedRequest: Record<string, unknown> = {
|
||||
method: expectation.httpRequest.method,
|
||||
path: expectation.httpRequest.path,
|
||||
};
|
||||
|
||||
if (method === 'GET') {
|
||||
if (expectation.httpRequest.queryStringParameters) {
|
||||
cleanedRequest.queryStringParameters = expectation.httpRequest.queryStringParameters;
|
||||
}
|
||||
} else if (method === 'POST' || method === 'PUT') {
|
||||
if (expectation.httpRequest.body) {
|
||||
cleanedRequest.body = expectation.httpRequest.body;
|
||||
}
|
||||
}
|
||||
|
||||
requestForProcessing = cleanedRequest;
|
||||
}
|
||||
|
||||
if (options?.dedupe) {
|
||||
const dedupeKey = JSON.stringify(requestForProcessing);
|
||||
|
||||
if (seenRequests.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRequests.add(dedupeKey);
|
||||
}
|
||||
|
||||
let processedExpectation: Expectation = {
|
||||
...expectation,
|
||||
httpRequest: requestForProcessing,
|
||||
times: {
|
||||
unlimited: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (options?.transform) {
|
||||
processedExpectation = options.transform(processedExpectation);
|
||||
}
|
||||
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(requestForProcessing))
|
||||
.digest('hex')
|
||||
.substring(0, 8);
|
||||
|
||||
const filename = `${Date.now()}-${hostName}-${method}-${expectation.httpRequest.path.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
|
||||
processedExpectation.id = filename;
|
||||
const filePath = join(targetDir, filename);
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(processedExpectation, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveExpectations() {
|
||||
return await this.client.retrieveActiveExpectations({ method: 'GET' });
|
||||
}
|
||||
}
|
||||
|
||||
export function createProxyHelper(ctx: HelperContext): ProxyServer {
|
||||
const result = ctx.serviceResults.proxy as ProxyResult | undefined;
|
||||
if (!result) {
|
||||
throw new Error('Proxy service not found in context');
|
||||
}
|
||||
const url = `http://${result.container.getHost()}:${result.container.getMappedPort(PORT)}`;
|
||||
return new ProxyServer(url);
|
||||
}
|
||||
|
||||
declare module './types' {
|
||||
interface ServiceHelpers {
|
||||
proxy: ProxyServer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,19 @@ export const redis: Service<RedisResult> = {
|
||||
};
|
||||
},
|
||||
|
||||
env(): Record<string, string> {
|
||||
env(result: RedisResult, external?: boolean): Record<string, string> {
|
||||
const host = external ? result.container.getHost() : HOSTNAME;
|
||||
const port = external ? String(result.container.getMappedPort(6379)) : '6379';
|
||||
return {
|
||||
QUEUE_BULL_REDIS_HOST: HOSTNAME,
|
||||
QUEUE_BULL_REDIS_PORT: '6379',
|
||||
// In container mode, EXECUTIONS_MODE is set by the stack based on worker count.
|
||||
// In external/local mode, redis implies the user wants queue mode.
|
||||
...(external ? { EXECUTIONS_MODE: 'queue' } : {}),
|
||||
QUEUE_BULL_REDIS_HOST: host,
|
||||
QUEUE_BULL_REDIS_PORT: port,
|
||||
N8N_CACHE_ENABLED: 'true',
|
||||
N8N_CACHE_BACKEND: 'redis',
|
||||
N8N_CACHE_REDIS_HOST: HOSTNAME,
|
||||
N8N_CACHE_REDIS_PORT: '6379',
|
||||
N8N_CACHE_REDIS_HOST: host,
|
||||
N8N_CACHE_REDIS_PORT: port,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mysqlService } from './mysql';
|
||||
import { ngrok } from './ngrok';
|
||||
import { createObservabilityHelper } from './observability';
|
||||
import { postgres } from './postgres';
|
||||
import { proxy } from './proxy';
|
||||
import { proxy, createProxyHelper } from './proxy';
|
||||
import { redis } from './redis';
|
||||
import { taskRunner } from './task-runner';
|
||||
import { tracing, createTracingHelper } from './tracing';
|
||||
@@ -45,6 +45,7 @@ export const helperFactories: Partial<HelperFactories> = {
|
||||
keycloak: createKeycloakHelper,
|
||||
observability: createObservabilityHelper,
|
||||
tracing: createTracingHelper,
|
||||
proxy: createProxyHelper,
|
||||
kafka: createKafkaHelper,
|
||||
localstack: createLocalStackHelper,
|
||||
};
|
||||
|
||||
@@ -81,10 +81,10 @@ export interface Service<TResult extends ServiceResult = ServiceResult> {
|
||||
options?: unknown,
|
||||
ctx?: StartContext,
|
||||
): Promise<TResult>;
|
||||
/** @example () => ({ QUEUE_BULL_REDIS_HOST: 'redis' }) */
|
||||
env?(result: TResult): Record<string, string>;
|
||||
/** @example () => ({ N8N_EXTERNAL_STORAGE_ENABLED: 'true' }) */
|
||||
extraEnv?(result: TResult): Record<string, string>;
|
||||
/** @param external When true, returns host-compatible values using mapped ports (for local dev) */
|
||||
env?(result: TResult, external?: boolean): Record<string, string>;
|
||||
/** @param external When true, returns host-compatible values using mapped ports (for local dev) */
|
||||
extraEnv?(result: TResult, external?: boolean): Record<string, string>;
|
||||
/** Verifies service is reachable from inside n8n containers */
|
||||
verifyFromN8n?(result: TResult, n8nContainers: StartedTestContainer[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,12 @@ export interface N8NStack {
|
||||
}
|
||||
|
||||
function shouldServiceStart(name: ServiceName, service: Service, ctx: StartContext): boolean {
|
||||
// Explicitly requested services always start
|
||||
if (ctx.config.services?.includes(name)) return true;
|
||||
if (service.shouldStart) {
|
||||
return service.shouldStart(ctx);
|
||||
}
|
||||
return ctx.config.services?.includes(name) ?? false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function groupByDependencyLevel(serviceNames: ServiceName[]): ServiceName[][] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CurrentsFixtures, CurrentsWorkerFixtures } from '@currents/playwright';
|
||||
import { fixtures as currentsFixtures } from '@currents/playwright';
|
||||
import { test as base, expect, request } from '@playwright/test';
|
||||
import type { ServiceHelpers } from 'n8n-containers/services/types';
|
||||
import type { N8NConfig, N8NStack } from 'n8n-containers/stack';
|
||||
import { createN8NStack } from 'n8n-containers/stack';
|
||||
|
||||
@@ -11,7 +12,6 @@ import { setupDefaultInterceptors } from '../config/intercepts';
|
||||
import { observabilityFixtures, type ObservabilityTestFixtures } from '../fixtures/observability';
|
||||
import { n8nPage } from '../pages/n8nPage';
|
||||
import { ApiHelpers } from '../services/api-helper';
|
||||
import { ProxyServer } from '../services/proxy-server';
|
||||
import { TestError, type TestRequirements } from '../Types';
|
||||
import { setupTestRequirements } from '../utils/requirements';
|
||||
import { getBackendUrl, getFrontendUrl } from '../utils/url-helper';
|
||||
@@ -21,7 +21,8 @@ type TestFixtures = {
|
||||
api: ApiHelpers;
|
||||
baseURL: string;
|
||||
setupRequirements: (requirements: TestRequirements) => Promise<void>;
|
||||
proxyServer: ProxyServer;
|
||||
/** Type-safe service helpers (mailpit, gitea, proxy, observability, etc.) */
|
||||
services: ServiceHelpers;
|
||||
/**
|
||||
* Direct URLs to each main instance (bypasses load balancer).
|
||||
* Only available in container mode with multi-main setup.
|
||||
@@ -280,25 +281,8 @@ export const test = base.extend<
|
||||
await use(setupFunction);
|
||||
},
|
||||
|
||||
proxyServer: async ({ n8nContainer }, use) => {
|
||||
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'),
|
||||
);
|
||||
|
||||
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 use(proxyServer);
|
||||
services: async ({ n8nContainer }, use) => {
|
||||
await use(n8nContainer.services);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -309,10 +293,8 @@ Fixture Dependency Graph:
|
||||
Worker: capability + project.containerConfig → n8nContainer → [backendUrl, frontendUrl, dbSetup]
|
||||
Test: frontendUrl + dbSetup → baseURL → n8n (uses backendUrl for API calls)
|
||||
backendUrl → api
|
||||
n8nContainer → services
|
||||
|
||||
n8nContainer provides unified access to:
|
||||
- services: Type-safe helpers (mailpit, gitea, observability, etc.)
|
||||
- logs/metrics: Shortcuts for observability queries
|
||||
- findContainers/stopContainer: Container operations for chaos testing
|
||||
- serviceResults: Raw service results (advanced use)
|
||||
services: Type-safe helpers (mailpit, gitea, proxy, observability, etc.)
|
||||
n8nContainer: Container lifecycle (stop, containers, mainUrls, etc.)
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,12 @@ import { getBackendUrl, getFrontendUrl } from './utils/url-helper';
|
||||
// - @licensed - enterprise license features (log streaming, SSO, etc.)
|
||||
// - @db:reset - tests needing per-test database reset (requires isolated containers)
|
||||
const CONTAINER_ONLY = new RegExp(
|
||||
`@capability:(${CONTAINER_ONLY_CAPABILITIES.join('|')})|@mode:(${CONTAINER_ONLY_MODES.join('|')})|@${LICENSED_TAG}|@db:reset`,
|
||||
[
|
||||
`@capability:(${CONTAINER_ONLY_CAPABILITIES.join('|')})`,
|
||||
`@mode:(${CONTAINER_ONLY_MODES.join('|')})`,
|
||||
`@${LICENSED_TAG}`,
|
||||
'@db:reset',
|
||||
].join('|'),
|
||||
);
|
||||
|
||||
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* ProxyServer service helper functions for Playwright tests
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import type { Expectation, RequestDefinition } from 'mockserver-client';
|
||||
import { mockServerClient as proxyServerClient } from 'mockserver-client';
|
||||
import type { HttpRequest, HttpResponse } from 'mockserver-client/mockServer';
|
||||
import type {
|
||||
MockServerClient,
|
||||
PathOrRequestDefinition,
|
||||
RequestResponse,
|
||||
} from 'mockserver-client/mockServerClient';
|
||||
import { join } from 'path';
|
||||
|
||||
export type RequestMade = {
|
||||
httpRequest?: HttpRequest;
|
||||
httpResponse?: HttpResponse;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
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 specified subfolder and mock them
|
||||
*/
|
||||
async loadExpectations(
|
||||
folderName: string,
|
||||
options: { strictBodyMatching?: boolean } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const targetDir = join(this.expectationsDir, folderName);
|
||||
const files = await fs.readdir(targetDir);
|
||||
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||
const expectations: Expectation[] = [];
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const filePath = join(targetDir, file);
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const expectation = JSON.parse(fileContent);
|
||||
|
||||
if (options.strictBodyMatching && expectation.httpRequest?.body) {
|
||||
expectation.httpRequest.body.matchType = 'STRICT';
|
||||
}
|
||||
|
||||
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: RequestDefinition, numberOfRequests: number): Promise<boolean> {
|
||||
try {
|
||||
await this.client.verify(request, numberOfRequests, numberOfRequests);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all expectations and logs from ProxyServer
|
||||
*/
|
||||
async clearAllExpectations(): Promise<void> {
|
||||
try {
|
||||
await this.client.clear('', 'ALL');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 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 request was made to ProxyServer
|
||||
*/
|
||||
async wasRequestMade(request: RequestDefinition, numberOfRequests = 1): Promise<boolean> {
|
||||
return await this.verifyRequest(request, numberOfRequests);
|
||||
}
|
||||
|
||||
async getAllRequestsMade(): Promise<RequestMade[]> {
|
||||
// @ts-expect-error mockserver types seem to be messed up
|
||||
return await this.client.retrieveRecordedRequestsAndResponses('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recorded expectations and write to files
|
||||
*
|
||||
* @param folderName - Target folder name for saving expectation files
|
||||
* @param options - Optional configuration
|
||||
* @param options.pathOrRequestDefinition - Filter expectations by path or request definition
|
||||
* @param options.host - Filter expectations by host name (partial match)
|
||||
* @param options.dedupe - Remove duplicate expectations based on request
|
||||
* @param options.raw - Save full original requests (true) or cleaned requests (false, default)
|
||||
* - raw: false (default) - Saves only essential fields: method, path, queryStringParameters (GET), body (POST/PUT)
|
||||
* - raw: true - Saves complete original request including all headers and metadata
|
||||
* @param options.transform - Transform function to modify expectation before saving
|
||||
*/
|
||||
async recordExpectations(
|
||||
folderName: string,
|
||||
options?: {
|
||||
pathOrRequestDefinition?: PathOrRequestDefinition;
|
||||
host?: string;
|
||||
dedupe?: boolean;
|
||||
raw?: boolean;
|
||||
transform?: (expectation: Expectation) => Expectation;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Retrieve recorded expectations from the mock server
|
||||
const recordedExpectations = await this.client.retrieveRecordedExpectations(
|
||||
options?.pathOrRequestDefinition,
|
||||
);
|
||||
|
||||
// Create target directory path
|
||||
const targetDir = join(this.expectationsDir, folderName);
|
||||
|
||||
// Ensure target directory exists
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const seenRequests = new Set<string>();
|
||||
|
||||
for (const expectation of recordedExpectations) {
|
||||
if (
|
||||
!expectation.httpRequest ||
|
||||
!(
|
||||
'method' in expectation.httpRequest &&
|
||||
typeof expectation.httpRequest.method === 'string' &&
|
||||
typeof expectation.httpRequest.path === 'string'
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract host for filename and filtering
|
||||
const headers = expectation.httpRequest.headers ?? {};
|
||||
const hostHeader = 'Host' in headers ? headers?.Host : undefined;
|
||||
const hostName = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? 'unknown-host');
|
||||
|
||||
if (options?.host && typeof hostName === 'string' && !hostName.includes(options.host)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const method = expectation.httpRequest.method;
|
||||
let requestForProcessing: Record<string, unknown> | HttpRequest;
|
||||
|
||||
if (options?.raw) {
|
||||
// Use raw request without cleaning
|
||||
requestForProcessing = expectation.httpRequest;
|
||||
} else {
|
||||
// Clean up the request data
|
||||
const cleanedRequest: Record<string, unknown> = {
|
||||
method: expectation.httpRequest.method,
|
||||
path: expectation.httpRequest.path,
|
||||
};
|
||||
|
||||
// Include different fields based on method
|
||||
if (method === 'GET') {
|
||||
// For GET requests, include queryStringParameters if present
|
||||
if (expectation.httpRequest.queryStringParameters) {
|
||||
cleanedRequest.queryStringParameters = expectation.httpRequest.queryStringParameters;
|
||||
}
|
||||
} else if (method === 'POST' || method === 'PUT') {
|
||||
// For POST/PUT requests, include body if present
|
||||
if (expectation.httpRequest.body) {
|
||||
cleanedRequest.body = expectation.httpRequest.body;
|
||||
}
|
||||
}
|
||||
|
||||
requestForProcessing = cleanedRequest;
|
||||
}
|
||||
|
||||
// Dedupe expectations if requested
|
||||
if (options?.dedupe) {
|
||||
const dedupeKey = JSON.stringify(requestForProcessing);
|
||||
|
||||
if (seenRequests.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRequests.add(dedupeKey);
|
||||
}
|
||||
|
||||
// Create expectation (cleaned or raw)
|
||||
let processedExpectation: Expectation = {
|
||||
...expectation,
|
||||
httpRequest: requestForProcessing,
|
||||
times: {
|
||||
unlimited: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Apply transform if provided
|
||||
if (options?.transform) {
|
||||
processedExpectation = options.transform(processedExpectation);
|
||||
}
|
||||
|
||||
// Generate unique filename based on request details
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(requestForProcessing))
|
||||
.digest('hex')
|
||||
.substring(0, 8);
|
||||
|
||||
const filename = `${Date.now()}-${hostName}-${method}-${expectation.httpRequest.path.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
|
||||
processedExpectation.id = filename;
|
||||
const filePath = join(targetDir, filename);
|
||||
|
||||
// Write expectation to JSON file
|
||||
await fs.writeFile(filePath, JSON.stringify(processedExpectation, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveExpectations() {
|
||||
return await this.client.retrieveActiveExpectations({ method: 'GET' });
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { expect, test } from '../../../fixtures/base';
|
||||
test.use({ capability: 'proxy' });
|
||||
|
||||
test.describe('Evaluations @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
|
||||
await n8n.goHome();
|
||||
});
|
||||
@@ -12,8 +12,8 @@ test.describe('Evaluations @capability:proxy', () => {
|
||||
// @AI team to look at this
|
||||
test.fixme(
|
||||
'should load evaluations workflow and execute twice @fixme',
|
||||
async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.loadExpectations('evaluations');
|
||||
async ({ n8n, services }) => {
|
||||
await services.proxy.loadExpectations('evaluations');
|
||||
|
||||
await n8n.api.credentials.createCredentialFromDefinition({
|
||||
name: 'Test Google Sheets',
|
||||
@@ -74,9 +74,9 @@ m82JpEptTfAxFHtd8+Sb0U2G
|
||||
await n8n.notifications.waitForNotificationAndClose('Successful', { timeout: 10000 });
|
||||
|
||||
// 💡 To update recordings, remove stored expectations, set real credentials above and rerecord here.
|
||||
// await proxyServer.recordExpectations('evaluations', { host: 'google', dedupe: true });
|
||||
// await services.proxy.recordExpectations('evaluations', { host: 'google', dedupe: true });
|
||||
|
||||
const batchUpdateRequests = (await proxyServer.getAllRequestsMade()).filter((request) => {
|
||||
const batchUpdateRequests = (await services.proxy.getAllRequestsMade()).filter((request) => {
|
||||
const path = request.httpRequest?.path;
|
||||
const method = request.httpRequest?.method;
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ const hitlForToolsTestConfig = {
|
||||
|
||||
test.use(hitlForToolsTestConfig);
|
||||
test.describe('HITL for Tools @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('hitl-for-tools');
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('hitl-for-tools');
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
});
|
||||
|
||||
|
||||
@@ -77,9 +77,9 @@ async function setupBasicAgentWorkflow(n8n: n8nPage, additionalNodes: string[] =
|
||||
|
||||
test.use({ capability: 'proxy' });
|
||||
test.describe('Langchain Integration @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('langchain');
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('langchain');
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
});
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ async function executeChatAndWaitForResponse(n8n: n8nPage, message: string) {
|
||||
|
||||
test.use({ capability: 'proxy' });
|
||||
test.describe('Langchain Integration @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('langchain');
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('langchain');
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
});
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ async function verifyChatMessages(n8n: n8nPage, expectedCount: number, inputMess
|
||||
|
||||
test.use({ capability: 'proxy' });
|
||||
test.describe('Langchain Integration @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('langchain');
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('langchain');
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ async function setupBasicAgentWorkflow(n8n: n8nPage, additionalNodes: string[] =
|
||||
|
||||
test.use({ capability: 'proxy' });
|
||||
test.describe('Langchain Integration @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('langchain');
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('langchain');
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 10000) {
|
||||
|
||||
test.use({ capability: 'proxy' });
|
||||
test.describe('Langchain Integration @capability:proxy', () => {
|
||||
test.beforeEach(async ({ n8n, proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('langchain');
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('langchain');
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,10 +31,10 @@ test.use({
|
||||
});
|
||||
|
||||
test.describe('Workflow Builder @auth:owner @ai @capability:proxy', () => {
|
||||
test.beforeEach(async ({ setupRequirements, proxyServer }) => {
|
||||
test.beforeEach(async ({ setupRequirements, services }) => {
|
||||
await setupRequirements(workflowBuilderEnabledRequirements);
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('workflow-builder');
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('workflow-builder');
|
||||
});
|
||||
|
||||
test('should show Build with AI button on empty canvas', async ({ n8n }) => {
|
||||
|
||||
@@ -9,9 +9,9 @@ test.describe('OIDC Authentication @capability:oidc', () => {
|
||||
test('should configure OIDC and login with Keycloak @auth:owner', async ({
|
||||
n8n,
|
||||
api,
|
||||
n8nContainer,
|
||||
services,
|
||||
}) => {
|
||||
const keycloak = n8nContainer.services.keycloak;
|
||||
const keycloak = services.keycloak;
|
||||
await api.enableFeature('oidc');
|
||||
await n8n.oidcComposer.configureOidc(
|
||||
keycloak.internalDiscoveryUrl,
|
||||
|
||||
@@ -2,14 +2,14 @@ import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.use({ capability: 'email' });
|
||||
|
||||
test('Password reset email is delivered @capability:email', async ({ api, n8nContainer }) => {
|
||||
test('Password reset email is delivered @capability:email', async ({ api, services }) => {
|
||||
const ownerEmail = 'nathan@n8n.io';
|
||||
const res = await api.request.post('/rest/forgot-password', {
|
||||
data: { email: ownerEmail },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const msg = await n8nContainer.services.mailpit.waitForMessage({
|
||||
const msg = await services.mailpit.waitForMessage({
|
||||
to: ownerEmail,
|
||||
subject: /password reset/i,
|
||||
});
|
||||
|
||||
@@ -5,34 +5,34 @@ import { test, expect } from '../../../fixtures/base';
|
||||
test.use({ capability: 'proxy' });
|
||||
// @capability:proxy tag ensures that test suite is only run when proxy is available
|
||||
test.describe('Proxy server @capability:proxy', () => {
|
||||
test.beforeEach(async ({ proxyServer }) => {
|
||||
await proxyServer.clearAllExpectations();
|
||||
test.beforeEach(async ({ services }) => {
|
||||
await services.proxy.clearAllExpectations();
|
||||
});
|
||||
|
||||
test('should verify ProxyServer container is running', async ({ proxyServer }) => {
|
||||
const mockResponse = await proxyServer.createGetExpectation('/health', {
|
||||
test('should verify ProxyServer container is running', async ({ services }) => {
|
||||
const mockResponse = await services.proxy.createGetExpectation('/health', {
|
||||
status: 'healthy',
|
||||
});
|
||||
|
||||
assert(typeof mockResponse !== 'string');
|
||||
expect(mockResponse.statusCode).toBe(201);
|
||||
|
||||
expect(await proxyServer.wasRequestMade({ method: 'GET', path: '/health' })).toBe(false);
|
||||
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/health' })).toBe(false);
|
||||
|
||||
// Verify the mock endpoint works
|
||||
const healthResponse = await fetch(`${proxyServer.url}/health`);
|
||||
const healthResponse = await fetch(`${services.proxy.url}/health`);
|
||||
expect(healthResponse.ok).toBe(true);
|
||||
const healthData = await healthResponse.json();
|
||||
expect(healthData.status).toBe('healthy');
|
||||
|
||||
expect(await proxyServer.wasRequestMade({ method: 'GET', path: '/health' })).toBe(true);
|
||||
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/health' })).toBe(true);
|
||||
});
|
||||
|
||||
test('should run a simple workflow calling http endpoint', async ({ n8n, proxyServer }) => {
|
||||
test('should run a simple workflow calling http endpoint', async ({ n8n, services }) => {
|
||||
const mockResponse = { data: 'Hello from ProxyServer!', test: '1' };
|
||||
|
||||
// Create expectation in mockserver to handle the request
|
||||
await proxyServer.createGetExpectation('/data', mockResponse, { test: '1' });
|
||||
await services.proxy.createGetExpectation('/data', mockResponse, { test: '1' });
|
||||
|
||||
await n8n.canvas.openNewWorkflow();
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Proxy server @capability:proxy', () => {
|
||||
|
||||
// Verify the request was handled by mockserver
|
||||
expect(
|
||||
await proxyServer.wasRequestMade({
|
||||
await services.proxy.wasRequestMade({
|
||||
method: 'GET',
|
||||
path: '/data',
|
||||
queryStringParameters: { test: ['1'] },
|
||||
@@ -54,14 +54,16 @@ test.describe('Proxy server @capability:proxy', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should use stored expectations respond to api request', async ({ proxyServer }) => {
|
||||
await proxyServer.loadExpectations('proxy-server');
|
||||
test('should use stored expectations respond to api request', async ({ services }) => {
|
||||
await services.proxy.loadExpectations('proxy-server');
|
||||
|
||||
const response = await fetch(`${proxyServer.url}/mock-endpoint`);
|
||||
const response = await fetch(`${services.proxy.url}/mock-endpoint`);
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.title).toBe('delectus aut autem');
|
||||
expect(await proxyServer.wasRequestMade({ method: 'GET', path: '/mock-endpoint' })).toBe(true);
|
||||
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/mock-endpoint' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('should run a simple workflow proxying HTTPS request', async ({ n8n }) => {
|
||||
|
||||
@@ -37,16 +37,16 @@ export const test = base.extend<ChatHubFixtures>({
|
||||
},
|
||||
|
||||
chatHubProxySetup: [
|
||||
async ({ proxyServer }, use) => {
|
||||
async ({ services }, use) => {
|
||||
// Setup
|
||||
await proxyServer.clearAllExpectations();
|
||||
await proxyServer.loadExpectations('chat-hub', { strictBodyMatching: true });
|
||||
await services.proxy.clearAllExpectations();
|
||||
await services.proxy.loadExpectations('chat-hub', { strictBodyMatching: true });
|
||||
|
||||
await use(undefined);
|
||||
|
||||
// Teardown
|
||||
if (!process.env.CI) {
|
||||
await proxyServer.recordExpectations('chat-hub', {
|
||||
await services.proxy.recordExpectations('chat-hub', {
|
||||
dedupe: true,
|
||||
transform: (expectation) => {
|
||||
const response = expectation.httpResponse as {
|
||||
|
||||
@@ -2,18 +2,19 @@ import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.use({ capability: 'email' });
|
||||
|
||||
test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, n8nContainer }) => {
|
||||
test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, services }) => {
|
||||
// Sign in to use internal APIs for creating credentials and workflows
|
||||
const mailpit = services.mailpit;
|
||||
|
||||
// Create SMTP credential targeting Mailpit
|
||||
// Create SMTP credential targeting Mailpit (uses internal hostname in container mode, localhost in local mode)
|
||||
const smtpCredential = await api.credentials.createCredential({
|
||||
name: 'SMTP (Test)',
|
||||
type: 'smtp',
|
||||
data: {
|
||||
user: '',
|
||||
password: '',
|
||||
host: 'mailpit',
|
||||
port: 1025,
|
||||
host: mailpit.smtpHost,
|
||||
port: mailpit.smtpPort,
|
||||
secure: false,
|
||||
disableStartTls: true,
|
||||
},
|
||||
@@ -73,7 +74,7 @@ test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, n8nCo
|
||||
'Workflow executed successfully',
|
||||
);
|
||||
|
||||
const msg = await n8nContainer.services.mailpit.waitForMessage({ to: toEmail, subject });
|
||||
const msg = await mailpit.waitForMessage({ to: toEmail, subject });
|
||||
|
||||
expect(msg).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,9 @@ test.describe('Kafka Nodes', () => {
|
||||
test('Kafka node publishes messages to topic @capability:kafka', async ({
|
||||
api,
|
||||
n8n,
|
||||
n8nContainer,
|
||||
services,
|
||||
}) => {
|
||||
const kafka = n8nContainer.services.kafka;
|
||||
const kafka = services.kafka;
|
||||
const topic = `producer-test-${nanoid()}`;
|
||||
const testPayload = { greeting: 'Hello from n8n Kafka node' };
|
||||
|
||||
@@ -98,8 +98,8 @@ test.describe('Kafka Nodes', () => {
|
||||
expect(JSON.parse(messages[0].value)).toMatchObject(testPayload);
|
||||
});
|
||||
|
||||
test('Kafka Trigger node processes messages @capability:kafka', async ({ api, n8nContainer }) => {
|
||||
const kafka = n8nContainer.services.kafka;
|
||||
test('Kafka Trigger node processes messages @capability:kafka', async ({ api, services }) => {
|
||||
const kafka = services.kafka;
|
||||
const topic = `trigger-test-${nanoid()}`;
|
||||
const groupId = `n8n-test-group-${nanoid()}`;
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ test.describe('Source Control Settings @capability:source-control @fixme', () =>
|
||||
let repoUrl: string;
|
||||
let repoName: string;
|
||||
|
||||
test.beforeEach(async ({ n8n, n8nContainer }) => {
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await n8n.api.enableFeature('sourceControl');
|
||||
const gitea = n8nContainer.services.gitea;
|
||||
const gitea = services.gitea;
|
||||
await initSourceControl({ n8n, gitea });
|
||||
|
||||
// Create unique repo with branches via API (not UI)
|
||||
@@ -56,8 +56,8 @@ test.describe('Source Control Settings @capability:source-control @fixme', () =>
|
||||
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch between branches', async ({ n8n, n8nContainer }) => {
|
||||
const gitea = n8nContainer.services.gitea;
|
||||
test('should switch between branches', async ({ n8n, services }) => {
|
||||
const gitea = services.gitea;
|
||||
await gitea.createBranch(repoName, 'development');
|
||||
await gitea.createBranch(repoName, 'staging');
|
||||
await gitea.createBranch(repoName, 'production');
|
||||
|
||||
+4
-7
@@ -20,16 +20,13 @@ test.describe('AWS Secrets Manager with LocalStack @capability:external-secrets
|
||||
return data[PROVIDER_NAME] ?? [];
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ n8n, n8nContainer }) => {
|
||||
await n8nContainer.services.localstack.secretsManager.clear();
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await services.localstack.secretsManager.clear();
|
||||
await n8n.api.enableFeature('externalSecrets');
|
||||
});
|
||||
|
||||
test('can configure, connect, and sync secrets from LocalStack', async ({
|
||||
n8n,
|
||||
n8nContainer,
|
||||
}) => {
|
||||
const { secretsManager } = n8nContainer.services.localstack;
|
||||
test('can configure, connect, and sync secrets from LocalStack', async ({ n8n, services }) => {
|
||||
const { secretsManager } = services.localstack;
|
||||
|
||||
await secretsManager.createSecret('api-key', 'secret-123');
|
||||
|
||||
|
||||
+6
-6
@@ -9,7 +9,7 @@ test.describe('Secret Providers Connections with LocalStack @capability:external
|
||||
const PROVIDER_KEY = 'aws-localstack-e2e';
|
||||
const PROVIDER_TYPE = 'awsSecretsManager';
|
||||
|
||||
test.beforeEach(async ({ n8n, n8nContainer }) => {
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
// N8N_ENV_FEAT_EXTERNAL_SECRETS_FOR_PROJECTS is set at container startup
|
||||
// via the external-secrets capability config
|
||||
|
||||
@@ -17,7 +17,7 @@ test.describe('Secret Providers Connections with LocalStack @capability:external
|
||||
await n8n.api.enableFeature('externalSecrets');
|
||||
|
||||
// Clear any existing secrets from previous tests
|
||||
await n8nContainer.services.localstack.secretsManager.clear();
|
||||
await services.localstack.secretsManager.clear();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ n8n }) => {
|
||||
@@ -29,16 +29,16 @@ test.describe('Secret Providers Connections with LocalStack @capability:external
|
||||
}
|
||||
});
|
||||
|
||||
test('can create a connection pointing to LocalStack', async ({ n8n, n8nContainer }) => {
|
||||
test('can create a connection pointing to LocalStack', async ({ n8n, services }) => {
|
||||
// Arrange: Seed secrets in LocalStack
|
||||
await n8nContainer.services.localstack.secretsManager.createSecret('e2e-api-key', 'secret-123');
|
||||
await n8nContainer.services.localstack.secretsManager.createSecret(
|
||||
await services.localstack.secretsManager.createSecret('e2e-api-key', 'secret-123');
|
||||
await services.localstack.secretsManager.createSecret(
|
||||
'e2e-db-credentials',
|
||||
JSON.stringify({ username: 'admin', password: 'hunter2' }),
|
||||
);
|
||||
|
||||
// Verify secrets exist in LocalStack
|
||||
const secrets = await n8nContainer.services.localstack.secretsManager.listSecrets();
|
||||
const secrets = await services.localstack.secretsManager.listSecrets();
|
||||
expect(secrets).toContain('e2e-api-key');
|
||||
expect(secrets).toContain('e2e-db-credentials');
|
||||
|
||||
|
||||
+4
-7
@@ -20,11 +20,8 @@ test.describe('Log Streaming to VictoriaLogs @capability:observability', () => {
|
||||
await n8n.api.enableFeature('logStreaming');
|
||||
});
|
||||
|
||||
test('should configure syslog destination and send test message', async ({
|
||||
api,
|
||||
n8nContainer,
|
||||
}) => {
|
||||
const obs = n8nContainer.services.observability;
|
||||
test('should configure syslog destination and send test message', async ({ api, services }) => {
|
||||
const obs = services.observability;
|
||||
|
||||
// Configure syslog destination pointing to VictoriaLogs
|
||||
// syslog contains: host, port, protocol, facility, appName
|
||||
@@ -57,8 +54,8 @@ test.describe('Log Streaming to VictoriaLogs @capability:observability', () => {
|
||||
await api.deleteLogStreamingDestination(destination.id);
|
||||
});
|
||||
|
||||
test('should query metrics from VictoriaMetrics', async ({ api, n8nContainer }) => {
|
||||
const obs = n8nContainer.services.observability;
|
||||
test('should query metrics from VictoriaMetrics', async ({ api, services }) => {
|
||||
const obs = services.observability;
|
||||
|
||||
// Import and activate a webhook workflow to generate metrics
|
||||
const { webhookPath, workflowId } = await api.workflows.importWorkflowFromFile(
|
||||
|
||||
+2
-2
@@ -18,9 +18,9 @@ test.describe('Log Streaming UI E2E @capability:observability', () => {
|
||||
|
||||
test('should configure syslog destination via UI and send test event', async ({
|
||||
n8n,
|
||||
n8nContainer,
|
||||
services,
|
||||
}) => {
|
||||
const obs = n8nContainer.services.observability;
|
||||
const obs = services.observability;
|
||||
|
||||
// ========== STEP 1: Configure Log Streaming via UI ==========
|
||||
await n8n.navigate.toLogStreaming();
|
||||
|
||||
@@ -18,10 +18,10 @@ test.describe('Pull resources from Git @capability:source-control @fixme', () =>
|
||||
|
||||
let gitRepo: GitRepoHelper;
|
||||
|
||||
test.beforeEach(async ({ n8n, n8nContainer }) => {
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await n8n.api.enableFeature('sourceControl');
|
||||
await n8n.api.enableFeature('variables');
|
||||
gitRepo = await setupGitRepo(n8n, n8nContainer.services.gitea);
|
||||
gitRepo = await setupGitRepo(n8n, services.gitea);
|
||||
});
|
||||
|
||||
test('should pull new resources from remote', async ({ n8n }) => {
|
||||
|
||||
@@ -24,11 +24,11 @@ test.describe('Push resources to Git @capability:source-control @fixme', () => {
|
||||
|
||||
let gitRepo: GitRepoHelper;
|
||||
|
||||
test.beforeEach(async ({ n8n, n8nContainer }) => {
|
||||
test.beforeEach(async ({ n8n, services }) => {
|
||||
await n8n.api.enableFeature('sourceControl');
|
||||
await n8n.api.enableFeature('variables');
|
||||
|
||||
gitRepo = await setupGitRepo(n8n, n8nContainer.services.gitea);
|
||||
gitRepo = await setupGitRepo(n8n, services.gitea);
|
||||
});
|
||||
|
||||
test('should push a new workflow', async ({ n8n }) => {
|
||||
|
||||
@@ -11,10 +11,11 @@ const getContainerName = (log: LogEntry): string | undefined => log.container_na
|
||||
|
||||
test('Leader election @mode:multi-main @chaostest @capability:observability', async ({
|
||||
n8nContainer,
|
||||
services,
|
||||
}) => {
|
||||
// Find the current leader by querying VictoriaLogs
|
||||
// Vector enriches logs with container_name from Docker metadata
|
||||
const leaderLog = await n8nContainer.logs.waitForLog('Leader is now this', {
|
||||
const leaderLog = await services.observability.logs.waitForLog('Leader is now this', {
|
||||
timeoutMs: 30000,
|
||||
start: '-5m',
|
||||
});
|
||||
@@ -30,7 +31,7 @@ test('Leader election @mode:multi-main @chaostest @capability:observability', as
|
||||
|
||||
// Wait for new leader election (another instance should take over)
|
||||
// Use LogsQL to exclude logs from the stopped container
|
||||
const newLeaderLog = await n8nContainer.logs.waitForLog(
|
||||
const newLeaderLog = await services.observability.logs.waitForLog(
|
||||
`Leader is now this AND NOT container_name:${currentLeader}`,
|
||||
{
|
||||
timeoutMs: 30000,
|
||||
|
||||
@@ -15,7 +15,7 @@ test.skip(
|
||||
description: 'CAT-1018',
|
||||
},
|
||||
},
|
||||
async ({ api, n8nContainer }) => {
|
||||
async ({ api, n8nContainer, services }) => {
|
||||
test.setTimeout(300000);
|
||||
|
||||
// ========== SETUP: Verify Initial Health ==========
|
||||
@@ -44,7 +44,7 @@ test.skip(
|
||||
|
||||
// ========== WAIT FOR CONNECTION ISSUES ==========
|
||||
// Query VictoriaLogs for database timeout messages
|
||||
await n8nContainer.logs.waitForLog('Database connection timed out', {
|
||||
await services.observability.logs.waitForLog('Database connection timed out', {
|
||||
timeoutMs: 20 * Time.seconds.toMilliseconds,
|
||||
start: '-1m',
|
||||
});
|
||||
@@ -64,7 +64,7 @@ test.skip(
|
||||
|
||||
// ========== VERIFY: Automatic Recovery ==========
|
||||
// Query VictoriaLogs for database recovery messages
|
||||
await n8nContainer.logs.waitForLog('Database connection recovered', {
|
||||
await services.observability.logs.waitForLog('Database connection recovered', {
|
||||
timeoutMs: 20 * Time.seconds.toMilliseconds,
|
||||
start: '-1m',
|
||||
});
|
||||
|
||||
+4
-4
@@ -60,8 +60,8 @@ test.describe('Multi-main Observability @capability:observability @mode:multi-ma
|
||||
* This tests the Prometheus-compatible /metrics endpoint exposure and
|
||||
* service discovery configuration in VictoriaMetrics.
|
||||
*/
|
||||
test('should scrape metrics from all n8n instances', async ({ n8nContainer }) => {
|
||||
const obs = n8nContainer.services.observability;
|
||||
test('should scrape metrics from all n8n instances', async ({ services }) => {
|
||||
const obs = services.observability;
|
||||
|
||||
// Expected targets: 2 mains + 1 worker = 3 instances
|
||||
const expectedTargets = 3;
|
||||
@@ -108,11 +108,11 @@ test.describe('Multi-main Observability @capability:observability @mode:multi-ma
|
||||
* - TCP syslog delivery from n8n to VictoriaLogs
|
||||
* - LogsQL query capability in VictoriaLogs
|
||||
*/
|
||||
test('should configure log streaming and receive events', async ({ api, n8nContainer }) => {
|
||||
test('should configure log streaming and receive events', async ({ api, services }) => {
|
||||
// ========== STEP 1: Enable log streaming feature ==========
|
||||
await api.enableFeature('logStreaming');
|
||||
|
||||
const obs = n8nContainer.services.observability;
|
||||
const obs = services.observability;
|
||||
|
||||
// ========== STEP 2: Configure syslog destination ==========
|
||||
// Create a syslog destination pointing to VictoriaLogs
|
||||
|
||||
@@ -11,8 +11,9 @@ test.use({
|
||||
test.describe('Memory Consumption @capability:observability', () => {
|
||||
test('Memory consumption baseline with starter plan resources', async ({
|
||||
n8nContainer,
|
||||
services,
|
||||
}, testInfo) => {
|
||||
const obs = n8nContainer.services.observability;
|
||||
const obs = services.observability;
|
||||
|
||||
const { heapUsedMB } = await getStableHeap(n8nContainer.baseUrl, obs.metrics);
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ test.describe('Memory Leak Detection @capability:observability', () => {
|
||||
await n8n.navigate.toWorkflows();
|
||||
}
|
||||
|
||||
test('Memory should be released after actions', async ({ n8nContainer, n8n }, testInfo) => {
|
||||
const obs = n8nContainer.services.observability;
|
||||
test('Memory should be released after actions', async ({
|
||||
n8nContainer,
|
||||
n8n,
|
||||
services,
|
||||
}, testInfo) => {
|
||||
const obs = services.observability;
|
||||
|
||||
const baseline = await getStableHeap(n8nContainer.baseUrl, obs.metrics);
|
||||
await performMemoryAction(n8n);
|
||||
|
||||
Generated
+5
-2
@@ -3673,6 +3673,9 @@ importers:
|
||||
kafkajs:
|
||||
specifier: 'catalog:'
|
||||
version: 2.2.4
|
||||
mockserver-client:
|
||||
specifier: ^5.15.0
|
||||
version: 5.15.0
|
||||
testcontainers:
|
||||
specifier: ^11.11.0
|
||||
version: 11.11.0
|
||||
@@ -31409,7 +31412,7 @@ snapshots:
|
||||
isstream: 0.1.2
|
||||
jsonwebtoken: 9.0.3
|
||||
mime-types: 2.1.35
|
||||
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.3))
|
||||
retry-axios: 2.6.0(axios@1.12.0)
|
||||
tough-cookie: 4.1.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -35934,7 +35937,7 @@ snapshots:
|
||||
onetime: 5.1.2
|
||||
signal-exit: 3.0.7
|
||||
|
||||
retry-axios@2.6.0(axios@1.12.0(debug@4.4.3)):
|
||||
retry-axios@2.6.0(axios@1.12.0):
|
||||
dependencies:
|
||||
axios: 1.12.0(debug@4.4.3)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user