diff --git a/test/e2e/fixtures/docker-probe.ts b/test/e2e/fixtures/docker-probe.ts index f3356e49ad9..66df9b60367 100644 --- a/test/e2e/fixtures/docker-probe.ts +++ b/test/e2e/fixtures/docker-probe.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { - spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, + spawnSync, } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -149,3 +149,29 @@ export class DockerProbe { return result; } } + +export class DockerPrerequisite { + constructor( + private readonly probe: DockerProbe, + private readonly skip: (reason: string) => never, + private readonly isCi = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true", + ) {} + + probeDocker(): Promise { + return this.probe.run(["info"], { artifactName: "docker-info" }); + } + + async requireDocker(): Promise { + const result = await this.probeDocker(); + if (result.exitCode === 0) return result; + const message = `Docker is required for this live E2E target:\n${resultText(result)}`; + if (this.isCi) throw new Error(message); + return this.skip(message); + } + + async expectMissingDocker(): Promise { + const result = await this.probeDocker(); + if (result.exitCode !== 0) return result; + throw new Error("Docker was expected to be unavailable for this E2E target"); + } +} diff --git a/test/e2e/fixtures/e2e-test.ts b/test/e2e/fixtures/e2e-test.ts index 0ebd05b425c..0fa65bd87ef 100644 --- a/test/e2e/fixtures/e2e-test.ts +++ b/test/e2e/fixtures/e2e-test.ts @@ -12,6 +12,7 @@ import { SandboxClient, StateClient, } from "./clients/index.ts"; +import { DockerPrerequisite, DockerProbe } from "./docker-probe.ts"; import { EnvironmentPhaseFixture, LifecyclePhaseFixture, @@ -26,6 +27,7 @@ export interface E2ETargetFixtures { artifacts: ArtifactSink; cleanup: CleanupRegistry; secrets: SecretStore; + docker: DockerPrerequisite; shellProbe: ShellProbe; host: HostCliClient; gateway: GatewayClient; @@ -55,6 +57,10 @@ export const test = base.extend({ }); } }, + docker: async ({ artifacts, secrets, skip }, use) => { + const probe = new DockerProbe(artifacts, (text, extra) => secrets.redact(text, extra)); + await use(new DockerPrerequisite(probe, skip)); + }, cleanup: async ({ artifacts, secrets }, use) => { const cleanup = new CleanupRegistry((text) => secrets.redact(text)); try { diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index d0472fa7657..8ae78216f36 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -601,7 +601,7 @@ async function assertGatewayRecovery( liveTest( "sandbox operations preserve list/status/logs/recovery/multi-sandbox contracts", - async ({ artifacts, cleanup, environment, host, sandbox, secrets, skip }) => { + async ({ artifacts, cleanup, docker, environment, host, sandbox, secrets }) => { const hosted = requireHostedInferenceConfig(secrets); await artifacts.writeJson("target.json", { @@ -625,17 +625,7 @@ liveTest( ], }); - const docker = await host.command("docker", ["info"], { - artifactName: "prereq-docker-info-sandbox-operations", - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }); - if (docker.exitCode !== 0) { - if (process.env.GITHUB_ACTIONS === "true") { - throw new Error(`Docker is required for sandbox operations E2E: ${resultText(docker)}`); - } - skip("Docker is required for sandbox operations E2E"); - } + await docker.requireDocker(); await environment.assertReady(ENVIRONMENT); cleanup.add("remove shared NemoClaw gateway registration", () => diff --git a/test/e2e/support/e2e-docker-prerequisite.test.ts b/test/e2e/support/e2e-docker-prerequisite.test.ts new file mode 100644 index 00000000000..050ceb59179 --- /dev/null +++ b/test/e2e/support/e2e-docker-prerequisite.test.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { ArtifactSink } from "../fixtures/artifacts.ts"; +import { DockerPrerequisite, DockerProbe } from "../fixtures/docker-probe.ts"; + +function prerequisite( + exitCode: number, + isCi: boolean, + skip = vi.fn((): never => { + throw new Error("skipped"); + }), +) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-prerequisite-")); + const probe = new DockerProbe( + new ArtifactSink(root), + (text) => text, + () => ({ + pid: 1, + output: [null, "", exitCode === 0 ? "" : "daemon unavailable"], + stdout: "", + stderr: exitCode === 0 ? "" : "daemon unavailable", + status: exitCode, + signal: null, + }), + ); + return { docker: new DockerPrerequisite(probe, skip, isCi), root, skip }; +} + +describe("Docker prerequisite", () => { + it("returns available and optional probe results with artifacts", async () => { + const { docker, root } = prerequisite(0, false); + try { + expect((await docker.probeDocker()).exitCode).toBe(0); + expect((await docker.requireDocker()).exitCode).toBe(0); + expect(fs.readdirSync(path.join(root, "docker")).length).toBe(6); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("skips locally but fails in CI when Docker is required", async () => { + const local = prerequisite(1, false); + const ci = prerequisite(1, true); + try { + await expect(local.docker.requireDocker()).rejects.toThrow("skipped"); + expect(local.skip).toHaveBeenCalledWith(expect.stringContaining("Docker is required")); + await expect(ci.docker.requireDocker()).rejects.toThrow(/daemon unavailable/); + } finally { + fs.rmSync(local.root, { recursive: true, force: true }); + fs.rmSync(ci.root, { recursive: true, force: true }); + } + }); + + it("supports intentionally missing Docker", async () => { + const missing = prerequisite(1, false); + const available = prerequisite(0, false); + try { + expect((await missing.docker.expectMissingDocker()).exitCode).toBe(1); + await expect(available.docker.expectMissingDocker()).rejects.toThrow( + /expected to be unavailable/, + ); + } finally { + fs.rmSync(missing.root, { recursive: true, force: true }); + fs.rmSync(available.root, { recursive: true, force: true }); + } + }); +});