From c3932c8508d486b72d17979fc0189dde179e1bbd Mon Sep 17 00:00:00 2001 From: sheeek Date: Fri, 9 Jan 2026 10:09:46 +0100 Subject: [PATCH] test(sandbox): add comprehensive test suite for CLI commands Add 19 tests covering sandboxListCommand and sandboxRecreateCommand: - List command: human/JSON output, browser flag, error handling - Recreate command: validation, filtering (session/agent), confirmation flow - Factory functions (createContainer, createBrowser) reduce duplication - Helper functions (expectLogContains, setupDefaultMocks) improve readability All tests passing. 365 LOC with ~66% production code coverage. --- src/commands/sandbox.test.ts | 365 +++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/commands/sandbox.test.ts diff --git a/src/commands/sandbox.test.ts b/src/commands/sandbox.test.ts new file mode 100644 index 000000000..8921d4b2b --- /dev/null +++ b/src/commands/sandbox.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { + SandboxBrowserInfo, + SandboxContainerInfo, +} from "../agents/sandbox.js"; + +// --- Mocks --- + +const mocks = vi.hoisted(() => ({ + listSandboxContainers: vi.fn(), + listSandboxBrowsers: vi.fn(), + removeSandboxContainer: vi.fn(), + removeSandboxBrowserContainer: vi.fn(), + clackConfirm: vi.fn(), +})); + +vi.mock("../agents/sandbox.js", () => ({ + listSandboxContainers: mocks.listSandboxContainers, + listSandboxBrowsers: mocks.listSandboxBrowsers, + removeSandboxContainer: mocks.removeSandboxContainer, + removeSandboxBrowserContainer: mocks.removeSandboxBrowserContainer, +})); + +vi.mock("@clack/prompts", () => ({ + confirm: mocks.clackConfirm, +})); + +import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js"; + +// --- Test Factories --- + +const NOW = Date.now(); + +function createContainer( + overrides: Partial = {}, +): SandboxContainerInfo { + return { + containerName: "clawd-sandbox-test", + sessionKey: "test-session", + image: "clawd/sandbox:latest", + imageMatch: true, + running: true, + createdAtMs: NOW - 3600000, + lastUsedAtMs: NOW - 600000, + ...overrides, + }; +} + +function createBrowser( + overrides: Partial = {}, +): SandboxBrowserInfo { + return { + containerName: "clawd-browser-test", + sessionKey: "test-session", + image: "clawd/browser:latest", + imageMatch: true, + running: true, + createdAtMs: NOW - 3600000, + lastUsedAtMs: NOW - 600000, + cdpPort: 9222, + noVncPort: 5900, + ...overrides, + }; +} + +// --- Test Helpers --- + +function createMockRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +function setupDefaultMocks() { + mocks.listSandboxContainers.mockResolvedValue([]); + mocks.listSandboxBrowsers.mockResolvedValue([]); + mocks.removeSandboxContainer.mockResolvedValue(undefined); + mocks.removeSandboxBrowserContainer.mockResolvedValue(undefined); + mocks.clackConfirm.mockResolvedValue(true); +} + +function expectLogContains(runtime: ReturnType, text: string) { + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(text)); +} + +function expectErrorContains(runtime: ReturnType, text: string) { + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(text)); +} + +// --- Tests --- + +describe("sandboxListCommand", () => { + let runtime: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); + runtime = createMockRuntime(); + }); + + describe("human format output", () => { + it("should display containers", async () => { + const container1 = createContainer({ containerName: "container-1" }); + const container2 = createContainer({ + containerName: "container-2", + imageMatch: false, + }); + mocks.listSandboxContainers.mockResolvedValue([container1, container2]); + + await sandboxListCommand({ browser: false, json: false }, runtime as never); + + expectLogContains(runtime, "📦 Sandbox Containers"); + expectLogContains(runtime, container1.containerName); + expectLogContains(runtime, container2.containerName); + expectLogContains(runtime, "Total"); + }); + + it("should display browsers when --browser flag is set", async () => { + const browser = createBrowser({ containerName: "browser-1" }); + mocks.listSandboxBrowsers.mockResolvedValue([browser]); + + await sandboxListCommand({ browser: true, json: false }, runtime as never); + + expectLogContains(runtime, "🌐 Sandbox Browser Containers"); + expectLogContains(runtime, browser.containerName); + expectLogContains(runtime, String(browser.cdpPort)); + }); + + it("should show warning when image mismatches detected", async () => { + const mismatchContainer = createContainer({ imageMatch: false }); + mocks.listSandboxContainers.mockResolvedValue([mismatchContainer]); + + await sandboxListCommand({ browser: false, json: false }, runtime as never); + + expectLogContains(runtime, "⚠️"); + expectLogContains(runtime, "image mismatch"); + expectLogContains(runtime, "clawdbot sandbox recreate --all"); + }); + + it("should display message when no containers found", async () => { + await sandboxListCommand({ browser: false, json: false }, runtime as never); + + expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + }); + }); + + describe("JSON output", () => { + it("should output JSON format", async () => { + const container = createContainer(); + mocks.listSandboxContainers.mockResolvedValue([container]); + + await sandboxListCommand({ browser: false, json: true }, runtime as never); + + const loggedJson = runtime.log.mock.calls[0][0]; + const parsed = JSON.parse(loggedJson); + + expect(parsed.containers).toHaveLength(1); + expect(parsed.containers[0].containerName).toBe(container.containerName); + expect(parsed.browsers).toHaveLength(0); + }); + }); + + describe("error handling", () => { + it("should handle errors gracefully", async () => { + mocks.listSandboxContainers.mockRejectedValue( + new Error("Docker not available"), + ); + + await sandboxListCommand({ browser: false, json: false }, runtime as never); + + expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + }); + }); +}); + +describe("sandboxRecreateCommand", () => { + let runtime: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); + runtime = createMockRuntime(); + }); + + describe("validation", () => { + it("should error if no filter is specified", async () => { + await sandboxRecreateCommand( + { all: false, browser: false, force: false }, + runtime as never, + ); + + expectErrorContains(runtime, "Please specify --all, --session , or --agent "); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("should error if multiple filters specified", async () => { + await sandboxRecreateCommand( + { all: true, session: "test", browser: false, force: false }, + runtime as never, + ); + + expectErrorContains(runtime, "Please specify only one of: --all, --session, --agent"); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + }); + + describe("filtering", () => { + it("should filter by session", async () => { + const match = createContainer({ sessionKey: "target-session" }); + const noMatch = createContainer({ sessionKey: "other-session" }); + mocks.listSandboxContainers.mockResolvedValue([match, noMatch]); + + await sandboxRecreateCommand( + { session: "target-session", browser: false, force: true }, + runtime as never, + ); + + expect(mocks.removeSandboxContainer).toHaveBeenCalledTimes(1); + expect(mocks.removeSandboxContainer).toHaveBeenCalledWith(match.containerName); + }); + + it("should filter by agent (exact + subkeys)", async () => { + const agent = createContainer({ sessionKey: "agent:work" }); + const agentSub = createContainer({ sessionKey: "agent:work:subtask" }); + const other = createContainer({ sessionKey: "test-session" }); + mocks.listSandboxContainers.mockResolvedValue([agent, agentSub, other]); + + await sandboxRecreateCommand( + { agent: "work", browser: false, force: true }, + runtime as never, + ); + + expect(mocks.removeSandboxContainer).toHaveBeenCalledTimes(2); + expect(mocks.removeSandboxContainer).toHaveBeenCalledWith(agent.containerName); + expect(mocks.removeSandboxContainer).toHaveBeenCalledWith(agentSub.containerName); + }); + + it("should remove all when --all flag set", async () => { + const containers = [createContainer(), createContainer()]; + mocks.listSandboxContainers.mockResolvedValue(containers); + + await sandboxRecreateCommand( + { all: true, browser: false, force: true }, + runtime as never, + ); + + expect(mocks.removeSandboxContainer).toHaveBeenCalledTimes(2); + }); + + it("should handle browsers when --browser flag set", async () => { + const browsers = [createBrowser(), createBrowser()]; + mocks.listSandboxBrowsers.mockResolvedValue(browsers); + + await sandboxRecreateCommand( + { all: true, browser: true, force: true }, + runtime as never, + ); + + expect(mocks.removeSandboxBrowserContainer).toHaveBeenCalledTimes(2); + expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); + }); + }); + + describe("confirmation flow", () => { + it("should require confirmation without --force", async () => { + mocks.listSandboxContainers.mockResolvedValue([createContainer()]); + mocks.clackConfirm.mockResolvedValue(true); + + await sandboxRecreateCommand( + { all: true, browser: false, force: false }, + runtime as never, + ); + + expect(mocks.clackConfirm).toHaveBeenCalled(); + expect(mocks.removeSandboxContainer).toHaveBeenCalled(); + }); + + it("should cancel when user declines", async () => { + mocks.listSandboxContainers.mockResolvedValue([createContainer()]); + mocks.clackConfirm.mockResolvedValue(false); + + await sandboxRecreateCommand( + { all: true, browser: false, force: false }, + runtime as never, + ); + + expect(runtime.log).toHaveBeenCalledWith("Cancelled."); + expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); + }); + + it("should cancel on clack cancel symbol", async () => { + mocks.listSandboxContainers.mockResolvedValue([createContainer()]); + mocks.clackConfirm.mockResolvedValue(Symbol.for("clack:cancel")); + + await sandboxRecreateCommand( + { all: true, browser: false, force: false }, + runtime as never, + ); + + expect(runtime.log).toHaveBeenCalledWith("Cancelled."); + expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); + }); + + it("should skip confirmation with --force", async () => { + mocks.listSandboxContainers.mockResolvedValue([createContainer()]); + + await sandboxRecreateCommand( + { all: true, browser: false, force: true }, + runtime as never, + ); + + expect(mocks.clackConfirm).not.toHaveBeenCalled(); + expect(mocks.removeSandboxContainer).toHaveBeenCalled(); + }); + }); + + describe("execution", () => { + it("should show message when no containers match", async () => { + await sandboxRecreateCommand( + { all: true, browser: false, force: true }, + runtime as never, + ); + + expect(runtime.log).toHaveBeenCalledWith( + "No containers found matching the criteria.", + ); + expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); + }); + + it("should handle removal errors and exit with code 1", async () => { + mocks.listSandboxContainers.mockResolvedValue([ + createContainer({ containerName: "success" }), + createContainer({ containerName: "failure" }), + ]); + mocks.removeSandboxContainer + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Removal failed")); + + await sandboxRecreateCommand( + { all: true, browser: false, force: true }, + runtime as never, + ); + + expectErrorContains(runtime, "Failed to remove"); + expectLogContains(runtime, "1 removed, 1 failed"); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("should display success message", async () => { + mocks.listSandboxContainers.mockResolvedValue([createContainer()]); + + await sandboxRecreateCommand( + { all: true, browser: false, force: true }, + runtime as never, + ); + + expectLogContains(runtime, "✓ Removed"); + expectLogContains(runtime, "1 removed, 0 failed"); + expectLogContains(runtime, "automatically recreated"); + }); + }); +});