openclaw/src/cli/daemon-cli.coverage.test.ts
kumarabhirup e865265f0f
Gateway: pre-build web app + Control UI before daemon install (2026.2.6-3.7)
The Next.js web app was only built inside the gateway process on first
boot.  When the daemon was freshly installed (e.g. `onboard
--install-daemon`), the LaunchAgent would start and block on `next
build`, causing a noticeably slow first startup.

Add `ensureWebAppBuilt()` to `src/gateway/server-web-app.ts` — a
standalone pre-build function that checks for `.next/BUILD_ID` and runs
dep install + `next build` if missing.  Skips silently when the web app
is disabled, already built, in dev mode, or inapplicable (global npm
install without `apps/web`).

Call both `ensureWebAppBuilt()` and `ensureControlUiAssetsBuilt()` before
the daemon is installed in every relevant path:

- Interactive onboarding (`onboarding.finalize.ts`) — moved the existing
  Control UI build from after the daemon install to before it, and added
  the web app build alongside it.
- Non-interactive onboarding (`daemon-install.ts`) — added both pre-build
  calls before `service.install()`.
- Standalone `openclaw gateway install` CLI (`daemon-cli/install.ts`) —
  added both pre-build calls before `service.install()`.
- Configure wizard (`configure.wizard.ts`) — added the web app build
  alongside the existing Control UI build.

Updated test mocks for `ensureWebAppBuilt` in onboarding, configure
wizard, and daemon CLI coverage tests.

Bumped version to 2026.2.6-3.7 and published to npm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 01:32:18 -08:00

293 lines
9.6 KiB
TypeScript

import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const callGateway = vi.fn(async () => ({ ok: true }));
const resolveGatewayProgramArguments = vi.fn(async () => ({
programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"],
}));
const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceReadCommand = vi.fn().mockResolvedValue(null);
const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" });
const findExtraGatewayServices = vi.fn(async () => []);
const inspectPortUsage = vi.fn(async (port: number) => ({
port,
status: "free",
listeners: [],
hints: [],
}));
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts),
}));
vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments: (opts: unknown) => resolveGatewayProgramArguments(opts),
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
install: serviceInstall,
uninstall: serviceUninstall,
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: serviceReadCommand,
readRuntime: serviceReadRuntime,
}),
}));
vi.mock("../daemon/legacy.js", () => ({
findLegacyGatewayServices: async () => [],
}));
vi.mock("../daemon/inspect.js", () => ({
findExtraGatewayServices: (env: unknown, opts?: unknown) => findExtraGatewayServices(env, opts),
renderGatewayServiceCleanupHints: () => [],
}));
vi.mock("../gateway/server-web-app.js", () => ({
ensureWebAppBuilt: vi.fn(async () => ({ ok: true, built: false })),
DEFAULT_WEB_APP_PORT: 3100,
}));
vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true, built: false })),
}));
vi.mock("../infra/ports.js", () => ({
inspectPortUsage: (port: number) => inspectPortUsage(port),
formatPortDiagnostics: () => ["Port 18789 is already in use."],
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: () => {},
}));
vi.mock("./progress.js", () => ({
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
}));
describe("daemon-cli coverage", () => {
const originalEnv = {
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH,
OPENCLAW_GATEWAY_PORT: process.env.OPENCLAW_GATEWAY_PORT,
OPENCLAW_PROFILE: process.env.OPENCLAW_PROFILE,
};
beforeEach(() => {
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli-state";
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli-state/openclaw.json";
delete process.env.OPENCLAW_GATEWAY_PORT;
delete process.env.OPENCLAW_PROFILE;
serviceReadCommand.mockResolvedValue(null);
});
afterEach(() => {
if (originalEnv.OPENCLAW_STATE_DIR !== undefined) {
process.env.OPENCLAW_STATE_DIR = originalEnv.OPENCLAW_STATE_DIR;
} else {
delete process.env.OPENCLAW_STATE_DIR;
}
if (originalEnv.OPENCLAW_CONFIG_PATH !== undefined) {
process.env.OPENCLAW_CONFIG_PATH = originalEnv.OPENCLAW_CONFIG_PATH;
} else {
delete process.env.OPENCLAW_CONFIG_PATH;
}
if (originalEnv.OPENCLAW_GATEWAY_PORT !== undefined) {
process.env.OPENCLAW_GATEWAY_PORT = originalEnv.OPENCLAW_GATEWAY_PORT;
} else {
delete process.env.OPENCLAW_GATEWAY_PORT;
}
if (originalEnv.OPENCLAW_PROFILE !== undefined) {
process.env.OPENCLAW_PROFILE = originalEnv.OPENCLAW_PROFILE;
} else {
delete process.env.OPENCLAW_PROFILE;
}
});
it("probes gateway status by default", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status"], { from: "user" });
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" }));
expect(findExtraGatewayServices).toHaveBeenCalled();
expect(inspectPortUsage).toHaveBeenCalled();
}, 20_000);
it("derives probe URL from service args + env (json)", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
inspectPortUsage.mockClear();
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
environment: {
OPENCLAW_PROFILE: "dev",
OPENCLAW_STATE_DIR: "/tmp/openclaw-daemon-state",
OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon-state/openclaw.json",
OPENCLAW_GATEWAY_PORT: "19001",
},
sourcePath: "/tmp/bot.molt.gateway.plist",
});
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status", "--json"], { from: "user" });
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:19001",
method: "status",
}),
);
expect(inspectPortUsage).toHaveBeenCalledWith(19001);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const parsed = JSON.parse(jsonLine ?? "{}") as {
gateway?: { port?: number; portSource?: string; probeUrl?: string };
config?: { mismatch?: boolean };
rpc?: { url?: string; ok?: boolean };
};
expect(parsed.gateway?.port).toBe(19001);
expect(parsed.gateway?.portSource).toBe("service args");
expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001");
expect(parsed.config?.mismatch).toBe(true);
expect(parsed.rpc?.url).toBe("ws://127.0.0.1:19001");
expect(parsed.rpc?.ok).toBe(true);
}, 20_000);
it("passes deep scan flag for daemon status", async () => {
findExtraGatewayServices.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status", "--deep"], { from: "user" });
expect(findExtraGatewayServices).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ deep: true }),
);
});
it("installs the daemon when requested", async () => {
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "install", "--port", "18789"], {
from: "user",
});
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("installs the daemon with json output", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], {
from: "user",
});
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const parsed = JSON.parse(jsonLine ?? "{}") as {
ok?: boolean;
action?: string;
result?: string;
};
expect(parsed.ok).toBe(true);
expect(parsed.action).toBe("install");
expect(parsed.result).toBe("installed");
});
it("starts and stops the daemon via service helpers", async () => {
serviceRestart.mockClear();
serviceStop.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "start"], { from: "user" });
await program.parseAsync(["daemon", "stop"], { from: "user" });
expect(serviceRestart).toHaveBeenCalledTimes(1);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
it("emits json for daemon start/stop", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceRestart.mockClear();
serviceStop.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "start", "--json"], { from: "user" });
await program.parseAsync(["daemon", "stop", "--json"], { from: "user" });
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
});
});