datahaven/test/scripts/test-parallel.ts

360 lines
11 KiB
TypeScript
Raw Permalink Normal View History

test: 🏗️ Setup e2e testing framework (#104) ## Implement E2E Testing Framework with Isolated Networks ### Summary Refactors the existing E2E testing infrastructure to provide isolated test environments with parallel execution support. Each test suite now runs in its own network namespace, preventing resource conflicts. ### Key Changes - **New Testing Framework** (`test/framework/`): Base classes for test lifecycle management with automatic setup/teardown - **Launcher Module** (`test/launcher/`): Extracted network orchestration logic from CLI handlers for reusability - **Parallel Execution**: Added `test-parallel.ts` script with concurrency limits to prevent resource exhaustion - **Test Isolation**: Each suite gets unique network IDs (format: `suiteName-timestamp`) and Docker networks - **Improved Test Organization**: Migrated tests to new framework, deprecated old test structure ### Test Improvements - Added 4 new test suites demonstrating framework usage. : - `contracts.test.ts` - Smart contract deployment/interaction - `datahaven-substrate.test.ts` - Substrate API operations - `cross-chain.test.ts` - Snowbridge cross-chain messaging - `ethereum-basic.test.ts` - Ethereum network operations > [!WARNING] The test suites themselves are bad and shouldn't be consider examples of good tests. They were AI generated just to test the concurrency of test runners ### Documentation - Added comprehensive framework overview (`E2E_FRAMEWORK_OVERVIEW.md`) - Updated README with parallel testing commands - Added test patterns and best practices ### Breaking Changes - Old test suites moved to `e2e - DEPRECATED/` directory - Test execution now requires extending `BaseTestSuite` class ### Testing Run tests with: `bun test:e2e` or `bun test:e2e:parallel` (with concurrency limits) ### TODO - [ ] Implement good test examples. - [ ] Implement useful test utils (like waiting for an event to show up in DataHaven or Ethereum). - [ ] Enforce tests with CI (currently cannot be done due to intermittent error when sending a transaction with PAPI). --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: undercover-cactus <lola@moonsonglabs.com>
2025-07-16 16:51:07 +00:00
#!/usr/bin/env bun
import { existsSync, mkdirSync } from "node:fs";
import { basename, join } from "node:path";
import { $ } from "bun";
import { logger, printHeader } from "../utils";
/**
* Script to run all test suites in parallel with concurrency control
*/
const TEST_TIMEOUT = 900000; // 15 minutes
const LOG_DIR = "tmp/e2e-test-logs";
const MAX_CONCURRENT_TESTS = 3; // Limit concurrent tests to prevent resource exhaustion
// Track all spawned processes for cleanup
const spawnedProcesses: Set<ReturnType<typeof Bun.spawn>> = new Set();
async function ensureLogDirectory() {
const logPath = join(process.cwd(), LOG_DIR);
if (!existsSync(logPath)) {
mkdirSync(logPath, { recursive: true });
}
// Clear content of existing .log files
try {
const existingLogs = await $`find ${logPath} -name "*.log" -type f`.text().catch(() => "");
const logFiles = existingLogs
.trim()
.split("\n")
.filter((file) => file.length > 0);
if (logFiles.length > 0) {
logger.info(`🧹 Clearing content of ${logFiles.length} existing log files...`);
// Truncate files to 0 bytes using Bun.write
for (const logFile of logFiles) {
await Bun.write(logFile, "");
}
}
} catch (error) {
logger.warn("Failed to clear existing log files:", error);
}
return logPath;
}
async function killAllProcesses() {
logger.info("🛑 Killing all spawned processes...");
// Kill all tracked processes and their children
const killPromises = Array.from(spawnedProcesses).map(async (proc) => {
try {
const pid = proc.pid;
logger.info(`Killing process tree for PID ${pid}...`);
// First, try to get all child processes
try {
// Get all descendant PIDs using pgrep
const childPids = await $`pgrep -P ${pid}`.text().catch(() => "");
const allPids = [
pid,
...childPids
.trim()
.split("\n")
.filter((p) => p)
]
.map((p) => Number.parseInt(p.toString(), 10))
test: 🏗️ Setup e2e testing framework (#104) ## Implement E2E Testing Framework with Isolated Networks ### Summary Refactors the existing E2E testing infrastructure to provide isolated test environments with parallel execution support. Each test suite now runs in its own network namespace, preventing resource conflicts. ### Key Changes - **New Testing Framework** (`test/framework/`): Base classes for test lifecycle management with automatic setup/teardown - **Launcher Module** (`test/launcher/`): Extracted network orchestration logic from CLI handlers for reusability - **Parallel Execution**: Added `test-parallel.ts` script with concurrency limits to prevent resource exhaustion - **Test Isolation**: Each suite gets unique network IDs (format: `suiteName-timestamp`) and Docker networks - **Improved Test Organization**: Migrated tests to new framework, deprecated old test structure ### Test Improvements - Added 4 new test suites demonstrating framework usage. : - `contracts.test.ts` - Smart contract deployment/interaction - `datahaven-substrate.test.ts` - Substrate API operations - `cross-chain.test.ts` - Snowbridge cross-chain messaging - `ethereum-basic.test.ts` - Ethereum network operations > [!WARNING] The test suites themselves are bad and shouldn't be consider examples of good tests. They were AI generated just to test the concurrency of test runners ### Documentation - Added comprehensive framework overview (`E2E_FRAMEWORK_OVERVIEW.md`) - Updated README with parallel testing commands - Added test patterns and best practices ### Breaking Changes - Old test suites moved to `e2e - DEPRECATED/` directory - Test execution now requires extending `BaseTestSuite` class ### Testing Run tests with: `bun test:e2e` or `bun test:e2e:parallel` (with concurrency limits) ### TODO - [ ] Implement good test examples. - [ ] Implement useful test utils (like waiting for an event to show up in DataHaven or Ethereum). - [ ] Enforce tests with CI (currently cannot be done due to intermittent error when sending a transaction with PAPI). --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: undercover-cactus <lola@moonsonglabs.com>
2025-07-16 16:51:07 +00:00
.filter((p) => !Number.isNaN(p));
logger.info(`Found PIDs to kill: ${allPids.join(", ")}`);
// Kill all processes in reverse order (children first)
for (const targetPid of allPids.reverse()) {
try {
await $`kill -TERM ${targetPid}`.quiet();
} catch {
// Process might already be dead
}
}
// Give processes a moment to clean up
await Bun.sleep(500);
// Force kill any remaining processes
for (const targetPid of allPids) {
try {
await $`kill -KILL ${targetPid}`.quiet();
} catch {
// Process already dead
}
}
} catch {
// Fallback: try process group kill
try {
await $`kill -TERM -${pid}`.quiet();
await Bun.sleep(500);
await $`kill -KILL -${pid}`.quiet();
} catch {
// Process group might not exist
}
}
// Also try to kill the process directly
try {
proc.kill("SIGKILL");
} catch {
// Process already dead
}
} catch (error) {
logger.error("Error killing process:", error);
}
});
await Promise.all(killPromises);
spawnedProcesses.clear();
// Also kill any lingering kurtosis or docker processes started by tests
try {
logger.info("Cleaning up any lingering test processes...");
// Kill kurtosis processes
await $`pkill -f "kurtosis.*e2e-test" || true`.quiet();
// Find and kill all containers with e2e-test prefix
const containers = await $`docker ps -q --filter "name=e2e-test"`.text().catch(() => "");
if (containers.trim()) {
logger.info("Killing e2e-test containers...");
await $`docker kill ${containers.trim().split("\n").join(" ")}`.quiet().catch(() => {});
}
// Also clean up any snowbridge containers
const snowbridgeContainers = await $`docker ps -q --filter "name=snowbridge"`
.text()
.catch(() => "");
if (snowbridgeContainers.trim()) {
logger.info("Killing snowbridge containers...");
await $`docker kill ${snowbridgeContainers.trim().split("\n").join(" ")}`
.quiet()
.catch(() => {});
}
// Kill any remaining bun test processes
await $`pkill -f "bun.*test.*\\.test\\.ts" || true`.quiet();
} catch {
// Ignore errors - processes might not exist
}
}
// Set up signal handlers for graceful shutdown
process.on("SIGINT", async () => {
logger.info("\n⚠ Received SIGINT, cleaning up...");
await killAllProcesses();
process.exit(130); // Standard exit code for SIGINT
});
process.on("SIGTERM", async () => {
logger.info("\n⚠ Received SIGTERM, cleaning up...");
await killAllProcesses();
process.exit(143); // Standard exit code for SIGTERM
});
// Handle uncaught exceptions
process.on("uncaughtException", async (error) => {
logger.error("💥 Uncaught exception:", error);
await killAllProcesses();
process.exit(1);
});
// Handle unhandled promise rejections
process.on("unhandledRejection", async (reason, _promise) => {
logger.error("💥 Unhandled promise rejection:", reason);
await killAllProcesses();
process.exit(1);
});
async function getTestFiles(): Promise<string[]> {
const result = await $`find suites -name "*.test.ts" -type f`.text();
return result
.trim()
.split("\n")
.filter((file) => file.length > 0);
}
async function runTest(
file: string,
logPath: string
): Promise<{
file: string;
success: boolean;
duration: string;
logFile: string;
exitCode?: number;
error?: any;
}> {
const startTime = Date.now();
const testName = basename(file, ".test.ts");
const logFile = join(logPath, `${testName}.log`);
logger.info(`📋 Starting ${file}...`);
try {
// Run each test file in its own process group, capturing all output to log file
const proc = Bun.spawn(["bun", "test", file, "--timeout", TEST_TIMEOUT.toString()], {
stdout: "pipe",
stderr: "pipe",
// Create a new process group so we can kill all child processes
env: {
...process.env,
// This will help identify processes started by this test run
E2E_TEST_RUN_ID: `e2e-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
});
// Track the spawned process
spawnedProcesses.add(proc);
// Create write stream for log file
const logFileHandle = Bun.file(logFile);
const writer = logFileHandle.writer();
// Write both stdout and stderr to the same log file
const decoder = new TextDecoder();
// Handle stdout
const stdoutReader = proc.stdout.getReader();
const stdoutPromise = (async () => {
while (true) {
const { done, value } = await stdoutReader.read();
if (done) break;
const text = decoder.decode(value);
await writer.write(text);
}
})();
// Handle stderr
const stderrReader = proc.stderr.getReader();
const stderrPromise = (async () => {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
const text = decoder.decode(value);
await writer.write(text);
}
})();
// Wait for process to complete
await Promise.all([stdoutPromise, stderrPromise]);
const exitCode = await proc.exited;
await writer.end();
// Remove from tracked processes
spawnedProcesses.delete(proc);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
if (exitCode === 0) {
logger.success(`${file} passed (${duration}s) - Log: ${logFile}`);
return { file, success: true, duration, logFile };
}
logger.error(`${file} failed (${duration}s) - Log: ${logFile}`);
return { file, success: false, duration, logFile, exitCode };
} catch (error) {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
logger.error(`${file} crashed (${duration}s) - Log: ${logFile}:`, error);
// Write error to log file
const errorLog = Bun.file(logFile);
await Bun.write(errorLog, `Test crashed with error:\n${error}\n`);
return { file, success: false, duration, error, logFile };
}
}
async function runTestsWithConcurrencyLimit() {
logger.info(`🚀 Starting test suites with max concurrency of ${MAX_CONCURRENT_TESTS}...`);
// Ensure log directory exists
const logPath = await ensureLogDirectory();
logger.info(`📁 Logs will be saved to: ${LOG_DIR}/`);
// Get all test files dynamically
const testFiles = await getTestFiles();
logger.info(`📋 Found ${testFiles.length} test files:`);
testFiles.forEach((file) => {
logger.info(` - ${file}`);
});
test: 🏗️ Setup e2e testing framework (#104) ## Implement E2E Testing Framework with Isolated Networks ### Summary Refactors the existing E2E testing infrastructure to provide isolated test environments with parallel execution support. Each test suite now runs in its own network namespace, preventing resource conflicts. ### Key Changes - **New Testing Framework** (`test/framework/`): Base classes for test lifecycle management with automatic setup/teardown - **Launcher Module** (`test/launcher/`): Extracted network orchestration logic from CLI handlers for reusability - **Parallel Execution**: Added `test-parallel.ts` script with concurrency limits to prevent resource exhaustion - **Test Isolation**: Each suite gets unique network IDs (format: `suiteName-timestamp`) and Docker networks - **Improved Test Organization**: Migrated tests to new framework, deprecated old test structure ### Test Improvements - Added 4 new test suites demonstrating framework usage. : - `contracts.test.ts` - Smart contract deployment/interaction - `datahaven-substrate.test.ts` - Substrate API operations - `cross-chain.test.ts` - Snowbridge cross-chain messaging - `ethereum-basic.test.ts` - Ethereum network operations > [!WARNING] The test suites themselves are bad and shouldn't be consider examples of good tests. They were AI generated just to test the concurrency of test runners ### Documentation - Added comprehensive framework overview (`E2E_FRAMEWORK_OVERVIEW.md`) - Updated README with parallel testing commands - Added test patterns and best practices ### Breaking Changes - Old test suites moved to `e2e - DEPRECATED/` directory - Test execution now requires extending `BaseTestSuite` class ### Testing Run tests with: `bun test:e2e` or `bun test:e2e:parallel` (with concurrency limits) ### TODO - [ ] Implement good test examples. - [ ] Implement useful test utils (like waiting for an event to show up in DataHaven or Ethereum). - [ ] Enforce tests with CI (currently cannot be done due to intermittent error when sending a transaction with PAPI). --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: undercover-cactus <lola@moonsonglabs.com>
2025-07-16 16:51:07 +00:00
// Create a queue of test files
const testQueue = [...testFiles];
const results: Array<Awaited<ReturnType<typeof runTest>>> = [];
const runningTests = new Map<string, Promise<any>>();
// Process tests with concurrency limit
while (testQueue.length > 0 || runningTests.size > 0) {
// Start new tests if we have capacity
while (runningTests.size < MAX_CONCURRENT_TESTS && testQueue.length > 0) {
const testFile = testQueue.shift();
if (!testFile) continue;
const testPromise = runTest(testFile, logPath);
runningTests.set(testFile, testPromise);
// Add 1 second delay between starting test suites to prevent resource contention
if (testQueue.length > 0) {
await Bun.sleep(1000);
}
// When test completes, remove it from running tests and store result
testPromise
.then((result) => {
runningTests.delete(testFile);
results.push(result);
})
.catch((error) => {
runningTests.delete(testFile);
results.push({
file: testFile,
success: false,
duration: "0",
logFile: join(logPath, `${basename(testFile, ".test.ts")}.log`),
error
});
});
}
// Wait for at least one test to complete before checking again
if (runningTests.size > 0) {
await Promise.race(runningTests.values());
}
}
// Summary
printHeader("📊 Test Summary");
const passed = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
results.forEach((result) => {
const icon = result.success ? "✅" : "❌";
logger.info(`${icon} ${result.file} (${result.duration}s)`);
logger.info(` 📄 Log: ${result.logFile}`);
});
logger.info(`Total: ${passed} passed, ${failed} failed`);
logger.info(`📁 All logs saved to: ${LOG_DIR}/`);
// Exit with error if any tests failed
if (failed > 0) {
logger.error("❌ Some tests failed! Check the logs for details.");
await killAllProcesses();
process.exit(1);
} else {
logger.success("All tests passed!");
await killAllProcesses();
}
}
// Run the tests
runTestsWithConcurrencyLimit().catch(async (error) => {
logger.error("Failed to run tests:", error);
await killAllProcesses();
process.exit(1);
});