datahaven/test/e2e/framework/suite.ts
Ahmad Kaouk f5067ea842
fix(e2e): stabilize submitter CI and local relayer startup (#470)
## Summary
- fixes the untrusted CI failure in `e2e-tests / E2E Tests with Kurtosis
Ethereum Network`
- keeps validator-set-submitter startup actionable by avoiding test-only
contract config imports during container startup
- improves submitter readiness diagnostics by capturing both stdout and
stderr from container logs and making streamed log matching robust to
chunked UTF-8 output
- reduces validator-set-submitter Docker build time in CI by building
from `test/` and adding a tight `test/.dockerignore`
- makes local arm64 E2E runs use a native local Snowbridge relayer image
instead of forcing `linux/amd64` emulation
- auto-builds the local relayer image when needed for `:local` tags

## Why
The original failing untrusted test started as a submitter container
startup problem, but the branch now also addresses a second timeout path
that showed up while debugging:
- the submitter image was being built from the repository root with a
large Docker context, which made the `validator-set-update` suite spend
most of its hook timeout budget inside `docker build`
- on Apple Silicon, forcing `datahavenxyz/snowbridge-relay:latest`
through `linux/amd64` caused `generate-beacon-checkpoint` to segfault
during local runs

These changes make the submitter failure actionable, cut the CI Docker
build context down substantially, and keep local E2E runs reliable on
arm64.

## Validation
- `cd test && bun fmt`
- `cd test && bun x tsc --noEmit`
- `bun test e2e/suites/validator-set-update.test.ts --timeout 900000`
- `cd test && docker build -f tools/validator-set-submitter/Dockerfile
-t datahavenxyz/validator-set-submitter:local .`
2026-03-06 13:08:13 +01:00

193 lines
6.5 KiB
TypeScript

import { afterAll, beforeAll } from "bun:test";
import readline from "node:readline";
import { isCI } from "launcher/network";
import { logger } from "utils";
import { launchNetwork } from "../../launcher";
import { getDefaultRelayerImageTag } from "../../launcher/network";
import type { LaunchNetworkResult } from "../../launcher/types";
import { ConnectorFactory, type TestConnectors } from "./connectors";
import { TestSuiteManager } from "./manager";
export interface TestSuiteOptions {
/** Unique name for the test suite */
suiteName: string;
/** Network configuration options */
networkOptions?: {
/** Slot time in milliseconds for the network */
slotTime?: number;
/** Enable Blockscout explorer for the network */
blockscout?: boolean;
/** Build DataHaven runtime from source, needed to reflect local changes */
buildDatahaven?: boolean;
/** Docker image tag for DataHaven node */
datahavenImageTag?: string;
/** Docker image tag for Snowbridge relayer */
relayerImageTag?: string;
};
/** Keep network running after tests complete for debugging */
keepAlive?: boolean;
}
export abstract class BaseTestSuite {
protected networkId: string;
protected connectors?: LaunchNetworkResult;
protected testConnectors?: TestConnectors;
private connectorFactory?: ConnectorFactory;
private options: TestSuiteOptions;
private manager: TestSuiteManager;
constructor(options: TestSuiteOptions) {
this.options = options;
// Generate unique network ID using suite name and timestamp
this.networkId = `${options.suiteName}-${Date.now()}`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
this.manager = TestSuiteManager.getInstance();
}
protected setupHooks(): void {
beforeAll(async () => {
logger.info(`🧪 Setting up test suite: ${this.options.suiteName}`);
logger.info(`📝 Network ID: ${this.networkId}`);
try {
// Register suite with manager
this.manager.registerSuite(this.options.suiteName, this.networkId);
// Launch the network
this.connectors = await launchNetwork({
networkId: this.networkId,
datahavenImageTag:
this.options.networkOptions?.datahavenImageTag || "datahavenxyz/datahaven:local",
relayerImageTag:
this.options.networkOptions?.relayerImageTag || getDefaultRelayerImageTag(),
buildDatahaven: false, // default to false in the test suite so we can speed up the CI
...this.options.networkOptions
});
// Create test connectors
this.connectorFactory = new ConnectorFactory(this.connectors);
this.testConnectors = await this.connectorFactory.createTestConnectors();
// Allow derived classes to perform additional setup
await this.onSetup();
logger.success(`Test suite setup complete: ${this.options.suiteName}`);
} catch (error) {
logger.error(`Failed to setup test suite: ${this.options.suiteName}`, error);
this.manager.failSuite(this.options.suiteName);
throw error;
}
});
afterAll(async () => {
logger.info(`🧹 Tearing down test suite: ${this.options.suiteName}`);
try {
if (this.options.keepAlive && !isCI) {
this.printNetworkInfo();
await this.waitForEnter();
}
// Allow derived classes to perform cleanup
await this.onTeardown();
// Cleanup test connectors
if (this.testConnectors && this.connectorFactory) {
await this.connectorFactory.cleanup(this.testConnectors);
}
// Cleanup the network
if (this.connectors?.cleanup) {
await this.connectors.cleanup();
}
// Mark suite as completed
this.manager.completeSuite(this.options.suiteName);
logger.success(`Test suite teardown complete: ${this.options.suiteName}`);
} catch (error) {
logger.error(`Error during test suite teardown: ${this.options.suiteName}`, error);
this.manager.failSuite(this.options.suiteName);
}
});
}
/**
* Override this method to perform additional setup after network launch
*/
protected async onSetup(): Promise<void> {
// Default implementation does nothing
}
/**
* Override this method to perform cleanup before network teardown
*/
protected async onTeardown(): Promise<void> {
// Default implementation does nothing
}
/**
* Get network connectors - throws if not initialized
*/
protected getConnectors(): LaunchNetworkResult {
if (!this.connectors) {
throw new Error("Network connectors not initialized. Did you call setupHooks()?");
}
return this.connectors;
}
/**
* Get test connectors - throws if not initialized
*/
public getTestConnectors(): TestConnectors {
if (!this.testConnectors) {
throw new Error("Test connectors not initialized. Did you call setupHooks()?");
}
return this.testConnectors;
}
/**
* Get connector factory - throws if not initialized
*/
public getConnectorFactory(): ConnectorFactory {
if (!this.connectorFactory) {
throw new Error("Connector factory not initialized. Did you call setupHooks()?");
}
return this.connectorFactory;
}
private printNetworkInfo(): void {
try {
const connectors = this.getConnectors();
const ln = connectors.launchedNetwork;
logger.info("🛠 Keep-alive mode enabled. Network will remain running until you press Enter.");
logger.info("📡 Network info:");
logger.info(` • Network ID: ${ln.networkId}`);
logger.info(` • Network Name: ${ln.networkName}`);
logger.info(` • DataHaven RPC: ${connectors.dataHavenRpcUrl}`);
logger.info(` • Ethereum RPC: ${connectors.ethereumRpcUrl}`);
logger.info(` • Ethereum CL: ${connectors.ethereumClEndpoint}`);
const containers = ln.containers || [];
if (containers.length > 0) {
logger.info(" • Containers:");
for (const c of containers) {
const pubPorts = Object.entries(c.publicPorts || {})
.map(([k, v]) => `${k}:${v}`)
.join(", ");
logger.info(` - ${c.name} [${pubPorts}]`);
}
}
} catch (e) {
logger.warn("Could not print network info", e as Error);
}
}
private async waitForEnter(): Promise<void> {
return await new Promise<void>((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question("\nPress Enter to teardown and cleanup... ", () => {
rl.close();
resolve();
});
});
}
}