From 17a9ee3f2148190702090d28ce809d5e02f56c06 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 12:04:52 -0700 Subject: [PATCH 1/6] perf(test): avoid loading onboard for gateway failure checks --- .../gateway-start-failure-integration.test.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/lib/onboard/gateway-start-failure-integration.test.ts b/src/lib/onboard/gateway-start-failure-integration.test.ts index 54837a54d68..d1f357cf09e 100644 --- a/src/lib/onboard/gateway-start-failure-integration.test.ts +++ b/src/lib/onboard/gateway-start-failure-integration.test.ts @@ -24,25 +24,22 @@ // test/onboard-gateway-docker-unreachable.test.ts. import { describe, expect, it, vi } from "vitest"; -// `handleFinalGatewayStartFailure` is exposed via `module.exports = {...}` at -// the bottom of onboard.ts (it is not a TypeScript `export`). The shared source -// hook preserves those CommonJS semantics without requiring a CLI build. -import * as onboardExports from "../onboard"; import { classifyGatewayStartFailure } from "../validation"; import { + createFinalGatewayStartFailureHandler, printDockerDaemonRecovery, reportLegacyGatewayStartResultFailure, } from "./gateway-start-failure"; -const handleFinalGatewayStartFailure: (opts: { - retries: number; - dockerUnreachable?: boolean; - collectDiagnostics?: () => string; - cleanupGateway?: () => void; - exitProcess?: (code: number) => never; - printError?: (message?: string) => void; -}) => never = (onboardExports as unknown as Record) - .handleFinalGatewayStartFailure as never; +// The production binding itself remains covered by +// test/gateway-final-failure-cleanup.test.ts. These helper and composition +// checks only need the production factory, and should not load onboard.ts's +// full dependency graph for every source-test worker. +const handleFinalGatewayStartFailure = createFinalGatewayStartFailureHandler({ + getGatewayName: () => "nemoclaw", + collectDiagnostics: () => "", + cleanupGateway: () => undefined, +}); // Real signatures the legacy script's fake openshell binary emitted from // `gateway start` to simulate Colima-stopped (macOS) and dockerd-stopped From bfbc4d8ecedf12eb9f6fd597093abf49d19171f4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 12:11:54 -0700 Subject: [PATCH 2/6] perf(test): run host alias checks in process --- .../sandbox/hosts/command-adapters.test.ts | 82 +++ src/lib/actions/sandbox/host-aliases.test.ts | 8 +- src/lib/actions/sandbox/host-aliases.ts | 97 ++-- test/cli/sandbox-host-aliases.test.ts | 543 +++++++++--------- .../cli/public-argv-translation.test.ts | 17 + 5 files changed, 437 insertions(+), 310 deletions(-) create mode 100644 src/commands/sandbox/hosts/command-adapters.test.ts diff --git a/src/commands/sandbox/hosts/command-adapters.test.ts b/src/commands/sandbox/hosts/command-adapters.test.ts new file mode 100644 index 00000000000..67fb00013a1 --- /dev/null +++ b/src/commands/sandbox/hosts/command-adapters.test.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + addSandboxHostAlias: vi.fn(), + listSandboxHostAliases: vi.fn(), + removeSandboxHostAlias: vi.fn(), +})); + +vi.mock("../../../lib/actions/sandbox/host-aliases", () => ({ + addSandboxHostAlias: mocks.addSandboxHostAlias, + listSandboxHostAliases: mocks.listSandboxHostAliases, + removeSandboxHostAlias: mocks.removeSandboxHostAlias, +})); + +import HostsAddCommand from "./add"; +import HostsListCommand from "./list"; +import HostsRemoveCommand from "./remove"; + +const rootDir = process.cwd(); + +describe("host alias oclif command adapters", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps parsed host alias arguments and dry-run flags to actions", async () => { + await HostsAddCommand.run(["alpha", "searxng.local", "192.168.1.105", "--dry-run"], rootDir); + await HostsListCommand.run(["alpha"], rootDir); + await HostsRemoveCommand.run(["alpha", "searxng.local", "--dry-run"], rootDir); + + expect(mocks.addSandboxHostAlias).toHaveBeenCalledWith("alpha", { + hostname: "searxng.local", + ip: "192.168.1.105", + dryRun: true, + }); + expect(mocks.listSandboxHostAliases).toHaveBeenCalledWith("alpha"); + expect(mocks.removeSandboxHostAlias).toHaveBeenCalledWith("alpha", { + hostname: "searxng.local", + dryRun: true, + }); + }); + + it("rejects unknown flags before invoking host alias actions", async () => { + await expect( + HostsAddCommand.run(["alpha", "searxng.local", "192.168.1.105", "--dry-rnu"], rootDir), + ).rejects.toThrow("Nonexistent flag: --dry-rnu"); + await expect( + HostsRemoveCommand.run(["alpha", "searxng.local", "--force"], rootDir), + ).rejects.toThrow("Nonexistent flag: --force"); + + expect(mocks.addSandboxHostAlias).not.toHaveBeenCalled(); + expect(mocks.removeSandboxHostAlias).not.toHaveBeenCalled(); + }); + + it("maps host alias action failures to command output and exit codes", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.addSandboxHostAlias.mockImplementationOnce(() => { + throw { + name: "HostAliasesCommandError", + lines: ["host alias failed", "try again"], + exitCode: 5, + }; + }); + + await expect( + HostsAddCommand.run(["alpha", "searxng.local", "192.168.1.105"], rootDir), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(5); + expect(error).toHaveBeenCalledWith("host alias failed"); + expect(error).toHaveBeenCalledWith("try again"); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); +}); diff --git a/src/lib/actions/sandbox/host-aliases.test.ts b/src/lib/actions/sandbox/host-aliases.test.ts index 4c36a41f63a..b87403920d5 100644 --- a/src/lib/actions/sandbox/host-aliases.test.ts +++ b/src/lib/actions/sandbox/host-aliases.test.ts @@ -96,11 +96,17 @@ describe("host alias legacy gateway support checks", () => { describe("legacy gateway Docker probe classification", () => { it("classifies exact gateway container matches as present", () => { - const result = probeLegacyGatewayContainerWithDeps(() => + const dockerPs = vi.fn(() => dockerPsResult({ stdout: "openshell-cluster-nemoclaw\nother-container\n" }), ); + const result = probeLegacyGatewayContainerWithDeps(dockerPs); expect(result).toEqual({ state: "present" }); + expect(dockerPs).toHaveBeenCalledWith(["ps", "--format", "{{.Names}}"], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + timeout: 5_000, + }); }); it("classifies missing exact gateway container matches as absent", () => { diff --git a/src/lib/actions/sandbox/host-aliases.ts b/src/lib/actions/sandbox/host-aliases.ts index b5e1d889a1c..e718d1b3701 100644 --- a/src/lib/actions/sandbox/host-aliases.ts +++ b/src/lib/actions/sandbox/host-aliases.ts @@ -3,11 +3,7 @@ import { isIP } from "node:net"; -import { - dockerExecFileSync, - dockerSpawnSync, - type DockerSpawnSyncResult, -} from "../../adapters/docker/exec"; +import { dockerExecFileSync, dockerSpawnSync } from "../../adapters/docker/exec"; import { CLI_NAME } from "../../cli/branding"; import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; @@ -26,6 +22,12 @@ export type LegacyGatewayHostAliasSupportDeps = { probeLegacyGatewayContainer: () => LegacyGatewayProbe; }; +export type SandboxHostAliasesDeps = Readonly< + LegacyGatewayHostAliasSupportDeps & { + runKubectlInClusterRaw: (args: string[]) => string; + } +>; + // Drivers that run a per-sandbox direct container (openshell-...) // instead of the legacy k3s gateway. They have no openshell-cluster-nemoclaw // container and no Kubernetes `Sandbox` custom resource, so the kubectl-based @@ -135,15 +137,8 @@ export function assertLegacyGatewayHostAliasSupportWithDeps( } } -function assertLegacyGatewayHostAliasSupport(sandboxName: string): void { - assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, { - getSandbox: registry.getSandbox, - probeLegacyGatewayContainer, - }); -} - export function probeLegacyGatewayContainerWithDeps( - dockerPs: () => DockerSpawnSyncResult, + dockerPs: typeof dockerSpawnSync, ): LegacyGatewayProbe { // `docker ps --filter name=...` accepts only substring or anchored regex // syntax (`name=^/$`) per the Docker CLI reference, and the @@ -151,7 +146,11 @@ export function probeLegacyGatewayContainerWithDeps( // `docker ps --format '{{.Names}}'` pattern used in // src/lib/sandbox/privileged-exec.ts and do the exact match in code so // there is no doubt about substring overlap or anchor support. - const result = dockerPs(); + const result = dockerPs(["ps", "--format", "{{.Names}}"], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + timeout: HOST_ALIAS_DOCKER_PROBE_TIMEOUT_MS, + }); if (result.error) { const code = (result.error as NodeJS.ErrnoException).code ?? ""; if (code === "ETIMEDOUT") { @@ -175,13 +174,7 @@ export function probeLegacyGatewayContainerWithDeps( } function probeLegacyGatewayContainer(): LegacyGatewayProbe { - return probeLegacyGatewayContainerWithDeps(() => - dockerSpawnSync(["ps", "--format", "{{.Names}}"], { - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - timeout: HOST_ALIAS_DOCKER_PROBE_TIMEOUT_MS, - }), - ); + return probeLegacyGatewayContainerWithDeps(dockerSpawnSync); } function validateHostAliasHostname(hostname: string): boolean { @@ -202,24 +195,33 @@ function runKubectlInClusterRaw(args: string[]): string { }); } +function productionHostAliasesDeps(): SandboxHostAliasesDeps { + return { + getSandbox: registry.getSandbox, + probeLegacyGatewayContainer, + runKubectlInClusterRaw, + }; +} + function throwKubectlError(action: string, error: unknown): never { const err = error as { stderr?: unknown; stdout?: unknown; message?: unknown; status?: number }; const detail = String(err?.stderr || err?.stdout || err?.message || "").trim(); hostAliasesFail(` Failed to ${action}.${detail ? ` ${detail}` : ""}`, err?.status || 1); } -function runKubectlInCluster(args: string[], action: string): string { +function runKubectlInCluster(args: string[], action: string, deps: SandboxHostAliasesDeps): string { try { - return runKubectlInClusterRaw(args); + return deps.runKubectlInClusterRaw(args); } catch (error) { throwKubectlError(action, error); } } -function getSandboxResource(sandboxName: string): SandboxResource { +function getSandboxResource(sandboxName: string, deps: SandboxHostAliasesDeps): SandboxResource { const raw = runKubectlInCluster( ["get", "sandbox", sandboxName, "-o", "json"], "read host aliases", + deps, ); try { return JSON.parse(raw) as SandboxResource; @@ -280,8 +282,9 @@ function patchHostAliases( sandboxName: string, resource: SandboxResource, hostAliases: HostAlias[], + deps: SandboxHostAliasesDeps, ): void { - runKubectlInClusterRaw([ + deps.runKubectlInClusterRaw([ "patch", "sandbox", sandboxName, @@ -296,13 +299,14 @@ function patchHostAliasesWithRetry( buildAliases: BuildHostAliases, initialResource: SandboxResource, initialAliases: HostAlias[], + deps: SandboxHostAliasesDeps, ): void { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const resource = attempt === 1 ? initialResource : getSandboxResource(sandboxName); + const resource = attempt === 1 ? initialResource : getSandboxResource(sandboxName, deps); const aliases = attempt === 1 ? initialAliases : buildAliases(resource); try { - patchHostAliases(sandboxName, resource, aliases); + patchHostAliases(sandboxName, resource, aliases, deps); return; } catch (error) { if (!isHostAliasPatchConflict(error) || attempt === maxAttempts) { @@ -313,8 +317,15 @@ function patchHostAliasesWithRetry( } export function listSandboxHostAliases(sandboxName: string): void { - assertLegacyGatewayHostAliasSupport(sandboxName); - const aliases = getHostAliases(getSandboxResource(sandboxName)); + listSandboxHostAliasesWithDeps(sandboxName, productionHostAliasesDeps()); +} + +export function listSandboxHostAliasesWithDeps( + sandboxName: string, + deps: SandboxHostAliasesDeps, +): void { + assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, deps); + const aliases = getHostAliases(getSandboxResource(sandboxName, deps)); if (aliases.length === 0) { console.log(` No host aliases configured for '${sandboxName}'.`); return; @@ -366,12 +377,20 @@ export function validateSandboxHostAliasRemoveOptions(options: RemoveSandboxHost export function addSandboxHostAlias( sandboxName: string, options: AddSandboxHostAliasOptions = {}, +): void { + addSandboxHostAliasWithDeps(sandboxName, options, productionHostAliasesDeps()); +} + +export function addSandboxHostAliasWithDeps( + sandboxName: string, + options: AddSandboxHostAliasOptions, + deps: SandboxHostAliasesDeps, ): void { const dryRun = Boolean(options.dryRun); const { hostname, ip } = validateSandboxHostAliasAddOptions(options); - assertLegacyGatewayHostAliasSupport(sandboxName); + assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, deps); - const resource = getSandboxResource(sandboxName); + const resource = getSandboxResource(sandboxName, deps); const buildAliases: BuildHostAliases = (currentResource) => { const aliases = normalizeHostAliases(currentResource); if (aliases.some((alias) => alias.hostnames.includes(hostname))) { @@ -392,19 +411,27 @@ export function addSandboxHostAlias( console.log(JSON.stringify(buildHostAliasesPatch(resource, aliases), null, 2)); return; } - patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases); + patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases, deps); console.log(` Added host alias ${hostname} -> ${ip}`); } export function removeSandboxHostAlias( sandboxName: string, options: RemoveSandboxHostAliasOptions = {}, +): void { + removeSandboxHostAliasWithDeps(sandboxName, options, productionHostAliasesDeps()); +} + +export function removeSandboxHostAliasWithDeps( + sandboxName: string, + options: RemoveSandboxHostAliasOptions, + deps: SandboxHostAliasesDeps, ): void { const dryRun = Boolean(options.dryRun); const { hostname } = validateSandboxHostAliasRemoveOptions(options); - assertLegacyGatewayHostAliasSupport(sandboxName); + assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, deps); - const resource = getSandboxResource(sandboxName); + const resource = getSandboxResource(sandboxName, deps); const buildAliases: BuildHostAliases = (currentResource) => { const original = normalizeHostAliases(currentResource); const aliases = original @@ -428,6 +455,6 @@ export function removeSandboxHostAlias( console.log(JSON.stringify(buildHostAliasesPatch(resource, aliases), null, 2)); return; } - patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases); + patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases, deps); console.log(` Removed host alias ${hostname}`); } diff --git a/test/cli/sandbox-host-aliases.test.ts b/test/cli/sandbox-host-aliases.test.ts index fd76e7b5ea8..1539015bb15 100644 --- a/test/cli/sandbox-host-aliases.test.ts +++ b/test/cli/sandbox-host-aliases.test.ts @@ -4,319 +4,314 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { - runWithEnv, - testTimeoutOptions, - writeHostAliasDockerStub, - writeSandboxRegistry, -} from "./helpers"; - -function makeCliFixture(prefix: string) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - return { - dockerLog, - home, - localBin, - env: { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, - }; -} + addSandboxHostAlias, + addSandboxHostAliasWithDeps, + HostAliasesCommandError, + listSandboxHostAliasesWithDeps, + removeSandboxHostAliasWithDeps, + type SandboxHostAliasesDeps, +} from "../../src/lib/actions/sandbox/host-aliases"; +import * as registry from "../../src/lib/state/registry"; + +type HostAlias = { ip: string; hostnames: string[] }; +type KubectlRunner = (args: string[]) => string; + +const tempDirs = new Set(); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const tempDir of tempDirs) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); -function writeDockerStub(localBin: string, lines: string[]): void { - fs.writeFileSync(path.join(localBin, "docker"), lines.join("\n"), { mode: 0o755 }); +function sandboxResource(resourceVersion: string, hostAliases: HostAlias[]): string { + return JSON.stringify({ + metadata: { resourceVersion }, + spec: { podTemplate: { spec: { hostAliases } } }, + }); } -function expectDockerProbeFailure(out: string): void { - expect(out).toContain( - "Could not verify the legacy OpenShell gateway container 'openshell-cluster-nemoclaw'.", - ); - expect(out).toContain("Docker probe failed:"); - expect(out).not.toContain( - "Host aliases require the legacy OpenShell gateway container 'openshell-cluster-nemoclaw' to be running.", - ); +function actionDeps(runKubectlInClusterRaw: KubectlRunner): SandboxHostAliasesDeps { + return { + getSandbox: () => ({}), + probeLegacyGatewayContainer: () => ({ state: "present" }), + runKubectlInClusterRaw, + }; } -function expectNoLegacyGatewayExec(log: string[]): void { - expect(log[0]).toBe("ps"); - expect(log).not.toContain("exec"); - expect(log).not.toContain("kubectl"); - expect(log).not.toContain("patch"); +function patchFromCall(args: string[]): Array<{ op: string; path: string; value: unknown }> { + const patchIndex = args.indexOf("-p"); + expect(patchIndex).toBeGreaterThanOrEqual(0); + return JSON.parse(args[patchIndex + 1] ?? "[]") as Array<{ + op: string; + path: string; + value: unknown; + }>; } -describe("CLI dispatch", () => { +describe("sandbox host alias actions", () => { it("adds host aliases with a sandbox json patch", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-add-"); - writeDockerStub(localBin, [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "%s\\n" "openshell-cluster-nemoclaw"', - " exit 0", - "fi", - 'if printf "%s\\n" "$@" | grep -q "^get$"; then', - ' printf "%s\\n" \'{"metadata":{"resourceVersion":"123"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"10.0.0.5","hostnames":["old.local"]}]}}}}\'', - "fi", - "exit 0", + const runKubectl = vi + .fn() + .mockReturnValueOnce(sandboxResource("123", [{ ip: "10.0.0.5", hostnames: ["old.local"] }])) + .mockReturnValueOnce(""); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + addSandboxHostAliasWithDeps( + "alpha", + { hostname: "searxng.local", ip: "192.168.1.105" }, + actionDeps(runKubectl), + ); + + expect(runKubectl).toHaveBeenNthCalledWith(1, ["get", "sandbox", "alpha", "-o", "json"]); + expect(runKubectl.mock.calls[1]?.[0]?.slice(0, 5)).toEqual([ + "patch", + "sandbox", + "alpha", + "--type=json", + "-p", ]); - - const r = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", env); - - expect(r.code).toBe(0); - expect(r.out).toContain("Added host alias searxng.local -> 192.168.1.105"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const psIndex = log.indexOf("ps"); - expect(psIndex).toBe(0); - expect(log[psIndex + 1]).toBe("--format"); - expect(log[psIndex + 2]).toBe("{{.Names}}"); - expect(log).not.toContain("--filter"); - const kubectlIndex = log.indexOf("kubectl"); - expect(kubectlIndex).toBeGreaterThan(psIndex); - expect(log[kubectlIndex - 1]).toBe("openshell-cluster-nemoclaw"); - expect(log[kubectlIndex - 2]).toBe("exec"); - expect(log).toContain("patch"); - expect(log).toContain("--type=json"); - const patch = JSON.parse(log[log.indexOf("-p") + 1]); - expect(patch[0]).toEqual({ - op: "test", - path: "/metadata/resourceVersion", - value: "123", - }); - expect(patch[1]).toEqual({ - op: "replace", - path: "/spec/podTemplate/spec/hostAliases", - value: [ - { ip: "10.0.0.5", hostnames: ["old.local"] }, - { ip: "192.168.1.105", hostnames: ["searxng.local"] }, - ], - }); + expect(patchFromCall(runKubectl.mock.calls[1]?.[0] ?? [])).toEqual([ + { op: "test", path: "/metadata/resourceVersion", value: "123" }, + { + op: "replace", + path: "/spec/podTemplate/spec/hostAliases", + value: [ + { ip: "10.0.0.5", hostnames: ["old.local"] }, + { ip: "192.168.1.105", hostnames: ["searxng.local"] }, + ], + }, + ]); + expect(log).toHaveBeenCalledWith(" Added host alias searxng.local -> 192.168.1.105"); }); it("lists host aliases from the sandbox resource", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-list-"); - writeDockerStub(localBin, [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "%s\\n" "openshell-cluster-nemoclaw"', - " exit 0", - "fi", - 'printf "%s\\n" \'{"metadata":{"resourceVersion":"123"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"192.168.1.105","hostnames":["searxng.local","search.lan"]}]}}}}\'', - ]); - - const r = runWithEnv("alpha hosts-list", env); - - expect(r.code).toBe(0); - expect(r.out).toContain("Host aliases for 'alpha'"); - expect(r.out).toContain("192.168.1.105 searxng.local, search.lan"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const kubectlIndex = log.indexOf("kubectl"); - expect(kubectlIndex).toBeGreaterThan(1); - expect(log[kubectlIndex - 1]).toBe("openshell-cluster-nemoclaw"); - expect(log[kubectlIndex - 2]).toBe("exec"); - expect(log).toContain("get"); + const runKubectl = vi + .fn() + .mockReturnValueOnce( + sandboxResource("123", [ + { ip: "192.168.1.105", hostnames: ["searxng.local", "search.lan"] }, + ]), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + listSandboxHostAliasesWithDeps("alpha", actionDeps(runKubectl)); + + expect(runKubectl).toHaveBeenCalledOnce(); + expect(log).toHaveBeenNthCalledWith(1, " Host aliases for 'alpha':"); + expect(log).toHaveBeenNthCalledWith(2, " 192.168.1.105 searxng.local, search.lan"); }); it("removes host aliases with a sandbox json patch", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-remove-"); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, - { ip: "192.168.1.10", hostnames: ["keep.local"] }, + const runKubectl = vi + .fn() + .mockReturnValueOnce( + sandboxResource("123", [ + { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, + { ip: "192.168.1.10", hostnames: ["keep.local"] }, + ]), + ) + .mockReturnValueOnce(""); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + removeSandboxHostAliasWithDeps("alpha", { hostname: "searxng.local" }, actionDeps(runKubectl)); + + expect(patchFromCall(runKubectl.mock.calls[1]?.[0] ?? [])).toEqual([ + { op: "test", path: "/metadata/resourceVersion", value: "123" }, + { + op: "replace", + path: "/spec/podTemplate/spec/hostAliases", + value: [ + { ip: "10.0.0.5", hostnames: ["old.local"] }, + { ip: "192.168.1.10", hostnames: ["keep.local"] }, + ], + }, ]); - - const r = runWithEnv("alpha hosts-remove searxng.local", env); - - expect(r.code).toBe(0); - expect(r.out).toContain("Removed host alias searxng.local"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const kubectlIndex = log.indexOf("kubectl"); - expect(kubectlIndex).toBeGreaterThan(1); - expect(log[kubectlIndex - 1]).toBe("openshell-cluster-nemoclaw"); - expect(log[kubectlIndex - 2]).toBe("exec"); - expect(log).toContain("patch"); - const patch = JSON.parse(log[log.lastIndexOf("-p") + 1]); - expect(patch[0]).toEqual({ - op: "test", - path: "/metadata/resourceVersion", - value: "123", - }); - expect(patch[1]).toEqual({ - op: "replace", - path: "/spec/podTemplate/spec/hostAliases", - value: [ - { ip: "10.0.0.5", hostnames: ["old.local"] }, - { ip: "192.168.1.10", hostnames: ["keep.local"] }, - ], - }); + expect(log).toHaveBeenCalledWith(" Removed host alias searxng.local"); }); it("rejects duplicate host aliases case-insensitively", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-duplicate-"); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["SearXNG.local"] }, - ]); - - const r = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", env); - - expect(r.code).toBe(1); - expect(r.out).toContain("Host alias 'searxng.local' already exists"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expect(log).not.toContain("patch"); + const runKubectl = vi + .fn() + .mockReturnValueOnce( + sandboxResource("123", [{ ip: "10.0.0.5", hostnames: ["SearXNG.local"] }]), + ); + + expect(() => + addSandboxHostAliasWithDeps( + "alpha", + { hostname: "searxng.local", ip: "192.168.1.105" }, + actionDeps(runKubectl), + ), + ).toThrow("Host alias 'searxng.local' already exists"); + expect(runKubectl).toHaveBeenCalledOnce(); }); it("previews host alias changes with dry-run without patching", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-dry-run-"); - writeHostAliasDockerStub(localBin, dockerLog, [ + const initial = sandboxResource("123", [ { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, ]); - - const add = runWithEnv("alpha hosts-add dry.local 192.168.1.105 --dry-run", env); - const remove = runWithEnv("alpha hosts-remove searxng.local --dry-run", env); - - expect(add.code).toBe(0); - expect(add.out).toContain('"/metadata/resourceVersion"'); - expect(add.out).toContain('"/spec/podTemplate/spec/hostAliases"'); - expect(add.out).toContain('"dry.local"'); - expect(add.out).toContain('"192.168.1.105"'); - expect(remove.code).toBe(0); - expect(remove.out).toContain('"/metadata/resourceVersion"'); - expect(remove.out).toContain('"/spec/podTemplate/spec/hostAliases"'); - expect(remove.out).toContain('"old.local"'); - expect(remove.out).not.toContain('"searxng.local"'); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expect(log).not.toContain("patch"); - }); - - it("rejects unknown host alias flags without patching", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-unknown-flag-"); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["searxng.local"] }, - ]); - - const add = runWithEnv("alpha hosts-add searxng.local 192.168.1.105 --dry-rnu", env); - const remove = runWithEnv("alpha hosts-remove searxng.local --force", env); - - expect(add.code).not.toBe(0); - expect(add.out).toContain("Nonexistent flag: --dry-rnu"); - expect(remove.code).not.toBe(0); - expect(remove.out).toContain("Nonexistent flag: --force"); - expect(fs.existsSync(dockerLog)).toBe(false); + const addKubectl = vi.fn().mockReturnValueOnce(initial); + const removeKubectl = vi.fn().mockReturnValueOnce(initial); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + addSandboxHostAliasWithDeps( + "alpha", + { hostname: "dry.local", ip: "192.168.1.105", dryRun: true }, + actionDeps(addKubectl), + ); + removeSandboxHostAliasWithDeps( + "alpha", + { hostname: "searxng.local", dryRun: true }, + actionDeps(removeKubectl), + ); + + const addPatch = JSON.parse(String(log.mock.calls[0]?.[0])) as Array>; + const removePatch = JSON.parse(String(log.mock.calls[1]?.[0])) as Array< + Record + >; + expect(addPatch).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "/metadata/resourceVersion", value: "123" }), + expect.objectContaining({ + path: "/spec/podTemplate/spec/hostAliases", + value: expect.arrayContaining([{ ip: "192.168.1.105", hostnames: ["dry.local"] }]), + }), + ]), + ); + expect(JSON.stringify(removePatch)).toContain("old.local"); + expect(JSON.stringify(removePatch)).not.toContain("searxng.local"); + expect(addKubectl).toHaveBeenCalledOnce(); + expect(removeKubectl).toHaveBeenCalledOnce(); }); it("retries host alias patches when the resource version changes", () => { - const { dockerLog, env, home, localBin } = makeCliFixture("nemoclaw-cli-hosts-retry-"); - const getCount = path.join(home, "get-count"); - const patchCount = path.join(home, "patch-count"); - writeDockerStub(localBin, [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - `get_count=${JSON.stringify(getCount)}`, - `patch_count=${JSON.stringify(patchCount)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "%s\\n" "openshell-cluster-nemoclaw"', - " exit 0", - "fi", - 'if printf "%s\\n" "$@" | grep -q "^get$"; then', - ' count=$(cat "$get_count" 2>/dev/null || echo 0)', - " count=$((count + 1))", - ' printf "%s" "$count" > "$get_count"', - ' if [ "$count" = "1" ]; then version=123; else version=124; fi', - ' printf \'{"metadata":{"resourceVersion":"%s"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"10.0.0.5","hostnames":["old.local"]}]}}}}\\n\' "$version"', - " exit 0", - "fi", - 'if printf "%s\\n" "$@" | grep -q "^patch$"; then', - ' count=$(cat "$patch_count" 2>/dev/null || echo 0)', - " count=$((count + 1))", - ' printf "%s" "$count" > "$patch_count"', - ' if [ "$count" = "1" ]; then', - ' echo "Operation cannot be fulfilled: the object has been modified" >&2', - " exit 1", - " fi", - "fi", - "exit 0", + const conflict = Object.assign(new Error("patch conflict"), { + status: 1, + stderr: "Operation cannot be fulfilled: the object has been modified", + }); + const runKubectl = vi + .fn() + .mockReturnValueOnce(sandboxResource("123", [{ ip: "10.0.0.5", hostnames: ["old.local"] }])) + .mockImplementationOnce(() => { + throw conflict; + }) + .mockReturnValueOnce(sandboxResource("124", [{ ip: "10.0.0.5", hostnames: ["old.local"] }])) + .mockReturnValueOnce(""); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + addSandboxHostAliasWithDeps( + "alpha", + { hostname: "retry.local", ip: "192.168.1.105" }, + actionDeps(runKubectl), + ); + + expect(runKubectl).toHaveBeenCalledTimes(4); + expect(runKubectl.mock.calls.map(([args]) => args[0])).toEqual([ + "get", + "patch", + "get", + "patch", ]); - - const r = runWithEnv("alpha hosts-add retry.local 192.168.1.105", env); - - expect(r.code).toBe(0); - expect(r.out).toContain("Added host alias retry.local -> 192.168.1.105"); - expect(fs.readFileSync(getCount, "utf8")).toBe("2"); - expect(fs.readFileSync(patchCount, "utf8")).toBe("2"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const patchArgs = log.filter((line) => line.startsWith("[")); - const finalPatch = patchArgs.at(-1); - expect(finalPatch).toBeDefined(); - expect(JSON.parse(finalPatch!)[0]).toEqual({ + expect(patchFromCall(runKubectl.mock.calls[3]?.[0] ?? [])[0]).toEqual({ op: "test", path: "/metadata/resourceVersion", value: "124", }); }); - it("classifies docker spawn ENOENT distinctly from a missing gateway", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-docker-enoent-")); - const emptyBin = path.join(home, "nodocker"); - fs.mkdirSync(emptyBin, { recursive: true }); - writeSandboxRegistry(home); - - const list = runWithEnv("alpha hosts-list", { HOME: home, PATH: emptyBin }); - - expect(list.code).toBe(1); - expectDockerProbeFailure(list.out); - expect(list.out).toContain("could not launch"); + it("stops before kubectl when the legacy gateway probe is unknown", () => { + const runKubectl = vi.fn(); + const deps: SandboxHostAliasesDeps = { + getSandbox: () => ({}), + probeLegacyGatewayContainer: () => ({ + state: "unknown", + reason: "docker ps timed out", + }), + runKubectlInClusterRaw: runKubectl, + }; + + expect(() => listSandboxHostAliasesWithDeps("alpha", deps)).toThrow( + new HostAliasesCommandError([ + " Could not verify the legacy OpenShell gateway container 'openshell-cluster-nemoclaw'.", + " Docker probe failed: docker ps timed out", + " Check whether the Docker daemon is reachable with `docker info`.", + ]), + ); + expect(runKubectl).not.toHaveBeenCalled(); }); +}); - it( - "classifies docker probe timeouts distinctly from a missing gateway", - testTimeoutOptions(60_000), - () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-docker-timeout-"); - writeDockerStub(localBin, [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - " sleep 20", - " exit 0", - "fi", - "exit 0", - ]); - - const list = runWithEnv("alpha hosts-list", env, 45_000); - - expect(list.code).toBe(1); - expectDockerProbeFailure(list.out); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expectNoLegacyGatewayExec(log); - }, - ); +describe("production host alias process adapters", () => { + it("probes Docker and patches through the legacy gateway container", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-host-alias-process-")); + tempDirs.add(tempDir); + const binDir = path.join(tempDir, "bin"); + const dockerLog = path.join(tempDir, "docker.jsonl"); + const dockerPath = path.join(binDir, "docker"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + dockerPath, + [ + `#!${process.execPath}`, + 'const fs = require("node:fs");', + "const args = process.argv.slice(2);", + `fs.appendFileSync(${JSON.stringify(dockerLog)}, JSON.stringify(args) + "\\n");`, + 'if (args[0] === "ps") { process.stdout.write("openshell-cluster-nemoclaw\\n"); process.exit(0); }', + 'if (args.includes("get")) { process.stdout.write(JSON.stringify({ metadata: { resourceVersion: "123" }, spec: { podTemplate: { spec: { hostAliases: [{ ip: "10.0.0.5", hostnames: ["old.local"] }] } } } })); }', + ].join("\n"), + { mode: 0o755 }, + ); + vi.stubEnv("PATH", `${binDir}:${process.env.PATH ?? ""}`); + vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha" } as never); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + addSandboxHostAlias("alpha", { + hostname: "searxng.local", + ip: "192.168.1.105", + }); - it("classifies docker probe failures distinctly from a missing gateway", () => { - const { dockerLog, env, localBin } = makeCliFixture("nemoclaw-cli-hosts-docker-down-"); - writeDockerStub(localBin, [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\\n" >&2', - " exit 1", - "fi", - "exit 0", + const calls = fs + .readFileSync(dockerLog, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(calls[0]).toEqual(["ps", "--format", "{{.Names}}"]); + expect(calls[1]).toEqual([ + "exec", + "openshell-cluster-nemoclaw", + "kubectl", + "-n", + "openshell", + "get", + "sandbox", + "alpha", + "-o", + "json", ]); - - const list = runWithEnv("alpha hosts-list", env); - - expect(list.code).toBe(1); - expectDockerProbeFailure(list.out); - expect(list.out).toContain("docker info"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expectNoLegacyGatewayExec(log); + expect(calls[2]?.slice(0, 10)).toEqual([ + "exec", + "openshell-cluster-nemoclaw", + "kubectl", + "-n", + "openshell", + "patch", + "sandbox", + "alpha", + "--type=json", + "-p", + ]); + expect(patchFromCall(calls[2]?.slice(5) ?? [])[0]).toEqual({ + op: "test", + path: "/metadata/resourceVersion", + value: "123", + }); + expect(log).toHaveBeenCalledWith(" Added host alias searxng.local -> 192.168.1.105"); }); }); diff --git a/test/package-contract/cli/public-argv-translation.test.ts b/test/package-contract/cli/public-argv-translation.test.ts index 6e40055abfe..fce5c628604 100644 --- a/test/package-contract/cli/public-argv-translation.test.ts +++ b/test/package-contract/cli/public-argv-translation.test.ts @@ -163,6 +163,23 @@ describe("translatePublicSandboxArgv", () => { "sandbox:gateway:token", ["alpha", "--quiet"], ); + expectNative( + translatePublicSandboxArgv("alpha", "hosts-add", [ + "searxng.local", + "192.168.1.105", + "--dry-run", + ]), + "sandbox:hosts:add", + ["alpha", "searxng.local", "192.168.1.105", "--dry-run"], + ); + expectNative(translatePublicSandboxArgv("alpha", "hosts-list", []), "sandbox:hosts:list", [ + "alpha", + ]); + expectNative( + translatePublicSandboxArgv("alpha", "hosts-remove", ["searxng.local", "--dry-run"]), + "sandbox:hosts:remove", + ["alpha", "searxng.local", "--dry-run"], + ); }); it("translates sandbox help to native oclif argv", () => { From 96cbccc8bdeccd3fe01224248a504335a4cf929b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 12:21:00 -0700 Subject: [PATCH 3/6] perf(test): batch Windows bootstrap PowerShell checks --- test/bootstrap-windows.test.ts | 682 +++++++++--------- .../support/bootstrap-windows-test-helpers.ts | 306 ++++++++ 2 files changed, 656 insertions(+), 332 deletions(-) create mode 100644 test/support/bootstrap-windows-test-helpers.ts diff --git a/test/bootstrap-windows.test.ts b/test/bootstrap-windows.test.ts index ce428253a4e..232e6cc3366 100644 --- a/test/bootstrap-windows.test.ts +++ b/test/bootstrap-windows.test.ts @@ -1,69 +1,56 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { testTimeout, testTimeoutOptions } from "./helpers/timeouts"; +import { + BOOTSTRAP_WINDOWS, + POWERSHELL_BATCH_EXEC_TIMEOUT_MS, + POWERSHELL_PROCESS_EXEC_TIMEOUT_MS, + type PowerShellBatchCase, + type PowerShellHarnessResult, + requirePowerShellBatchResult, + resolvePowerShell, + runPowerShellBatch, + runPowerShellProcess, +} from "./support/bootstrap-windows-test-helpers"; -import { execTimeout, testTimeoutOptions } from "./helpers/timeouts"; - -const BOOTSTRAP_WINDOWS = path.join(import.meta.dirname, "..", "scripts", "bootstrap-windows.ps1"); -const POWERSHELL_EXEC_TIMEOUT_MS = execTimeout(20_000); const POWERSHELL_TEST_TIMEOUT = testTimeoutOptions( - Math.max(30_000, POWERSHELL_EXEC_TIMEOUT_MS + 5_000), + Math.max(30_000, POWERSHELL_PROCESS_EXEC_TIMEOUT_MS + 5_000), ); - -function resolvePowerShell() { - for (const command of ["pwsh", "powershell"]) { - const result = spawnSync( - command, - ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], - { encoding: "utf8" }, - ); - if (result.status === 0) return command; - } - return null; -} - const POWERSHELL = resolvePowerShell(); -const itPowerShell = (name: string, fn: () => void) => +const POWERSHELL_BATCH_CASES: PowerShellBatchCase[] = []; +let powerShellBatchResults: ReadonlyMap = new Map(); +const POWERSHELL_BATCH_TEST_TIMEOUT_MS = testTimeout( + Math.max(65_000, POWERSHELL_BATCH_EXEC_TIMEOUT_MS + 5_000), +); +const itPowerShellProcess = (name: string, fn: () => void) => (POWERSHELL ? it : it.skip)(name, POWERSHELL_TEST_TIMEOUT, fn); - -function runPowerShellHarness(script: string) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-bootstrap-windows-")); - const harness = path.join(tmp, "harness.ps1"); - try { - fs.writeFileSync(harness, script); - const result = spawnSync( - POWERSHELL ?? "pwsh", - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", harness], - { - encoding: "utf8", - timeout: POWERSHELL_EXEC_TIMEOUT_MS, - env: { - ...process.env, - TEMP: process.env.TEMP ?? process.env.TMPDIR ?? os.tmpdir(), - TMP: process.env.TMP ?? process.env.TMPDIR ?? os.tmpdir(), - NEMOCLAW_BOOTSTRAP_WINDOWS_SOURCE_ONLY: "1", - SystemRoot: process.env.SystemRoot ?? "C:\\Windows", - }, - }, - ); - return { - stdout: result.stdout, - stderr: result.stderr, - status: result.status ?? 1, - }; - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} +const itPowerShell = ( + name: string, + script: string, + assertions: (result: PowerShellHarnessResult) => void, +) => { + POWERSHELL_BATCH_CASES.push({ id: name, script }); + (POWERSHELL ? it : it.skip)(name, POWERSHELL_TEST_TIMEOUT, () => + assertions(requirePowerShellBatchResult(powerShellBatchResults, name)), + ); +}; describe("Windows bootstrap WSL distro preflight", () => { - itPowerShell("starts Docker Desktop without restart when it was not already running", () => { - const result = runPowerShellHarness(` + beforeAll( + POWERSHELL + ? () => { + powerShellBatchResults = runPowerShellBatch(POWERSHELL, POWERSHELL_BATCH_CASES); + } + : () => undefined, + POWERSHELL_BATCH_TEST_TIMEOUT_MS, + ); + + itPowerShell( + "starts Docker Desktop without restart when it was not already running", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -83,18 +70,18 @@ function Write-Status { param([string]$Message, [string]$Level = 'INFO') } Start-DockerDesktop $script:events | ConvertTo-Json -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "[]"); - expect(parsed).toEqual(["start-Docker Desktop.exe", "wait-ready", "minimize", "foreground"]); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "[]"); + expect(parsed).toEqual(["start-Docker Desktop.exe", "wait-ready", "minimize", "foreground"]); + }, + ); itPowerShell( "restarts Docker Desktop when it was already running before settings changed", - () => { - const result = runPowerShellHarness(` + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -114,8 +101,8 @@ function Write-Status { param([string]$Message, [string]$Level = 'INFO') } Start-DockerDesktop $script:events | ConvertTo-Json -Compress -`); - +`, + (result) => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "[]"); @@ -123,8 +110,9 @@ $script:events | ConvertTo-Json -Compress }, ); - itPowerShell("repairs WSL when status reports the runtime is missing", () => { - const result = runPowerShellHarness(` + itPowerShell( + "repairs WSL when status reports the runtime is missing", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -170,22 +158,24 @@ try { requestReboot = $script:requestReboot outcome = $script:outcome } | ConvertTo-Json -Depth 5 -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Windows reports that the WSL runtime is not installed"); - expect(result.stdout).toContain("Attempting WSL repair: wsl --install --no-distribution"); - expect(result.stdout).toContain("WSL repair command completed successfully."); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--status"]); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--install --no-distribution"]); - expect(parsed.requestReboot).toBe(true); - expect(parsed.outcome).toContain("REBOOT_REQUESTED"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Windows reports that the WSL runtime is not installed"); + expect(result.stdout).toContain("Attempting WSL repair: wsl --install --no-distribution"); + expect(result.stdout).toContain("WSL repair command completed successfully."); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--status"]); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--install --no-distribution"]); + expect(parsed.requestReboot).toBe(true); + expect(parsed.outcome).toContain("REBOOT_REQUESTED"); + }, + ); - itPowerShell("continues when WSL repair succeeds without a reboot-required message", () => { - const result = runPowerShellHarness(` + itPowerShell( + "continues when WSL repair succeeds without a reboot-required message", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -232,26 +222,26 @@ try { statusCalls = $script:statusCalls outcome = $script:outcome } | ConvertTo-Json -Depth 5 -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("WSL repair command completed successfully."); - expect(result.stdout).toContain("WSL status verified after repair."); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toEqual([ - ["wsl.exe", "--status"], - ["wsl.exe", "--install --no-distribution"], - ["wsl.exe", "--status"], - ]); - expect(parsed.statusCalls).toBe(2); - expect(parsed.outcome).toBe("success"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("WSL repair command completed successfully."); + expect(result.stdout).toContain("WSL status verified after repair."); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toEqual([ + ["wsl.exe", "--status"], + ["wsl.exe", "--install --no-distribution"], + ["wsl.exe", "--status"], + ]); + expect(parsed.statusCalls).toBe(2); + expect(parsed.outcome).toBe("success"); + }, + ); itPowerShell( "stops when WSL repair succeeds without reboot but status remains unavailable", - () => { - const result = runPowerShellHarness(` + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -298,8 +288,8 @@ try { statusCalls = $script:statusCalls outcome = $script:outcome } | ConvertTo-Json -Depth 5 -Compress -`); - +`, + (result) => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toContain( @@ -321,8 +311,9 @@ try { }, ); - itPowerShell("prints repair instructions when automatic WSL repair fails", () => { - const result = runPowerShellHarness(` + itPowerShell( + "prints repair instructions when automatic WSL repair fails", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -359,39 +350,41 @@ try { nativeCalls = $script:nativeCalls outcome = $script:outcome } | ConvertTo-Json -Depth 5 -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Windows Subsystem for Linux could not be verified."); - expect(result.stdout).toContain( - "The command 'wsl --status' exited with code 50, so this script cannot safely install or run the Ubuntu-24.04 WSL distro yet.", - ); - expect(result.stdout).toContain("Attempting WSL repair: wsl --install --no-distribution"); - expect(result.stdout).toContain("Automatic WSL repair did not complete."); - const normalizedStdout = result.stdout.replace(/\r\n/g, "\n"); - expect(normalizedStdout).toContain( - "Forbidden (403).\n\nAutomatic WSL repair did not complete.", - ); - expect(normalizedStdout).not.toMatch( - /Forbidden \(403\)\.\n(?:[ \t]*\n){2,}Automatic WSL repair/, - ); - expect(result.stdout).toContain( - "The command 'wsl --install --no-distribution' exited with code 1.", - ); - expect(result.stdout).toContain("The online WSL installer returned Forbidden (403)"); - expect(result.stdout).toContain( - "Offline install docs: https://learn.microsoft.com/en-us/windows/wsl/install#offline-install", - ); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--status"]); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--install --no-distribution"]); - expect(parsed.outcome).toContain("wsl --install --no-distribution failed"); - expect(parsed.outcome).toContain("exit code 1"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Windows Subsystem for Linux could not be verified."); + expect(result.stdout).toContain( + "The command 'wsl --status' exited with code 50, so this script cannot safely install or run the Ubuntu-24.04 WSL distro yet.", + ); + expect(result.stdout).toContain("Attempting WSL repair: wsl --install --no-distribution"); + expect(result.stdout).toContain("Automatic WSL repair did not complete."); + const normalizedStdout = result.stdout.replace(/\r\n/g, "\n"); + expect(normalizedStdout).toContain( + "Forbidden (403).\n\nAutomatic WSL repair did not complete.", + ); + expect(normalizedStdout).not.toMatch( + /Forbidden \(403\)\.\n(?:[ \t]*\n){2,}Automatic WSL repair/, + ); + expect(result.stdout).toContain( + "The command 'wsl --install --no-distribution' exited with code 1.", + ); + expect(result.stdout).toContain("The online WSL installer returned Forbidden (403)"); + expect(result.stdout).toContain( + "Offline install docs: https://learn.microsoft.com/en-us/windows/wsl/install#offline-install", + ); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--status"]); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--install --no-distribution"]); + expect(parsed.outcome).toContain("wsl --install --no-distribution failed"); + expect(parsed.outcome).toContain("exit code 1"); + }, + ); - itPowerShell("attempts WSL repair when WSL 2 cannot start", () => { - const result = runPowerShellHarness(` + itPowerShell( + "attempts WSL repair when WSL 2 cannot start", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -438,23 +431,26 @@ try { nativeCalls = $script:nativeCalls outcome = $script:outcome } | ConvertTo-Json -Depth 5 -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Windows reports that WSL 2 cannot start yet."); - expect(result.stdout).toContain("reboot"); - expect(result.stdout).toContain("enable virtualization"); - expect(result.stdout).toContain("Attempting WSL repair: wsl --install --no-distribution"); - expect(result.stdout).toContain("Automatic WSL repair did not complete."); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--status"]); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--install --no-distribution"]); - expect(parsed.outcome).toContain("wsl --install --no-distribution failed"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Windows reports that WSL 2 cannot start yet."); + expect(result.stdout).toContain("reboot"); + expect(result.stdout).toContain("enable virtualization"); + expect(result.stdout).toContain("Attempting WSL repair: wsl --install --no-distribution"); + expect(result.stdout).toContain("Automatic WSL repair did not complete."); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--status"]); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "--install --no-distribution"]); + expect(parsed.outcome).toContain("wsl --install --no-distribution failed"); + }, + ); - itPowerShell("prints a manual resume command before prompting for reboot", () => { - const result = runPowerShellHarness(` + itPowerShellProcess("prints a manual resume command before prompting for reboot", () => { + const result = runPowerShellProcess( + POWERSHELL ?? "pwsh", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -462,7 +458,8 @@ function Register-ResumeRunOnce { Write-Status 'Registered best-effort reboot re function Read-Host { param([string]$Prompt) Write-Host $Prompt; return 'n' } Request-Reboot -`); +`, + ); expect(result.status).toBe(0); expect(result.stderr).toBe(""); @@ -477,8 +474,7 @@ Request-Reboot itPowerShell( "installs missing Ubuntu 24.04 through first-run setup before Docker integration", - () => { - const result = runPowerShellHarness(` + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -517,8 +513,8 @@ Ensure-UbuntuWsl statusMessages = $script:statusMessages installDistroAtHandoff = $script:InstallDistroAtHandoff } | ConvertTo-Json -Compress -`); - +`, + (result) => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); @@ -553,8 +549,9 @@ Ensure-UbuntuWsl }, ); - itPowerShell("requests reboot when Ubuntu install exits before distro registration", () => { - const result = runPowerShellHarness(` + itPowerShell( + "requests reboot when Ubuntu install exits before distro registration", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -619,50 +616,52 @@ try { requestReboot = $script:requestReboot outcome = $script:outcome } | ConvertTo-Json -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(result.stdout).toContain("WSL install output:"); - expect(result.stdout).toContain( - "The requested operation is successful. Changes will not be effective until the system is rebooted.", - ); - expect(result.stdout).toContain( - "Ubuntu installer command exited. This window will close automatically.", - ); - expect(result.stdout).toContain("[PowerShell transcript metadata redacted.]"); - expect(result.stdout).not.toContain("Windows PowerShell transcript"); - expect(result.stdout).not.toContain("Username:"); - expect(result.stdout).not.toContain("Machine:"); - expect(result.stdout).not.toContain("Host Application:"); - expect(result.stdout).not.toContain("PSVersion:"); - expect(result.stdout).not.toContain("Benutzername:"); - expect(result.stdout).not.toContain("Computer:"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); - expect(result.stdout).not.toContain("TEST-HOST"); - expect(result.stdout).not.toContain("$statusPath = 'status-file'"); - expect(result.stdout).not.toContain("$transcriptStarted = $false"); - expect(result.stdout).not.toContain("Start-Transcript"); - expect(result.stdout).not.toContain("WriteAllText"); - expect(result.stdout).not.toContain( - "& 'C:\\\\WINDOWS\\\\System32\\\\wsl.exe' --install -d 'Ubuntu-24.04'", - ); - expect(result.stdout).not.toContain("Transcript started, output file is"); - expect(result.stdout).not.toContain("Status file is"); - expect(result.stdout).not.toContain("End time:"); - expect(parsed.statusMessages).toContain( - "WARN:Ubuntu-24.04 install command completed, but the distro is not registered yet.", - ); - expect(parsed.statusMessages).toContain( - "WARN:A reboot is required before WSL can finish registering the distro.", - ); - expect(parsed.requestReboot).toBe(true); - expect(parsed.outcome).toContain("REBOOT_REQUESTED"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(result.stdout).toContain("WSL install output:"); + expect(result.stdout).toContain( + "The requested operation is successful. Changes will not be effective until the system is rebooted.", + ); + expect(result.stdout).toContain( + "Ubuntu installer command exited. This window will close automatically.", + ); + expect(result.stdout).toContain("[PowerShell transcript metadata redacted.]"); + expect(result.stdout).not.toContain("Windows PowerShell transcript"); + expect(result.stdout).not.toContain("Username:"); + expect(result.stdout).not.toContain("Machine:"); + expect(result.stdout).not.toContain("Host Application:"); + expect(result.stdout).not.toContain("PSVersion:"); + expect(result.stdout).not.toContain("Benutzername:"); + expect(result.stdout).not.toContain("Computer:"); + expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("TEST-HOST"); + expect(result.stdout).not.toContain("$statusPath = 'status-file'"); + expect(result.stdout).not.toContain("$transcriptStarted = $false"); + expect(result.stdout).not.toContain("Start-Transcript"); + expect(result.stdout).not.toContain("WriteAllText"); + expect(result.stdout).not.toContain( + "& 'C:\\\\WINDOWS\\\\System32\\\\wsl.exe' --install -d 'Ubuntu-24.04'", + ); + expect(result.stdout).not.toContain("Transcript started, output file is"); + expect(result.stdout).not.toContain("Status file is"); + expect(result.stdout).not.toContain("End time:"); + expect(parsed.statusMessages).toContain( + "WARN:Ubuntu-24.04 install command completed, but the distro is not registered yet.", + ); + expect(parsed.statusMessages).toContain( + "WARN:A reboot is required before WSL can finish registering the distro.", + ); + expect(parsed.requestReboot).toBe(true); + expect(parsed.outcome).toContain("REBOOT_REQUESTED"); + }, + ); - itPowerShell("fails closed when a PowerShell transcript header is incomplete", () => { - const result = runPowerShellHarness(` + itPowerShell( + "fails closed when a PowerShell transcript header is incomplete", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -674,17 +673,19 @@ Machine: TEST-HOST '@ Convert-WslInstallLogForDisplay -Log $log -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.trim()).toBe("[PowerShell transcript metadata redacted.]"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); - expect(result.stdout).not.toContain("TEST-HOST"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("[PowerShell transcript metadata redacted.]"); + expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("TEST-HOST"); + }, + ); - itPowerShell("redacts transcript markers when separators are missing", () => { - const result = runPowerShellHarness(` + itPowerShell( + "redacts transcript markers when separators are missing", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -697,18 +698,20 @@ Log file: C:\\Users\\example\\install.log '@ Convert-WslInstallLogForDisplay -Log $log -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.trim()).toBe("[PowerShell transcript metadata redacted.]"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); - expect(result.stdout).not.toContain("TEST-HOST"); - expect(result.stdout).not.toContain("C:\\Users\\example\\install.log"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("[PowerShell transcript metadata redacted.]"); + expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("TEST-HOST"); + expect(result.stdout).not.toContain("C:\\Users\\example\\install.log"); + }, + ); - itPowerShell("recognizes transcript separators with a BOM and indentation", () => { - const result = runPowerShellHarness(` + itPowerShell( + "recognizes transcript separators with a BOM and indentation", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -726,34 +729,36 @@ $log = @( ) -join [Environment]::NewLine Convert-WslInstallLogForDisplay -Log $log -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("[PowerShell transcript metadata redacted.]"); - expect(result.stdout).toContain("Useful WSL output"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); - expect(result.stdout).not.toContain("TEST-HOST"); - expect(result.stdout).not.toContain("Windows PowerShell transcript"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("[PowerShell transcript metadata redacted.]"); + expect(result.stdout).toContain("Useful WSL output"); + expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("TEST-HOST"); + expect(result.stdout).not.toContain("Windows PowerShell transcript"); + }, + ); - itPowerShell("preserves plain WSL output without transcript evidence", () => { - const result = runPowerShellHarness(` + itPowerShell( + "preserves plain WSL output without transcript evidence", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} Convert-WslInstallLogForDisplay -Log 'Invalid distribution name: NotARealDistro' -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.trim()).toBe("Invalid distribution name: NotARealDistro"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("Invalid distribution name: NotARealDistro"); + }, + ); itPowerShell( "does not request reboot when Ubuntu install exits without registering the distro", - () => { - const result = runPowerShellHarness(` + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -793,8 +798,8 @@ try { statusPathExists = Test-Path -LiteralPath $script:statusPath logPathExists = Test-Path -LiteralPath $script:logPath } | ConvertTo-Json -Compress -`); - +`, + (result) => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toContain("WSL install output:"); @@ -819,8 +824,9 @@ try { }, ); - itPowerShell("reports failed Ubuntu install output in the main window", () => { - const result = runPowerShellHarness(` + itPowerShell( + "reports failed Ubuntu install output in the main window", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -848,21 +854,23 @@ try { statusPathExists = Test-Path -LiteralPath $script:statusPath logPathExists = Test-Path -LiteralPath $script:logPath } | ConvertTo-Json -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("WSL install output:"); - expect(result.stdout).toContain("Simulated WSL install failure"); - expect(result.stdout).toContain("NemoClaw on Windows ARM requires WSL2 Ubuntu 24.04."); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.outcome).toContain("WSL distro install command failed with exit code 87"); - expect(parsed.statusPathExists).toBe(false); - expect(parsed.logPathExists).toBe(false); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("WSL install output:"); + expect(result.stdout).toContain("Simulated WSL install failure"); + expect(result.stdout).toContain("NemoClaw on Windows ARM requires WSL2 Ubuntu 24.04."); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.outcome).toContain("WSL distro install command failed with exit code 87"); + expect(parsed.statusPathExists).toBe(false); + expect(parsed.logPathExists).toBe(false); + }, + ); - itPowerShell("verifies WSL startup even when Docker Desktop install is disabled", () => { - const result = runPowerShellHarness(` + itPowerShell( + "verifies WSL startup even when Docker Desktop install is disabled", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -886,18 +894,20 @@ Ensure-UbuntuWsl nativeCalls = $script:nativeCalls statusMessages = $script:statusMessages } | ConvertTo-Json -Depth 5 -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); - expect(parsed.statusMessages).toContain("Verified WSL distro 'Ubuntu-24.04' starts."); - expect(parsed.statusMessages).toContain("Ubuntu-24.04 is ready."); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); + expect(parsed.statusMessages).toContain("Verified WSL distro 'Ubuntu-24.04' starts."); + expect(parsed.statusMessages).toContain("Ubuntu-24.04 is ready."); + }, + ); - itPowerShell("fails an already registered distro that cannot start", () => { - const result = runPowerShellHarness(` + itPowerShell( + "fails an already registered distro that cannot start", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -927,18 +937,22 @@ try { statusMessages = $script:statusMessages outcome = $script:outcome } | ConvertTo-Json -Depth 5 -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); - expect(parsed.outcome).toContain("WSL distro 'Ubuntu-24.04' is registered but could not start"); - expect(parsed.statusMessages).not.toContain("Ubuntu-24.04 is ready."); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); + expect(parsed.outcome).toContain( + "WSL distro 'Ubuntu-24.04' is registered but could not start", + ); + expect(parsed.statusMessages).not.toContain("Ubuntu-24.04 is ready."); + }, + ); - itPowerShell("prints the issue 3974 guidance when the deferred Ubuntu launch fails", () => { - const result = runPowerShellHarness(` + itPowerShell( + "prints the issue 3974 guidance when the deferred Ubuntu launch fails", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -950,22 +964,24 @@ $script:InstallDistroAtHandoff = $true try { Open-UbuntuForInstaller Write-Host 'UNEXPECTED_SUCCESS' - exit 3 + throw 'UNEXPECTED_SUCCESS' } catch { Write-Host "CAUGHT: $($_.Exception.Message)" } -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("NemoClaw on Windows ARM requires WSL2 Ubuntu 24.04."); - expect(result.stdout).toContain("Please run: wsl --install -d Ubuntu-24.04"); - expect(result.stdout).toContain("Then re-run this installer."); - expect(result.stdout).toContain("CAUGHT: launch failed"); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("NemoClaw on Windows ARM requires WSL2 Ubuntu 24.04."); + expect(result.stdout).toContain("Please run: wsl --install -d Ubuntu-24.04"); + expect(result.stdout).toContain("Then re-run this installer."); + expect(result.stdout).toContain("CAUGHT: launch failed"); + }, + ); - itPowerShell("opens the final Ubuntu handoff as one plain PowerShell-hosted WSL launch", () => { - const result = runPowerShellHarness(` + itPowerShell( + "opens the final Ubuntu handoff as one plain PowerShell-hosted WSL launch", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -994,28 +1010,30 @@ Open-UbuntuForInstaller stopCalls = $script:stopCalls startProcessCalls = $script:startProcessCalls } | ConvertTo-Json -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.nativeCalls).toEqual([]); - expect(parsed.stopCalls).toEqual([]); - expect(parsed.startProcessCalls).toHaveLength(1); - expect(parsed.startProcessCalls[0][0]).toBe("powershell.exe"); - expect(parsed.startProcessCalls[0][1]).toContain("-Command"); - expect(parsed.startProcessCalls[0][1]).toContain("& 'wsl.exe' -d 'Ubuntu-24.04'"); - for (const launch of parsed.startProcessCalls.map((call: string[]) => call[1])) { - expect(launch).not.toContain("-- "); - expect(launch).not.toContain("bash"); - expect(launch).not.toContain("curl"); - expect(launch).not.toContain("true"); - expect(launch).not.toContain("nemoclaw.sh"); - } - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toEqual([]); + expect(parsed.stopCalls).toEqual([]); + expect(parsed.startProcessCalls).toHaveLength(1); + expect(parsed.startProcessCalls[0][0]).toBe("powershell.exe"); + expect(parsed.startProcessCalls[0][1]).toContain("-Command"); + expect(parsed.startProcessCalls[0][1]).toContain("& 'wsl.exe' -d 'Ubuntu-24.04'"); + for (const launch of parsed.startProcessCalls.map((call: string[]) => call[1])) { + expect(launch).not.toContain("-- "); + expect(launch).not.toContain("bash"); + expect(launch).not.toContain("curl"); + expect(launch).not.toContain("true"); + expect(launch).not.toContain("nemoclaw.sh"); + } + }, + ); - itPowerShell("repairs Docker Desktop WSL integration settings for the target distro", () => { - const result = runPowerShellHarness(` + itPowerShell( + "repairs Docker Desktop WSL integration settings for the target distro", + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -1041,22 +1059,22 @@ $result = [pscustomobject]@{ } Remove-Item -Path $settingsDir -Recurse -Force $result | ConvertTo-Json -Compress -`); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); - expect(parsed.wslEngineEnabled).toBe(true); - expect(parsed.enableIntegrationWithDefaultWslDistro).toBe(false); - expect(parsed.integratedWslDistros).toContain("Debian"); - expect(parsed.integratedWslDistros).toContain("Ubuntu-24.04"); - expect(parsed.backupCount).toBe(1); - }); +`, + (result) => { + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.wslEngineEnabled).toBe(true); + expect(parsed.enableIntegrationWithDefaultWslDistro).toBe(false); + expect(parsed.integratedWslDistros).toContain("Debian"); + expect(parsed.integratedWslDistros).toContain("Ubuntu-24.04"); + expect(parsed.backupCount).toBe(1); + }, + ); itPowerShell( "creates Docker Desktop WSL integration settings when the settings file is missing", - () => { - const result = runPowerShellHarness(` + ` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -1075,8 +1093,8 @@ $settings = Get-Content -Path $settingsPath -Raw | ConvertFrom-Json integratedWslDistros = $settings.integratedWslDistros backupCount = @(Get-ChildItem -Path $dockerDir -Filter 'settings-store.json.bak.*' -ErrorAction SilentlyContinue).Count } | ConvertTo-Json -Compress -`); - +`, + (result) => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); diff --git a/test/support/bootstrap-windows-test-helpers.ts b/test/support/bootstrap-windows-test-helpers.ts new file mode 100644 index 00000000000..369454e9646 --- /dev/null +++ b/test/support/bootstrap-windows-test-helpers.ts @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { execTimeout } from "../helpers/timeouts"; + +export const BOOTSTRAP_WINDOWS = path.join( + import.meta.dirname, + "..", + "..", + "scripts", + "bootstrap-windows.ps1", +); +export const POWERSHELL_PROCESS_EXEC_TIMEOUT_MS = execTimeout(20_000); +export const POWERSHELL_BATCH_EXEC_TIMEOUT_MS = execTimeout(60_000); + +const BATCH_RESULT_PREFIX = "NEMOCLAW_POWERSHELL_BATCH_RESULT="; +const POWERSHELL_BATCH_RUNNER = String.raw` +param( + [Parameter(Mandatory = $true)] + [string]$ManifestPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$resultPrefix = 'NEMOCLAW_POWERSHELL_BATCH_RESULT=' + +function Get-ProcessEnvironmentSnapshot { + $snapshot = @{} + foreach ($entry in [Environment]::GetEnvironmentVariables('Process').GetEnumerator()) { + $snapshot[[string]$entry.Key] = [string]$entry.Value + } + return $snapshot +} + +function Restore-ProcessEnvironment { + param([Parameter(Mandatory = $true)] [hashtable]$Snapshot) + + $current = [Environment]::GetEnvironmentVariables('Process') + foreach ($name in @($current.Keys)) { + if (-not $Snapshot.ContainsKey([string]$name)) { + [Environment]::SetEnvironmentVariable([string]$name, $null, 'Process') + } + } + foreach ($entry in $Snapshot.GetEnumerator()) { + [Environment]::SetEnvironmentVariable( + [string]$entry.Key, + [string]$entry.Value, + 'Process' + ) + } +} + +function Convert-CaseOutput { + param([AllowEmptyCollection()] [object[]]$Items) + + $lines = @( + foreach ($item in @($Items)) { + if ($item -is [System.Management.Automation.InformationRecord]) { + [string]$item.MessageData + } elseif ($item -is [System.Management.Automation.WarningRecord]) { + [string]$item.Message + } elseif ($item -is [System.Management.Automation.VerboseRecord]) { + [string]$item.Message + } elseif ($item -is [System.Management.Automation.DebugRecord]) { + [string]$item.Message + } else { + [string]$item + } + } + ) + return ($lines -join [Environment]::NewLine) +} + +$manifest = Get-Content -LiteralPath $ManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json +$records = @( + foreach ($case in @($manifest.cases)) { + $environmentSnapshot = Get-ProcessEnvironmentSnapshot + $locationSnapshot = Get-Location + $lastExitCodeVariable = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $status = 0 + $stdout = '' + $stderr = '' + + try { + $global:LASTEXITCODE = 0 + $caseScript = [ScriptBlock]::Create([string]$case.script) + $mergedOutput = @( + New-Module -ScriptBlock $caseScript -ReturnResult -Function @() 2>&1 3>&1 4>&1 5>&1 6>&1 + ) + $output = @( + $mergedOutput | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } + ) + $errors = @( + $mergedOutput | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } + ) + $stdout = Convert-CaseOutput -Items $output + $stderr = Convert-CaseOutput -Items $errors + } catch { + $status = 1 + $stderr = [string]$_ + } finally { + $stopwatch.Stop() + Restore-ProcessEnvironment -Snapshot $environmentSnapshot + Set-Location -LiteralPath $locationSnapshot.Path + if ($null -eq $lastExitCodeVariable) { + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + } else { + Set-Variable -Name LASTEXITCODE -Scope Global -Value $lastExitCodeVariable.Value + } + } + + [pscustomobject]@{ + id = [string]$case.id + status = $status + stdout = $stdout + stderr = $stderr + durationMs = $stopwatch.Elapsed.TotalMilliseconds + } + } +) + +$json = [pscustomobject]@{ + version = 1 + cases = $records +} | ConvertTo-Json -Depth 5 -Compress +$payload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) +[Console]::Out.WriteLine($resultPrefix + $payload) +`; + +export type PowerShellHarnessResult = { + stdout: string; + stderr: string; + status: number; + durationMs?: number; +}; + +export type PowerShellBatchCase = { + id: string; + script: string; +}; + +type PowerShellBatchPayload = { + version: number; + cases: Array<{ + id: string; + status: number; + stdout?: string | null; + stderr?: string | null; + durationMs?: number; + }>; +}; + +function powerShellEnvironment(): NodeJS.ProcessEnv { + return { + ...process.env, + TEMP: process.env.TEMP ?? process.env.TMPDIR ?? os.tmpdir(), + TMP: process.env.TMP ?? process.env.TMPDIR ?? os.tmpdir(), + NEMOCLAW_BOOTSTRAP_WINDOWS_SOURCE_ONLY: "1", + SystemRoot: process.env.SystemRoot ?? "C:\\Windows", + }; +} + +export function resolvePowerShell(): string | null { + for (const command of ["pwsh", "powershell"]) { + const result = spawnSync( + command, + ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], + { encoding: "utf8" }, + ); + if (result.status === 0) return command; + } + return null; +} + +export function runPowerShellProcess(powerShell: string, script: string): PowerShellHarnessResult { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-bootstrap-windows-")); + const harness = path.join(tmp, "harness.ps1"); + try { + fs.writeFileSync(harness, script); + const result = spawnSync( + powerShell, + ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", harness], + { + encoding: "utf8", + timeout: POWERSHELL_PROCESS_EXEC_TIMEOUT_MS, + env: powerShellEnvironment(), + }, + ); + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status ?? 1, + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +function decodeBatchPayload(result: PowerShellHarnessResult): PowerShellBatchPayload { + const marker = result.stdout + .split(/\r?\n/) + .filter((line) => line.startsWith(BATCH_RESULT_PREFIX)) + .at(-1); + if (!marker) { + throw new Error( + `PowerShell batch emitted no result marker (status ${result.status}).\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + const encoded = marker.slice(BATCH_RESULT_PREFIX.length); + return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as PowerShellBatchPayload; +} + +function validateBatchPayload( + payload: PowerShellBatchPayload, + cases: readonly PowerShellBatchCase[], +): void { + if (payload.version !== 1) { + throw new Error(`Unsupported PowerShell batch result version: ${payload.version}`); + } + const expectedIds = new Set(cases.map(({ id }) => id)); + const actualIds = new Set(payload.cases.map(({ id }) => id)); + if (expectedIds.size !== cases.length) { + throw new Error("PowerShell batch case IDs must be unique"); + } + if (actualIds.size !== payload.cases.length) { + throw new Error("PowerShell batch result IDs must be unique"); + } + const missing = [...expectedIds].filter((id) => !actualIds.has(id)); + const unexpected = [...actualIds].filter((id) => !expectedIds.has(id)); + if (missing.length > 0 || unexpected.length > 0) { + throw new Error( + `PowerShell batch result mismatch (missing: ${missing.join(", ") || "none"}; unexpected: ${unexpected.join(", ") || "none"})`, + ); + } +} + +export function runPowerShellBatch( + powerShell: string, + cases: readonly PowerShellBatchCase[], +): Map { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-bootstrap-windows-batch-")); + const runner = path.join(tmp, "batch-runner.ps1"); + const manifest = path.join(tmp, "cases.json"); + try { + fs.writeFileSync(runner, POWERSHELL_BATCH_RUNNER); + fs.writeFileSync(manifest, JSON.stringify({ cases })); + const processResult = spawnSync( + powerShell, + [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + runner, + "-ManifestPath", + manifest, + ], + { + encoding: "utf8", + timeout: POWERSHELL_BATCH_EXEC_TIMEOUT_MS, + env: powerShellEnvironment(), + }, + ); + const result = { + stdout: processResult.stdout, + stderr: processResult.stderr, + status: processResult.status ?? 1, + }; + if (result.status !== 0 || result.stderr.trim() !== "") { + throw new Error( + `PowerShell batch failed (status ${result.status}).\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + const payload = decodeBatchPayload(result); + validateBatchPayload(payload, cases); + return new Map( + payload.cases.map((entry) => [ + entry.id, + { + status: entry.status, + stdout: String(entry.stdout ?? ""), + stderr: String(entry.stderr ?? ""), + durationMs: entry.durationMs, + }, + ]), + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +export function requirePowerShellBatchResult( + results: ReadonlyMap, + id: string, +): PowerShellHarnessResult { + const result = results.get(id); + if (!result) throw new Error(`PowerShell batch returned no result for: ${id}`); + return result; +} From 5b057c5a1a3021a348df991877098ca5311fb019 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 12:26:32 -0700 Subject: [PATCH 4/6] perf(test): keep CLI dispatch diagnostics in process --- test/cli-oclif-compatibility.test.ts | 168 +-------- test/cli/dispatch-basics.test.ts | 373 +++++++++---------- test/support/public-dispatch-test-harness.ts | 138 +++++++ 3 files changed, 320 insertions(+), 359 deletions(-) create mode 100644 test/support/public-dispatch-test-harness.ts diff --git a/test/cli-oclif-compatibility.test.ts b/test/cli-oclif-compatibility.test.ts index 5b65f8d0c16..647f9eb735a 100644 --- a/test/cli-oclif-compatibility.test.ts +++ b/test/cli-oclif-compatibility.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import SandboxStatusCommand from "../src/commands/sandbox/status"; import StatusCommand from "../src/commands/status"; +import { withDirectPublicDispatch } from "./support/public-dispatch-test-harness.js"; const require = createRequire(import.meta.url); const requireCache: Record = require.cache as any; @@ -19,163 +20,6 @@ function restoreCache(path: string, prior: unknown): void { else delete requireCache[path]; } -type DirectStatusDispatchHarness = { - dispatchCli: (argv: string[]) => Promise; - exitSpy: ReturnType; - runOclifArgv: ReturnType; - runOclifCommandById: ReturnType; - stderr: string[]; -}; - -async function withDirectStatusDispatch( - run: (harness: DirectStatusDispatchHarness) => Promise, -): Promise { - const publicDispatchPath = require.resolve("../src/lib/cli/public-dispatch.js"); - const oclifRunnerPath = require.resolve("../src/lib/cli/oclif-runner.js"); - const sandboxConnectPath = require.resolve("../src/lib/actions/sandbox/connect.js"); - const priorPublicDispatch = require.cache[publicDispatchPath]; - const priorOclifRunner = require.cache[oclifRunnerPath]; - const priorSandboxConnect = require.cache[sandboxConnectPath]; - const runOclifArgv = vi.fn(async () => undefined); - const runOclifCommandById = vi.fn(async () => undefined); - const stderr: string[] = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((message = "") => { - stderr.push(String(message)); - }); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { - throw new Error(`process.exit:${String(code)}`); - }) as never); - - requireCache[oclifRunnerPath] = { - id: oclifRunnerPath, - filename: oclifRunnerPath, - loaded: true, - exports: { runOclifArgv, runOclifCommandById }, - } as any; - requireCache[sandboxConnectPath] = { - id: sandboxConnectPath, - filename: sandboxConnectPath, - loaded: true, - exports: { - isSandboxConnectFlag: vi.fn(() => false), - parseSandboxConnectArgs: vi.fn(), - printSandboxConnectHelp: vi.fn(), - }, - } as any; - - try { - delete require.cache[publicDispatchPath]; - const { dispatchCli } = require(publicDispatchPath); - await run({ dispatchCli, exitSpy, runOclifArgv, runOclifCommandById, stderr }); - } finally { - errorSpy.mockRestore(); - exitSpy.mockRestore(); - restoreCache(publicDispatchPath, priorPublicDispatch); - restoreCache(oclifRunnerPath, priorOclifRunner); - restoreCache(sandboxConnectPath, priorSandboxConnect); - } -} - -type DirectSandboxRecoveryDispatchHarness = { - dispatchCli: (argv: string[]) => Promise; - exitSpy: ReturnType; - getSandbox: ReturnType; - listSandboxes: ReturnType; - recoverRegistryEntries: ReturnType; - runOclifArgv: ReturnType; - runOclifCommandById: ReturnType; - sandboxes: Map; - stderr: string[]; -}; - -async function withDirectSandboxRecoveryDispatch( - run: (harness: DirectSandboxRecoveryDispatchHarness) => Promise, -): Promise { - const publicDispatchPath = require.resolve("../src/lib/cli/public-dispatch.js"); - const oclifRunnerPath = require.resolve("../src/lib/cli/oclif-runner.js"); - const sandboxConnectPath = require.resolve("../src/lib/actions/sandbox/connect.js"); - const registryPath = require.resolve("../src/lib/state/registry.js"); - const registryRecoveryPath = require.resolve("../src/lib/registry-recovery-action.js"); - const priorPublicDispatch = require.cache[publicDispatchPath]; - const priorOclifRunner = require.cache[oclifRunnerPath]; - const priorSandboxConnect = require.cache[sandboxConnectPath]; - const priorRegistry = require.cache[registryPath]; - const priorRegistryRecovery = require.cache[registryRecoveryPath]; - const sandboxes = new Map(); - const getSandbox = vi.fn((name: string) => sandboxes.get(name) ?? null); - const listSandboxes = vi.fn(() => ({ - sandboxes: [...sandboxes.values()], - defaultSandbox: null, - })); - const recoverRegistryEntries = vi.fn(async () => ({ - ...listSandboxes(), - recoveredFromSession: false, - recoveredFromGateway: 0, - })); - const runOclifArgv = vi.fn(async () => undefined); - const runOclifCommandById = vi.fn(async () => undefined); - const stderr: string[] = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((message = "") => { - stderr.push(String(message)); - }); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { - throw new Error(`process.exit:${String(code)}`); - }) as never); - - requireCache[registryPath] = { - id: registryPath, - filename: registryPath, - loaded: true, - exports: { getSandbox, listSandboxes }, - } as any; - requireCache[registryRecoveryPath] = { - id: registryRecoveryPath, - filename: registryRecoveryPath, - loaded: true, - exports: { recoverRegistryEntries }, - } as any; - requireCache[oclifRunnerPath] = { - id: oclifRunnerPath, - filename: oclifRunnerPath, - loaded: true, - exports: { runOclifArgv, runOclifCommandById }, - } as any; - requireCache[sandboxConnectPath] = { - id: sandboxConnectPath, - filename: sandboxConnectPath, - loaded: true, - exports: { - isSandboxConnectFlag: vi.fn(() => false), - parseSandboxConnectArgs: vi.fn(), - printSandboxConnectHelp: vi.fn(), - }, - } as any; - - try { - delete require.cache[publicDispatchPath]; - const { dispatchCli } = require(publicDispatchPath); - await run({ - dispatchCli, - exitSpy, - getSandbox, - listSandboxes, - recoverRegistryEntries, - runOclifArgv, - runOclifCommandById, - sandboxes, - stderr, - }); - } finally { - errorSpy.mockRestore(); - exitSpy.mockRestore(); - restoreCache(publicDispatchPath, priorPublicDispatch); - restoreCache(oclifRunnerPath, priorOclifRunner); - restoreCache(sandboxConnectPath, priorSandboxConnect); - restoreCache(registryPath, priorRegistry); - restoreCache(registryRecoveryPath, priorRegistryRecovery); - } -} - describe("oclif compatibility dispatch", () => { afterEach(() => { vi.restoreAllMocks(); @@ -382,7 +226,7 @@ describe("oclif compatibility dispatch", () => { }); it("recovers a requested sandbox, rereads the registry, and dispatches connect", async () => { - await withDirectSandboxRecoveryDispatch( + await withDirectPublicDispatch( async ({ dispatchCli, getSandbox, @@ -424,7 +268,7 @@ describe("oclif compatibility dispatch", () => { }); it("guides a missing requested sandbox after recovery finds a different live sandbox", async () => { - await withDirectSandboxRecoveryDispatch( + await withDirectPublicDispatch( async ({ dispatchCli, exitSpy, @@ -637,7 +481,7 @@ describe("oclif compatibility dispatch", () => { }); it("corrects a single sandbox-like global status argument without a CLI subprocess", async () => { - await withDirectStatusDispatch( + await withDirectPublicDispatch( async ({ dispatchCli, exitSpy, runOclifArgv, runOclifCommandById, stderr }) => { const cases = [ { argv: ["status", "alpha"], command: "nemoclaw alpha status" }, @@ -675,7 +519,7 @@ describe("oclif compatibility dispatch", () => { }); it("leaves ambiguous or unsafe global status arguments to the strict parser", async () => { - await withDirectStatusDispatch( + await withDirectPublicDispatch( async ({ dispatchCli, exitSpy, runOclifArgv, runOclifCommandById, stderr }) => { const cases = [ ["status", "--bogus"], @@ -729,7 +573,7 @@ describe("oclif compatibility dispatch", () => { }); it("routes sandbox status help directly and keeps its JSON help metadata", async () => { - await withDirectStatusDispatch(async ({ dispatchCli, runOclifArgv, runOclifCommandById }) => { + await withDirectPublicDispatch(async ({ dispatchCli, runOclifArgv, runOclifCommandById }) => { await dispatchCli(["alpha", "status", "--help"]); expect(runOclifCommandById).toHaveBeenCalledWith( "sandbox:status", diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index 916657cf9ef..f118a1a02f9 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { help } from "../../src/lib/actions/root-help.js"; import { normalizeArgv } from "../../src/lib/cli/argv-normalizer.js"; import { globalCommandTokens } from "../../src/lib/cli/command-registry.js"; +import { withDirectPublicDispatch } from "../support/public-dispatch-test-harness.js"; import { CLI, @@ -169,14 +170,40 @@ describe("CLI dispatch", () => { expect(r.out).toContain("langchain-deepagents-code"); }); - it("exits 0 for --help", () => { - expect(run("--help").code).toBe(0); + it("exits 0 for --help", async () => { + const dockerHost = process.env.DOCKER_HOST; + + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, runOclifArgv, runOclifCommandById }) => { + await dispatchCli(["--help"]); + + expect(runOclifCommandById).toHaveBeenCalledWith( + "root:help", + [], + expect.objectContaining({ rootDir: process.cwd() }), + ); + expect(runOclifArgv).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }, + ); + + expect(process.env.DOCKER_HOST).toBe(dockerHost); }); - it("version exits 0", () => { - const r = run("version"); - expect(r.code).toBe(0); - expect(r.out.trim()).toMatch(/^nemoclaw v/); + it("version exits 0", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, runOclifArgv, runOclifCommandById }) => { + await dispatchCli(["version"]); + + expect(runOclifCommandById).toHaveBeenCalledWith( + "root:version", + [], + expect.objectContaining({ rootDir: process.cwd() }), + ); + expect(runOclifArgv).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }, + ); }); it("normalizes -h as a root-help alias", () => { @@ -189,147 +216,124 @@ describe("CLI dispatch", () => { }); it("no args exits 0 (shows help)", () => { - const r = run(""); - expect(r.code).toBe(0); - expect(r.out.includes("nemoclaw")).toBeTruthy(); + const result = run(""); + + expect(result.code).toBe(0); + expect(result.out).toContain("nemoclaw"); }); - it("bare unknown name surfaces sandbox-not-found (#2164)", testTimeoutOptions(35_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-unknown-sandbox-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync(path.join(localBin, "openshell"), "#!/usr/bin/env bash\nexit 1\n", { - mode: 0o755, - }); + it("bare unknown name surfaces sandbox-not-found (#2164)", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, recoverRegistryEntries, stderr }) => { + await expect(dispatchCli(["boguscmd"])).rejects.toThrow("process.exit:1"); - const r = runWithEnv( - "boguscmd", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, + expect(recoverRegistryEntries).toHaveBeenCalledWith({ + requestedSandboxName: "boguscmd", + }); + expect(stderr.join("\n")).toContain("Sandbox 'boguscmd' does not exist"); + expect(exitSpy).toHaveBeenCalledWith(1); }, - execTimeout(30_000), ); - expect(r.code).toBe(1); - expect(r.out.includes("Sandbox 'boguscmd' does not exist")).toBeTruthy(); }); - it("unknown command with non-sandbox action exits 1", () => { - const r = run("boguscmd boguscmd2"); - expect(r.code).toBe(1); - expect(r.out.includes("Unknown command")).toBeTruthy(); + it("unknown command with non-sandbox action exits 1", async () => { + await withDirectPublicDispatch(async ({ dispatchCli, exitSpy, stderr }) => { + await expect(dispatchCli(["boguscmd", "boguscmd2"])).rejects.toThrow("process.exit:1"); + + expect(stderr.join("\n")).toContain("Unknown command: boguscmd"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); - it("routes a missing-sandbox inference action through name validation, not Unknown action (#5977)", () => { + it("routes a missing-sandbox inference action through name validation, not Unknown action (#5977)", async () => { // `inference` is a known sandbox action token, so a missing sandbox name // must surface the sandbox-not-found path — never the NemoClaw-owned // `Unknown action: inference` reporter that originally broke the workflow. - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inference-missing-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, recoverRegistryEntries, stderr }) => { + await expect(dispatchCli(["missing-sb", "inference", "get"])).rejects.toThrow( + "process.exit:1", + ); + + const output = stderr.join("\n"); + expect(recoverRegistryEntries).toHaveBeenCalledWith({ + requestedSandboxName: "missing-sb", + }); + expect(output).toContain("Sandbox 'missing-sb' does not exist"); + expect(output).not.toContain("Unknown action: inference"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, ); - - try { - const r = runWithEnv( - "missing-sb inference get", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "0", - }, - execTimeout(30_000), - ); - expect(r.code).toBe(1); - expect(r.out).toContain("Sandbox 'missing-sb' does not exist"); - expect(r.out).not.toContain("Unknown action: inference"); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } }); - it("lists inference among Valid actions when reporting an unknown sandbox action (#5977)", () => { + it("lists inference among Valid actions when reporting an unknown sandbox action (#5977)", async () => { // The reporter-facing action list is derived from registered sandbox // commands; the new sandbox-scoped inference route must appear there so // users discover it instead of hitting the old dead end. - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inference-valid-actions-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, recoverRegistryEntries, stderr }) => { + await expect(dispatchCli(["alpha", "bogus-action-5977"])).rejects.toThrow("process.exit:1"); + + const output = stderr.join("\n"); + expect(output).toContain("Unknown action: bogus-action-5977"); + expect(output).toMatch(/Valid actions:.*\binference\b/); + expect(recoverRegistryEntries).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + { sandboxNames: ["alpha"] }, ); - writeSandboxRegistry(home, "alpha"); + }); - try { - const r = runWithEnv( - "alpha bogus-action-5977", + it("points OpenShell-only commands at openshell instead of sandbox connect (#3388)", async () => { + await withDirectPublicDispatch(async ({ dispatchCli, exitSpy, resetObservedCalls, stderr }) => { + const cases = [ { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "0", + argv: ["term"], + entered: "term", + command: "Run: openshell term", + note: null, }, - execTimeout(30_000), - ); - expect(r.code).toBe(1); - expect(r.out).toContain("Unknown action: bogus-action-5977"); - expect(r.out).toMatch(/Valid actions:.*\binference\b/); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); + { + argv: ["policy", "set"], + entered: "policy set", + command: "Run: openshell policy set --policy --wait ", + note: "nemoclaw policy-add ", + }, + { + argv: ["gateway", "stop"], + entered: "gateway stop", + command: "Run: openshell gateway stop -g nemoclaw", + note: null, + }, + ]; - it("points OpenShell-only commands at openshell instead of sandbox connect (#3388)", () => { - const term = run("term"); - expect(term.code).toBe(1); - expect(term.out).toContain("Unknown nemoclaw command: term"); - expect(term.out).toContain("Run: openshell term"); - expect(term.out).not.toContain("Try: nemoclaw connect"); - - const policy = run("policy set"); - expect(policy.code).toBe(1); - expect(policy.out).toContain("Unknown nemoclaw command: policy set"); - expect(policy.out).toContain( - "Run: openshell policy set --policy --wait ", - ); - expect(policy.out).toContain("nemoclaw policy-add "); - expect(policy.out).not.toContain("Try: nemoclaw connect"); - - const gateway = run("gateway stop"); - expect(gateway.code).toBe(1); - expect(gateway.out).toContain("Unknown nemoclaw command: gateway stop"); - expect(gateway.out).toContain("Run: openshell gateway stop -g nemoclaw"); - expect(gateway.out).not.toContain("Try: nemoclaw connect"); + for (const testCase of cases) { + resetObservedCalls(); + + await expect(dispatchCli(testCase.argv)).rejects.toThrow("process.exit:1"); + + const output = stderr.join("\n"); + expect(output).toContain(`Unknown nemoclaw command: ${testCase.entered}`); + expect(output).toContain(testCase.command); + if (testCase.note) expect(output).toContain(testCase.note); + expect(output).not.toContain("Try: nemoclaw connect"); + expect(exitSpy).toHaveBeenCalledWith(1); + } + }); }); - it("suggests list for a mistyped list command", () => { - // Isolate from any real openshell gateway on the host so recovery - // doesn't intercept the typo suggestion. - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-typo-suggest-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); + it("suggests list for a mistyped list command", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, recoverRegistryEntries, stderr }) => { + await expect(dispatchCli(["liost"])).rejects.toThrow("process.exit:1"); - try { - const r = runWithEnv("liost", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "0", - }); - expect(r.code).toBe(1); - expect(r.out).toContain("Unknown command: liost"); - expect(r.out).toContain("Did you mean: nemoclaw list?"); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } + const output = stderr.join("\n"); + expect(recoverRegistryEntries).toHaveBeenCalledWith({ requestedSandboxName: "liost" }); + expect(output).toContain("Unknown command: liost"); + expect(output).toContain("Did you mean: nemoclaw list?"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + ); }); it("recovers a live sandbox before suggesting a bare command typo", () => { @@ -426,91 +430,66 @@ describe("CLI dispatch", () => { } }); - it("explains sandbox connect command order when the sandbox name is last", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-order-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, + it("explains sandbox connect command order when the sandbox name is last", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, recoverRegistryEntries, stderr }) => { + await expect(dispatchCli(["hermes", "connect", "alpha"])).rejects.toThrow("process.exit:1"); + + const output = stderr.join("\n"); + expect(recoverRegistryEntries).toHaveBeenCalledWith({ requestedSandboxName: "hermes" }); + expect(output).toContain("Sandbox 'hermes' does not exist"); + expect(output).toContain("Command order is: nemoclaw connect"); + expect(output).toContain("Did you mean: nemoclaw alpha connect?"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + { sandboxNames: ["alpha"] }, ); - - const r = runWithEnv("hermes connect alpha", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out).toContain("Sandbox 'hermes' does not exist"); - expect(r.out).toContain("Command order is: nemoclaw connect"); - expect(r.out).toContain("Did you mean: nemoclaw alpha connect?"); }); - it("suggests the closest registered sandbox name for a mistyped sandbox action", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-typo-action-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "my-assistant"); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("my-assitant status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out).toContain("Sandbox 'my-assitant' does not exist"); - expect(r.out).toContain("Did you mean: nemoclaw my-assistant status?"); - expect(r.out).toContain("Registered sandboxes: my-assistant"); - }); + it("suggests the closest registered sandbox name for a mistyped sandbox action", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, stderr }) => { + await expect(dispatchCli(["my-assitant", "status"])).rejects.toThrow("process.exit:1"); - it("suggests the closest registered sandbox name when a bare typo lacks a known action", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-typo-bare-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "my-assistant"); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, + const output = stderr.join("\n"); + expect(output).toContain("Sandbox 'my-assitant' does not exist"); + expect(output).toContain("Did you mean: nemoclaw my-assistant status?"); + expect(output).toContain("Registered sandboxes: my-assistant"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + { sandboxNames: ["my-assistant"] }, ); - - const r = runWithEnv("my-assitant unknownaction", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out).toContain("Unknown command: my-assitant"); - expect(r.out).toContain("Did you mean: nemoclaw my-assistant connect?"); - expect(r.out).toContain("Registered sandboxes: my-assistant"); }); - it("omits the did-you-mean hint when no registered sandbox is within edit-distance threshold", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-typo-miss-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha"); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, + it("suggests the closest registered sandbox name when a bare typo lacks a known action", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, stderr }) => { + await expect(dispatchCli(["my-assitant", "unknownaction"])).rejects.toThrow( + "process.exit:1", + ); + + const output = stderr.join("\n"); + expect(output).toContain("Unknown command: my-assitant"); + expect(output).toContain("Did you mean: nemoclaw my-assistant connect?"); + expect(output).toContain("Registered sandboxes: my-assistant"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + { sandboxNames: ["my-assistant"] }, ); + }); - const r = runWithEnv("zulu-quebec status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); + it("omits the did-you-mean hint when no registered sandbox is within edit-distance threshold", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, exitSpy, stderr }) => { + await expect(dispatchCli(["zulu-quebec", "status"])).rejects.toThrow("process.exit:1"); - expect(r.code).toBe(1); - expect(r.out).toContain("Sandbox 'zulu-quebec' does not exist"); - expect(r.out).not.toContain("Did you mean: nemoclaw alpha"); - expect(r.out).toContain("Registered sandboxes: alpha"); + const output = stderr.join("\n"); + expect(output).toContain("Sandbox 'zulu-quebec' does not exist"); + expect(output).not.toContain("Did you mean: nemoclaw alpha"); + expect(output).toContain("Registered sandboxes: alpha"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + { sandboxNames: ["alpha"] }, + ); }); }); diff --git a/test/support/public-dispatch-test-harness.ts b/test/support/public-dispatch-test-harness.ts new file mode 100644 index 00000000000..11a0575d71e --- /dev/null +++ b/test/support/public-dispatch-test-harness.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +type SandboxStub = { name: string }; + +export type DirectPublicDispatchHarness = { + dispatchCli: (argv: string[]) => Promise; + exitSpy: ReturnType; + getSandbox: ReturnType; + listSandboxes: ReturnType; + recoverRegistryEntries: ReturnType; + resetObservedCalls: () => void; + runOclifArgv: ReturnType; + runOclifCommandById: ReturnType; + sandboxes: Map; + stderr: string[]; +}; + +type DirectPublicDispatchOptions = { + sandboxNames?: readonly string[]; +}; + +const requireCache = require.cache as Record; + +function restoreCache(modulePath: string, prior: NodeModule | undefined): void { + if (prior) requireCache[modulePath] = prior; + else delete requireCache[modulePath]; +} + +function cacheModule(modulePath: string, exports: Record): void { + requireCache[modulePath] = { + id: modulePath, + filename: modulePath, + loaded: true, + exports, + } as NodeModule; +} + +/** + * Run the public dispatcher against deterministic in-memory registry and oclif + * adapters. The real dispatcher is reloaded for each invocation so its lazy + * module caches cannot leak between tests. + */ +export async function withDirectPublicDispatch( + run: (harness: DirectPublicDispatchHarness) => Promise, + options: DirectPublicDispatchOptions = {}, +): Promise { + const publicDispatchPath = require.resolve("../../src/lib/cli/public-dispatch.js"); + const oclifRunnerPath = require.resolve("../../src/lib/cli/oclif-runner.js"); + const sandboxConnectPath = require.resolve("../../src/lib/actions/sandbox/connect.js"); + const registryPath = require.resolve("../../src/lib/state/registry.js"); + const registryRecoveryPath = require.resolve("../../src/lib/registry-recovery-action.js"); + const runnerPath = require.resolve("../../src/lib/runner.js"); + const priorPublicDispatch = requireCache[publicDispatchPath]; + const priorOclifRunner = requireCache[oclifRunnerPath]; + const priorSandboxConnect = requireCache[sandboxConnectPath]; + const priorRegistry = requireCache[registryPath]; + const priorRegistryRecovery = requireCache[registryRecoveryPath]; + const priorRunner = requireCache[runnerPath]; + const priorDockerHost = process.env.DOCKER_HOST; + const sandboxes = new Map( + (options.sandboxNames ?? []).map((name) => [name, { name }]), + ); + const getSandbox = vi.fn((name: string) => sandboxes.get(name) ?? null); + const listSandboxes = vi.fn(() => ({ + sandboxes: [...sandboxes.values()], + defaultSandbox: sandboxes.keys().next().value ?? null, + })); + const recoverRegistryEntries = vi.fn(async () => ({ + ...listSandboxes(), + recoveredFromSession: false, + recoveredFromGateway: 0, + })); + const runOclifArgv = vi.fn(async () => undefined); + const runOclifCommandById = vi.fn(async () => undefined); + const stderr: string[] = []; + const previousExitCode = process.exitCode; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + stderr.push(args.map(String).join(" ")); + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit:${String(code)}`); + }) as never); + const resetObservedCalls = () => { + stderr.length = 0; + exitSpy.mockClear(); + getSandbox.mockClear(); + listSandboxes.mockClear(); + recoverRegistryEntries.mockClear(); + runOclifArgv.mockClear(); + runOclifCommandById.mockClear(); + }; + + cacheModule(registryPath, { getSandbox, listSandboxes }); + cacheModule(registryRecoveryPath, { recoverRegistryEntries }); + cacheModule(oclifRunnerPath, { runOclifArgv, runOclifCommandById }); + cacheModule(sandboxConnectPath, { + isSandboxConnectFlag: vi.fn(() => false), + parseSandboxConnectArgs: vi.fn(), + printSandboxConnectHelp: vi.fn(), + }); + + try { + delete requireCache[publicDispatchPath]; + const { dispatchCli } = require(publicDispatchPath) as { + dispatchCli: (argv: string[]) => Promise; + }; + await run({ + dispatchCli, + exitSpy, + getSandbox, + listSandboxes, + recoverRegistryEntries, + resetObservedCalls, + runOclifArgv, + runOclifCommandById, + sandboxes, + stderr, + }); + } finally { + process.exitCode = previousExitCode; + errorSpy.mockRestore(); + exitSpy.mockRestore(); + restoreCache(publicDispatchPath, priorPublicDispatch); + restoreCache(oclifRunnerPath, priorOclifRunner); + restoreCache(sandboxConnectPath, priorSandboxConnect); + restoreCache(registryPath, priorRegistry); + restoreCache(registryRecoveryPath, priorRegistryRecovery); + restoreCache(runnerPath, priorRunner); + if (priorDockerHost === undefined) { + delete process.env.DOCKER_HOST; + } else { + process.env.DOCKER_HOST = priorDockerHost; + } + } +} From e1ba40601f0e4927aadb17280a16bd83d38d3721 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 12:44:19 -0700 Subject: [PATCH 5/6] test(cli): keep dispatch expectations linear --- test/cli/dispatch-basics.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index f118a1a02f9..573491b8889 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -291,19 +291,19 @@ describe("CLI dispatch", () => { argv: ["term"], entered: "term", command: "Run: openshell term", - note: null, + notes: [], }, { argv: ["policy", "set"], entered: "policy set", command: "Run: openshell policy set --policy --wait ", - note: "nemoclaw policy-add ", + notes: ["nemoclaw policy-add "], }, { argv: ["gateway", "stop"], entered: "gateway stop", command: "Run: openshell gateway stop -g nemoclaw", - note: null, + notes: [], }, ]; @@ -315,7 +315,7 @@ describe("CLI dispatch", () => { const output = stderr.join("\n"); expect(output).toContain(`Unknown nemoclaw command: ${testCase.entered}`); expect(output).toContain(testCase.command); - if (testCase.note) expect(output).toContain(testCase.note); + for (const note of testCase.notes) expect(output).toContain(note); expect(output).not.toContain("Try: nemoclaw connect"); expect(exitSpy).toHaveBeenCalledWith(1); } From 0b5a6104001b47284d76eec5978d3f70bac0dd56 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 13:01:22 -0700 Subject: [PATCH 6/6] test(windows): tighten transcript redaction assertions --- test/bootstrap-windows.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/bootstrap-windows.test.ts b/test/bootstrap-windows.test.ts index 232e6cc3366..0ad190dd401 100644 --- a/test/bootstrap-windows.test.ts +++ b/test/bootstrap-windows.test.ts @@ -636,14 +636,14 @@ try { expect(result.stdout).not.toContain("PSVersion:"); expect(result.stdout).not.toContain("Benutzername:"); expect(result.stdout).not.toContain("Computer:"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("EXAMPLE\\bootstrap-user"); expect(result.stdout).not.toContain("TEST-HOST"); expect(result.stdout).not.toContain("$statusPath = 'status-file'"); expect(result.stdout).not.toContain("$transcriptStarted = $false"); expect(result.stdout).not.toContain("Start-Transcript"); expect(result.stdout).not.toContain("WriteAllText"); expect(result.stdout).not.toContain( - "& 'C:\\\\WINDOWS\\\\System32\\\\wsl.exe' --install -d 'Ubuntu-24.04'", + "& 'C:\\WINDOWS\\System32\\wsl.exe' --install -d 'Ubuntu-24.04'", ); expect(result.stdout).not.toContain("Transcript started, output file is"); expect(result.stdout).not.toContain("Status file is"); @@ -678,7 +678,7 @@ Convert-WslInstallLogForDisplay -Log $log expect(result.status).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout.trim()).toBe("[PowerShell transcript metadata redacted.]"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("EXAMPLE\\bootstrap-user"); expect(result.stdout).not.toContain("TEST-HOST"); }, ); @@ -703,7 +703,7 @@ Convert-WslInstallLogForDisplay -Log $log expect(result.status).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout.trim()).toBe("[PowerShell transcript metadata redacted.]"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("EXAMPLE\\bootstrap-user"); expect(result.stdout).not.toContain("TEST-HOST"); expect(result.stdout).not.toContain("C:\\Users\\example\\install.log"); }, @@ -735,7 +735,7 @@ Convert-WslInstallLogForDisplay -Log $log expect(result.stderr).toBe(""); expect(result.stdout).toContain("[PowerShell transcript metadata redacted.]"); expect(result.stdout).toContain("Useful WSL output"); - expect(result.stdout).not.toContain("EXAMPLE\\\\bootstrap-user"); + expect(result.stdout).not.toContain("EXAMPLE\\bootstrap-user"); expect(result.stdout).not.toContain("TEST-HOST"); expect(result.stdout).not.toContain("Windows PowerShell transcript"); },