import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; const promptMocks = vi.hoisted(() => ({ confirm: vi.fn(async () => true), isCancel: vi.fn(() => false), spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn(), })), })); const spawnMock = vi.hoisted(() => vi.fn()); const webRuntimeMocks = vi.hoisted(() => ({ DEFAULT_WEB_APP_PORT: 3100, ensureManagedWebRuntime: vi.fn(async () => ({ ready: true, reason: "ready" })), evaluateMajorVersionTransition: vi.fn(() => ({ previousMajor: 2, currentMajor: 2, isMajorTransition: false, })), readLastKnownWebPort: vi.fn(() => 3100), readManagedWebRuntimeManifest: vi.fn(() => ({ schemaVersion: 1, deployedDenchVersion: "2.1.0", deployedAt: "2026-01-01T00:00:00.000Z", sourceStandaloneServer: "/tmp/server.js", lastPort: 3100, lastGatewayPort: 19001, })), resolveCliPackageRoot: vi.fn(() => "/tmp/pkg"), resolveManagedWebRuntimeServerPath: vi.fn(() => "/tmp/.openclaw-dench/web-runtime/app/server.js"), resolveOpenClawCommandOrThrow: vi.fn(() => "/usr/local/bin/openclaw"), resolveProfileStateDir: vi.fn(() => "/tmp/.openclaw-dench"), runOpenClawCommand: vi.fn(async () => ({ code: 0, stdout: '{"ok":true}', stderr: "" })), startManagedWebRuntime: vi.fn(() => ({ started: true, pid: 7788, runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", })), stopManagedWebRuntime: vi.fn(async () => ({ port: 3100, stoppedPids: [1234], skippedForeignPids: [], })), waitForWebRuntime: vi.fn(async () => ({ ok: true, reason: "profiles payload shape is valid" })), })); vi.mock("@clack/prompts", () => ({ confirm: promptMocks.confirm, isCancel: promptMocks.isCancel, spinner: promptMocks.spinner, })); vi.mock("node:child_process", () => ({ spawn: spawnMock, })); vi.mock("./web-runtime.js", () => ({ DEFAULT_WEB_APP_PORT: webRuntimeMocks.DEFAULT_WEB_APP_PORT, ensureManagedWebRuntime: webRuntimeMocks.ensureManagedWebRuntime, evaluateMajorVersionTransition: webRuntimeMocks.evaluateMajorVersionTransition, readLastKnownWebPort: webRuntimeMocks.readLastKnownWebPort, readManagedWebRuntimeManifest: webRuntimeMocks.readManagedWebRuntimeManifest, resolveCliPackageRoot: webRuntimeMocks.resolveCliPackageRoot, resolveManagedWebRuntimeServerPath: webRuntimeMocks.resolveManagedWebRuntimeServerPath, resolveOpenClawCommandOrThrow: webRuntimeMocks.resolveOpenClawCommandOrThrow, resolveProfileStateDir: webRuntimeMocks.resolveProfileStateDir, runOpenClawCommand: webRuntimeMocks.runOpenClawCommand, startManagedWebRuntime: webRuntimeMocks.startManagedWebRuntime, stopManagedWebRuntime: webRuntimeMocks.stopManagedWebRuntime, waitForWebRuntime: webRuntimeMocks.waitForWebRuntime, })); import { restartWebRuntimeCommand, startWebRuntimeCommand, stopWebRuntimeCommand, updateWebRuntimeCommand, } from "./web-runtime-command.js"; function createMockChild(code = 0): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: ReturnType; } { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: ReturnType; }; child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = vi.fn(); queueMicrotask(() => { child.emit("close", code); }); return child; } function runtimeStub(): RuntimeEnv { return { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; } describe("updateWebRuntimeCommand", () => { beforeEach(() => { spawnMock.mockReset(); spawnMock.mockImplementation(() => createMockChild(0)); promptMocks.confirm.mockReset(); promptMocks.confirm.mockImplementation(async () => true); promptMocks.isCancel.mockReset(); promptMocks.isCancel.mockImplementation(() => false); webRuntimeMocks.ensureManagedWebRuntime.mockReset(); webRuntimeMocks.ensureManagedWebRuntime.mockImplementation( async () => ({ ready: true, reason: "ready" }) as { ready: boolean; reason: string }, ); webRuntimeMocks.stopManagedWebRuntime.mockReset(); webRuntimeMocks.stopManagedWebRuntime.mockImplementation( async () => ({ port: 3100, stoppedPids: [1234], skippedForeignPids: [], }) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] }, ); webRuntimeMocks.evaluateMajorVersionTransition.mockReset(); webRuntimeMocks.evaluateMajorVersionTransition.mockImplementation(() => ({ previousMajor: 2, currentMajor: 2, isMajorTransition: false, })); webRuntimeMocks.readManagedWebRuntimeManifest.mockReset(); webRuntimeMocks.readManagedWebRuntimeManifest.mockImplementation(() => ({ schemaVersion: 1, deployedDenchVersion: "2.1.0", deployedAt: "2026-01-01T00:00:00.000Z", sourceStandaloneServer: "/tmp/server.js", lastPort: 3100, lastGatewayPort: 19001, })); webRuntimeMocks.startManagedWebRuntime.mockReset(); webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({ started: true, pid: 7788, runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", })); webRuntimeMocks.waitForWebRuntime.mockReset(); webRuntimeMocks.waitForWebRuntime.mockImplementation( async () => ({ ok: true, reason: "profiles payload shape is valid" }) as { ok: boolean; reason: string; }, ); }); it("fails closed in non-interactive major upgrades without explicit approval (enforces mandatory operator consent)", async () => { webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({ previousMajor: 2, currentMajor: 3, isMajorTransition: true, }); const runtime = runtimeStub(); await expect( updateWebRuntimeCommand( { nonInteractive: true, yes: false, }, runtime, ), ).rejects.toThrow("Major Dench upgrade detected"); expect(spawnMock).not.toHaveBeenCalled(); expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled(); }); it("runs OpenClaw update before refreshing web runtime on major transitions (protects upgrade compatibility)", async () => { webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({ previousMajor: 2, currentMajor: 3, isMajorTransition: true, }); const runtime = runtimeStub(); const summary = await updateWebRuntimeCommand( { nonInteractive: true, yes: true, }, runtime, ); expect(spawnMock).toHaveBeenCalledWith( "/usr/local/bin/openclaw", ["update", "--yes"], expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }), ); expect(webRuntimeMocks.ensureManagedWebRuntime).toHaveBeenCalled(); expect(summary.majorGate.required).toBe(true); }); it("skips OpenClaw update on minor upgrades while still refreshing runtime (avoids unnecessary blocking)", async () => { webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({ previousMajor: 2, currentMajor: 2, isMajorTransition: false, }); const runtime = runtimeStub(); const summary = await updateWebRuntimeCommand( { nonInteractive: true, }, runtime, ); expect(spawnMock).not.toHaveBeenCalled(); expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, includeLegacyStandalone: true, }); expect(webRuntimeMocks.ensureManagedWebRuntime).toHaveBeenCalled(); expect(summary.ready).toBe(true); }); }); describe("stopWebRuntimeCommand", () => { it("reports foreign listeners without terminating them (preserves process boundaries)", async () => { webRuntimeMocks.stopManagedWebRuntime.mockResolvedValue({ port: 3100, stoppedPids: [], skippedForeignPids: [91, 92], }); const runtime = runtimeStub(); const summary = await stopWebRuntimeCommand( { webPort: "3100", }, runtime, ); expect(summary.stoppedPids).toEqual([]); expect(summary.skippedForeignPids).toEqual([91, 92]); }); }); describe("startWebRuntimeCommand", () => { beforeEach(() => { webRuntimeMocks.ensureManagedWebRuntime.mockClear(); webRuntimeMocks.stopManagedWebRuntime.mockReset(); webRuntimeMocks.stopManagedWebRuntime.mockImplementation( async () => ({ port: 3100, stoppedPids: [1234], skippedForeignPids: [], }) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] }, ); webRuntimeMocks.startManagedWebRuntime.mockReset(); webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({ started: true, pid: 7788, runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", })); webRuntimeMocks.waitForWebRuntime.mockReset(); webRuntimeMocks.waitForWebRuntime.mockImplementation( async () => ({ ok: true, reason: "profiles payload shape is valid" }) as { ok: boolean; reason: string; }, ); }); it("fails closed when non-dench listeners still own the port (prevents cross-process takeover)", async () => { webRuntimeMocks.stopManagedWebRuntime.mockResolvedValue({ port: 3100, stoppedPids: [], skippedForeignPids: [9912], }); const runtime = runtimeStub(); await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("non-Dench listener"); expect(webRuntimeMocks.startManagedWebRuntime).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); it("fails with actionable remediation when managed runtime is missing (requires explicit update/bootstrap)", async () => { webRuntimeMocks.startManagedWebRuntime.mockReturnValue({ started: false, runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", reason: "runtime-missing", }); const runtime = runtimeStub(); await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("npx denchclaw update"); expect(spawnMock).not.toHaveBeenCalled(); }); it("starts managed runtime without triggering update/install workflow (start-only behavior)", async () => { const runtime = runtimeStub(); const summary = await startWebRuntimeCommand( { webPort: "3100", }, runtime, ); expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, includeLegacyStandalone: true, }); expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, gatewayPort: 19001, }); expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(summary.started).toBe(true); }); it("falls back to DenchClaw port 19001 when manifest has no lastGatewayPort (prevents 18789 hijack)", async () => { webRuntimeMocks.readManagedWebRuntimeManifest.mockReturnValue({ schemaVersion: 1, deployedDenchVersion: "2.1.0", deployedAt: "2026-01-01T00:00:00.000Z", sourceStandaloneServer: "/tmp/server.js", lastPort: 3100, }); const runtime = runtimeStub(); await startWebRuntimeCommand({ webPort: "3100" }, runtime); expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith( expect.objectContaining({ gatewayPort: 19001 }), ); }); it("falls back to DenchClaw port 19001 when manifest is null (fresh install, prevents 18789 hijack)", async () => { webRuntimeMocks.readManagedWebRuntimeManifest.mockReturnValue(null); const runtime = runtimeStub(); await startWebRuntimeCommand({ webPort: "3100" }, runtime); expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith( expect.objectContaining({ gatewayPort: 19001 }), ); }); }); describe("restartWebRuntimeCommand", () => { beforeEach(() => { webRuntimeMocks.ensureManagedWebRuntime.mockClear(); webRuntimeMocks.stopManagedWebRuntime.mockReset(); webRuntimeMocks.stopManagedWebRuntime.mockImplementation( async () => ({ port: 3100, stoppedPids: [1234], skippedForeignPids: [], }) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] }, ); webRuntimeMocks.startManagedWebRuntime.mockReset(); webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({ started: true, pid: 7788, runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", })); webRuntimeMocks.waitForWebRuntime.mockReset(); webRuntimeMocks.waitForWebRuntime.mockImplementation( async () => ({ ok: true, reason: "profiles payload shape is valid" }) as { ok: boolean; reason: string; }, ); }); it("stops and restarts managed runtime (same stop+start lifecycle as start command)", async () => { const runtime = runtimeStub(); const summary = await restartWebRuntimeCommand( { webPort: "3100", }, runtime, ); expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, includeLegacyStandalone: true, }); expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, gatewayPort: 19001, }); expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled(); expect(summary.started).toBe(true); }); it("outputs restart heading instead of start (distinct user-facing label)", async () => { const runtime = runtimeStub(); await restartWebRuntimeCommand({}, runtime); const logCalls = (runtime.log as ReturnType).mock.calls.map( ([msg]: [string]) => msg, ); expect(logCalls.some((msg) => typeof msg === "string" && msg.includes("restart"))).toBe(true); expect(logCalls.some((msg) => typeof msg === "string" && /\bstart\b/.test(msg) && !msg.includes("restart"))).toBe(false); }); });