diff --git a/test/.dockerignore b/test/.dockerignore new file mode 100644 index 00000000..4731dc5a --- /dev/null +++ b/test/.dockerignore @@ -0,0 +1,15 @@ +# Keep submitter image build context minimal. +* + +!package.json +!bun.lock +!tsconfig.json +!bunfig.toml +!.papi/ +!.papi/** +!tools/validator-set-submitter/ +!tools/validator-set-submitter/** +!contract-bindings/ +!contract-bindings/** +!utils/ +!utils/** diff --git a/test/e2e/framework/submitter.ts b/test/e2e/framework/submitter.ts index cad168eb..66ce466f 100644 --- a/test/e2e/framework/submitter.ts +++ b/test/e2e/framework/submitter.ts @@ -17,13 +17,13 @@ const SUBMITTER_READY_TIMEOUT_SECONDS = 30; const SUBMITTER_LOG_TAIL_LINES = 200; /** - * Builds the validator-set-submitter Docker image from the repo root. + * Builds the validator-set-submitter Docker image from the test directory. */ export async function buildSubmitterImage(): Promise { logger.debug("Building validator-set-submitter Docker image..."); - const repoRoot = path.resolve(import.meta.dir, "../../.."); - await $`docker build -f test/tools/validator-set-submitter/Dockerfile -t ${SUBMITTER_IMAGE} .` - .cwd(repoRoot) + const testRoot = path.resolve(import.meta.dir, "../.."); + await $`docker build -f tools/validator-set-submitter/Dockerfile -t ${SUBMITTER_IMAGE} .` + .cwd(testRoot) .quiet(); logger.debug("Validator-set-submitter image built successfully"); } @@ -106,9 +106,11 @@ export async function launchSubmitter(options: LaunchSubmitterOptions): Promise< timeoutSeconds: SUBMITTER_READY_TIMEOUT_SECONDS }); } catch (error) { + const logResult = await $`docker logs --tail ${SUBMITTER_LOG_TAIL_LINES} ${containerName}` + .nothrow() + .quiet(); const logs = - (await $`docker logs --tail ${SUBMITTER_LOG_TAIL_LINES} ${containerName}`.nothrow().text()) || - ""; + `${logResult.stdout.toString()}${logResult.stderr.toString()}`.trim() || ""; await stopSubmitter(containerName); throw new Error( `Submitter did not become ready. Expected log "${SUBMITTER_READY_LOG}". Last ${SUBMITTER_LOG_TAIL_LINES} log lines:\n${logs}`, diff --git a/test/e2e/framework/suite.ts b/test/e2e/framework/suite.ts index 5afb7144..4a74252a 100644 --- a/test/e2e/framework/suite.ts +++ b/test/e2e/framework/suite.ts @@ -3,6 +3,7 @@ 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"; @@ -57,7 +58,7 @@ export abstract class BaseTestSuite { datahavenImageTag: this.options.networkOptions?.datahavenImageTag || "datahavenxyz/datahaven:local", relayerImageTag: - this.options.networkOptions?.relayerImageTag || "datahavenxyz/snowbridge-relay:latest", + this.options.networkOptions?.relayerImageTag || getDefaultRelayerImageTag(), buildDatahaven: false, // default to false in the test suite so we can speed up the CI ...this.options.networkOptions }); diff --git a/test/launcher/network/index.ts b/test/launcher/network/index.ts index 55c1139f..783eb162 100644 --- a/test/launcher/network/index.ts +++ b/test/launcher/network/index.ts @@ -144,6 +144,7 @@ export const launchNetwork = async ( options: NetworkLaunchOptions ): Promise => { const networkId = options.networkId; + const relayerImageTag = options.relayerImageTag || getDefaultRelayerImageTag(); const launchedNetwork = new LaunchedNetwork(); launchedNetwork.networkName = networkId; let injectContracts = false; @@ -177,7 +178,7 @@ export const launchNetwork = async ( { networkId, datahavenImageTag: options.datahavenImageTag || "datahavenxyz/datahaven:local", - relayerImageTag: options.relayerImageTag || "datahavenxyz/snowbridge-relay:latest", + relayerImageTag, authorityIds: TEST_AUTHORITY_IDS, buildDatahaven: options.buildDatahaven ?? !isCI, // if not specified, default to false for CI, true for local testing datahavenBuildExtraArgs: options.datahavenBuildExtraArgs || "--features=fast-runtime" @@ -248,14 +249,10 @@ export const launchNetwork = async ( // 7. Launch relayers logger.info("❄️ Launching Snowbridge relayers..."); - if (!options.relayerImageTag) { - throw new Error("Relayer image tag not specified"); - } - await launchRelayers( { networkId, - relayerImageTag: options.relayerImageTag, + relayerImageTag, kurtosisEnclaveName }, launchedNetwork @@ -297,4 +294,13 @@ export const launchNetwork = async ( } }; +export const getDefaultRelayerImageTag = (): string => { + if (process.env.RELAYER_IMAGE_TAG) { + return process.env.RELAYER_IMAGE_TAG; + } + return process.arch === "arm64" + ? "datahavenxyz/snowbridge-relay:local" + : "datahavenxyz/snowbridge-relay:latest"; +}; + export const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; diff --git a/test/launcher/relayers.ts b/test/launcher/relayers.ts index a64cbad4..d1f33569 100644 --- a/test/launcher/relayers.ts +++ b/test/launcher/relayers.ts @@ -93,6 +93,47 @@ export const RELAYER_CONFIG_PATHS = { SOLOCHAIN: path.join(RELAYER_CONFIG_DIR, "solochain-relay.json") }; +const LOCAL_RELAYER_SOURCE_DIR = path.resolve( + import.meta.dir, + "..", + "..", + "contracts", + "lib", + "snowbridge", + "relayer" +); + +const isLocalRelayerImage = (relayerImageTag: string): boolean => + relayerImageTag.endsWith(":local"); + +const ensureLocalRelayerImage = async (relayerImageTag: string): Promise => { + if (!isLocalRelayerImage(relayerImageTag)) { + return; + } + + const localImageExists = await $`docker image inspect ${relayerImageTag}`.nothrow().quiet(); + if (localImageExists.exitCode === 0) { + logger.debug(`Local relayer image already available: ${relayerImageTag}`); + return; + } + + const dockerfilePath = path.join(LOCAL_RELAYER_SOURCE_DIR, "Dockerfile"); + const dockerfileExists = await Bun.file(dockerfilePath).exists(); + invariant( + dockerfileExists, + `❌ Local relayer Dockerfile not found at ${dockerfilePath}. Cannot build ${relayerImageTag}` + ); + + logger.info( + `🐳 Local relayer image ${relayerImageTag} not found. Building from ${LOCAL_RELAYER_SOURCE_DIR} for ${process.arch}...` + ); + await runShellCommandWithLogger(`docker build -f Dockerfile -t ${relayerImageTag} .`, { + cwd: LOCAL_RELAYER_SOURCE_DIR, + logLevel: "debug" + }); + logger.success(`✅ Built local relayer image: ${relayerImageTag}`); +}; + /** * Generates configuration files for relayers. * @@ -278,16 +319,16 @@ export const initEthClientPallet = async ( process.platform === "linux" ? "--add-host host.docker.internal:host-gateway" : ""; // Opportunistic pull - pull the image from Docker Hub only if it's not a local image - const isLocal = relayerImageTag.endsWith(":local"); + const isLocal = isLocalRelayerImage(relayerImageTag); + const platformParam = isLocal ? "" : "--platform linux/amd64"; logger.debug("Generating beacon checkpoint"); const datastoreHostPath = path.resolve(datastorePath); - const command = `docker run \ + const command = `docker run ${platformParam} \ -v ${beaconConfigHostPath}:${beaconConfigContainerPath}:ro \ -v ${checkpointHostPath}:${checkpointContainerPath} \ -v ${datastoreHostPath}:/data \ --name generate-beacon-checkpoint-${networkId} \ - --platform linux/amd64 \ --workdir /app \ ${addHostParam} \ ${launchedNetwork.networkName ? `--network ${launchedNetwork.networkName}` : ""} \ @@ -400,6 +441,7 @@ export const launchRelayers = async ( const { relayerImageTag, kurtosisEnclaveName } = options; invariant(relayerImageTag, "❌ relayerImageTag is required"); + await ensureLocalRelayerImage(relayerImageTag); await killExistingContainers("snowbridge-"); @@ -623,7 +665,7 @@ const launchRelayerContainers = async ( launchedNetwork: LaunchedNetwork, networkId: string ): Promise => { - const isLocal = relayerImageTag.endsWith(":local"); + const isLocal = isLocalRelayerImage(relayerImageTag); const networkName = launchedNetwork.networkName; invariant(networkName, "❌ Docker network name not found in LaunchedNetwork instance"); const restartArgs = ["--restart", "on-failure:5"]; @@ -641,8 +683,7 @@ const launchRelayerContainers = async ( "docker", "run", "-d", - "--platform", - "linux/amd64", + ...(isLocal ? [] : ["--platform", "linux/amd64"]), "--add-host", "host.docker.internal:host-gateway", "--name", diff --git a/test/tools/validator-set-submitter/Dockerfile b/test/tools/validator-set-submitter/Dockerfile index 84edba6d..584a0f20 100644 --- a/test/tools/validator-set-submitter/Dockerfile +++ b/test/tools/validator-set-submitter/Dockerfile @@ -1,7 +1,8 @@ # Validator Set Submitter image # -# Build from the repository root: -# docker build -f test/tools/validator-set-submitter/Dockerfile \ +# Build from the test directory: +# cd test +# docker build -f tools/validator-set-submitter/Dockerfile \ # -t datahavenxyz/validator-set-submitter:local . # # Runtime expectations: @@ -13,8 +14,8 @@ FROM oven/bun:1.3.3-slim AS deps WORKDIR /app -COPY test/package.json test/bun.lock test/tsconfig.json ./ -COPY test/.papi ./.papi +COPY package.json bun.lock tsconfig.json ./ +COPY .papi ./.papi RUN bun install --frozen-lockfile --production FROM oven/bun:1.3.3-slim @@ -24,10 +25,10 @@ WORKDIR /app RUN useradd -m -u 1001 -U -s /bin/sh -d /submitter submitter COPY --from=deps /app/node_modules ./node_modules -COPY test/tsconfig.json test/bunfig.toml ./ -COPY test/tools/validator-set-submitter/ ./tools/validator-set-submitter/ -COPY test/contract-bindings/ ./contract-bindings/ -COPY test/utils/ ./utils/ +COPY tsconfig.json bunfig.toml ./ +COPY tools/validator-set-submitter/ ./tools/validator-set-submitter/ +COPY contract-bindings/ ./contract-bindings/ +COPY utils/ ./utils/ ENV NODE_ENV=production diff --git a/test/tools/validator-set-submitter/config.ts b/test/tools/validator-set-submitter/config.ts index dc9a73d8..4c6107c9 100644 --- a/test/tools/validator-set-submitter/config.ts +++ b/test/tools/validator-set-submitter/config.ts @@ -1,4 +1,3 @@ -import { parseDeploymentsFile } from "utils"; import { parseEther } from "viem"; import { parse as parseYaml } from "yaml"; @@ -37,6 +36,7 @@ export async function loadConfig( let serviceManagerAddress = optionalHexString(raw, "service_manager_address"); if (!serviceManagerAddress) { + const { parseDeploymentsFile } = await import("../../utils/contracts.ts"); const deployments = await parseDeploymentsFile(networkId); serviceManagerAddress = deployments.ServiceManager; } diff --git a/test/utils/docker.ts b/test/utils/docker.ts index 7029e765..b1c00b62 100644 --- a/test/utils/docker.ts +++ b/test/utils/docker.ts @@ -178,6 +178,13 @@ export async function waitForLog(opts: { const { readable } = Transform.toWeb(pass); const decoder = new TextDecoder(); + let bufferedLogs = ""; + const hasHit = (text: string): boolean => { + if (typeof opts.search === "string") return text.includes(opts.search); + // Avoid stateful regex surprises with /g or /y across multiple checks. + opts.search.lastIndex = 0; + return opts.search.test(text); + }; const timer = setTimeout( () => pass.destroy( @@ -190,14 +197,16 @@ export async function waitForLog(opts: { 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(); + bufferedLogs += decoder.decode(chunk as Uint8Array, { stream: true }); + if (hasHit(bufferedLogs)) return bufferedLogs.trim(); + if (bufferedLogs.length > 64_000) { + bufferedLogs = bufferedLogs.slice(-64_000); + } } + bufferedLogs += decoder.decode(); + if (hasHit(bufferedLogs)) return bufferedLogs.trim(); + throw new Error( `Log stream ended before "${opts.search}" appeared for container ${opts.containerName}` );