// Docker E2E aggregate scheduler. // Builds shared Docker images, prepares one OpenClaw npm tarball, assigns lanes // to bare/functional images, and runs lanes through weighted resource pools. import assert from "node:assert/strict"; import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import { mkdir, open, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { DEFAULT_E2E_BARE_IMAGE, DEFAULT_E2E_FUNCTIONAL_IMAGE, DEFAULT_LIVE_RETRIES, DEFAULT_PARALLELISM, DEFAULT_PROFILE, DEFAULT_RESOURCE_LIMITS, DEFAULT_TAIL_PARALLELISM, RELEASE_PATH_PROFILE, findLaneByName, laneResources, laneSummary, laneWeight, lanesNeedE2eImageKind, lanesNeedOpenClawPackage, normalizeReleaseProfile, parseLaneSelection, parseLiveMode, parseProfile, resolveDockerE2ePlan, } from "./lib/docker-e2e-plan.mts"; import type { DockerE2eLane } from "./lib/docker-e2e-scenarios.mts"; import { sleep } from "./lib/sleep.mjs"; import { createPrepublishPluginRegistryArtifact, inspectNpmPackageTarball, validatePrepublishPluginRegistryArtifact, } from "./prepublish-plugin-registry-artifact.mjs"; const SCRIPT_ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const ROOT_DIR = path.resolve(process.env.OPENCLAW_DOCKER_E2E_REPO_ROOT || SCRIPT_ROOT_DIR); const LIVE_DOCKER_DEFAULT_HARNESS_DIR = path.basename(SCRIPT_ROOT_DIR) === ".release-harness" && ROOT_DIR !== SCRIPT_ROOT_DIR ? ".release-harness" : "."; const DEFAULT_FAILURE_TAIL_LINES = 80; const DEFAULT_LANE_TIMEOUT_MS = 120 * 60 * 1000; const DEFAULT_LANE_START_STAGGER_MS = 2_000; const DEFAULT_STATUS_INTERVAL_MS = 30_000; const DEFAULT_PREFLIGHT_RUN_TIMEOUT_MS = 60_000; const CLEANUP_SMOKE_NAME = "cleanup-smoke"; export const SHELL_CAPTURE_MAX_CHARS = 1024 * 1024; export const LOG_TAIL_MAX_BYTES = 1024 * 1024; const SHELL_TIMEOUT_KILL_GRACE_MS = 10_000; const SHELL_POST_FORCE_KILL_WAIT_MS = 1_000; const SHELL_PROCESS_GROUP_EXIT_POLL_MS = 25; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json"); const DEFAULT_GITHUB_WORKFLOW = "openclaw-live-and-e2e-checks-reusable.yml"; const CANDIDATE_ENV_KEYS = "OPENCLAW_DOCKER_E2E_SELECTED_SHA OPENCLAW_CURRENT_PACKAGE_TGZ OPENCLAW_CURRENT_PACKAGE_VERSION OPENCLAW_CURRENT_PACKAGE_SHA256".split( " ", ); const REGISTRY_ENV_KEYS = "OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256".split( " ", ); type SchedulerLimits = ReturnType; type DockerCandidatePlan = ReturnType["plan"]; type SchedulerActiveState = { count: number; resources: Map; weight: number; }; type SchedulerLane = Pick & Partial>; type TimingStore = Awaited>; type ShellCommandResult = Omit, "signal"> & { signal: NodeJS.Signals | null; }; type ShellCaptureResult = Omit, "signal"> & { signal: NodeJS.Signals | null; }; type ShellCommandOptions = { command: string; env: NodeJS.ProcessEnv; label: string; logFile?: string; noOutputTimeoutMs?: number; timeoutKillGraceMs?: number; timeoutMs?: number; }; type ShellCaptureOptions = Omit & Required>; type LaneResult = Omit>, "imageKind"> & { imageKind?: DockerE2eLane["e2eImageKind"]; targetable?: boolean; }; type RunLaneSummary = Pick & Partial; type RunSummary = Record & { failures?: RunLaneSummary[]; github?: ReturnType; images?: { bare?: string; functional?: string }; lanes?: RunLaneSummary[]; status?: string; }; type LanePoolOptions = SchedulerLimits & { failFast: boolean; poolLabel: string; startStaggerMs: number; statusIntervalMs: number; timeoutMs: number; }; type ForegroundEntry = { command: string; env?: NodeJS.ProcessEnv; label: string; phaseDetails?: Record; phases?: Array>; }; type ShutdownSignal = "SIGINT" | "SIGKILL" | "SIGTERM"; const IS_MAIN = (() => { const entrypoint = process.argv[1]; if (!entrypoint) { return false; } try { // Node resolves ESM URLs through symlinks, but argv keeps the invoked path. return fs.realpathSync(entrypoint) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })(); function dockerAllUsage() { return [ "Usage: node scripts/test-docker-all.mjs [--plan-json | --prepare-only=]", "", "Options:", " --plan-json Print the resolved Docker E2E plan as JSON and exit.", " --prepare-only Prepare one immutable candidate manifest and exit.", " -h, --help Show this help.", "", "Lane selection and scheduler settings are configured with OPENCLAW_DOCKER_ALL_* env vars.", ].join("\n"); } export function parseDockerAllCliArgs(argv: readonly string[]) { const options: { help: boolean; planJson: boolean; prepareOnly?: string } = { help: false, planJson: false, }; for (const arg of argv) { if (arg === "--plan-json") { options.planJson = true; } else if (arg.startsWith("--prepare-only=")) { options.prepareOnly = arg.slice("--prepare-only=".length); if (!options.prepareOnly) { throw new Error(`--prepare-only requires a manifest path\n\n${dockerAllUsage()}`); } } else if (arg === "--help" || arg === "-h") { options.help = true; } else { throw new Error(`unknown argument: ${arg}\n\n${dockerAllUsage()}`); } } assert(!(options.planJson && options.prepareOnly), "conflicting plan/prep options"); return options; } let cliOptions: ReturnType = { help: false, planJson: false, }; if (IS_MAIN) { try { cliOptions = parseDockerAllCliArgs(process.argv.slice(2)); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } if (cliOptions.help) { console.log(dockerAllUsage()); process.exit(0); } } function parsePositiveInt(raw: string | undefined, fallback: number, label: string) { if (raw === undefined || raw === "") { return fallback; } const text = raw.trim(); if (!/^\d+$/u.test(text)) { throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(raw)}`); } const parsed = Number(text); if (!Number.isSafeInteger(parsed) || parsed < 1) { throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(raw)}`); } return parsed; } function parseNonNegativeInt(raw: string | undefined, fallback: number, label: string) { if (raw === undefined || raw === "") { return fallback; } const text = raw.trim(); if (!/^\d+$/u.test(text)) { throw new Error(`${label} must be a non-negative integer. Got: ${JSON.stringify(raw)}`); } const parsed = Number(text); if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new Error(`${label} must be a non-negative integer. Got: ${JSON.stringify(raw)}`); } return parsed; } function parseBool(raw: string | undefined, fallback: boolean) { if (raw === undefined || raw === "") { return fallback; } return !/^(?:0|false|no)$/i.test(raw); } function normalizeReleaseProfileEnv(raw: string | undefined) { const profile = raw?.trim(); if (!profile) { return normalizeReleaseProfile(undefined); } if (profile === "minimum" || profile === "beta" || profile === "stable" || profile === "full") { return normalizeReleaseProfile(profile); } throw new Error( `release profile must be one of: beta, stable, full. Got: ${JSON.stringify(raw)}`, ); } function numericTimerValueMs(valueMs: unknown) { const value = Number(valueMs); return Number.isFinite(value) ? Math.floor(value) : undefined; } function resolveDockerSchedulerTimeoutMs( valueMs: unknown, fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS, ) { const value = numericTimerValueMs(valueMs) ?? numericTimerValueMs(fallbackMs); return Math.min(Math.max(value ?? MAX_TIMER_TIMEOUT_MS, 1), MAX_TIMER_TIMEOUT_MS); } function resolveOptionalTimerTimeoutMs(valueMs: unknown) { const value = numericTimerValueMs(valueMs); if (value === undefined || value <= 0) { return undefined; } return resolveDockerSchedulerTimeoutMs(value); } function resourceLimitsSummary(resourceLimits: Record) { return Object.entries(resourceLimits) .map(([resource, limit]) => `${resource}=${String(limit)}`) .join(" "); } export function describeDockerSchedulerLimits(parallelism: number, options: SchedulerLimits) { return `parallelism=${parallelism} weightLimit=${options.weightLimit} resources=${resourceLimitsSummary( options.resourceLimits, )}`; } function parseSchedulerOptions(env: NodeJS.ProcessEnv, parallelism: number) { const weightLimit = parsePositiveInt( env.OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT, parallelism, "OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT", ); const resourceLimits: Record = {}; for (const [resource, fallback] of Object.entries(DEFAULT_RESOURCE_LIMITS)) { const envName = `OPENCLAW_DOCKER_ALL_${resource.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_LIMIT`; resourceLimits[resource] = parsePositiveInt( env[envName], Math.min(parallelism, fallback), envName, ); } return { resourceLimits, weightLimit, }; } export function canStartSchedulerLane( candidate: SchedulerLane, active: SchedulerActiveState, parallelism: number, options: SchedulerLimits, ) { const weight = Math.max(1, candidate.weight ?? 1); if (active.count >= parallelism) { return false; } const exceedsWeightLimit = active.weight + weight > options.weightLimit; const resources = [...new Set(["docker", ...(candidate.resources ?? [])])]; const exceedsResourceLimit = resources.some((resource) => { const limit = options.resourceLimits[resource] ?? options.weightLimit; const current = active.resources.get(resource) ?? 0; return current + weight > limit; }); if (!exceedsWeightLimit && !exceedsResourceLimit) { return true; } return active.count === 0; } function timingSeconds(timings: Record, poolLane: DockerE2eLane) { const timing = timings[poolLane.name]; const fromStore = isRecord(timing) ? timing.durationSeconds : undefined; if (typeof fromStore === "number" && Number.isFinite(fromStore) && fromStore > 0) { return fromStore; } return poolLane.estimateSeconds ?? 0; } function orderLanes(poolLanes: DockerE2eLane[], timingStore?: unknown) { const timings = isRecord(timingStore) && isRecord(timingStore.lanes) ? timingStore.lanes : {}; return poolLanes .map((poolLane, index) => ({ index, poolLane, seconds: timingSeconds(timings, poolLane), })) .toSorted((a, b) => b.seconds - a.seconds || a.index - b.index) .map(({ poolLane }) => poolLane); } function utcStampForPath() { return new Date().toISOString().replaceAll("-", "").replaceAll(":", "").replace(/\..*$/, "Z"); } function utcStamp() { return new Date().toISOString().replace(/\..*$/, "Z"); } function appendExtension(env: NodeJS.ProcessEnv, extension: string) { const current = env.OPENCLAW_DOCKER_BUILD_EXTENSIONS ?? env.OPENCLAW_EXTENSIONS ?? ""; const tokens = current.split(/\s+/).filter(Boolean); if (!tokens.includes(extension)) { tokens.push(extension); } env.OPENCLAW_DOCKER_BUILD_EXTENSIONS = tokens.join(" "); } function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env = { ...process.env, ...extra, }; const pathEntries = [ env.PATH, env.PNPM_HOME, env.npm_execpath ? path.dirname(env.npm_execpath) : undefined, path.dirname(process.execPath), ] .flatMap((entry) => (entry ? entry.split(path.delimiter) : [])) .filter(Boolean); env.PATH = [...new Set(pathEntries)].join(path.delimiter); return env; } function gitOutput(repoRoot: string, args: string[]) { return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim(); } function rootPackageVersion(repoRoot: string) { return JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version as string; } function readCompleteTuple(env: NodeJS.ProcessEnv, keys: readonly string[], label: string) { const entries = keys.flatMap((key) => (env[key] ? [[key, env[key]]] : [])); const complete = entries.length === keys.length; assert(!entries.length || complete, `${label} fields must be complete`); return complete ? Object.fromEntries(entries) : undefined; } function validateRegistryEnvironment(baseEnv: NodeJS.ProcessEnv, plan: DockerCandidatePlan) { validatePrepublishPluginRegistryArtifact({ artifactDir: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR!, expectedCandidateVersion: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION!, expectedManifestSha256: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256!, expectedSourceSha: baseEnv.OPENCLAW_DOCKER_E2E_SELECTED_SHA!, requiredPackages: plan.requiredPrepublishPluginPackages, }); } export function validateDockerCandidateEnvironment( baseEnv: NodeJS.ProcessEnv, plan: DockerCandidatePlan, repoRoot = ROOT_DIR, ) { const strictCandidate = CANDIDATE_ENV_KEYS.slice(2).some((key) => baseEnv[key]); if (!strictCandidate || !plan.needs.package) { baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ &&= path.resolve(baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ); const registryDir = baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR; if (!plan.needs.prepublishPluginRegistry || !registryDir) { delete baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR; return; } baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR = path.resolve(registryDir); validateRegistryEnvironment(baseEnv, plan); return; } const candidate = readCompleteTuple(baseEnv, CANDIDATE_ENV_KEYS, "Docker candidate")!; const registry = readCompleteTuple(baseEnv, REGISTRY_ENV_KEYS, "Docker candidate registry"); const packagePath = candidate.OPENCLAW_CURRENT_PACKAGE_TGZ; if ( !path.isAbsolute(packagePath) || gitOutput(repoRoot, ["rev-parse", "HEAD"]) !== candidate.OPENCLAW_DOCKER_E2E_SELECTED_SHA ) { throw new Error("Docker candidate path must be absolute and selected SHA must equal HEAD"); } const packed = inspectNpmPackageTarball(packagePath); if ( packed.packageJson.name !== "openclaw" || packed.packageJson.version !== rootPackageVersion(repoRoot) || packed.packageJson.version !== candidate.OPENCLAW_CURRENT_PACKAGE_VERSION || packed.sha256 !== candidate.OPENCLAW_CURRENT_PACKAGE_SHA256 ) { throw new Error("Docker candidate package identity differs from the immutable tuple"); } if (registry) { const registryDir = registry.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR; assert(path.isAbsolute(registryDir), "Docker candidate registry path must be absolute"); validateRegistryEnvironment(baseEnv, plan); } else if (plan.needs.prepublishPluginRegistry) { throw new Error("Docker plan requires a prepublish plugin registry tuple"); } } function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; } function maybeGhcrImage(value: unknown) { return typeof value === "string" && value.startsWith("ghcr.io/") ? value : ""; } export function githubWorkflowRerunCommand( laneNames: readonly string[], ref: string, env: NodeJS.ProcessEnv = process.env, ) { const workflowRef = env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF || undefined; const releasePath = env.OPENCLAW_DOCKER_ALL_PROFILE === RELEASE_PATH_PROFILE; const allowUnreleasedChangelog = env.OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG === "true"; const bareImage = maybeGhcrImage(env.OPENCLAW_DOCKER_E2E_BARE_IMAGE); const functionalImage = maybeGhcrImage(env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE); const fields = [ "gh workflow run", shellQuote(env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW), ...(workflowRef ? ["--ref", shellQuote(workflowRef)] : []), "-f", `ref=${shellQuote(ref)}`, "-f", "include_repo_e2e=false", "-f", `include_release_path_suites=${releasePath ? "true" : "false"}`, "-f", "include_openwebui=false", "-f", `docker_lanes=${shellQuote(laneNames.join(" "))}`, "-f", "include_live_suites=false", "-f", "live_models_only=false", ]; if (allowUnreleasedChangelog) { fields.push("-f", "allow_unreleased_changelog=true"); } if (env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC) { fields.push( "-f", `published_upgrade_survivor_baseline=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}`, ); } if (env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS) { fields.push( "-f", `published_upgrade_survivor_baselines=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}`, ); } if (env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS) { fields.push( "-f", `published_upgrade_survivor_scenarios=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS)}`, ); } if (bareImage) { fields.push("-f", `docker_e2e_bare_image=${shellQuote(bareImage)}`); } if (functionalImage) { fields.push("-f", `docker_e2e_functional_image=${shellQuote(functionalImage)}`); } if (bareImage || functionalImage) { fields.push("-f", "shared_image_policy=existing-only"); } return fields.join(" "); } export function buildLaneRerunCommand(name: string, baseEnv: NodeJS.ProcessEnv) { const poolLane = findLaneByName(name); const build = name.startsWith("live-") ? "1" : "0"; const image = poolLane ? e2eImageForLane(poolLane, baseEnv) : baseEnv.OPENCLAW_DOCKER_E2E_IMAGE; const env: Array = [ ["OPENCLAW_DOCKER_ALL_LANES", name], ["OPENCLAW_DOCKER_ALL_BUILD", build], ["OPENCLAW_DOCKER_ALL_PREFLIGHT", "0"], ["OPENCLAW_SKIP_DOCKER_BUILD", "1"], ["OPENCLAW_DOCKER_E2E_IMAGE", image || DEFAULT_E2E_FUNCTIONAL_IMAGE], ["OPENCLAW_DOCKER_E2E_BARE_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE], ["OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE], ...CANDIDATE_ENV_KEYS.map((key) => [key, baseEnv[key]] as const), ...REGISTRY_ENV_KEYS.map((key) => [key, baseEnv[key]] as const), ["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC], ["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS], ["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS], ]; if (baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND) { env.push(["OPENCLAW_DOCKER_ALL_PNPM_COMMAND", baseEnv.OPENCLAW_DOCKER_ALL_PNPM_COMMAND]); } return `${env .filter( (entry): entry is readonly [string, string] => entry[1] !== undefined && entry[1] !== "", ) .map(([key, value]) => `${key}=${shellQuote(value)}`) .join(" ")} pnpm test:docker:all`; } function liveDockerHarnessScriptCommand(script: string) { return `bash -c 'harness="\${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-${LIVE_DOCKER_DEFAULT_HARNESS_DIR}}"; OPENCLAW_LIVE_DOCKER_REPO_ROOT="\${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" bash "$harness/scripts/${script}"'`; } async function loadTimingStore(file: string, enabled: boolean) { if (!enabled) { return { enabled: false, file, lanes: {}, version: 1 }; } const raw = await readFile(file, "utf8").catch(() => ""); if (!raw.trim()) { return { enabled: true, file, lanes: {}, version: 1 }; } try { const parsed: unknown = JSON.parse(raw); return { enabled: true, file, lanes: isRecord(parsed) && isRecord(parsed.lanes) ? parsed.lanes : {}, version: 1, }; } catch (error) { console.warn( `WARN: ignoring unreadable Docker lane timings ${file}: ${ error instanceof Error ? error.message : String(error) }`, ); return { enabled: true, file, lanes: {}, version: 1 }; } } async function writeTimingStore(timingStore: TimingStore, results: LaneResult[]) { if (!timingStore.enabled || results.length === 0) { return; } const next = { lanes: { ...timingStore.lanes }, updatedAt: new Date().toISOString(), version: 1, }; for (const result of results) { if (!result || typeof result.elapsedSeconds !== "number") { continue; } next.lanes[result.name] = { durationSeconds: result.elapsedSeconds, status: result.status, timedOut: result.timedOut, updatedAt: new Date().toISOString(), }; } await mkdir(path.dirname(timingStore.file), { recursive: true }); await fs.promises.writeFile(timingStore.file, `${JSON.stringify(next, null, 2)}\n`); timingStore.lanes = next.lanes; console.log(`==> Docker lane timings: ${timingStore.file}`); } function githubRunSummary(env: NodeJS.ProcessEnv) { return { ref: env.GITHUB_REF_NAME || undefined, repository: env.GITHUB_REPOSITORY || undefined, runId: env.GITHUB_RUN_ID || undefined, runUrl: env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined, selectedSha: env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || undefined, sha: env.GITHUB_SHA || undefined, workflow: env.GITHUB_WORKFLOW || undefined, }; } export async function writeRunSummary( logDir: string, summary: RunSummary, env: NodeJS.ProcessEnv = process.env, ) { const file = path.join(logDir, "summary.json"); const payload = { ...summary, // Summary reruns do not carry failure-index commands, so preserve this exact package intent. allowUnreleasedChangelog: env.OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG === "true" ? true : undefined, packageArtifactName: env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined, finishedAt: new Date().toISOString(), github: githubRunSummary(env), version: 1, }; await fs.promises.writeFile(file, `${JSON.stringify(payload, null, 2)}\n`); await writeFailureIndex(logDir, payload, env); console.log(`==> Docker run summary: ${file}`); } async function writeFailureIndex(logDir: string, summary: RunSummary, env: NodeJS.ProcessEnv) { const ref = summary.github?.selectedSha || env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || summary.github?.sha || summary.github?.ref || env.GITHUB_SHA || "HEAD"; const failures = Array.isArray(summary.failures) ? summary.failures : (summary.lanes ?? []).filter((lane) => lane.status !== 0); const workflowRerunFailures = failures.filter((failure) => failure.targetable !== false); const lanes = failures.map((failure) => ({ ghWorkflowCommand: failure.targetable === false ? undefined : githubWorkflowRerunCommand([failure.name], ref, env), image: failure.image, imageKind: failure.imageKind, lane: failure.name, logFile: failure.logFile, name: failure.name, noOutputTimedOut: failure.noOutputTimedOut, rerunCommand: failure.rerunCommand, status: failure.status, targetable: failure.targetable, timedOut: failure.timedOut, })); const failureIndex = { combinedGhWorkflowCommand: workflowRerunFailures.length > 0 ? githubWorkflowRerunCommand( workflowRerunFailures.map((failure) => failure.name), ref, env, ) : undefined, generatedAt: new Date().toISOString(), lanes, note: "Targeted GitHub reruns repack the exact selected ref and reuse only GHCR-backed shared images when the generated command includes docker_e2e_*_image inputs.", images: summary.images, packageArtifactName: env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined, ref, runUrl: summary.github?.runUrl, status: summary.status, version: 1, workflow: env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW, }; await fs.promises.writeFile( path.join(logDir, "failures.json"), `${JSON.stringify(failureIndex, null, 2)}\n`, ); } function phaseElapsedSeconds(startedAtMs: number) { return Math.round((Date.now() - startedAtMs) / 1000); } function laneAttempt( attempt: number, startedAtMs: number, result: Pick, ) { return { attempt, elapsedSeconds: phaseElapsedSeconds(startedAtMs), finishedAt: new Date().toISOString(), noOutputTimedOut: result.noOutputTimedOut, startedAt: new Date(startedAtMs).toISOString(), status: result.status, timedOut: result.timedOut, }; } function cleanupSmokeResult( baseEnv: NodeJS.ProcessEnv, logFile: string, command: string, startedAtMs: number, result: Pick, ) { return { command, attempts: [laneAttempt(1, startedAtMs, result)], elapsedSeconds: phaseElapsedSeconds(startedAtMs), finishedAt: new Date().toISOString(), image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, logFile, name: CLEANUP_SMOKE_NAME, noOutputTimedOut: result.noOutputTimedOut, rerunCommand: command, startedAt: new Date(startedAtMs).toISOString(), status: result.status, targetable: false, timedOut: result.timedOut, }; } async function runPhase( phases: Array>, name: string, details: Record, fn: () => Promise, ): Promise { const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); let status: "failed" | "passed" = "passed"; let errorMessage: string | undefined; try { const result = await fn(); return result; } catch (error) { status = "failed"; errorMessage = error instanceof Error ? error.message : String(error); throw error; } finally { const phase = { ...details, name, startedAt, status, ...(errorMessage === undefined ? {} : { error: errorMessage }), elapsedSeconds: phaseElapsedSeconds(startedAtMs), finishedAt: new Date().toISOString(), }; phases.push(phase); console.log(`==> Phase ${phase.status}: ${name} ${phase.elapsedSeconds}s`); } } function printLaneManifest( label: string, poolLanes: DockerE2eLane[], timingStore: TimingStore, ): void { console.log(`==> ${label} lanes (${poolLanes.length})`); for (const [index, poolLane] of poolLanes.entries()) { const seconds = timingSeconds(timingStore.lanes, poolLane); const estimate = seconds > 0 ? ` last=${Math.round(seconds)}s` : ""; console.log(` ${index + 1}. ${laneSummary(poolLane)}${estimate}`); } } export function dockerPreflightContainerNames(raw: string): string[] { return raw.split(/\r?\n/).flatMap((line) => { const name = line.trim().split(/\s+/, 1)[0]; return name && /^(?:openclaw-[a-z0-9-]+-e2e-\d+|openclaw-openwebui(?:-gateway)?-\d+)$/u.test(name) ? [name] : []; }); } export function resolveDockerPreflightPlatform(arch: NodeJS.Architecture = process.arch) { return arch === "arm64" ? "linux/arm64" : "linux/amd64"; } export function dockerPreflightSmokeCommand(arch: NodeJS.Architecture = process.arch) { const platform = resolveDockerPreflightPlatform(arch); return `docker run --rm --platform ${shellQuote(platform)} alpine:3.20 true`; } export function runShellCommand({ command, env, label, logFile, timeoutMs, noOutputTimeoutMs, timeoutKillGraceMs = SHELL_TIMEOUT_KILL_GRACE_MS, }: ShellCommandOptions) { if (activeChildrenShutdownPromise) { return activeChildrenShutdownPromise.then(() => shellCommandSkippedForShutdown()); } return new Promise((resolve) => { const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(timeoutMs); const resolvedNoOutputTimeoutMs = resolveOptionalTimerTimeoutMs(noOutputTimeoutMs); const resolvedTimeoutKillGraceMs = resolveDockerSchedulerTimeoutMs( timeoutKillGraceMs, SHELL_TIMEOUT_KILL_GRACE_MS, ); const pipeOutput = Boolean(logFile || resolvedNoOutputTimeoutMs); const child = spawn("bash", ["-c", command], { cwd: ROOT_DIR, detached: process.platform !== "win32", env, stdio: pipeOutput ? ["ignore", "pipe", "pipe"] : "inherit", }); activeChildren.set(child, resolvedTimeoutKillGraceMs); let timedOut = false; let noOutputTimedOut = false; let killTimer: ReturnType | undefined; let killAt: number | undefined; const stream = logFile ? fs.createWriteStream(logFile, { flags: "a" }) : undefined; let noOutputTimer: ReturnType | undefined; const terminateForTimeout = (message: string, options: { noOutput?: boolean } = {}) => { if (timedOut) { return; } timedOut = true; noOutputTimedOut = options.noOutput === true; if (stream) { stream.write(`\n==> [${label}] ${message}; sending SIGTERM\n`); } else { console.error(`==> [${label}] ${message}; sending SIGTERM`); } terminateChild(child, "SIGTERM"); killAt = Date.now() + resolvedTimeoutKillGraceMs; killTimer = setTimeout(() => terminateChild(child, "SIGKILL"), resolvedTimeoutKillGraceMs); killTimer.unref?.(); }; const resetNoOutputTimer = () => { if (!resolvedNoOutputTimeoutMs || timedOut) { return; } if (noOutputTimer) { clearTimeout(noOutputTimer); } noOutputTimer = setTimeout(() => { terminateForTimeout(`no output for ${resolvedNoOutputTimeoutMs}ms`, { noOutput: true }); }, resolvedNoOutputTimeoutMs); noOutputTimer.unref?.(); }; const timeoutTimer = resolvedTimeoutMs !== undefined ? setTimeout(() => { terminateForTimeout(`timeout after ${resolvedTimeoutMs}ms`); }, resolvedTimeoutMs) : undefined; timeoutTimer?.unref?.(); if (stream) { stream.write(`==> [${label}] command: ${command}\n`); stream.write(`==> [${label}] started: ${utcStamp()}\n`); } if (pipeOutput && child.stdout && child.stderr) { const writeOutput = (target: NodeJS.WriteStream, chunk: Uint8Array) => { resetNoOutputTimer(); if (stream) { stream.write(chunk); } else { target.write(chunk); } }; child.stdout.on("data", (chunk: Uint8Array) => writeOutput(process.stdout, chunk)); child.stderr.on("data", (chunk: Uint8Array) => writeOutput(process.stderr, chunk)); resetNoOutputTimer(); } child.on("close", (status, signal) => { if (timeoutTimer) { clearTimeout(timeoutTimer); } if (noOutputTimer) { clearTimeout(noOutputTimer); } const finish = () => { if (killTimer) { clearTimeout(killTimer); } killAt = undefined; activeChildren.delete(child); const exitCode = typeof status === "number" ? status : signal ? 128 : 1; if (stream) { stream.write( `\n==> [${label}] finished: ${utcStamp()} status=${exitCode}${ noOutputTimedOut ? " noOutputTimedOut=true" : "" }\n`, ); stream.end(); } resolve({ signal, status: exitCode, timedOut, noOutputTimedOut }); }; if (timedOut) { void finishTimedOutShellProcessTree(child, killAt, resolvedTimeoutKillGraceMs).then( finish, finish, ); return; } finish(); }); }); } export function appendBoundedShellCapture( current: string, chunk: unknown, maxChars = SHELL_CAPTURE_MAX_CHARS, ) { const combined = `${current}${String(chunk)}`; if (combined.length <= maxChars) { return { text: combined, truncated: false }; } return { text: combined.slice(-maxChars), truncated: true }; } export function runShellCaptureCommand({ command, env, label, timeoutMs, timeoutKillGraceMs = SHELL_TIMEOUT_KILL_GRACE_MS, }: ShellCaptureOptions) { if (activeChildrenShutdownPromise) { return activeChildrenShutdownPromise.then(() => shellCaptureSkippedForShutdown(label)); } return new Promise((resolve) => { const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(timeoutMs); const resolvedTimeoutKillGraceMs = resolveDockerSchedulerTimeoutMs( timeoutKillGraceMs, SHELL_TIMEOUT_KILL_GRACE_MS, ); const child = spawn("bash", ["-c", command], { cwd: ROOT_DIR, detached: process.platform !== "win32", env, stdio: ["ignore", "pipe", "pipe"], }); activeChildren.set(child, resolvedTimeoutKillGraceMs); let stdout = ""; let stderr = ""; let stdoutTruncated = false; let stderrTruncated = false; let timedOut = false; let killTimer: ReturnType | undefined; let killAt: number | undefined; const timeoutTimer = resolvedTimeoutMs !== undefined ? setTimeout(() => { timedOut = true; terminateChild(child, "SIGTERM"); killAt = Date.now() + resolvedTimeoutKillGraceMs; killTimer = setTimeout( () => terminateChild(child, "SIGKILL"), resolvedTimeoutKillGraceMs, ); killTimer.unref?.(); }, resolvedTimeoutMs) : undefined; timeoutTimer?.unref?.(); child.stdout.on("data", (chunk: Buffer) => { const next = appendBoundedShellCapture(stdout, chunk); stdout = next.text; stdoutTruncated ||= next.truncated; }); child.stderr.on("data", (chunk: Buffer) => { const next = appendBoundedShellCapture(stderr, chunk); stderr = next.text; stderrTruncated ||= next.truncated; }); child.on("close", (status, signal) => { if (timeoutTimer) { clearTimeout(timeoutTimer); } const finish = () => { if (killTimer) { clearTimeout(killTimer); } killAt = undefined; activeChildren.delete(child); const exitCode = typeof status === "number" ? status : signal ? 128 : 1; resolve({ label, signal, status: exitCode, stderr, stderrTruncated, stdout, stdoutTruncated, timedOut, }); }; if (timedOut) { void finishTimedOutShellProcessTree(child, killAt, resolvedTimeoutKillGraceMs).then( finish, finish, ); return; } finish(); }); }); } async function runForeground(label: string, command: string, env: NodeJS.ProcessEnv) { console.log(`==> ${label}`); const result = await runShellCommand({ command, env, label }); if (result.status !== 0) { throw new Error(`${label} failed with status ${result.status}`); } } export async function runCleanupSmokePhase( baseEnv: NodeJS.ProcessEnv, logDir: string, phases: Array>, ) { const command = "pnpm test:docker:cleanup"; const logFile = path.join(logDir, `${CLEANUP_SMOKE_NAME}.log`); const startedAtMs = Date.now(); let failure: LaneResult | undefined; try { await runPhase(phases, CLEANUP_SMOKE_NAME, {}, async () => { const result = await runShellCommand({ command, env: baseEnv, label: CLEANUP_SMOKE_NAME, logFile, }); if (result.status !== 0) { failure = cleanupSmokeResult(baseEnv, logFile, command, startedAtMs, result); } if (failure) { throw new Error( `Run cleanup smoke after parallel lanes failed with status ${failure.status}`, ); } }); } catch (error) { if (!failure) { const status = 1; const message = error instanceof Error ? error.message : String(error); await fs.promises.writeFile( logFile, [ `==> [${CLEANUP_SMOKE_NAME}] command: ${command}`, `==> [${CLEANUP_SMOKE_NAME}] status: ${status}`, `==> [${CLEANUP_SMOKE_NAME}] error: ${message}`, ].join("\n"), ); failure = cleanupSmokeResult(baseEnv, logFile, command, startedAtMs, { noOutputTimedOut: false, status, timedOut: false, }); } } return failure; } async function runForegroundGroup(entries: ForegroundEntry[], env: NodeJS.ProcessEnv) { const failures: Array<{ entry: ForegroundEntry; error: unknown }> = []; for (const entry of entries) { try { const { command, label, phaseDetails = {}, phases } = entry; const entryEnv = { ...env, ...entry.env }; if (phases) { await runPhase(phases, `build:${label}`, phaseDetails, async () => { await runForeground(label, command, entryEnv); }); } else { await runForeground(label, command, entryEnv); } } catch (error) { failures.push({ entry, error }); } } if (failures.length > 0) { throw new Error( failures .map( ({ entry, error }) => `${entry.label}: ${error instanceof Error ? error.message : String(error)}`, ) .join("\n"), ); } } async function runDockerPreflight( baseEnv: NodeJS.ProcessEnv, options: { cleanup: boolean; enabled: boolean; runTimeoutMs: number }, ) { if (!options.enabled) { console.log("==> Docker preflight: skipped"); return; } console.log("==> Docker preflight"); const version = await runShellCaptureCommand({ command: "docker version --format '{{.Server.Version}}'", env: baseEnv, label: "docker-version", timeoutMs: 20_000, }); if (version.status !== 0) { throw new Error( `Docker preflight failed: docker version status=${version.status}\n${version.stderr}${version.stdout}`, ); } console.log(`==> Docker server: ${version.stdout.trim()}`); if (options.cleanup) { const stale = await runShellCaptureCommand({ command: "docker ps -a --filter status=created --filter status=exited --filter status=dead --format '{{.Names}} {{.Status}}'", env: baseEnv, label: "docker-stale-list", timeoutMs: 20_000, }); if (stale.status === 0) { const names = dockerPreflightContainerNames(stale.stdout); if (names.length > 0) { console.log(`==> Docker preflight cleanup: ${names.join(", ")}`); const cleanup = await runShellCommand({ command: `docker rm -f ${names.map(shellQuote).join(" ")}`, env: baseEnv, label: "docker-stale-cleanup", timeoutMs: 90_000, }); if (cleanup.status !== 0) { throw new Error(`Docker preflight cleanup failed with status ${cleanup.status}`); } } } } const startedAt = Date.now(); const run = await runShellCommand({ command: dockerPreflightSmokeCommand(), env: baseEnv, label: "docker-run-smoke", timeoutMs: options.runTimeoutMs, }); const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); if (run.status !== 0) { throw new Error( `Docker preflight failed: ${dockerPreflightSmokeCommand()} status=${run.status} elapsed=${elapsedSeconds}s`, ); } console.log(`==> Docker preflight run: ${elapsedSeconds}s`); } async function prepareOpenClawPackage(baseEnv: NodeJS.ProcessEnv, logDir: string) { const existing = baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ; if (existing) { const packageTgz = path.resolve(existing); baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz; baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0"; baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0"; console.log(`==> OpenClaw package: ${packageTgz}`); return; } const packDir = path.join(logDir, "openclaw-package"); await mkdir(packDir, { recursive: true }); const packageTgz = path.join(packDir, "openclaw-current.tgz"); await runForeground( "Prepare OpenClaw package once", `node ${shellQuote(path.join(ROOT_DIR, "scripts/package-openclaw-for-docker.mjs"))} --source-dir ${shellQuote(ROOT_DIR)} --allow-unreleased-changelog --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`, baseEnv, ); await fs.promises.access(packageTgz); // Preserve current-tree package intent in generated GitHub reruns; otherwise a local QA // failure would retry through the strict release path and fail before reaching its lane. baseEnv.OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG = "true"; baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz; baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0"; baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0"; console.log(`==> OpenClaw package: ${baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ}`); } async function prepareDockerCandidate( plan: DockerCandidatePlan, logDir: string, manifestPath: string, ) { const sourceSha = gitOutput(ROOT_DIR, ["rev-parse", "HEAD"]); let candidate = null; if (plan.needs.package) { if (gitOutput(ROOT_DIR, ["status", "--porcelain=v1"])) { throw new Error("repository has working-tree changes; refusing to prepare Docker candidate"); } const candidateEnv = commandEnv(); for (const key of [...CANDIDATE_ENV_KEYS, ...REGISTRY_ENV_KEYS]) { delete candidateEnv[key]; } await prepareOpenClawPackage(candidateEnv, logDir); const packagePath = candidateEnv.OPENCLAW_CURRENT_PACKAGE_TGZ!; const packed = inspectNpmPackageTarball(packagePath); const version = rootPackageVersion(ROOT_DIR); if (packed.packageJson.name !== "openclaw" || packed.packageJson.version !== version) { throw new Error("packed Docker candidate name or version differs from the root package"); } let registry = null; if (plan.needs.prepublishPluginRegistry) { const registryDir = path.join(logDir, "prepublish-plugin-registry"); fs.rmSync(registryDir, { force: true, recursive: true }); const artifact = createPrepublishPluginRegistryArtifact({ repoRoot: ROOT_DIR, outputDir: registryDir, sourceSha, candidateVersion: version, requiredPackages: plan.requiredPrepublishPluginPackages, }); registry = { dir: registryDir, candidateVersion: version, manifestSha256: artifact.manifestSha256, }; } candidate = { package: { path: packagePath, name: packed.packageJson.name, version, sha256: packed.sha256 }, registry, }; } await mkdir(path.dirname(manifestPath), { recursive: true }); fs.writeFileSync( manifestPath, `${JSON.stringify({ schema: "openclaw.qa-docker-candidate/v1", schemaVersion: 1, sourceSha, candidate }, null, 2)}\n`, ); } function e2eImageForLane(poolLane: DockerE2eLane, baseEnv: NodeJS.ProcessEnv) { if (poolLane.e2eImageKind === "bare") { return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE; } if (poolLane.e2eImageKind === "functional") { return baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE; } return undefined; } type DockerLaneEnv = { [key: string]: string | undefined; OPENCLAW_DOCKER_CACHE_HOME_DIR: string; OPENCLAW_DOCKER_CLI_TOOLS_DIR: string; }; function laneEnv( poolLane: DockerE2eLane, baseEnv: NodeJS.ProcessEnv, logDir: string, cacheKey: string | undefined, ): DockerLaneEnv { const name = poolLane.name; const cacheName = cacheKey || name; const env: DockerLaneEnv = { ...baseEnv, OPENCLAW_DOCKER_CACHE_HOME_DIR: process.env.OPENCLAW_DOCKER_CACHE_HOME_DIR ?? path.join(logDir, `${cacheName}-cache`), OPENCLAW_DOCKER_CLI_TOOLS_DIR: process.env.OPENCLAW_DOCKER_CLI_TOOLS_DIR ?? path.join(logDir, `${cacheName}-cli-tools`), }; env.OPENCLAW_DOCKER_ALL_LANE_NAME = name; const image = e2eImageForLane(poolLane, baseEnv); if (image) { env.OPENCLAW_DOCKER_E2E_IMAGE = image; } if (poolLane.e2eImageKind) { env.OPENCLAW_DOCKER_E2E_IMAGE_KIND = poolLane.e2eImageKind; } return env; } async function runLane( lane: DockerE2eLane, baseEnv: NodeJS.ProcessEnv, logDir: string, fallbackTimeoutMs: number, ) { const { name } = lane; const timeoutMs = lane.timeoutMs ?? fallbackTimeoutMs; const noOutputTimeoutMs = lane.noOutputTimeoutMs; const logFile = path.join(logDir, `${name}.log`); const env = laneEnv(lane, baseEnv, logDir, lane.cacheKey); const pnpmCommand = env.OPENCLAW_DOCKER_ALL_PNPM_COMMAND?.trim(); const command = pnpmCommand ? lane.command.replace(/(^|\s)pnpm(?=\s)/g, `$1${shellQuote(pnpmCommand)}`) : lane.command; await mkdir(env.OPENCLAW_DOCKER_CLI_TOOLS_DIR, { recursive: true }); await mkdir(env.OPENCLAW_DOCKER_CACHE_HOME_DIR, { recursive: true }); await fs.promises.writeFile( logFile, [ `==> [${name}] cli tools dir: ${env.OPENCLAW_DOCKER_CLI_TOOLS_DIR}`, `==> [${name}] cache dir: ${env.OPENCLAW_DOCKER_CACHE_HOME_DIR}`, `==> [${name}] timeout: ${timeoutMs}ms`, `==> [${name}] no output timeout: ${noOutputTimeoutMs ?? 0}ms`, `==> [${name}] retries: ${lane.retries ?? 0}`, `==> [${name}] e2e image kind: ${lane.e2eImageKind ?? "none"}`, `==> [${name}] e2e image: ${env.OPENCLAW_DOCKER_E2E_IMAGE ?? ""}`, "", ].join("\n"), ); console.log(`==> [${name}] start`); const startedAt = Date.now(); const startedAtIso = new Date(startedAt).toISOString(); let result: ShellCommandResult; const attempts: ReturnType[] = []; const maxAttempts = 1 + Math.max(0, lane.retries ?? 0); for (let attempt = 1; ; attempt += 1) { const attemptStartedAt = Date.now(); if (attempt > 1) { await fs.promises.appendFile(logFile, `\n==> [${name}] retry attempt ${attempt}\n`); console.log(`==> [${name}] retry ${attempt}/${maxAttempts}`); } result = await runShellCommand({ command, env, label: name, logFile, timeoutMs, noOutputTimeoutMs, }); attempts.push(laneAttempt(attempt, attemptStartedAt, result)); if (result.status === 0 || attempt >= maxAttempts) { break; } const retryable = result.timedOut || (await laneLogMatchesRetryPattern(logFile, lane.retryPatterns)); if (!retryable) { break; } } const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); if (result.status === 0) { console.log(`==> [${name}] pass ${elapsedSeconds}s`); } else { const timeoutLabel = result.timedOut ? " timeout" : ""; console.error( `==> [${name}] fail${timeoutLabel} status=${result.status} ${elapsedSeconds}s log=${logFile}`, ); } return { command, attempts, finishedAt: new Date().toISOString(), image: env.OPENCLAW_DOCKER_E2E_IMAGE, imageKind: lane.e2eImageKind, logFile, name, elapsedSeconds, rerunCommand: buildLaneRerunCommand(name, baseEnv), startedAt: startedAtIso, status: result.status, noOutputTimedOut: result.noOutputTimedOut, timedOut: result.timedOut, }; } async function runLanePool( poolLanes: DockerE2eLane[], baseEnv: NodeJS.ProcessEnv, logDir: string, parallelism: number, options: LanePoolOptions, ) { const failures: LaneResult[] = []; const results: LaneResult[] = []; const pending = [...poolLanes]; const running = new Map>(); const active = { count: 0, resources: new Map(), weight: 0, } satisfies SchedulerActiveState; const activeLanes = new Map(); let lastLaneStartAt = 0; let laneStartQueue: Promise = Promise.resolve(); const statusTimer = options.statusIntervalMs > 0 ? setInterval(() => { const runningSummary = [...activeLanes] .map(([name, startedAt]) => `${name}:${Math.round((Date.now() - startedAt) / 1000)}s`) .join(", "); const resources = [...active.resources.entries()] .map(([resource, value]) => `${resource}=${value}`) .join(" "); console.log( `==> [${options.poolLabel}] active=${active.count} pending=${pending.length} ${resources}${ runningSummary ? ` lanes=${runningSummary}` : "" }`, ); }, options.statusIntervalMs) : undefined; statusTimer?.unref?.(); async function waitForLaneStartSlot() { if (options.startStaggerMs <= 0) { return; } const previous = laneStartQueue; let releaseNext = () => {}; laneStartQueue = new Promise((resolve) => { releaseNext = resolve; }); await previous; const waitMs = Math.max(0, lastLaneStartAt + options.startStaggerMs - Date.now()); if (waitMs > 0) { await sleep(waitMs); } lastLaneStartAt = Date.now(); releaseNext(); } function canStartLane(candidate: DockerE2eLane) { return canStartSchedulerLane(candidate, active, parallelism, options); } function reserve(candidate: DockerE2eLane) { const weight = laneWeight(candidate); active.count += 1; active.weight += weight; for (const resource of laneResources(candidate)) { active.resources.set(resource, (active.resources.get(resource) ?? 0) + weight); } } function release(candidate: DockerE2eLane) { const weight = laneWeight(candidate); active.count -= 1; active.weight -= weight; for (const resource of laneResources(candidate)) { const next = (active.resources.get(resource) ?? 0) - weight; if (next > 0) { active.resources.set(resource, next); } else { active.resources.delete(resource); } } } async function startLane(poolLane: DockerE2eLane) { await waitForLaneStartSlot(); reserve(poolLane); activeLanes.set(poolLane.name, Date.now()); const id = Symbol(poolLane.name); const promise = runLane(poolLane, baseEnv, logDir, options.timeoutMs) .then((result) => ({ id, result })) .finally(() => { activeLanes.delete(poolLane.name); release(poolLane); }); running.set(id, promise); } try { while (pending.length > 0 || running.size > 0) { let started = false; if (!options.failFast || failures.length === 0) { for (let index = 0; index < pending.length;) { const candidate = pending[index]; if (!candidate) { break; } if (!canStartLane(candidate)) { index += 1; continue; } pending.splice(index, 1); await startLane(candidate); started = true; } } if (started) { continue; } if (running.size === 0) { const blocked = pending.map(laneSummary).join(", "); throw new Error( `No Docker lanes fit scheduler limits (${describeDockerSchedulerLimits( parallelism, options, )}): ${blocked}. Tune OPENCLAW_DOCKER_ALL_PARALLELISM, OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT, or OPENCLAW_DOCKER_ALL__LIMIT.`, ); } const { id, result } = await Promise.race(running.values()); running.delete(id); results.push(result); if (result.status !== 0) { failures.push(result); } if (options.failFast && failures.length > 0) { const remainingResults = await Promise.all(running.values()); running.clear(); for (const remaining of remainingResults) { results.push(remaining.result); if (remaining.result.status !== 0) { failures.push(remaining.result); } } break; } } } finally { if (statusTimer) { clearInterval(statusTimer); } } return { failures, results }; } export async function tailFile(file: string, lines: number, maxBytes = LOG_TAIL_MAX_BYTES) { let content: string; try { const handle = await open(file, "r"); try { const stat = await handle.stat(); const bytesToRead = Math.min(Math.max(1, maxBytes), stat.size); const buffer = Buffer.alloc(bytesToRead); await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead); content = buffer.toString("utf8"); } finally { await handle.close().catch(() => {}); } } catch { content = ""; } const tail = content.split(/\r?\n/).slice(-lines).join("\n"); return tail.trimEnd(); } async function laneLogMatchesRetryPattern(logFile: string, patterns: RegExp[]) { if (!patterns || patterns.length === 0) { return false; } const tail = await tailFile(logFile, 160); return patterns.some((pattern) => pattern.test(tail)); } async function printFailureSummary(failures: LaneResult[], tailLines: number) { console.error(`ERROR: ${failures.length} Docker lane(s) failed.`); for (const failure of failures) { console.error(`---- ${failure.name} failed (status=${failure.status}): ${failure.logFile}`); const tail = await tailFile(failure.logFile, tailLines); if (tail) { console.error(tail); } } } const activeChildren = new Map(); let activeChildrenShutdownPromise: Promise | undefined; function shellCommandSkippedForShutdown(signal: ShutdownSignal | null = null) { return { noOutputTimedOut: false, signal, status: 143, timedOut: false, }; } function shellCaptureSkippedForShutdown(label: string, signal: ShutdownSignal | null = null) { return { label, signal, status: 143, stderr: "", stderrTruncated: false, stdout: "", stdoutTruncated: false, timedOut: false, }; } function shellProcessGroupAlive(child: ChildProcess) { if (process.platform === "win32" || !child.pid) { return false; } try { process.kill(-child.pid, 0); return true; } catch (error) { return error instanceof Error && "code" in error && error.code === "EPERM"; } } async function waitForShellProcessGroupExit(child: ChildProcess, timeoutMs: number) { const deadlineAt = Date.now() + timeoutMs; while (Date.now() < deadlineAt) { if (!shellProcessGroupAlive(child)) { return true; } await new Promise((resolvePoll) => { setTimeout(resolvePoll, SHELL_PROCESS_GROUP_EXIT_POLL_MS); }); } return !shellProcessGroupAlive(child); } async function finishTimedOutShellProcessTree( child: ChildProcess, killAt: number | undefined, timeoutKillGraceMs: number, ) { if (!shellProcessGroupAlive(child)) { return; } const graceRemainingMs = killAt === undefined ? timeoutKillGraceMs : Math.max(0, killAt - Date.now()); if (graceRemainingMs > 0) { await waitForShellProcessGroupExit(child, graceRemainingMs); } if (shellProcessGroupAlive(child)) { terminateChild(child, "SIGKILL"); } await waitForShellProcessGroupExit(child, SHELL_POST_FORCE_KILL_WAIT_MS); } function terminateChild(child: ChildProcess, signal: ShutdownSignal) { if (process.platform !== "win32" && child.pid) { try { process.kill(-child.pid, signal); return; } catch { // Fall back to killing the direct child below. } } child.kill(signal); } function terminateActiveChildren(signal: ShutdownSignal) { for (const child of activeChildren.keys()) { terminateChild(child, signal); } } async function shutdownActiveChildren(signal: ShutdownSignal, exitCode: number) { if (activeChildrenShutdownPromise) { terminateActiveChildren("SIGKILL"); return activeChildrenShutdownPromise; } const children = [...activeChildren.entries()]; terminateActiveChildren(signal); activeChildrenShutdownPromise = Promise.all( children.map(([child, timeoutKillGraceMs]) => finishTimedOutShellProcessTree(child, Date.now() + timeoutKillGraceMs, timeoutKillGraceMs), ), ).finally(() => { process.exit(exitCode); }); return activeChildrenShutdownPromise; } process.on("SIGINT", () => { void shutdownActiveChildren("SIGINT", 130); }); process.on("SIGTERM", () => { void shutdownActiveChildren("SIGTERM", 143); }); async function main() { const runStartedAt = new Date().toISOString(); const phases: Array> = []; const parallelism = parsePositiveInt( process.env.OPENCLAW_DOCKER_ALL_PARALLELISM, DEFAULT_PARALLELISM, "OPENCLAW_DOCKER_ALL_PARALLELISM", ); const tailParallelism = parsePositiveInt( process.env.OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM, Math.min(parallelism, DEFAULT_TAIL_PARALLELISM), "OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM", ); const tailLines = parsePositiveInt( process.env.OPENCLAW_DOCKER_ALL_FAILURE_TAIL_LINES, DEFAULT_FAILURE_TAIL_LINES, "OPENCLAW_DOCKER_ALL_FAILURE_TAIL_LINES", ); const laneTimeoutMs = parsePositiveInt( process.env.OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS, DEFAULT_LANE_TIMEOUT_MS, "OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS", ); const laneStartStaggerMs = parseNonNegativeInt( process.env.OPENCLAW_DOCKER_ALL_START_STAGGER_MS, DEFAULT_LANE_START_STAGGER_MS, "OPENCLAW_DOCKER_ALL_START_STAGGER_MS", ); const statusIntervalMs = parseNonNegativeInt( process.env.OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS, DEFAULT_STATUS_INTERVAL_MS, "OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS", ); const preflightRunTimeoutMs = parsePositiveInt( process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT_RUN_TIMEOUT_MS, DEFAULT_PREFLIGHT_RUN_TIMEOUT_MS, "OPENCLAW_DOCKER_ALL_PREFLIGHT_RUN_TIMEOUT_MS", ); const failFast = parseBool(process.env.OPENCLAW_DOCKER_ALL_FAIL_FAST, true); const dryRun = parseBool(process.env.OPENCLAW_DOCKER_ALL_DRY_RUN, false); const preflightEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT, true); const preflightCleanup = parseBool(process.env.OPENCLAW_DOCKER_ALL_PREFLIGHT_CLEANUP, true); const timingsEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_TIMINGS, true); const buildEnabled = parseBool(process.env.OPENCLAW_DOCKER_ALL_BUILD, true); const allowFrozenTargetScenarioOmissions = parseBool( process.env.OPENCLAW_ALLOW_FROZEN_TARGET_SCENARIO_OMISSIONS, false, ); const planJson = cliOptions.planJson || parseBool(process.env.OPENCLAW_DOCKER_ALL_PLAN_JSON, false); const planReleaseAll = parseBool(process.env.OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL, false); const profile = parseProfile(process.env.OPENCLAW_DOCKER_ALL_PROFILE); const releaseProfile = normalizeReleaseProfileEnv( process.env.OPENCLAW_DOCKER_ALL_RELEASE_PROFILE || process.env.OPENCLAW_RELEASE_PROFILE, ); const releaseChunk = process.env.OPENCLAW_DOCKER_ALL_CHUNK || process.env.DOCKER_E2E_CHUNK || ""; const includeOpenWebUI = parseBool( process.env.OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI ?? process.env.INCLUDE_OPENWEBUI, true, ); const selectedLaneNamesRaw = process.env.OPENCLAW_DOCKER_ALL_LANES || process.env.DOCKER_E2E_LANES || ""; const selectedLaneNames = parseLaneSelection(selectedLaneNamesRaw); if (selectedLaneNamesRaw && selectedLaneNames.length === 0) { throw new Error("OPENCLAW_DOCKER_ALL_LANES must include at least one lane name"); } const liveMode = parseLiveMode(process.env.OPENCLAW_DOCKER_ALL_LIVE_MODE); const liveRetries = parseNonNegativeInt( process.env.OPENCLAW_DOCKER_ALL_LIVE_RETRIES, DEFAULT_LIVE_RETRIES, "OPENCLAW_DOCKER_ALL_LIVE_RETRIES", ); const timingsFile = path.resolve( process.env.OPENCLAW_DOCKER_ALL_TIMINGS_FILE || DEFAULT_TIMINGS_FILE, ); const runId = process.env.OPENCLAW_DOCKER_ALL_RUN_ID || utcStampForPath(); const logDir = path.resolve( process.env.OPENCLAW_DOCKER_ALL_LOG_DIR || path.join(ROOT_DIR, ".artifacts/docker-tests", runId), ); const baseEnv = commandEnv({ OPENCLAW_DOCKER_E2E_BARE_IMAGE: process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE || process.env.OPENCLAW_DOCKER_E2E_IMAGE || DEFAULT_E2E_BARE_IMAGE, OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE || process.env.OPENCLAW_DOCKER_E2E_IMAGE || DEFAULT_E2E_FUNCTIONAL_IMAGE, }); baseEnv.OPENCLAW_DOCKER_E2E_IMAGE = process.env.OPENCLAW_DOCKER_E2E_IMAGE || baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE; const writeSummary = (summary: RunSummary) => writeRunSummary(logDir, summary, baseEnv); appendExtension(baseEnv, "matrix"); appendExtension(baseEnv, "acpx"); appendExtension(baseEnv, "codex"); const timingStore = await loadTimingStore(timingsFile, timingsEnabled); const { omittedUnsupportedLaneNames, orderedLanes, orderedTailLanes, plan, scheduledLanes } = resolveDockerE2ePlan({ includeOpenWebUI, liveMode, liveRetries, orderLanes, planReleaseAll: planJson && planReleaseAll, profile, releaseChunk, releaseProfile, selectedLaneNames, timingStore, upgradeSurvivorBaselines: process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS, upgradeSurvivorScenarios: process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, upgradeSurvivorTargetRoot: process.env.OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT, allowFrozenTargetScenarioOmissions, candidatePackageRoot: ROOT_DIR, }); if (omittedUnsupportedLaneNames.length > 0 && !allowFrozenTargetScenarioOmissions) { throw new Error( `frozen target scenario omissions require trusted workflow opt-in: ${omittedUnsupportedLaneNames.join(", ")}`, ); } if (scheduledLanes.length === 0 && omittedUnsupportedLaneNames.length === 0) { throw new Error( [ "resolved zero Docker lanes", `profile=${profile}`, `releaseChunk=${releaseChunk || ""}`, `releaseProfile=${releaseProfile}`, `liveMode=${liveMode}`, `includeOpenWebUI=${includeOpenWebUI ? "1" : "0"}`, `selectedLanes=${selectedLaneNames.length > 0 ? selectedLaneNames.join(",") : ""}`, ].join("; "), ); } const omittedUnsupportedLanes = omittedUnsupportedLaneNames.length > 0 ? omittedUnsupportedLaneNames : undefined; if (planJson) { process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); return; } if (cliOptions.prepareOnly) { await prepareDockerCandidate(plan, logDir, path.resolve(cliOptions.prepareOnly)); return; } await mkdir(logDir, { recursive: true }); console.log(`==> Docker test logs: ${logDir}`); console.log(`==> Profile: ${profile}${releaseChunk ? ` chunk=${releaseChunk}` : ""}`); if (profile === RELEASE_PATH_PROFILE) { console.log(`==> Release profile: ${releaseProfile}`); } console.log(`==> Parallelism: ${parallelism}`); console.log(`==> Tail parallelism: ${tailParallelism}`); console.log(`==> Lane timeout: ${laneTimeoutMs}ms`); console.log(`==> Live mode: ${liveMode}`); console.log(`==> Live retries: ${liveRetries}`); console.log(`==> Lane start stagger: ${laneStartStaggerMs}ms`); console.log(`==> Status interval: ${statusIntervalMs}ms`); console.log(`==> Fail fast: ${failFast ? "yes" : "no"}`); console.log(`==> Dry run: ${dryRun ? "yes" : "no"}`); console.log( `==> Docker preflight: ${preflightEnabled ? "yes" : "no"}${ preflightCleanup ? " cleanup=yes" : " cleanup=no" }`, ); console.log(`==> Build shared Docker images: ${buildEnabled ? "yes" : "no"}`); console.log(`==> Docker E2E bare image: ${baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE}`); console.log(`==> Docker E2E functional image: ${baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}`); if (profile === RELEASE_PATH_PROFILE) { console.log(`==> Include Open WebUI: ${includeOpenWebUI ? "yes" : "no"}`); } if (selectedLaneNames.length > 0) { console.log(`==> Selected lanes: ${selectedLaneNames.join(", ")}`); } console.log(`==> Docker lane timings: ${timingStore.enabled ? timingsFile : "disabled"}`); console.log(`==> Live-test bundled plugins: ${baseEnv.OPENCLAW_DOCKER_BUILD_EXTENSIONS}`); const schedulerOptions = parseSchedulerOptions(process.env, parallelism); const tailSchedulerOptions = parseSchedulerOptions(process.env, tailParallelism); console.log( `==> Scheduler: weight=${schedulerOptions.weightLimit} ${resourceLimitsSummary(schedulerOptions.resourceLimits)}`, ); console.log( `==> Tail scheduler: weight=${tailSchedulerOptions.weightLimit} ${resourceLimitsSummary(tailSchedulerOptions.resourceLimits)}`, ); printLaneManifest("Main", orderedLanes, timingStore); printLaneManifest("Tail", orderedTailLanes, timingStore); if (omittedUnsupportedLanes) { console.log( `==> Docker lanes omitted: target lacks ${omittedUnsupportedLanes.join(", ")} scenario support`, ); } if (dryRun) { console.log("==> Dry run complete"); return; } validateDockerCandidateEnvironment(baseEnv, plan); // Planning can report unsupported scenarios, but execution cannot pass when // frozen-target omissions leave no selected lane to run. if (scheduledLanes.length === 0) { await writeSummary({ chunk: releaseChunk || undefined, failures: [], lanes: [], omittedUnsupportedLanes, phases, profile, selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined, startedAt: runStartedAt, status: "failed", }); throw new Error( `resolved zero runnable Docker lanes; frozen target does not support: ${omittedUnsupportedLaneNames.join(", ")}`, ); } await runPhase( phases, "docker-preflight", { cleanup: preflightCleanup, enabled: preflightEnabled }, async () => { await runDockerPreflight(baseEnv, { cleanup: preflightCleanup, enabled: preflightEnabled, runTimeoutMs: preflightRunTimeoutMs, }); }, ); if (lanesNeedOpenClawPackage(scheduledLanes)) { await runPhase(phases, "prepare-openclaw-package", {}, async () => { await prepareOpenClawPackage(baseEnv, logDir); }); } else { console.log("==> OpenClaw package: not needed for selected lanes"); } if (buildEnabled) { const buildEntries: ForegroundEntry[] = []; if (scheduledLanes.some((poolLane) => poolLane.needsLiveImage)) { buildEntries.push({ command: liveDockerHarnessScriptCommand("test-live-build-docker.sh"), label: "shared live-test image once", phaseDetails: { imageKind: "live" }, phases, }); } if (lanesNeedE2eImageKind(scheduledLanes, "bare")) { buildEntries.push({ command: "pnpm test:docker:e2e-build", env: { OPENCLAW_DOCKER_E2E_IMAGE: baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE, OPENCLAW_DOCKER_E2E_TARGET: "bare", }, label: `shared bare Docker E2E image once: ${baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE}`, phaseDetails: { image: baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE, imageKind: "bare" }, phases, }); } if (lanesNeedE2eImageKind(scheduledLanes, "functional")) { buildEntries.push({ command: "pnpm test:docker:e2e-build", env: { OPENCLAW_DOCKER_E2E_IMAGE: baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE, OPENCLAW_DOCKER_E2E_TARGET: "functional", }, label: `shared functional Docker E2E image once: ${baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}`, phaseDetails: { image: baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE, imageKind: "functional", }, phases, }); } await runForegroundGroup(buildEntries, baseEnv); } else { console.log(`==> Shared Docker image builds: skipped`); } const options = { ...schedulerOptions, failFast, poolLabel: "main", startStaggerMs: laneStartStaggerMs, statusIntervalMs, timeoutMs: laneTimeoutMs, } satisfies LanePoolOptions; const mainResult = await runPhase(phases, "main-lane-pool", { lanes: orderedLanes.length }, () => runLanePool(orderedLanes, baseEnv, logDir, parallelism, options), ); const failures = [...mainResult.failures]; const allResults = [...mainResult.results]; await writeTimingStore(timingStore, mainResult.results); if (failFast && failures.length > 0) { await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, images: { bare: baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE, functional: baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE, }, lanes: allResults, omittedUnsupportedLanes, phases, profile, selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined, startedAt: runStartedAt, status: "failed", }); await printFailureSummary(failures, tailLines); process.exit(1); } if (orderedTailLanes.length > 0) { console.log("==> Running provider-sensitive Docker tail lanes"); const tailResult = await runPhase( phases, "tail-lane-pool", { lanes: orderedTailLanes.length }, () => runLanePool(orderedTailLanes, baseEnv, logDir, tailParallelism, { ...options, ...tailSchedulerOptions, poolLabel: "tail", }), ); failures.push(...tailResult.failures); allResults.push(...tailResult.results); await writeTimingStore(timingStore, tailResult.results); } else { console.log("==> Provider-sensitive Docker tail lanes: none"); } if (failures.length > 0) { await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, images: { bare: baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE, functional: baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE, }, lanes: allResults, omittedUnsupportedLanes, phases, profile, selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined, startedAt: runStartedAt, status: "failed", }); await printFailureSummary(failures, tailLines); process.exit(1); } if (profile === DEFAULT_PROFILE && selectedLaneNames.length === 0) { const cleanupFailure = await runCleanupSmokePhase(baseEnv, logDir, phases); if (cleanupFailure) { failures.push(cleanupFailure); } } else { console.log("==> Cleanup smoke after parallel lanes: skipped for selected/release lanes"); } await writeTimingStore(timingStore, allResults); if (failures.length > 0) { await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, images: { bare: baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE, functional: baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE, }, lanes: allResults, omittedUnsupportedLanes, phases, profile, selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined, startedAt: runStartedAt, status: "failed", }); await printFailureSummary(failures, tailLines); process.exit(1); } await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, images: { bare: baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE, functional: baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE, }, lanes: allResults, omittedUnsupportedLanes, phases, profile, selectedLanes: selectedLaneNames.length > 0 ? selectedLaneNames : undefined, startedAt: runStartedAt, status: "passed", }); console.log("==> Docker test suite passed"); } if (IS_MAIN) { await main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); }