|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +/** |
| 5 | + * Tests for the GPU-passthrough mismatch recovery hint (#3456 sub-bug #3). |
| 6 | + * |
| 7 | + * The hint replaces a hard-coded line that printed a literal `<name>` |
| 8 | + * placeholder and assumed at least one sandbox was registered — which broke |
| 9 | + * the install-loop recovery flow when the registry was empty (the State A / |
| 10 | + * State B dead loop the reporter hit on six Linux hosts). |
| 11 | + */ |
| 12 | + |
| 13 | +import { describe, expect, it, vi } from "vitest"; |
| 14 | +import { gpuPassthroughRecoveryLines, reportGpuPassthroughRecovery } from "./gpu-recovery"; |
| 15 | + |
| 16 | +describe("gpuPassthroughRecoveryLines", () => { |
| 17 | + it("never emits a literal `<name>` placeholder for any input", () => { |
| 18 | + for (const names of [null, [], ["alpha"], ["alpha", "beta"], ["alpha", "beta", "gamma"]]) { |
| 19 | + const lines = gpuPassthroughRecoveryLines(names); |
| 20 | + expect(lines.join("\n")).not.toMatch(/<name>/); |
| 21 | + } |
| 22 | + }); |
| 23 | + |
| 24 | + it("suggests `nemoclaw uninstall` when no sandboxes are registered (null input)", () => { |
| 25 | + const lines = gpuPassthroughRecoveryLines(null); |
| 26 | + const joined = lines.join("\n"); |
| 27 | + expect(joined).toContain("Existing gateway was started without GPU passthrough"); |
| 28 | + expect(joined).toContain("nemoclaw uninstall"); |
| 29 | + expect(joined).toContain("nemoclaw onboard --gpu"); |
| 30 | + // Must NOT suggest the destroy form — there is nothing to destroy. |
| 31 | + expect(joined).not.toMatch(/nemoclaw [a-z-]+ destroy/); |
| 32 | + }); |
| 33 | + |
| 34 | + it("suggests `nemoclaw uninstall` when no sandboxes are registered (empty array)", () => { |
| 35 | + const lines = gpuPassthroughRecoveryLines([]); |
| 36 | + expect(lines.join("\n")).toContain("nemoclaw uninstall"); |
| 37 | + expect(lines.join("\n")).not.toMatch(/nemoclaw [a-z-]+ destroy/); |
| 38 | + }); |
| 39 | + |
| 40 | + it("suggests destroy for a single registered sandbox with --cleanup-gateway", () => { |
| 41 | + const lines = gpuPassthroughRecoveryLines(["my-assistant"]); |
| 42 | + const joined = lines.join("\n"); |
| 43 | + expect(joined).toContain("nemoclaw my-assistant destroy --yes --cleanup-gateway"); |
| 44 | + expect(joined).toContain("nemoclaw onboard --gpu"); |
| 45 | + // The single-sandbox form must not suggest uninstall — destroy is enough. |
| 46 | + expect(joined).not.toContain("nemoclaw uninstall"); |
| 47 | + }); |
| 48 | + |
| 49 | + it("lists every registered sandbox and only appends --cleanup-gateway to the last", () => { |
| 50 | + const lines = gpuPassthroughRecoveryLines(["alpha", "beta", "gamma"]); |
| 51 | + const joined = lines.join("\n"); |
| 52 | + expect(joined).toContain("nemoclaw alpha destroy --yes"); |
| 53 | + expect(joined).toContain("nemoclaw beta destroy --yes"); |
| 54 | + expect(joined).toContain("nemoclaw gamma destroy --yes --cleanup-gateway"); |
| 55 | + // Only one --cleanup-gateway across all rows. |
| 56 | + expect(joined.match(/--cleanup-gateway/g) ?? []).toHaveLength(1); |
| 57 | + // alpha/beta lines must NOT have --cleanup-gateway. |
| 58 | + const alphaLine = lines.find((line) => line.includes("nemoclaw alpha destroy")); |
| 59 | + const betaLine = lines.find((line) => line.includes("nemoclaw beta destroy")); |
| 60 | + expect(alphaLine).not.toContain("--cleanup-gateway"); |
| 61 | + expect(betaLine).not.toContain("--cleanup-gateway"); |
| 62 | + }); |
| 63 | + |
| 64 | + it("filters out empty/whitespace names defensively", () => { |
| 65 | + // Belt-and-suspenders: if registry.listSandboxes() ever returns a row with |
| 66 | + // an empty name, we shouldn't render `nemoclaw destroy --yes` (the very |
| 67 | + // bug shape this fix exists to prevent). |
| 68 | + const lines = gpuPassthroughRecoveryLines(["", " ", "real"]); |
| 69 | + const joined = lines.join("\n"); |
| 70 | + expect(joined).toContain("nemoclaw real destroy --yes --cleanup-gateway"); |
| 71 | + // No double-spaced "nemoclaw destroy" rendering. |
| 72 | + expect(joined).not.toMatch(/nemoclaw\s{2,}destroy/); |
| 73 | + }); |
| 74 | +}); |
| 75 | + |
| 76 | +describe("reportGpuPassthroughRecovery", () => { |
| 77 | + it("emits the empty-registry path when loadNames returns no names", () => { |
| 78 | + const emit = vi.fn(); |
| 79 | + reportGpuPassthroughRecovery(emit, () => []); |
| 80 | + const joined = emit.mock.calls.map((c) => c[0]).join("\n"); |
| 81 | + expect(joined).toContain("nemoclaw uninstall"); |
| 82 | + expect(joined).not.toMatch(/<name>/); |
| 83 | + }); |
| 84 | + |
| 85 | + it("emits the multi-sandbox path when loadNames returns several names", () => { |
| 86 | + const emit = vi.fn(); |
| 87 | + reportGpuPassthroughRecovery(emit, () => ["alpha", "beta"]); |
| 88 | + const joined = emit.mock.calls.map((c) => c[0]).join("\n"); |
| 89 | + expect(joined).toContain("nemoclaw alpha destroy --yes"); |
| 90 | + expect(joined).toContain("nemoclaw beta destroy --yes --cleanup-gateway"); |
| 91 | + }); |
| 92 | +}); |
0 commit comments