mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
import type { StartedTestContainer, StoppedTestContainer } from 'testcontainers';
|
|
|
|
export interface LogMatch {
|
|
container: StartedTestContainer;
|
|
containerName: string;
|
|
message: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
interface WaitForLogOptions {
|
|
namePattern?: string | RegExp;
|
|
timeoutMs?: number;
|
|
caseSensitive?: boolean;
|
|
}
|
|
|
|
interface StreamLogMatch {
|
|
line: string;
|
|
date: Date | null;
|
|
}
|
|
|
|
/**
|
|
* Container helpers bound to a specific set of containers
|
|
*/
|
|
export class ContainerTestHelpers {
|
|
private static readonly DEFAULT_TIMEOUT_MS = 30000;
|
|
|
|
private static readonly POLL_INTERVAL_MS = 1000;
|
|
|
|
// Containers
|
|
private containers: StartedTestContainer[];
|
|
|
|
constructor(containers: StartedTestContainer[]) {
|
|
this.containers = containers;
|
|
}
|
|
|
|
/**
|
|
* Read logs from a container
|
|
*/
|
|
async readLogs(containerNamePattern: string | RegExp, since?: number): Promise<string> {
|
|
const container = this.findContainers(containerNamePattern)[0];
|
|
if (!container) {
|
|
console.warn(`No container found matching pattern: ${containerNamePattern}`);
|
|
return '';
|
|
}
|
|
|
|
return await this.readLogsFromContainer(container, since);
|
|
}
|
|
|
|
/**
|
|
* Wait for a log message matching pattern (case-insensitive by default)
|
|
* Uses streaming approach for immediate detection
|
|
*/
|
|
async waitForLog(
|
|
messagePattern: string | RegExp,
|
|
options: WaitForLogOptions = {},
|
|
): Promise<LogMatch> {
|
|
const {
|
|
namePattern,
|
|
timeoutMs = ContainerTestHelpers.DEFAULT_TIMEOUT_MS,
|
|
caseSensitive = false,
|
|
} = options;
|
|
|
|
const messageRegex = this.createRegex(messagePattern, caseSensitive);
|
|
const targetContainers = namePattern ? this.findContainers(namePattern) : this.containers;
|
|
const startTime = Date.now();
|
|
|
|
console.log(
|
|
`🔍 Waiting for log pattern: ${messageRegex} in ${targetContainers.length} containers (timeout: ${timeoutMs}ms)`,
|
|
);
|
|
|
|
// First check: scan existing logs quickly
|
|
const existingMatch = await this.findFirstMatchingLog(targetContainers, messageRegex);
|
|
if (existingMatch) {
|
|
console.log(`✅ Found existing log in ${existingMatch.containerName}`);
|
|
return existingMatch;
|
|
}
|
|
|
|
// Monitor new logs with streaming approach
|
|
return await this.pollForNewLogs(targetContainers, messageRegex, startTime, timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* Find the first matching log across multiple containers
|
|
*/
|
|
private async findFirstMatchingLog(
|
|
containers: StartedTestContainer[],
|
|
messageRegex: RegExp,
|
|
sinceTimestamp?: number,
|
|
): Promise<LogMatch | null> {
|
|
const matchPromises = containers.map(async (container) => {
|
|
const match = await this.findLogInContainer(container, messageRegex, sinceTimestamp);
|
|
if (match) {
|
|
return {
|
|
container,
|
|
containerName: container.getName(),
|
|
message: match.line,
|
|
timestamp: match.date ?? new Date(),
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const results = await Promise.all(matchPromises);
|
|
return results.find((result) => result !== null) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Poll containers for new logs matching the pattern
|
|
*/
|
|
private async pollForNewLogs(
|
|
targetContainers: StartedTestContainer[],
|
|
messageRegex: RegExp,
|
|
startTime: number,
|
|
timeoutMs: number,
|
|
): Promise<LogMatch> {
|
|
let currentCheckTime = Math.floor(Date.now() / 1000);
|
|
let iteration = 0;
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
iteration++;
|
|
await this.sleep(ContainerTestHelpers.POLL_INTERVAL_MS);
|
|
|
|
// Capture the timestamp for this iteration to avoid race conditions
|
|
const checkTimestamp = currentCheckTime;
|
|
|
|
// Check all containers concurrently
|
|
const matchPromises = targetContainers.map((container) =>
|
|
this.checkContainerForMatch(container, messageRegex, checkTimestamp),
|
|
);
|
|
|
|
const results = await Promise.all(matchPromises);
|
|
const found = results.find((result) => result !== null);
|
|
|
|
if (found) {
|
|
console.log(`✅ Found new log in ${found.containerName} (iteration ${iteration})`);
|
|
return found;
|
|
}
|
|
|
|
// Update timestamp for next iteration
|
|
currentCheckTime = Math.floor(Date.now() / 1000);
|
|
|
|
// Progress indicator
|
|
if (iteration % 10 === 0) {
|
|
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
console.log(`⏱️ Still waiting... (${elapsedSeconds}s elapsed)`);
|
|
}
|
|
}
|
|
|
|
console.log(`❌ Timeout reached after ${timeoutMs}ms`);
|
|
throw new Error(`Timeout reached after ${timeoutMs}ms`);
|
|
}
|
|
|
|
/**
|
|
* Check a single container for matching logs
|
|
*/
|
|
private async checkContainerForMatch(
|
|
container: StartedTestContainer,
|
|
messageRegex: RegExp,
|
|
sinceTimestamp: number,
|
|
): Promise<LogMatch | null> {
|
|
const match = await this.findLogInContainer(container, messageRegex, sinceTimestamp);
|
|
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
container,
|
|
containerName: container.getName(),
|
|
message: match.line,
|
|
timestamp: match.date ?? new Date(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all log messages matching pattern (case-insensitive by default)
|
|
*/
|
|
async getLogs(
|
|
messagePattern: string | RegExp,
|
|
namePattern?: string | RegExp,
|
|
caseSensitive = false,
|
|
): Promise<LogMatch[]> {
|
|
const messageRegex = this.createRegex(messagePattern, caseSensitive);
|
|
const targetContainers = namePattern ? this.findContainers(namePattern) : this.containers;
|
|
|
|
console.log(
|
|
`🔍 Getting all logs matching: ${messageRegex} from ${targetContainers.length} containers`,
|
|
);
|
|
|
|
const logPromises = targetContainers.map(async (container) => {
|
|
const logs = await this.readLogsFromContainer(container);
|
|
return this.findAllLogMatches(logs, messageRegex, container);
|
|
});
|
|
|
|
const results = await Promise.all(logPromises);
|
|
const matches = results.flat();
|
|
|
|
console.log(`📈 Total matches found: ${matches.length}`);
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Find containers by name pattern
|
|
*/
|
|
findContainers(namePattern: string | RegExp): StartedTestContainer[] {
|
|
const regex = typeof namePattern === 'string' ? new RegExp(namePattern) : namePattern;
|
|
const foundContainers = this.containers.filter((container) => regex.test(container.getName()));
|
|
|
|
console.log(`🔎 Found ${foundContainers.length} containers matching pattern`);
|
|
return foundContainers;
|
|
}
|
|
|
|
/**
|
|
* Stop container by name pattern
|
|
*/
|
|
async stopContainer(namePattern: string | RegExp): Promise<StoppedTestContainer | null> {
|
|
const container = this.findContainers(namePattern)[0];
|
|
return container ? await container.stop() : null;
|
|
}
|
|
|
|
// Private helper methods
|
|
|
|
private createRegex(pattern: string | RegExp, caseSensitive: boolean): RegExp {
|
|
return typeof pattern === 'string' ? new RegExp(pattern, caseSensitive ? 'g' : 'gi') : pattern;
|
|
}
|
|
|
|
/**
|
|
* Strip ANSI escape codes from log text
|
|
*/
|
|
private stripAnsiCodes(text: string): string {
|
|
return text.replace(/\x1B\[[0-9;]*[mGKH]/g, '');
|
|
}
|
|
|
|
/**
|
|
* Extract timestamp from log line
|
|
*/
|
|
private extractTimestamp(line: string): Date | null {
|
|
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
|
|
return timestampMatch ? new Date(timestampMatch[1]) : null;
|
|
}
|
|
|
|
/**
|
|
* Find log line in container using streaming approach with early exit
|
|
*/
|
|
private async findLogInContainer(
|
|
container: StartedTestContainer,
|
|
messageRegex: RegExp,
|
|
since?: number,
|
|
): Promise<StreamLogMatch | null> {
|
|
try {
|
|
const logOptions: any = {};
|
|
if (since !== undefined) {
|
|
logOptions.since = since;
|
|
}
|
|
|
|
const stream = await container.logs(logOptions);
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
let buffer = '';
|
|
|
|
const timeout = setTimeout(() => {
|
|
stream.destroy();
|
|
resolve(null); // Timeout means no match found
|
|
}, 5000); // Shorter timeout for individual container checks
|
|
|
|
const onData = (chunk: Buffer | string) => {
|
|
buffer += chunk.toString();
|
|
const lines = buffer.split('\n');
|
|
|
|
// Keep the last incomplete line in buffer
|
|
buffer = lines.pop() ?? '';
|
|
|
|
// Check complete lines
|
|
for (const line of lines) {
|
|
const cleanLine = this.stripAnsiCodes(line.trim());
|
|
if (cleanLine && messageRegex.test(cleanLine)) {
|
|
clearTimeout(timeout);
|
|
stream.destroy();
|
|
resolve({
|
|
line: cleanLine,
|
|
date: this.extractTimestamp(cleanLine),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
stream.on('data', onData);
|
|
|
|
stream.on('end', () => {
|
|
clearTimeout(timeout);
|
|
stream.destroy();
|
|
resolve(null); // No match found
|
|
});
|
|
|
|
stream.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
stream.destroy();
|
|
console.error(`❌ Stream error from ${container.getName()}:`, error);
|
|
reject(error);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.warn(`❌ Failed to search logs from ${container.getName()}: ${error as string}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async readLogsFromContainer(
|
|
container: StartedTestContainer,
|
|
since?: number,
|
|
): Promise<string> {
|
|
try {
|
|
const logOptions: any = {};
|
|
if (since !== undefined) {
|
|
logOptions.since = since;
|
|
}
|
|
|
|
const stream = await container.logs(logOptions);
|
|
let allData = '';
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
stream.destroy();
|
|
reject(new Error('Log read timeout'));
|
|
}, 10000);
|
|
|
|
stream.on('data', (chunk: Buffer | string) => {
|
|
allData += chunk.toString();
|
|
});
|
|
|
|
stream.on('end', () => {
|
|
clearTimeout(timeout);
|
|
stream.destroy();
|
|
resolve(this.stripAnsiCodes(allData));
|
|
});
|
|
|
|
stream.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
stream.destroy();
|
|
console.error(`❌ Stream error from ${container.getName()}:`, error);
|
|
reject(error);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.warn(`❌ Failed to read logs from ${container.getName()}: ${error as string}`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private findAllLogMatches(
|
|
logs: string,
|
|
messageRegex: RegExp,
|
|
container: StartedTestContainer,
|
|
): LogMatch[] {
|
|
const lines = logs.split('\n').filter((line) => line.trim());
|
|
const matches: LogMatch[] = [];
|
|
|
|
for (const line of lines) {
|
|
const cleanLine = line.trim();
|
|
if (messageRegex.test(cleanLine)) {
|
|
matches.push({
|
|
container,
|
|
containerName: container.getName(),
|
|
message: cleanLine,
|
|
timestamp: this.extractTimestamp(cleanLine) ?? new Date(),
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
private sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
}
|