Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ vi.mock("../../state/gateway", () => ({
),
}));
vi.mock("../../state/mcp-lifecycle-lock", () => ({
withMcpLifecycleLock: vi.fn((_key, fn) => fn()),
withSandboxMutationLock: vi.fn((_sandbox, fn) => fn()),
}));
vi.mock("../../state/registry", () => ({
Expand Down
223 changes: 223 additions & 0 deletions src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
HERMES_DASHBOARD_ENABLE_ENV,
HERMES_DASHBOARD_INTERNAL_PORT_ENV,
HERMES_DASHBOARD_PORT_ENV,
HERMES_DASHBOARD_TUI_ENV,
} from "../../hermes-dashboard";
import { resolveRebuildHermesDashboardEnv } from "./rebuild-durable-config";
import * as f from "./snapshot-restore-test-fixture";

const dashboardPortMocks = vi.hoisted(() => ({
findAvailableDashboardPort: vi.fn(() => 18901),
getRegistryOccupiedDashboardPorts: vi.fn(() => new Map<string, string>()),
withDashboardPortReservationLock: vi.fn(async (operation: () => unknown) => await operation()),
}));

vi.mock("../../onboard/dashboard-port", () => ({
findAvailableDashboardPort: dashboardPortMocks.findAvailableDashboardPort,
getRegistryOccupiedDashboardPorts: dashboardPortMocks.getRegistryOccupiedDashboardPorts,
withDashboardPortReservationLock: dashboardPortMocks.withDashboardPortReservationLock,
}));

beforeEach(f.resetSnapshotRestoreMocks);
afterEach(f.cleanupSnapshotRestoreMocks);
describe("runSandboxSnapshot restore: clone dashboard port identity", () => {
it("allocates the auto-created clone its own dashboard port instead of inheriting the source's (#6746)", async () => {
let registeredClone: f.SandboxRecord | null = null;
f.registerSandboxMock.mockImplementation(
(entry) => (registeredClone = entry as f.SandboxRecord),
);
f.getSandboxMock.mockImplementation((name) =>
name === "alpha"
? {
name: "alpha",
agent: "openclaw",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
dashboardPort: 18790,
}
: registeredClone,
);
f.captureOpenshellMock.mockImplementation((args) =>
f.openshellResponses(args, {
"sandbox exec": { status: 0, output: f.dcodeProbeOutput("idle") },
"sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
}),
);
f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"]));
f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture });
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" });
expect(dashboardPortMocks.findAvailableDashboardPort).toHaveBeenCalledWith(
"beta",
18790,
expect.any(String),
undefined,
expect.any(Map),
);
expect(dashboardPortMocks.withDashboardPortReservationLock).toHaveBeenCalledOnce();
const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? [];
expect(createArgs.slice(createArgs.lastIndexOf("--") + 1)).toEqual([
"env",
"NEMOCLAW_OBSERVABILITY=0",
"CHAT_UI_URL=http://127.0.0.1:18901",
"NEMOCLAW_DASHBOARD_PORT=18901",
"nemoclaw-start",
]);
expect(f.registerSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "beta",
dashboardPort: 18901,
}),
);
});

it("keeps a Hermes clone rebuildable with its new public port and inherited internal port (#6746)", async () => {
dashboardPortMocks.findAvailableDashboardPort.mockReturnValueOnce(18902);
let registeredClone: f.SandboxRecord | null = null;
f.registerSandboxMock.mockImplementation(
(entry) => (registeredClone = entry as f.SandboxRecord),
);
f.getSandboxMock.mockImplementation((name) =>
name === "alpha"
? {
name: "alpha",
agent: "hermes",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
dashboardPort: 18790,
hermesDashboardEnabled: true,
hermesDashboardPort: 18790,
hermesDashboardInternalPort: 18901,
hermesDashboardTui: true,
}
: registeredClone,
);
f.captureOpenshellMock.mockImplementation((args) =>
f.openshellResponses(args, {
"sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") },
"sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
}),
);
f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"]));
f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture });
const { runSandboxSnapshot } = await import("./snapshot");

await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" });

expect(dashboardPortMocks.findAvailableDashboardPort).toHaveBeenCalledWith(
"beta",
18790,
expect.any(String),
undefined,
new Map([["18901", "alpha (Hermes dashboard internal)"]]),
);
expect(f.registerSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "beta",
dashboardPort: 18902,
hermesDashboardPort: 18902,
hermesDashboardInternalPort: 18901,
hermesDashboardTui: true,
}),
);
const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? [];
expect(createArgs.slice(createArgs.lastIndexOf("--") + 1)).toEqual([
"env",
"NEMOCLAW_OBSERVABILITY=0",
"CHAT_UI_URL=http://127.0.0.1:18902",
"NEMOCLAW_DASHBOARD_PORT=18902",
`${HERMES_DASHBOARD_ENABLE_ENV}=1`,
`${HERMES_DASHBOARD_PORT_ENV}=18902`,
`${HERMES_DASHBOARD_INTERNAL_PORT_ENV}=18901`,
`${HERMES_DASHBOARD_TUI_ENV}=1`,
"nemoclaw-start",
]);
expect(resolveRebuildHermesDashboardEnv("hermes", registeredClone as never, 18902)).toEqual({
ok: true,
env: {
[HERMES_DASHBOARD_ENABLE_ENV]: "1",
[HERMES_DASHBOARD_PORT_ENV]: "18902",
[HERMES_DASHBOARD_INTERNAL_PORT_ENV]: "18901",
[HERMES_DASHBOARD_TUI_ENV]: "1",
},
});
});

it("aborts before deleting a --force destination when no dashboard port is free (#6746)", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
dashboardPortMocks.findAvailableDashboardPort.mockImplementationOnce(() => {
throw new Error("All dashboard ports in range 18789-18799 are occupied:");
});
f.getSandboxMock.mockImplementation((name) => ({
name: name ?? "alpha",
agent: "openclaw",
imageTag: `nemoclaw-${name}:test`,
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
dashboardPort: 18790,
}));
f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"]));
f.captureOpenshellMock.mockImplementation((args) =>
f.openshellResponses(args, {
"sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") },
"sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
}),
);
f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture });
const { runSandboxSnapshot } = await import("./snapshot");

await expect(
runSandboxSnapshot("alpha", { kind: "restore", to: "beta", force: true, yes: true }),
).rejects.toMatchObject({ exitCode: 1 });

expect(dashboardPortMocks.findAvailableDashboardPort).toHaveBeenCalled();
expect(consoleError.mock.calls.flat().join("\n")).toContain("are occupied");
expect(f.lifecycleMock.events).not.toContain("delete");
expect(f.streamSandboxCreateMock).not.toHaveBeenCalled();
expect(f.registerSandboxMock).not.toHaveBeenCalled();
});

it("registers a clone of a source without a dashboard port with the field unset (#6746)", async () => {
let registeredClone: f.SandboxRecord | null = null;
f.registerSandboxMock.mockImplementation(
(entry) => (registeredClone = entry as f.SandboxRecord),
);
f.getSandboxMock.mockImplementation((name) =>
name === "alpha"
? {
name: "alpha",
agent: "openclaw",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
}
: registeredClone,
);
f.captureOpenshellMock.mockImplementation((args) =>
f.openshellResponses(args, {
"sandbox exec": { status: 0, output: f.dcodeProbeOutput("idle") },
"sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
}),
);
f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"]));
f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture });
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" });
expect(dashboardPortMocks.findAvailableDashboardPort).not.toHaveBeenCalled();
expect(f.registerSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "beta", dashboardPort: null }),
);
});
});
5 changes: 5 additions & 0 deletions src/lib/actions/sandbox/snapshot-restore-test-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export type SandboxRecord = {
observabilityEnabled?: boolean;
provider?: string | null;
model?: string | null;
dashboardPort?: number | null;
hermesDashboardEnabled?: boolean;
hermesDashboardPort?: number | null;
hermesDashboardInternalPort?: number | null;
hermesDashboardTui?: boolean;
};
export type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime";

Expand Down
Loading
Loading