From 177bb4aa85e85e0dbb8f1bf030a3b7db0d589a06 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 18:57:37 -0700 Subject: [PATCH 1/9] test(vitest): add owned resource fixtures Signed-off-by: Carlos Villela --- test/cli/debug-command.test.ts | 59 ++-- test/cli/doctor-gateway-token.test.ts | 227 +++++++------- test/cli/helpers.test.ts | 86 ++++- test/cli/helpers.ts | 70 +++-- test/cli/logs.test.ts | 71 +++-- test/cli/sandbox-mutations.test.ts | 113 ++++--- test/cli/snapshot-shields.test.ts | 71 ++--- test/helpers/child-process-lifecycle.ts | 69 ++++ test/helpers/owned-test-resources.ts | 152 +++++++++ test/hermes-tool-gateway-broker.test.ts | 365 +++++++++++----------- test/ollama-auth-proxy-handler-helpers.ts | 96 +++--- test/ollama-auth-proxy-handler.test.ts | 60 +++- test/owned-test-resources.test.ts | 138 ++++++++ 13 files changed, 1067 insertions(+), 510 deletions(-) create mode 100644 test/helpers/child-process-lifecycle.ts create mode 100644 test/helpers/owned-test-resources.ts create mode 100644 test/owned-test-resources.test.ts diff --git a/test/cli/debug-command.test.ts b/test/cli/debug-command.test.ts index 9547f730b74..5d833fb6688 100644 --- a/test/cli/debug-command.test.ts +++ b/test/cli/debug-command.test.ts @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, test as it } from "../helpers/owned-test-resources"; + import { createDebugCommandTestEnv, run, @@ -23,24 +24,28 @@ describe("CLI debug command", () => { expect(r.out.includes("--output")).toBeTruthy(); }); - it("debug --quick exits 0 and produces diagnostic output", testTimeoutOptions(30_000), () => { - const r = runWithEnv( - "debug --quick", - createDebugCommandTestEnv("nemoclaw-cli-debug-quick-"), - 30000, - ); - expect(r.code).toBe(0); - expect(r.out.includes("Collecting diagnostics")).toBeTruthy(); - expect(r.out.includes("System")).toBeTruthy(); - expect(r.out.includes("Onboard Session")).toBeTruthy(); - expect(r.out.includes("Done")).toBeTruthy(); - }); + it( + "debug --quick exits 0 and produces diagnostic output", + testTimeoutOptions(30_000), + ({ resources }) => { + const r = runWithEnv( + "debug --quick", + createDebugCommandTestEnv(resources, "nemoclaw-cli-debug-quick-"), + 30000, + ); + expect(r.code).toBe(0); + expect(r.out.includes("Collecting diagnostics")).toBeTruthy(); + expect(r.out.includes("System")).toBeTruthy(); + expect(r.out.includes("Onboard Session")).toBeTruthy(); + expect(r.out.includes("Done")).toBeTruthy(); + }, + ); it.skipIf(os.platform() !== "linux")( "debug --quick explains restricted dmesg instead of printing raw stderr on Linux", testTimeoutOptions(30_000), - () => { - const env = createDebugCommandTestEnv("nemoclaw-cli-debug-dmesg-"); + ({ resources }) => { + const env = createDebugCommandTestEnv(resources, "nemoclaw-cli-debug-dmesg-"); const localBin = env.PATH?.split(path.delimiter)[0]; if (!localBin) throw new Error("Expected debug test PATH to include a fake bin dir"); fs.writeFileSync( @@ -82,15 +87,21 @@ describe("CLI debug command", () => { expect(r.out.includes("nemoclaw debug")).toBeTruthy(); }); - it("debug --sandbox NAME targets the specified sandbox", testTimeoutOptions(30_000), () => { - const r = runWithEnv( - "debug --quick --sandbox mybox", - createDebugCommandTestEnv("nemoclaw-cli-debug-sandbox-", { extraSandboxNames: ["mybox"] }), - 30000, - ); - expect(r.code).toBe(0); - expect(r.out).toContain("Collecting diagnostics for sandbox 'mybox'"); - }); + it( + "debug --sandbox NAME targets the specified sandbox", + testTimeoutOptions(30_000), + ({ resources }) => { + const r = runWithEnv( + "debug --quick --sandbox mybox", + createDebugCommandTestEnv(resources, "nemoclaw-cli-debug-sandbox-", { + extraSandboxNames: ["mybox"], + }), + 30000, + ); + expect(r.code).toBe(0); + expect(r.out).toContain("Collecting diagnostics for sandbox 'mybox'"); + }, + ); it("debug --sandbox NAME rejects an unregistered name and exits non-zero", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-debug-unknown-")); diff --git a/test/cli/doctor-gateway-token.test.ts b/test/cli/doctor-gateway-token.test.ts index 26a539286c8..37e87b457f6 100644 --- a/test/cli/doctor-gateway-token.test.ts +++ b/test/cli/doctor-gateway-token.test.ts @@ -3,9 +3,9 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; + +import { describe, expect, test as it } from "../helpers/owned-test-resources"; import { createCloudflaredServiceDir, @@ -89,8 +89,8 @@ function parseJsonPrefix(output: string): T { } describe("CLI dispatch", () => { - it("gateway-token help uses native oclif usage", testTimeoutOptions(15_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-token-help-")); + it("gateway-token help uses native oclif usage", testTimeoutOptions(15_000), ({ resources }) => { + const { home } = resources.home("nemoclaw-cli-token-help-"); writeSandboxRegistry(home); const r = runWithEnv("alpha gateway-token --help", { HOME: home }); @@ -103,36 +103,40 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Hermes' API_SERVER_KEY"); }); - it("doctor fails a present sandbox that is not Ready", testTimeoutOptions(15_000), () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-not-ready-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Creating\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); + it( + "doctor fails a present sandbox that is not Ready", + testTimeoutOptions(15_000), + ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-not-ready-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") printf "NAME STATUS\\nalpha Creating\\n"; exit 0 ;;', + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ]); - const r = setup.runDoctor(); + const r = setup.runDoctor(); - expect(r.code).toBe(1); - const report = JSON.parse(r.out) as { - checks: Array<{ label: string; status: string; detail: string }>; - }; - const liveSandbox = report.checks.find((check) => check.label === "Live sandbox"); - expect(liveSandbox).toEqual( - expect.objectContaining({ - status: "fail", - detail: expect.stringContaining("Creating"), - }), - ); - }); + expect(r.code).toBe(1); + const report = JSON.parse(r.out) as { + checks: Array<{ label: string; status: string; detail: string }>; + }; + const liveSandbox = report.checks.find((check) => check.label === "Live sandbox"); + expect(liveSandbox).toEqual( + expect.objectContaining({ + status: "fail", + detail: expect.stringContaining("Creating"), + }), + ); + }, + ); it( "doctor does not inspect the legacy k3s gateway container in Docker-driver mode", testTimeoutOptions(15_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-docker-driver-", [ + ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-docker-driver-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', @@ -195,8 +199,8 @@ describe("CLI dispatch", () => { }, ); - it("doctor checks the sandbox persisted non-default gateway", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-named-gateway-", [ + it("doctor checks the sandbox persisted non-default gateway", ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-named-gateway-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw-8090\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw-8090") printf "Gateway: nemoclaw-8090\\n"; exit 0 ;;', @@ -232,8 +236,8 @@ describe("CLI dispatch", () => { it( "doctor still inspects the legacy k3s gateway container for the kubernetes driver", testTimeoutOptions(15_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-k8s-driver-", [ + ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-k8s-driver-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', @@ -262,8 +266,8 @@ describe("CLI dispatch", () => { it( "doctor accepts a local openshell-gateway process when legacy inspect fails", testTimeoutOptions(15_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-local-gateway-", [ + ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-local-gateway-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', @@ -310,16 +314,20 @@ describe("CLI dispatch", () => { it( "doctor treats local gateway process evidence as informational until OpenShell verifies the named gateway", testTimeoutOptions(15_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-unverified-local-gateway-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "gateway select nemoclaw") exit 1 ;;', - ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', - ' "sandbox list") echo "should not query sandbox list" >> "$marker_file"; exit 0 ;;', - "esac", - ]); + ({ resources }) => { + const setup = createDoctorTestSetup( + resources, + "nemoclaw-cli-doctor-unverified-local-gateway-", + [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "gateway select nemoclaw") exit 1 ;;', + ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', + ' "sandbox list") echo "should not query sandbox list" >> "$marker_file"; exit 0 ;;', + "esac", + ], + ); writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); const hostCalls = path.join(setup.home, "host-calls"); writeDockerInspectFailureStub(setup, hostCalls); @@ -348,15 +356,19 @@ describe("CLI dispatch", () => { it( "doctor reports unavailable local gateway probe tools while trusting a verified named gateway", testTimeoutOptions(15_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-missing-local-probe-tools-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); + ({ resources }) => { + const setup = createDoctorTestSetup( + resources, + "nemoclaw-cli-doctor-missing-local-probe-tools-", + [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ], + ); writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); const hostCalls = path.join(setup.home, "host-calls"); writeDockerInspectFailureStub(setup, hostCalls); @@ -389,8 +401,8 @@ describe("CLI dispatch", () => { it( "doctor reports fresh shields state as not configured instead of down", testTimeoutOptions(30_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-shields-default-", [ + ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-shields-default-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', @@ -418,8 +430,8 @@ describe("CLI dispatch", () => { it( "doctor does not query sandbox state from a different active gateway", testTimeoutOptions(15_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-wrong-gateway-", [ + ({ resources }) => { + const setup = createDoctorTestSetup(resources, "nemoclaw-cli-doctor-wrong-gateway-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', @@ -438,29 +450,35 @@ describe("CLI dispatch", () => { }, ); - it("doctor treats a live non-cloudflared PID as stale", testTimeoutOptions(15_000), () => { - const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorpid-"); - const setup = createDoctorTestSetup( - "nemoclaw-cli-doctor-wrong-cloudflared-pid-", - [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` "sandbox list") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ], - sandboxName, - ); - const sleeper = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { - stdio: "ignore", - }); - const sleeperPid = sleeper.pid; - if (typeof sleeperPid !== "number") { - throw new Error("expected spawned helper process to have a PID"); - } + it( + "doctor treats a live non-cloudflared PID as stale", + testTimeoutOptions(15_000), + ({ resources }) => { + const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorpid-"); + resources.ownDirectory(serviceDir); + const setup = createDoctorTestSetup( + resources, + "nemoclaw-cli-doctor-wrong-cloudflared-pid-", + [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` "sandbox list") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ], + sandboxName, + ); + const sleeper = resources.ownChild( + spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { + stdio: "ignore", + }), + ); + const sleeperPid = sleeper.pid; + if (typeof sleeperPid !== "number") { + throw new Error("expected spawned helper process to have a PID"); + } - try { fs.writeFileSync(path.join(serviceDir, "cloudflared.pid"), String(sleeperPid)); const r = setup.runDoctor(`${sandboxName} doctor --json`); @@ -474,15 +492,14 @@ describe("CLI dispatch", () => { detail: `stale PID ${sleeperPid}`, }), ); - } finally { - sleeper.kill(); - fs.rmSync(serviceDir, { recursive: true, force: true }); - } - }); + }, + ); - it("doctor accepts a live cloudflared PID", testTimeoutOptions(35_000), () => { + it("doctor accepts a live cloudflared PID", testTimeoutOptions(35_000), ({ resources }) => { const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorcloudflared-"); + resources.ownDirectory(serviceDir); const setup = createDoctorTestSetup( + resources, "nemoclaw-cli-doctor-cloudflared-pid-", [ 'case "$*" in', @@ -494,35 +511,31 @@ describe("CLI dispatch", () => { ], sandboxName, ); - const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cloudflared-shim-")); + const shimDir = resources.temporaryDirectory("nemoclaw-cloudflared-shim-"); const cloudflaredBin = path.join(shimDir, "cloudflared"); fs.symlinkSync(process.execPath, cloudflaredBin); - const sleeper = spawn(cloudflaredBin, ["-e", "setTimeout(() => {}, 30000)"], { - stdio: "ignore", - }); + const sleeper = resources.ownChild( + spawn(cloudflaredBin, ["-e", "setTimeout(() => {}, 30000)"], { + stdio: "ignore", + }), + ); const sleeperPid = sleeper.pid; if (typeof sleeperPid !== "number") { throw new Error("expected spawned helper process to have a PID"); } - try { - fs.writeFileSync(path.join(serviceDir, "cloudflared.pid"), String(sleeperPid)); - const r = setup.runDoctor(`${sandboxName} doctor --json`); + fs.writeFileSync(path.join(serviceDir, "cloudflared.pid"), String(sleeperPid)); + const r = setup.runDoctor(`${sandboxName} doctor --json`); - const report = JSON.parse(r.out) as { - checks: Array<{ label: string; status: string; detail: string }>; - }; - const cloudflared = report.checks.find((check) => check.label === "cloudflared"); - expect(cloudflared).toEqual( - expect.objectContaining({ - status: "ok", - detail: `running (PID ${sleeperPid})`, - }), - ); - } finally { - sleeper.kill(); - fs.rmSync(serviceDir, { recursive: true, force: true }); - fs.rmSync(shimDir, { recursive: true, force: true }); - } + const report = JSON.parse(r.out) as { + checks: Array<{ label: string; status: string; detail: string }>; + }; + const cloudflared = report.checks.find((check) => check.label === "cloudflared"); + expect(cloudflared).toEqual( + expect.objectContaining({ + status: "ok", + detail: `running (PID ${sleeperPid})`, + }), + ); }); }); diff --git a/test/cli/helpers.test.ts b/test/cli/helpers.test.ts index b174056f243..212d7cb2dd2 100644 --- a/test/cli/helpers.test.ts +++ b/test/cli/helpers.test.ts @@ -6,13 +6,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { nodeOptionsWithoutSourceLoader, SOURCE_REQUIRE_HOOK, sourceLoaderNodeOptions, } from "../helpers/source-loader-options"; +import { testTimeoutOptions } from "../helpers/timeouts"; import { runWithEnv } from "./helpers"; const tempDirs = new Set(); @@ -151,6 +152,89 @@ describe("source-loader Node options", () => { expect(JSON.parse(fs.readFileSync(marker, "utf8"))).toEqual({ hasTypeScriptHook: true }); }); + it("removes the implicit CLI HOME after a synchronous invocation", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-owned-home-")); + tempDirs.add(directory); + const marker = path.join(directory, "home.txt"); + const preload = path.join(directory, "record-home.cjs"); + fs.writeFileSync( + preload, + `require("node:fs").writeFileSync(${JSON.stringify(marker)}, process.env.HOME ?? "");`, + ); + + const result = runWithEnv("--version", { + NODE_OPTIONS: `${sourceLoaderNodeOptions(undefined)} --require=${preload}`, + }); + const implicitHome = fs.readFileSync(marker, "utf8"); + + expect(result.code).toBe(0); + expect(path.isAbsolute(implicitHome)).toBe(true); + expect(fs.existsSync(implicitHome)).toBe(false); + }); + + it("removes the implicit CLI HOME after a failed invocation", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-failed-home-")); + tempDirs.add(directory); + const marker = path.join(directory, "home.txt"); + const preload = path.join(directory, "record-home.cjs"); + fs.writeFileSync( + preload, + `require("node:fs").writeFileSync(${JSON.stringify(marker)}, process.env.HOME ?? "");`, + ); + + const result = runWithEnv("not-a-command", { + NODE_OPTIONS: `${sourceLoaderNodeOptions(undefined)} --require=${preload}`, + }); + const implicitHome = fs.readFileSync(marker, "utf8"); + + expect(result.code).not.toBe(0); + expect(fs.existsSync(implicitHome)).toBe(false); + }); + + it( + "removes the implicit CLI HOME after a timed-out invocation", + testTimeoutOptions(10_000), + () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-timeout-home-")); + tempDirs.add(directory); + const marker = path.join(directory, "home.txt"); + const preload = path.join(directory, "record-home-and-wait.cjs"); + fs.writeFileSync( + preload, + [ + `require("node:fs").writeFileSync(${JSON.stringify(marker)}, process.env.HOME ?? "");`, + "setInterval(() => {}, 1000);", + ].join("\n"), + ); + + const result = runWithEnv( + "--version", + { NODE_OPTIONS: `${sourceLoaderNodeOptions(undefined)} --require=${preload}` }, + 2_000, + ); + const implicitHome = fs.readFileSync(marker, "utf8"); + + expect(result.code).not.toBe(0); + expect(result.out).toContain("ETIMEDOUT"); + expect(fs.existsSync(implicitHome)).toBe(false); + }, + ); + + it("uses an explicit CLI HOME without allocating a hidden one", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-explicit-home-")); + tempDirs.add(directory); + const mkdtemp = vi.spyOn(fs, "mkdtempSync"); + try { + const result = runWithEnv("--version", { HOME: directory }); + + expect(result.code).toBe(0); + expect(mkdtemp).not.toHaveBeenCalled(); + expect(fs.existsSync(directory)).toBe(true); + } finally { + mkdtemp.mockRestore(); + } + }); + it("quotes preload paths that contain spaces for Node (#6245)", () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw node options ")); tempDirs.add(directory); diff --git a/test/cli/helpers.ts b/test/cli/helpers.ts index d8a6efe76ea..2c946f67546 100644 --- a/test/cli/helpers.ts +++ b/test/cli/helpers.ts @@ -8,6 +8,7 @@ import os from "node:os"; import path from "node:path"; import { parse as parseYaml } from "yaml"; +import type { OwnedTestResources } from "../helpers/owned-test-resources"; import { execTimeout, testTimeout, testTimeoutOptions } from "../helpers/timeouts"; export { execTimeout, testTimeout, testTimeoutOptions }; @@ -180,32 +181,39 @@ function runWithEnvInternal( const parsedArgs = splitCliArgs(args); const mergeStderrOnSuccess = parsedArgs.includes("2>&1"); const cliArgs = parsedArgs.filter((token) => token !== "2>&1"); - const result = spawnSync(process.execPath, [CLI, ...cliArgs], { - encoding: "utf-8", - input, - stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], - timeout, - env: { - ...process.env, - HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")), - NEMOCLAW_HEALTH_POLL_COUNT: "1", - NEMOCLAW_HEALTH_POLL_INTERVAL: "0", - // #4710: the post-recovery settle-confirm waits 25s by default; CLI - // tests disable it to stay fast. Settle behavior has dedicated - // coverage in process-recovery.test.ts and a targeted CLI test in - // connect-recovery-settle.test.ts that overrides this with a short window. - NEMOCLAW_GATEWAY_RECOVERY_SETTLE_SECONDS: "0", - ...env, - }, - }); - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - const errorOutput = result.error ? String(result.error) : ""; - const code = typeof result.status === "number" ? result.status : 1; - if (code === 0) { - return { code, out: mergeStderrOnSuccess ? `${stdout}${stderr}` : stdout }; + const implicitHome = Object.hasOwn(env, "HOME") + ? null + : fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")); + try { + const result = spawnSync(process.execPath, [CLI, ...cliArgs], { + encoding: "utf-8", + input, + stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + timeout, + env: { + ...process.env, + ...(implicitHome ? { HOME: implicitHome } : {}), + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + // #4710: the post-recovery settle-confirm waits 25s by default; CLI + // tests disable it to stay fast. Settle behavior has dedicated + // coverage in process-recovery.test.ts and a targeted CLI test in + // connect-recovery-settle.test.ts that overrides this with a short window. + NEMOCLAW_GATEWAY_RECOVERY_SETTLE_SECONDS: "0", + ...env, + }, + }); + const stdout = result.stdout || ""; + const stderr = result.stderr || ""; + const errorOutput = result.error ? String(result.error) : ""; + const code = typeof result.status === "number" ? result.status : 1; + if (code === 0) { + return { code, out: mergeStderrOnSuccess ? `${stdout}${stderr}` : stdout }; + } + return { code, out: `${stdout}${stderr}${errorOutput}` }; + } finally { + if (implicitHome) fs.rmSync(implicitHome, { force: true, recursive: true }); } - return { code, out: `${stdout}${stderr}${errorOutput}` }; } export function readRecordedArgs(markerFile: string): string[] { @@ -303,12 +311,12 @@ type LogsTestSetupOptions = { }; export function createLogsTestSetup( + resources: OwnedTestResources, prefix: string, openshellLines: string[] = [], options: LogsTestSetupOptions = {}, ) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const localBin = path.join(home, "bin"); + const { home, bin: localBin } = resources.home(prefix); const markerFile = path.join(home, "logs-calls"); const gatewayStartedLines = options.gatewayStartedMarker ? [` printf '%s\\n' ${JSON.stringify(options.gatewayStartedMarker)} >> "$marker_file"`] @@ -357,12 +365,12 @@ export function createLogsTestSetup( } export function createDoctorTestSetup( + resources: OwnedTestResources, prefix: string, openshellLines: string[], sandboxName = "alpha", ) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const localBin = path.join(home, "bin"); + const { home, bin: localBin } = resources.home(prefix); const markerFile = path.join(home, "doctor-calls"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home, sandboxName); @@ -431,11 +439,11 @@ export function createCloudflaredServiceDir(prefix: string): { } export function createDebugCommandTestEnv( + resources: OwnedTestResources, prefix: string, options: { extraSandboxNames?: string[] } = {}, ): Record { - const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const localBin = path.join(home, "bin"); + const { home, bin: localBin } = resources.home(prefix); const sandboxName = `${prefix}${process.pid.toString(36)}-${Date.now().toString(36)}`; fs.mkdirSync(localBin, { recursive: true }); // Register the env-sourced sandbox plus any extra names supplied via the diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index 29c15d3bcaf..8885a2fd01d 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -5,7 +5,8 @@ import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; + +import { describe, expect, test as it } from "../helpers/owned-test-resources"; import { CLI, @@ -22,8 +23,8 @@ import { } from "./helpers"; describe("CLI dispatch", () => { - it("routes logs to OpenClaw and OpenShell log sources", () => { - const setup = createLogsTestSetup("nemoclaw-cli-logs-routing-"); + it("routes logs to OpenClaw and OpenShell log sources", ({ resources }) => { + const setup = createLogsTestSetup(resources, "nemoclaw-cli-logs-routing-"); const r = setup.runLogs(); const calls = setup.readCalls(); @@ -37,8 +38,8 @@ describe("CLI dispatch", () => { expect(r.out).toContain(FAKE_OPENSHELL_LOG_LINE); }); - it("shows logs help without calling OpenShell", () => { - const setup = createLogsTestSetup("nemoclaw-cli-logs-help-"); + it("shows logs help without calling OpenShell", ({ resources }) => { + const setup = createLogsTestSetup(resources, "nemoclaw-cli-logs-help-"); const r = setup.runLogs("alpha logs --help"); expect(r.code).toBe(0); @@ -49,8 +50,8 @@ describe("CLI dispatch", () => { expect(setup.readCalls()).toEqual([]); }); - it("rejects unknown logs flags before calling OpenShell", () => { - const setup = createLogsTestSetup("nemoclaw-cli-logs-unknown-"); + it("rejects unknown logs flags before calling OpenShell", ({ resources }) => { + const setup = createLogsTestSetup(resources, "nemoclaw-cli-logs-unknown-"); const r = setup.runLogs("alpha logs --bogus 2>&1"); expect(r.code).not.toBe(0); @@ -58,8 +59,10 @@ describe("CLI dispatch", () => { expect(setup.readCalls()).toEqual([]); }); - it("continues to OpenShell logs when the OpenClaw gateway log probe times out", () => { - const setup = createLogsTestSetup("nemoclaw-cli-logs-openclaw-timeout-", [ + it("continues to OpenShell logs when the OpenClaw gateway log probe times out", ({ + resources, + }) => { + const setup = createLogsTestSetup(resources, "nemoclaw-cli-logs-openclaw-timeout-", [ 'if [ "$1" = "sandbox" ]; then', " while true; do :; done", "fi", @@ -73,8 +76,8 @@ describe("CLI dispatch", () => { expect(r.out).toContain(FAKE_OPENSHELL_LOG_LINE); }); - it("maps --follow to OpenShell live log streaming", () => { - const setup = createLogsTestSetup("nemoclaw-cli-logs-follow-"); + it("maps --follow to OpenShell live log streaming", ({ resources }) => { + const setup = createLogsTestSetup(resources, "nemoclaw-cli-logs-follow-"); const r = setup.runLogs("alpha logs --follow"); const calls = setup.readCalls(); @@ -86,10 +89,11 @@ describe("CLI dispatch", () => { expect(r.out).toContain(FAKE_OPENSHELL_LOG_LINE); }); - it("starts OpenClaw logs before enabling audit logs for logs --follow", () => { + it("starts OpenClaw logs before enabling audit logs for logs --follow", ({ resources }) => { const gatewayStartedMarker = "gateway-started"; const auditCompleteMarker = "audit-enabled"; const setup = createLogsTestSetup( + resources, "nemoclaw-cli-logs-follow-audit-slow-", [ 'if [ "$1" = "settings" ]; then', @@ -126,12 +130,10 @@ describe("CLI dispatch", () => { it( "keeps logs --follow running when one log source exits", testTimeoutOptions(10_000), - async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-source-exit-")); - const localBin = path.join(home, "bin"); + async ({ resources }) => { + const { home, bin: localBin } = resources.home("nemoclaw-cli-logs-follow-source-exit-"); const registryDir = path.join(home, ".nemoclaw"); const markerFile = path.join(home, "logs-follow-source-exit-args"); - fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(registryDir, { recursive: true }); writeHealthyDockerStub(localBin); fs.writeFileSync( @@ -171,11 +173,13 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); - const child = spawn(process.execPath, [CLI, "alpha", "logs", "--follow"], { - cwd: path.join(import.meta.dirname, ".."), - env: { ...process.env, HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, - stdio: "ignore", - }); + const child = resources.ownChild( + spawn(process.execPath, [CLI, "alpha", "logs", "--follow"], { + cwd: path.join(import.meta.dirname, ".."), + env: { ...process.env, HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, + stdio: "ignore", + }), + ); const exitPromise = waitForChildExit(child); const readCalls = () => fs.existsSync(markerFile) ? fs.readFileSync(markerFile, "utf8").trim().split(/\n/) : []; @@ -198,11 +202,13 @@ describe("CLI dispatch", () => { expect(isChildRunning(child)).toBe(true); expect(calls).toContain("logs alpha -n 200 --source all --tail"); expect(calls).toContain("sandbox exec -n alpha -- tail -n 200 -f /tmp/gateway.log"); + child.kill("SIGTERM"); + expect(await exitPromise).toBe(143); } finally { if (isChildRunning(child)) { - child.kill("SIGTERM"); + child.kill("SIGKILL"); } - expect(await exitPromise).toBe(143); + await exitPromise; } }, ); @@ -210,12 +216,10 @@ describe("CLI dispatch", () => { it( "waits for logs --follow children to stop after SIGTERM", testTimeoutOptions(10_000), - async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-sigterm-wait-")); - const localBin = path.join(home, "bin"); + async ({ resources }) => { + const { home, bin: localBin } = resources.home("nemoclaw-cli-logs-follow-sigterm-wait-"); const markerFile = path.join(home, "logs-follow-sigterm-wait-args"); const releaseFile = path.join(home, "release-log-children"); - fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); writeHealthyDockerStub(localBin); fs.writeFileSync( @@ -237,11 +241,13 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); - const child = spawn(process.execPath, [CLI, "alpha", "logs", "--follow"], { - cwd: path.join(import.meta.dirname, ".."), - env: { ...process.env, HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, - stdio: "ignore", - }); + const child = resources.ownChild( + spawn(process.execPath, [CLI, "alpha", "logs", "--follow"], { + cwd: path.join(import.meta.dirname, ".."), + env: { ...process.env, HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, + stdio: "ignore", + }), + ); let hasExited = false; const exitPromise = waitForChildExit(child).then((code) => { hasExited = true; @@ -289,6 +295,7 @@ describe("CLI dispatch", () => { if (isChildRunning(child)) { child.kill("SIGKILL"); } + await exitPromise; } }, ); diff --git a/test/cli/sandbox-mutations.test.ts b/test/cli/sandbox-mutations.test.ts index b6f98d4468e..4b7faaca62c 100644 --- a/test/cli/sandbox-mutations.test.ts +++ b/test/cli/sandbox-mutations.test.ts @@ -2,9 +2,9 @@ // 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 } from "vitest"; + +import { describe, expect, test as it } from "../helpers/owned-test-resources"; import { runWithEnv, runWithInput, testTimeoutOptions, writeSandboxRegistry } from "./helpers"; @@ -50,11 +50,11 @@ function writePolicyMutationOpenshellStub(home: string): string { } describe("CLI dispatch", () => { - it("connect help uses native oclif usage through the public sandbox route", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inspection-help-")); + it("connect help uses native oclif usage through the public sandbox route", ({ testHome }) => { + const { home } = testHome; writeSandboxRegistry(home); - const connect = runWithEnv("alpha connect --help", { HOME: home }); + const connect = runWithEnv("alpha connect --help", testHome.environment()); expect(connect.code).toBe(0); expect(connect.out).toContain("Usage: nemoclaw alpha connect"); @@ -64,99 +64,112 @@ describe("CLI dispatch", () => { it( "keeps public compatibility help routes for sandbox command families", testTimeoutOptions(30_000), - () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-family-help-")); + ({ testHome }) => { + const { home } = testHome; writeSandboxRegistry(home); - const logs = runWithEnv("alpha logs --help", { HOME: home }); + const logs = runWithEnv("alpha logs --help", testHome.environment()); expect(logs.code).toBe(0); expect(logs.out).toContain("$ nemoclaw sandbox logs "); expect(logs.out).toContain("--tail"); - const policy = runWithEnv("alpha policy-add --help", { HOME: home }); + const policy = runWithEnv("alpha policy-add --help", testHome.environment()); expect(policy.code).toBe(0); expect(policy.out).toContain("$ nemoclaw sandbox policy add "); - const hosts = runWithEnv("alpha hosts-add --help", { HOME: home }); + const hosts = runWithEnv("alpha hosts-add --help", testHome.environment()); expect(hosts.code).toBe(0); expect(hosts.out).toContain("$ nemoclaw sandbox hosts add "); - const channels = runWithEnv("alpha channels add --help", { HOME: home }); + const channels = runWithEnv("alpha channels add --help", testHome.environment()); expect(channels.code).toBe(0); expect(channels.out).toContain("$ nemoclaw sandbox channels add "); - const config = runWithEnv("alpha config get --help", { HOME: home }); + const config = runWithEnv("alpha config get --help", testHome.environment()); expect(config.code).toBe(0); expect(config.out).toContain("$ nemoclaw sandbox config get "); expect(config.out).toContain("--format json|yaml"); }, ); - it("keeps public mutation dry-runs and native sandbox command routes", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-route-smoke-")); + it("keeps public mutation dry-runs and native sandbox command routes", ({ testHome }) => { + const { home } = testHome; writeSandboxRegistry(home); - const policy = runWithEnv("alpha policy-add github --dry-run", { HOME: home }); + const policy = runWithEnv("alpha policy-add github --dry-run", testHome.environment()); expect(policy.code).toBe(0); expect(policy.out).toContain("--dry-run: no changes applied."); - const channels = runWithEnv("alpha channels add telegram --dry-run", { HOME: home }); + const channels = runWithEnv("alpha channels add telegram --dry-run", testHome.environment()); expect(channels.code).toBe(0); expect(channels.out).toContain("--dry-run: would enable channel 'telegram' for 'alpha'."); - const snapshots = runWithEnv("sandbox snapshot list alpha", { HOME: home }); + const snapshots = runWithEnv("sandbox snapshot list alpha", testHome.environment()); expect(snapshots.code).toBe(0); expect(snapshots.out).toContain("No snapshots found for 'alpha'."); }); - it("keeps public policy-add/remove built-in mutation routes", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-policy-mutation-")); + it("keeps public policy-add/remove built-in mutation routes", ({ testHome }) => { + const { home } = testHome; writeSandboxRegistry(home); const openshell = writePolicyMutationOpenshellStub(home); - const add = runWithEnv("alpha policy-add github --yes", { - HOME: home, - NEMOCLAW_OPENSHELL_BIN: openshell, - }); + const add = runWithEnv( + "alpha policy-add github --yes", + testHome.environment({ + NEMOCLAW_OPENSHELL_BIN: openshell, + }), + ); expect(add.code).toBe(0); expect(add.out).toContain("Applied preset: github"); expect(readSandboxPolicies(home)).toContain("github"); - const remove = runWithEnv("alpha policy-remove github -y", { - HOME: home, - NEMOCLAW_OPENSHELL_BIN: openshell, - }); + const remove = runWithEnv( + "alpha policy-remove github -y", + testHome.environment({ + NEMOCLAW_OPENSHELL_BIN: openshell, + }), + ); expect(remove.code).toBe(0); expect(remove.out).toContain("Removed preset: github"); expect(readSandboxPolicies(home)).not.toContain("github"); }); - it("keeps public policy-add non-interactive missing-preset failure before mutation", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-policy-noninteractive-")); + it("keeps public policy-add non-interactive missing-preset failure before mutation", ({ + testHome, + }) => { + const { home } = testHome; writeSandboxRegistry(home); const openshell = writePolicyMutationOpenshellStub(home); - const result = runWithEnv("alpha policy-add", { - HOME: home, - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_OPENSHELL_BIN: openshell, - }); + const result = runWithEnv( + "alpha policy-add", + testHome.environment({ + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_OPENSHELL_BIN: openshell, + }), + ); expect(result.code).toBe(1); expect(result.out).toContain("Non-interactive mode requires a preset name."); expect(readSandboxPolicies(home)).toEqual([]); }); - it("keeps public policy-add missing-preset failure when stdin contains probe output", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-policy-stdin-")); + it("keeps public policy-add missing-preset failure when stdin contains probe output", ({ + testHome, + }) => { + const { home } = testHome; writeSandboxRegistry(home); const openshell = writePolicyMutationOpenshellStub(home); - const result = runWithInput("alpha policy-add", "/usr/bin/dmesg\n3", { - HOME: home, - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_OPENSHELL_BIN: openshell, - }); + const result = runWithInput( + "alpha policy-add", + "/usr/bin/dmesg\n3", + testHome.environment({ + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_OPENSHELL_BIN: openshell, + }), + ); expect(result.code).toBe(1); expect(result.out).toContain("Non-interactive mode requires a preset name."); @@ -164,14 +177,20 @@ describe("CLI dispatch", () => { expect(readSandboxPolicies(home)).toEqual([]); }); - it("sandbox channels start rejects a sandbox missing from the registry (#4584)", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-channels-missing-")); + it("sandbox channels start rejects a sandbox missing from the registry (#4584)", ({ + testHome, + }) => { + const { home } = testHome; writeSandboxRegistry(home); - const startMissing = runWithEnv("sandbox channels start does-not-exist telegram", { - HOME: home, - }); - const stopMissing = runWithEnv("sandbox channels stop does-not-exist telegram", { HOME: home }); + const startMissing = runWithEnv( + "sandbox channels start does-not-exist telegram", + testHome.environment(), + ); + const stopMissing = runWithEnv( + "sandbox channels stop does-not-exist telegram", + testHome.environment(), + ); expect(startMissing.code).toBe(1); expect(startMissing.out).toContain("Sandbox 'does-not-exist' not found in the registry."); diff --git a/test/cli/snapshot-shields.test.ts b/test/cli/snapshot-shields.test.ts index 71b29e1ef30..4e7e4dd6c94 100644 --- a/test/cli/snapshot-shields.test.ts +++ b/test/cli/snapshot-shields.test.ts @@ -1,70 +1,71 @@ // 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 } from "vitest"; +import { describe, expect, test as it } from "../helpers/owned-test-resources"; import { runWithEnv, testTimeoutOptions, writeSandboxRegistry } from "./helpers"; describe("CLI dispatch", () => { - it("shields help uses native oclif usage", testTimeoutOptions(30_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-shields-help-")); + it("shields help uses native oclif usage", testTimeoutOptions(30_000), ({ testHome }) => { + const { home } = testHome; writeSandboxRegistry(home); - const down = runWithEnv("alpha shields down --help", { HOME: home }); + const down = runWithEnv("alpha shields down --help", testHome.environment()); expect(down.code).toBe(0); expect(down.out).toContain("$ nemoclaw sandbox shields down "); - const up = runWithEnv("alpha shields up --help", { HOME: home }); + const up = runWithEnv("alpha shields up --help", testHome.environment()); expect(up.code).toBe(0); expect(up.out).toContain("$ nemoclaw sandbox shields up "); - const status = runWithEnv("alpha shields status --help", { HOME: home }); + const status = runWithEnv("alpha shields status --help", testHome.environment()); expect(status.code).toBe(0); expect(status.out).toContain("$ nemoclaw sandbox shields status "); }); - it("snapshot subcommand help uses native oclif usage", testTimeoutOptions(30_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-snapshot-help-")); - writeSandboxRegistry(home); + it( + "snapshot subcommand help uses native oclif usage", + testTimeoutOptions(30_000), + ({ testHome }) => { + const { home } = testHome; + writeSandboxRegistry(home); - const parent = runWithEnv("alpha snapshot --help", { HOME: home }); - expect(parent.code).toBe(0); - expect(parent.out).toContain("$ nemoclaw sandbox snapshot "); - expect(parent.out).toContain("sandbox snapshot create"); - expect(parent.out).toContain("sandbox snapshot list"); + const parent = runWithEnv("alpha snapshot --help", testHome.environment()); + expect(parent.code).toBe(0); + expect(parent.out).toContain("$ nemoclaw sandbox snapshot "); + expect(parent.out).toContain("sandbox snapshot create"); + expect(parent.out).toContain("sandbox snapshot list"); - const list = runWithEnv("alpha snapshot list --help", { HOME: home }); - expect(list.code).toBe(0); - expect(list.out).toContain("$ nemoclaw sandbox snapshot list "); + const list = runWithEnv("alpha snapshot list --help", testHome.environment()); + expect(list.code).toBe(0); + expect(list.out).toContain("$ nemoclaw sandbox snapshot list "); - const create = runWithEnv("alpha snapshot create --help", { HOME: home }); - expect(create.code).toBe(0); - expect(create.out).toContain("$ nemoclaw sandbox snapshot create [--name