import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = await vi.hoisted(async () => { const { createCliRuntimeMock } = await import("../test-runtime-mock.js"); return { ...createCliRuntimeMock(vi), create: vi.fn(), list: vi.fn(), status: vi.fn(), logs: vi.fn(), lifecycle: vi.fn(), upgrade: vi.fn(), remove: vi.fn(), backup: vi.fn(), restore: vi.fn(), doctor: vi.fn(), }; }); vi.mock("../../runtime.js", () => ({ defaultRuntime: mocks.defaultRuntime })); vi.mock("../../fleet/service.runtime.js", () => ({ createFleetService: () => ({ create: mocks.create, list: mocks.list, status: mocks.status, logs: mocks.logs, lifecycle: mocks.lifecycle, upgrade: mocks.upgrade, remove: mocks.remove, backup: mocks.backup, restore: mocks.restore, doctor: mocks.doctor, }), })); import { runFleetCreateCommand, runFleetListCommand, runFleetLogsCommand, runFleetRemoveCommand, runFleetStatusCommand, runFleetBackupCommand, runFleetRestoreCommand, runFleetDoctorCommand, } from "./commands.runtime.js"; describe("fleet command output", () => { beforeEach(() => { vi.clearAllMocks(); mocks.runtimeLogs.length = 0; mocks.runtimeErrors.length = 0; process.exitCode = undefined; }); it("writes backup and restore JSON results", async () => { const backup = { tenant: "acme", archivePath: "/tmp/a.tgz", fileCount: 1, skippedSymlinks: 0, skippedSpecial: 0, note: "secret note", }; const restore = { tenant: "acme", archivePath: "/tmp/a.tgz", token: "new-token", tokenNote: "Shown once.", started: false, url: "http://127.0.0.1:19100", }; mocks.backup.mockResolvedValue(backup); mocks.restore.mockResolvedValue(restore); await runFleetBackupCommand({ tenant: "acme", json: true }); await runFleetRestoreCommand({ tenant: "acme", from: "/tmp/a.tgz", force: false, json: true }); expect(mocks.defaultRuntime.writeJson).toHaveBeenNthCalledWith(1, backup); expect(mocks.defaultRuntime.writeJson).toHaveBeenNthCalledWith(2, restore); }); it("surfaces skipped symlink counts in human backup output", async () => { mocks.backup.mockResolvedValue({ tenant: "acme", archivePath: "/tmp/a.tgz", fileCount: 3, skippedSymlinks: 2, skippedSpecial: 1, note: "secret note", }); await runFleetBackupCommand({ tenant: "acme", json: false }); expect(mocks.runtimeLogs.join("\n")).toContain("Skipped 2 symlink(s) and 1 special file(s)"); }); it("sets exitCode when doctor reports a failure", async () => { const reports = [ { tenant: "acme", findings: [{ check: "port-binding", status: "fail", detail: "bad binding" }], }, ]; mocks.doctor.mockResolvedValue(reports); await runFleetDoctorCommand({ tenant: "acme", json: true }); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(reports); expect(process.exitCode).toBe(1); }); it("writes the documented secret-bearing create JSON shape", async () => { const result = { tenant: "acme", containerName: "openclaw-cell-acme", port: 19_100, image: "ghcr.io/openclaw/openclaw:latest", runtime: "docker" as const, started: true, token: "gw-token", tokenNote: "Shown once. Store this Gateway token securely.", url: "http://127.0.0.1:19100", nextStep: "Open http://127.0.0.1:19100, then configure per-tenant channel accounts inside the cell.", }; mocks.create.mockResolvedValue(result); await runFleetCreateCommand({ tenant: "acme", json: true }); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(result); }); it("prints the Gateway token exactly once in human create output", async () => { mocks.create.mockResolvedValue({ tenant: "acme", containerName: "openclaw-cell-acme", port: 19_100, image: "image", runtime: "docker", started: true, token: "one-token", tokenNote: "Shown once. Store this Gateway token securely.", url: "http://127.0.0.1:19100", nextStep: "Open the cell.", }); await runFleetCreateCommand({ tenant: "acme", json: false }); expect(mocks.runtimeLogs.join("\n").match(/one-token/gu)).toHaveLength(1); expect(mocks.runtimeLogs.join("\n")).toContain("Shown once"); }); it("wraps deterministic list JSON in a cells object", async () => { const cells = [ { tenant: "acme", state: "running", port: 19_100, image: "image", created: "2026-01-01T00:00:00.000Z", }, ]; mocks.list.mockResolvedValue(cells); await runFleetListCommand({ json: true }); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ cells }); }); it("delegates logs without adding formatted output", async () => { const options = { tenant: "acme", follow: true, tail: 100, since: "10m" }; await runFleetLogsCommand(options); expect(mocks.logs).toHaveBeenCalledWith(options); expect(mocks.defaultRuntime.log).not.toHaveBeenCalled(); expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled(); }); it("writes status JSON and describes retained data on removal", async () => { const status = { tenant: "acme", containerName: "openclaw-cell-acme", runtime: "docker" as const, port: 19_100, image: "image", created: "2026-01-01T00:00:00.000Z", dataDir: "/tmp/acme", container: { state: "running", running: true, managed: true }, health: { status: "ok" as const, url: "http://127.0.0.1:19100/healthz", httpStatus: 200, }, }; mocks.status.mockResolvedValue(status); mocks.remove.mockResolvedValue({ tenant: "acme", action: "rm", dataPurged: false }); await runFleetStatusCommand({ tenant: "acme", json: true }); await runFleetRemoveCommand({ tenant: "acme", force: false, purgeData: false }); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(status); expect(mocks.runtimeLogs).toContain("Removed fleet cell acme; data retained."); }); });