Files
openclaw/src/cli/deps.test.ts
Taylor Asplund 874ff7089c fix: ensure CLI exits after command completion (#12906)
* fix: ensure CLI exits after command completion

The CLI process would hang indefinitely after commands like
`openclaw gateway restart` completed successfully.  Two root causes:

1. `runCli()` returned without calling `process.exit()` after
   `program.parseAsync()` resolved, and Commander.js does not
   force-exit the process.

2. `daemon-cli/register.ts` eagerly called `createDefaultDeps()`
   which imported all messaging-provider modules, creating persistent
   event-loop handles that prevented natural Node exit.

Changes:
- Add `flushAndExit()` helper that drains stdout/stderr before calling
  `process.exit()`, preventing truncated piped output in CI/scripts.
- Call `flushAndExit()` after both `tryRouteCli()` and
  `program.parseAsync()` resolve.
- Remove unnecessary `void createDefaultDeps()` from daemon-cli
  registration — daemon lifecycle commands never use messaging deps.
- Make `serveAcpGateway()` return a promise that resolves on
  intentional shutdown (SIGINT/SIGTERM), so `openclaw acp` blocks
  `parseAsync` for the bridge lifetime and exits cleanly on signal.
- Handle the returned promise in the standalone main-module entry
  point to avoid unhandled rejections.

Fixes #12904

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refactor CLI lifecycle and lazy outbound deps (#12906) (thanks @DrCrinkle)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 00:34:33 +01:00

94 lines
3.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { createDefaultDeps } from "./deps.js";
const moduleLoads = vi.hoisted(() => ({
whatsapp: vi.fn(),
telegram: vi.fn(),
discord: vi.fn(),
slack: vi.fn(),
signal: vi.fn(),
imessage: vi.fn(),
}));
const sendFns = vi.hoisted(() => ({
whatsapp: vi.fn(async () => ({ messageId: "w1", toJid: "whatsapp:1" })),
telegram: vi.fn(async () => ({ messageId: "t1", chatId: "telegram:1" })),
discord: vi.fn(async () => ({ messageId: "d1", channelId: "discord:1" })),
slack: vi.fn(async () => ({ messageId: "s1", channelId: "slack:1" })),
signal: vi.fn(async () => ({ messageId: "sg1", conversationId: "signal:1" })),
imessage: vi.fn(async () => ({ messageId: "i1", chatId: "imessage:1" })),
}));
vi.mock("../channels/web/index.js", () => {
moduleLoads.whatsapp();
return { sendMessageWhatsApp: sendFns.whatsapp };
});
vi.mock("../telegram/send.js", () => {
moduleLoads.telegram();
return { sendMessageTelegram: sendFns.telegram };
});
vi.mock("../discord/send.js", () => {
moduleLoads.discord();
return { sendMessageDiscord: sendFns.discord };
});
vi.mock("../slack/send.js", () => {
moduleLoads.slack();
return { sendMessageSlack: sendFns.slack };
});
vi.mock("../signal/send.js", () => {
moduleLoads.signal();
return { sendMessageSignal: sendFns.signal };
});
vi.mock("../imessage/send.js", () => {
moduleLoads.imessage();
return { sendMessageIMessage: sendFns.imessage };
});
describe("createDefaultDeps", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not load provider modules until a dependency is used", async () => {
const deps = createDefaultDeps();
expect(moduleLoads.whatsapp).not.toHaveBeenCalled();
expect(moduleLoads.telegram).not.toHaveBeenCalled();
expect(moduleLoads.discord).not.toHaveBeenCalled();
expect(moduleLoads.slack).not.toHaveBeenCalled();
expect(moduleLoads.signal).not.toHaveBeenCalled();
expect(moduleLoads.imessage).not.toHaveBeenCalled();
const sendTelegram = deps.sendMessageTelegram as unknown as (
...args: unknown[]
) => Promise<unknown>;
await sendTelegram("chat", "hello", { verbose: false });
expect(moduleLoads.telegram).toHaveBeenCalledTimes(1);
expect(sendFns.telegram).toHaveBeenCalledTimes(1);
expect(moduleLoads.whatsapp).not.toHaveBeenCalled();
expect(moduleLoads.discord).not.toHaveBeenCalled();
expect(moduleLoads.slack).not.toHaveBeenCalled();
expect(moduleLoads.signal).not.toHaveBeenCalled();
expect(moduleLoads.imessage).not.toHaveBeenCalled();
});
it("reuses module cache after first dynamic import", async () => {
const deps = createDefaultDeps();
const sendDiscord = deps.sendMessageDiscord as unknown as (
...args: unknown[]
) => Promise<unknown>;
await sendDiscord("channel", "first", { verbose: false });
await sendDiscord("channel", "second", { verbose: false });
expect(moduleLoads.discord).toHaveBeenCalledTimes(1);
expect(sendFns.discord).toHaveBeenCalledTimes(2);
});
});