Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion test/e2e/fixtures/docker-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<DockerCommandResult> {
return this.probe.run(["info"], { artifactName: "docker-info" });
}

async requireDocker(): Promise<DockerCommandResult> {
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<DockerCommandResult> {
const result = await this.probeDocker();
if (result.exitCode !== 0) return result;
throw new Error("Docker was expected to be unavailable for this E2E target");
}
}
6 changes: 6 additions & 0 deletions test/e2e/fixtures/e2e-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SandboxClient,
StateClient,
} from "./clients/index.ts";
import { DockerPrerequisite, DockerProbe } from "./docker-probe.ts";
import {
EnvironmentPhaseFixture,
LifecyclePhaseFixture,
Expand All @@ -26,6 +27,7 @@ export interface E2ETargetFixtures {
artifacts: ArtifactSink;
cleanup: CleanupRegistry;
secrets: SecretStore;
docker: DockerPrerequisite;
shellProbe: ShellProbe;
host: HostCliClient;
gateway: GatewayClient;
Expand Down Expand Up @@ -55,6 +57,10 @@ export const test = base.extend<E2ETargetFixtures>({
});
}
},
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 {
Expand Down
14 changes: 2 additions & 12 deletions test/e2e/live/sandbox-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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", () =>
Expand Down
74 changes: 74 additions & 0 deletions test/e2e/support/e2e-docker-prerequisite.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});