datahaven/test/utils/docker.ts
Facundo Farall 9b311e00ef
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 18:51:07 +02:00

203 lines
6.2 KiB
TypeScript

import { type Duplex, PassThrough, Transform } from "node:stream";
import Docker from "dockerode";
import invariant from "tiny-invariant";
import { logger, type ServiceInfo, StandardServiceMappings } from "utils";
const docker = new Docker({});
export const getServicesFromDocker = async (): Promise<ServiceInfo[]> => {
const containers = await docker.listContainers();
const services: ServiceInfo[] = [];
for (const mapping of StandardServiceMappings) {
try {
const container = containers.find((container) =>
container.Names.some((name) => name.includes(mapping.containerPattern))
);
if (!container) {
logger.warn(`Container with pattern "${mapping.containerPattern}" not found.`);
services.push({
service: mapping.service,
port: "Not found",
url: "N/A"
});
continue;
}
const portMappings = container.Ports.filter(
(port) => port.PrivatePort === mapping.internalPort && port.Type === mapping.protocol
);
let selectedMapping = portMappings.find((port) => port.IP === "0.0.0.0" || port.IP === ":::");
if (!selectedMapping && portMappings.length > 0) {
selectedMapping = portMappings[0];
}
if (!selectedMapping || !selectedMapping.PublicPort) {
logger.warn(
`Port mapping not found for ${mapping.service} (${mapping.internalPort}/${mapping.protocol}).`
);
services.push({
service: mapping.service,
port: "Not found",
url: "N/A"
});
continue;
}
services.push({
service: mapping.service,
port: selectedMapping.PublicPort.toString(),
url: `http://127.0.0.1:${selectedMapping.PublicPort}`
});
} catch (error) {
logger.error(`Error getting info for ${mapping.service}:`, error);
services.push({
service: mapping.service,
port: "Error",
url: "N/A"
});
}
}
return services;
};
export const getContainersMatchingImage = async (imageName: string) => {
const containers = await docker.listContainers({ all: true });
const matches = containers.filter((container) => container.Image.includes(imageName));
return matches;
};
export const getContainersByPrefix = async (prefix: string) => {
const containers = await docker.listContainers({ all: true });
const matches = containers.filter((container) =>
container.Names.some((name) => name.startsWith(`/${prefix}`))
);
return matches;
};
export const getPublicPort = async (
containerName: string,
internalPort: number
): Promise<number> => {
const docker = new Docker();
const containers = await docker.listContainers();
const container = containers.find((container) =>
container.Names.some((name) => name.includes(containerName))
);
invariant(container, `❌ container ${container} cannot be found in running container list`);
const portMappings = container.Ports.find(
(port) => port.PrivatePort === internalPort && port.Type === "tcp"
);
logger.debug(`Port mappings for ${containerName}:${internalPort}`, portMappings);
invariant(portMappings, `❌ port mapping not found for ${containerName}:${internalPort}`);
return portMappings.PublicPort;
};
export async function waitForLog(opts: {
search: string | RegExp;
containerName: string;
timeoutSeconds?: number;
}): Promise<string> {
const container = docker.getContainer(opts.containerName);
await container.inspect();
const timeoutMs = (opts.timeoutSeconds ?? 10) * 1_000;
const rawStream = (await container.logs({
stdout: true,
stderr: true,
follow: true,
since: 0
})) as Duplex;
const pass = new PassThrough();
container.modem.demuxStream(rawStream, pass, pass);
const { readable } = Transform.toWeb(pass);
const decoder = new TextDecoder();
const timer = setTimeout(
() =>
pass.destroy(
new Error(
`Timed out after ${timeoutMs} ms waiting for "${opts.search}" in ${opts.containerName}`
)
),
timeoutMs
);
try {
for await (const chunk of readable) {
const text = decoder.decode(chunk as Uint8Array, { stream: false });
const hit =
typeof opts.search === "string" ? text.includes(opts.search) : opts.search.test(text);
if (hit) return text.trim();
}
throw new Error(
`Log stream ended before "${opts.search}" appeared for container ${opts.containerName}`
);
} finally {
if (timer) {
clearTimeout(timer);
}
if (pass && typeof pass.destroy === "function" && !pass.destroyed) {
pass.destroy();
}
if (rawStream) {
if (typeof rawStream.destroy === "function" && !rawStream.destroyed) {
rawStream.destroy();
}
const socket = (rawStream as any).socket;
if (socket && typeof socket.destroy === "function" && !socket.destroyed) {
socket.destroy();
}
}
}
}
export const waitForContainerToStart = async (
containerName: string,
options?: { timeoutSeconds?: number }
) => {
logger.debug(`Waiting for container ${containerName} to start...`);
const docker = new Docker();
const seconds = options?.timeoutSeconds ?? 30;
for (let i = 0; i < seconds; i++) {
const containers = await docker.listContainers();
const container = containers.find((container) =>
container.Names.some((name) => name.includes(containerName))
);
if (container) {
logger.debug(`Container ${containerName} started after ${i} seconds`);
return;
}
await Bun.sleep(1000);
}
invariant(
false,
`❌ container ${containerName} cannot be found in running container list after ${seconds} seconds`
);
};
export const killExistingContainers = async (prefix: string) => {
logger.debug(`Searching for containers with image ${prefix}...`);
const containerInfos = await getContainersByPrefix(prefix);
if (containerInfos.length === 0) {
logger.debug(`No containers found with name starting with "${prefix}"`);
return;
}
const promises = containerInfos.map(({ Id }) => docker.getContainer(Id).remove({ force: true }));
await Promise.all(promises);
logger.debug(`${containerInfos.length} containers with name starting with "${prefix}" killed`);
};