diff --git a/src/crestodian/crestodian.test.ts b/src/crestodian/crestodian.test.ts index e4ed04aa246..67f8ba318c9 100644 --- a/src/crestodian/crestodian.test.ts +++ b/src/crestodian/crestodian.test.ts @@ -1,127 +1,115 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { runCrestodian } from "./crestodian.js"; import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; +import type { CrestodianOverview } from "./overview.js"; -vi.mock("./probes.js", () => ({ - probeLocalCommand: vi.fn(async (command: string) => ({ - command, - found: false, - error: "not found", - })), - probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), -})); +const overview: CrestodianOverview = { + defaultAgentId: "main", + defaultModel: "openai/gpt-5.5", + agents: [{ id: "main", isDefault: true, model: "openai/gpt-5.5" }], + config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, + tools: { + codex: { command: "codex", found: false, error: "not found" }, + claude: { command: "claude", found: false, error: "not found" }, + apiKeys: { openai: true, anthropic: false }, + }, + gateway: { + url: "ws://127.0.0.1:18789", + source: "local loopback", + reachable: false, + error: "offline", + }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, +}; -vi.mock("./overview.js", () => ({ - formatCrestodianOverview: () => "Default model: openai/gpt-5.5", - loadCrestodianOverview: vi.fn(async () => ({ - defaultAgentId: "main", - defaultModel: "openai/gpt-5.5", - agents: [{ id: "main", isDefault: true, model: "openai/gpt-5.5" }], - config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, - tools: { - codex: { command: "codex", found: false, error: "not found" }, - claude: { command: "claude", found: false, error: "not found" }, - apiKeys: { openai: true, anthropic: false }, - }, - gateway: { - url: "ws://127.0.0.1:18789", - source: "local loopback", - reachable: false, - error: "offline", - }, - references: { - docsUrl: "https://docs.openclaw.ai", - sourceUrl: "https://github.com/openclaw/openclaw", - }, - })), -})); +const crestodianOverviewDeps = { + formatOverview: () => "Default model: openai/gpt-5.5", + loadOverview: async () => overview, +}; describe("runCrestodian", () => { - beforeEach(() => { - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - it("uses the assistant planner only to choose typed operations", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); const { runtime, lines } = createCrestodianTestRuntime(); - const runGatewayRestart = vi.fn(async () => {}); - const onReady = vi.fn(); + let runGatewayRestartCalls = 0; + let onReadyCalls = 0; await runCrestodian( { message: "the local bridge looks sleepy, poke it", - deps: { runGatewayRestart }, - onReady, + deps: { + runGatewayRestart: async () => { + runGatewayRestartCalls += 1; + }, + }, + onReady: () => { + onReadyCalls += 1; + }, planWithAssistant: async () => ({ reply: "I can queue a Gateway restart.", command: "restart gateway", modelLabel: "openai/gpt-5.5", }), + ...crestodianOverviewDeps, }, runtime, ); - expect(runGatewayRestart).not.toHaveBeenCalled(); - expect(onReady).not.toHaveBeenCalled(); + expect(runGatewayRestartCalls).toBe(0); + expect(onReadyCalls).toBe(0); expect(lines.join("\n")).toContain("[crestodian] planner: openai/gpt-5.5"); expect(lines.join("\n")).toContain("[crestodian] interpreted: restart gateway"); expect(lines.join("\n")).toContain("Plan: restart the Gateway. Say yes to apply."); }); it("keeps deterministic parsing ahead of the assistant planner", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-deterministic-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); const { runtime, lines } = createCrestodianTestRuntime(); - const planner = vi.fn(async () => ({ command: "restart gateway" })); - const onReady = vi.fn(); + let plannerCalls = 0; + let onReadyCalls = 0; await runCrestodian( { message: "models", - planWithAssistant: planner, - onReady, + planWithAssistant: async () => { + plannerCalls += 1; + return { command: "restart gateway" }; + }, + onReady: () => { + onReadyCalls += 1; + }, + ...crestodianOverviewDeps, }, runtime, ); - expect(planner).not.toHaveBeenCalled(); - expect(onReady).not.toHaveBeenCalled(); + expect(plannerCalls).toBe(0); + expect(onReadyCalls).toBe(0); expect(lines.join("\n")).toContain("Default model:"); }); it("starts interactive Crestodian in the TUI shell", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-tui-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); const { runtime, lines } = createCrestodianTestRuntime(); - const runInteractiveTui = vi.fn(async () => {}); - const onReady = vi.fn(); + let runInteractiveTuiCalls = 0; + let onReadyCalls = 0; await runCrestodian( { input: { isTTY: true } as unknown as NodeJS.ReadableStream, output: { isTTY: true } as unknown as NodeJS.WritableStream, - runInteractiveTui, - onReady, + runInteractiveTui: async () => { + runInteractiveTuiCalls += 1; + }, + onReady: () => { + onReadyCalls += 1; + }, }, runtime, ); - expect(runInteractiveTui).toHaveBeenCalledWith( - expect.objectContaining({ runInteractiveTui }), - runtime, - ); - expect(onReady).toHaveBeenCalledTimes(1); + expect(runInteractiveTuiCalls).toBe(1); + expect(onReadyCalls).toBe(1); expect(lines.join("\n")).not.toContain("Say: status"); }); }); diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index 243a670440c..0aee6a46d5b 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -8,7 +8,11 @@ import { isPersistentCrestodianOperation, type CrestodianCommandDeps, } from "./operations.js"; -import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js"; +import { + formatCrestodianOverview, + loadCrestodianOverview, + type CrestodianOverview, +} from "./overview.js"; type CrestodianInteractiveRunner = ( opts: RunCrestodianOptions, @@ -22,12 +26,27 @@ export type RunCrestodianOptions = { interactive?: boolean; onReady?: () => void; deps?: CrestodianCommandDeps; + formatOverview?: (overview: CrestodianOverview) => string; + loadOverview?: typeof loadCrestodianOverview; planWithAssistant?: CrestodianAssistantPlanner; input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; runInteractiveTui?: CrestodianInteractiveRunner; }; +function crestodianCommandDepsFromOptions( + opts: RunCrestodianOptions, +): CrestodianCommandDeps | undefined { + if (!opts.deps && !opts.formatOverview && !opts.loadOverview) { + return undefined; + } + return { + ...opts.deps, + ...(opts.formatOverview ? { formatOverview: opts.formatOverview } : {}), + ...(opts.loadOverview ? { loadOverview: opts.loadOverview } : {}), + }; +} + async function runOneShot( input: string, runtime: RuntimeEnv, @@ -36,7 +55,7 @@ async function runOneShot( const operation = await resolveCrestodianOperation(input, runtime, opts); await executeCrestodianOperation(operation, runtime, { approved: opts.yes === true || !isPersistentCrestodianOperation(operation), - deps: opts.deps, + deps: crestodianCommandDepsFromOptions(opts), }); } @@ -45,7 +64,7 @@ export async function runCrestodian( runtime: RuntimeEnv = defaultRuntime, ): Promise { if (opts.json) { - const overview = await loadCrestodianOverview(); + const overview = await (opts.loadOverview ?? loadCrestodianOverview)(); writeRuntimeJson(runtime, overview); return; } @@ -58,9 +77,9 @@ export async function runCrestodian( delayMs: 0, fallback: "none", }, - async () => await loadCrestodianOverview(), + async () => await (opts.loadOverview ?? loadCrestodianOverview)(), ); - runtime.log(formatCrestodianOverview(overview)); + runtime.log((opts.formatOverview ?? formatCrestodianOverview)(overview)); runtime.log(""); await runOneShot(opts.message, runtime, opts); return; diff --git a/src/crestodian/dialogue.ts b/src/crestodian/dialogue.ts index b0c74c6c35a..8e56d1ed474 100644 --- a/src/crestodian/dialogue.ts +++ b/src/crestodian/dialogue.ts @@ -5,9 +5,10 @@ import { parseCrestodianOperation, type CrestodianOperation, } from "./operations.js"; -import type { CrestodianOverview } from "./overview.js"; +import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js"; export type CrestodianDialogueOptions = { + loadOverview?: typeof loadCrestodianOverview; planWithAssistant?: CrestodianAssistantPlanner; }; @@ -28,8 +29,7 @@ export async function resolveCrestodianOperation( if (!shouldAskAssistant(input, operation)) { return operation; } - const { loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); + const overview = await (opts.loadOverview ?? loadCrestodianOverview)(); const planner = opts.planWithAssistant ?? (await import("./assistant.js")).planCrestodianCommand; const plan = await planner({ input, overview }); if (!plan) { diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 588d53ac61b..46ecf592d82 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -10,6 +10,8 @@ import type { CrestodianOverview } from "./overview.js"; type ConfigModule = typeof import("../config/config.js"); type ConfigFileSnapshot = Awaited>; +type CrestodianOverviewLoader = () => Promise; +type CrestodianOverviewFormatter = (overview: CrestodianOverview) => string; export type CrestodianOperation = | { kind: "none"; message: string } @@ -47,6 +49,8 @@ export type CrestodianOperationResult = { }; export type CrestodianCommandDeps = { + formatOverview?: CrestodianOverviewFormatter; + loadOverview?: CrestodianOverviewLoader; runAgentsAdd?: ( opts: { name?: string; @@ -352,6 +356,27 @@ async function readConfigFileSnapshotLazy(): Promise { return await readConfigFileSnapshot(); } +async function loadOverviewForOperation( + deps: CrestodianCommandDeps | undefined, +): Promise { + if (deps?.loadOverview) { + return await deps.loadOverview(); + } + const { loadCrestodianOverview } = await import("./overview.js"); + return await loadCrestodianOverview(); +} + +async function formatOverviewForOperation( + overview: CrestodianOverview, + deps: CrestodianCommandDeps | undefined, +): Promise { + if (deps?.formatOverview) { + return deps.formatOverview(overview); + } + const { formatCrestodianOverview } = await import("./overview.js"); + return formatCrestodianOverview(overview); +} + async function loadConfigFileMutationHelpers(): Promise<{ mutateConfigFile: ConfigModule["mutateConfigFile"]; readConfigFileSnapshot: ConfigModule["readConfigFileSnapshot"]; @@ -388,9 +413,9 @@ function createNoExitRuntime(runtime: RuntimeEnv): RuntimeEnv { async function resolveTuiAgentId(params: { requestedAgentId: string | undefined; requestedWorkspace?: string; + deps?: CrestodianCommandDeps; }): Promise { - const { loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForOperation(params.deps); const workspace = params.requestedWorkspace ? resolveUserPath(params.requestedWorkspace) : undefined; @@ -429,14 +454,12 @@ export async function executeCrestodianOperation( return { applied: false, exitsInteractive: operation.message.includes("Bye.") }; } if (operation.kind === "overview") { - const { formatCrestodianOverview, loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); - runtime.log(formatCrestodianOverview(overview)); + const overview = await loadOverviewForOperation(opts.deps); + runtime.log(await formatOverviewForOperation(overview, opts.deps)); return { applied: false }; } if (operation.kind === "agents") { - const { loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForOperation(opts.deps); runtime.log( [ "Agents:", @@ -456,8 +479,7 @@ export async function executeCrestodianOperation( return { applied: false }; } if (operation.kind === "models") { - const { loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForOperation(opts.deps); runtime.log( [ `Default model: ${overview.defaultModel ?? "not configured"}`, @@ -480,8 +502,7 @@ export async function executeCrestodianOperation( return { applied: false }; } if (operation.kind === "setup") { - const { loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForOperation(opts.deps); const setupModel = chooseSetupModel(overview, operation.model); if (!opts.approved) { const message = [ @@ -720,8 +741,7 @@ export async function executeCrestodianOperation( return { applied: false }; } if (operation.kind === "gateway-status") { - const { loadCrestodianOverview } = await import("./overview.js"); - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForOperation(opts.deps); runtime.log(formatGatewayStatusLine(overview)); return { applied: false }; } @@ -782,6 +802,7 @@ export async function executeCrestodianOperation( const agentId = await resolveTuiAgentId({ requestedAgentId: operation.agentId, requestedWorkspace: operation.workspace, + deps: opts.deps, }); const session = agentId ? buildAgentMainSessionKey({ agentId }) : undefined; const runTui = opts.deps?.runTui ?? (await import("../tui/tui.js")).runTui; diff --git a/src/crestodian/tui-backend.test.ts b/src/crestodian/tui-backend.test.ts index 9d603bd60b2..073f139bfd4 100644 --- a/src/crestodian/tui-backend.test.ts +++ b/src/crestodian/tui-backend.test.ts @@ -1,58 +1,34 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; - -const mocks = vi.hoisted(() => ({ - runTui: vi.fn(async (_opts: unknown) => ({ exitReason: "exit" as const })), -})); - -vi.mock("../tui/tui.js", () => ({ - runTui: mocks.runTui, -})); - -vi.mock("./probes.js", () => ({ - probeLocalCommand: vi.fn(async (command: string) => ({ - command, - found: false, - error: "not found", - })), - probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), -})); - -vi.mock("./overview.js", () => ({ - formatCrestodianOverview: () => "Default model: openai/gpt-5.5", - formatCrestodianStartupMessage: () => "Default model: openai/gpt-5.5", - loadCrestodianOverview: vi.fn(async () => ({ - defaultAgentId: "main", - defaultModel: "openai/gpt-5.5", - agents: [{ id: "main", isDefault: true, model: "openai/gpt-5.5" }], - config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, - tools: { - codex: { command: "codex", found: false, error: "not found" }, - claude: { command: "claude", found: false, error: "not found" }, - apiKeys: { openai: true, anthropic: false }, - }, - gateway: { - url: "ws://127.0.0.1:18789", - source: "local loopback", - reachable: false, - error: "offline", - }, - references: { - docsUrl: "https://docs.openclaw.ai", - sourceUrl: "https://github.com/openclaw/openclaw", - }, - })), -})); - +import type { CrestodianOverview } from "./overview.js"; import { runCrestodianTui } from "./tui-backend.js"; +const overview: CrestodianOverview = { + defaultAgentId: "main", + defaultModel: "openai/gpt-5.5", + agents: [{ id: "main", isDefault: true, model: "openai/gpt-5.5" }], + config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, + tools: { + codex: { command: "codex", found: false, error: "not found" }, + claude: { command: "claude", found: false, error: "not found" }, + apiKeys: { openai: true, anthropic: false }, + }, + gateway: { + url: "ws://127.0.0.1:18789", + source: "local loopback", + reachable: false, + error: "offline", + }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, +}; + function createRuntime(): RuntimeEnv { return { - log: vi.fn(), - error: vi.fn(), + log: () => undefined, + error: () => undefined, exit: (code) => { throw new Error(`exit ${code}`); }, @@ -60,32 +36,32 @@ function createRuntime(): RuntimeEnv { } describe("runCrestodianTui", () => { - beforeEach(() => { - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - mocks.runTui.mockClear(); - }); - it("runs Crestodian inside the shared TUI shell", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-tui-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + let runTuiCalls = 0; + let runTuiOptions: unknown; - await runCrestodianTui({}, createRuntime()); - - expect(mocks.runTui).toHaveBeenCalledWith( - expect.objectContaining({ - local: true, - session: "agent:crestodian:main", - historyLimit: 200, - config: {}, - title: "openclaw crestodian", - }), + await runCrestodianTui( + { + deps: { + loadOverview: async () => overview, + }, + runTui: async (opts) => { + runTuiCalls += 1; + runTuiOptions = opts; + return { exitReason: "exit" }; + }, + }, + createRuntime(), ); - const callOptions = mocks.runTui.mock.calls[0]?.[0] as { backend?: unknown } | undefined; - expect(callOptions?.backend).toBeTruthy(); + + expect(runTuiCalls).toBe(1); + expect(runTuiOptions).toMatchObject({ + local: true, + session: "agent:crestodian:main", + historyLimit: 200, + config: {}, + title: "openclaw crestodian", + }); + expect((runTuiOptions as { backend?: unknown }).backend).toBeTruthy(); }); }); diff --git a/src/crestodian/tui-backend.ts b/src/crestodian/tui-backend.ts index e8f33d8d463..5243de26abf 100644 --- a/src/crestodian/tui-backend.ts +++ b/src/crestodian/tui-backend.ts @@ -10,7 +10,7 @@ import type { TuiModelChoice, TuiSessionList, } from "../tui/tui-backend.js"; -import { runTui } from "../tui/tui.js"; +import { runTui as defaultRunTui } from "../tui/tui.js"; import type { CrestodianAssistantPlanner } from "./assistant.js"; import { approvalQuestion, isYes, resolveCrestodianOperation } from "./dialogue.js"; import { @@ -21,10 +21,13 @@ import { } from "./operations.js"; import { formatCrestodianStartupMessage, loadCrestodianOverview } from "./overview.js"; +type RunTui = typeof defaultRunTui; + export type CrestodianTuiOptions = { yes?: boolean; deps?: CrestodianCommandDeps; planWithAssistant?: CrestodianAssistantPlanner; + runTui?: RunTui; }; type CrestodianHistoryMessage = { @@ -52,6 +55,13 @@ function createCaptureRuntime(): CaptureRuntime { }; } +async function loadOverviewForTui(opts: CrestodianTuiOptions) { + if (opts.deps?.loadOverview) { + return await opts.deps.loadOverview(); + } + return await loadCrestodianOverview(); +} + function message(role: "assistant" | "user", text: string): CrestodianHistoryMessage { return { role, @@ -143,7 +153,7 @@ class CrestodianTuiBackend implements TuiBackend { } async listSessions(): Promise { - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForTui(this.opts); const model = splitModelRef(overview.defaultModel); return { ts: Date.now(), @@ -200,7 +210,7 @@ class CrestodianTuiBackend implements TuiBackend { async resetSession(): Promise<{ ok: boolean }> { this.pending = null; - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForTui(this.opts); this.messages.splice( 0, this.messages.length, @@ -210,7 +220,7 @@ class CrestodianTuiBackend implements TuiBackend { } async getGatewayStatus(): Promise { - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForTui(this.opts); return overview.gateway.reachable ? "Gateway reachable" : "Gateway unreachable"; } @@ -316,8 +326,9 @@ export async function runCrestodianTui( ): Promise { let nextInput: string | undefined; for (;;) { - const overview = await loadCrestodianOverview(); + const overview = await loadOverviewForTui(opts); const backend = new CrestodianTuiBackend(opts, formatCrestodianStartupMessage(overview)); + const runTui = opts.runTui ?? defaultRunTui; await runTui({ local: true, session: CRESTODIAN_SESSION_KEY, diff --git a/src/entry.test.ts b/src/entry.test.ts index 7854a9b6d40..19858963a3b 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -1,60 +1,64 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { tryHandleRootHelpFastPath } from "./entry.js"; -const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false)); - -vi.mock("./cli/root-help-metadata.js", () => ({ - outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock, -})); - describe("entry root help fast path", () => { it("prefers precomputed root help text when available", async () => { - outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true); + let outputPrecomputedRootHelpTextCalls = 0; const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { env: {}, + outputPrecomputedRootHelpText: () => { + outputPrecomputedRootHelpTextCalls += 1; + return true; + }, }); expect(handled).toBe(true); - expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); + expect(outputPrecomputedRootHelpTextCalls).toBe(1); }); it("renders root help without importing the full program", async () => { - const outputRootHelpMock = vi.fn(); + let outputRootHelpCalls = 0; const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { - outputRootHelp: outputRootHelpMock, + outputRootHelp: () => { + outputRootHelpCalls += 1; + }, env: {}, }); expect(handled).toBe(true); - expect(outputRootHelpMock).toHaveBeenCalledTimes(1); + expect(outputRootHelpCalls).toBe(1); }); it("ignores non-root help invocations", async () => { - const outputRootHelpMock = vi.fn(); + let outputRootHelpCalls = 0; const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], { - outputRootHelp: outputRootHelpMock, + outputRootHelp: () => { + outputRootHelpCalls += 1; + }, env: {}, }); expect(handled).toBe(false); - expect(outputRootHelpMock).not.toHaveBeenCalled(); + expect(outputRootHelpCalls).toBe(0); }); it("skips the host help fast path when a container target is active", async () => { - const outputRootHelpMock = vi.fn(); + let outputRootHelpCalls = 0; const handled = await tryHandleRootHelpFastPath( ["node", "openclaw", "--container", "demo", "--help"], { - outputRootHelp: outputRootHelpMock, + outputRootHelp: () => { + outputRootHelpCalls += 1; + }, env: {}, }, ); expect(handled).toBe(false); - expect(outputRootHelpMock).not.toHaveBeenCalled(); + expect(outputRootHelpCalls).toBe(0); }); }); diff --git a/src/entry.ts b/src/entry.ts index 7a34f8d0103..a7cab31b955 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -134,6 +134,7 @@ if ( export async function tryHandleRootHelpFastPath( argv: string[], deps: { + outputPrecomputedRootHelpText?: () => boolean; outputRootHelp?: () => void | Promise; onError?: (error: unknown) => void; env?: NodeJS.ProcessEnv; @@ -159,7 +160,9 @@ export async function tryHandleRootHelpFastPath( await deps.outputRootHelp(); return true; } - const { outputPrecomputedRootHelpText } = await import("./cli/root-help-metadata.js"); + const outputPrecomputedRootHelpText = + deps.outputPrecomputedRootHelpText ?? + (await import("./cli/root-help-metadata.js")).outputPrecomputedRootHelpText; if (!outputPrecomputedRootHelpText()) { const { outputRootHelp } = await import("./cli/program/root-help.js"); await outputRootHelp(); diff --git a/src/library.test.ts b/src/library.test.ts index 5aeca9413e9..72957119465 100644 --- a/src/library.test.ts +++ b/src/library.test.ts @@ -1,10 +1,7 @@ import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import ts from "typescript"; import { describe, expect, it } from "vitest"; -const libraryPath = resolve(dirname(fileURLToPath(import.meta.url)), "library.ts"); +const libraryPath = new URL("./library.ts", import.meta.url); const lazyRuntimeSpecifiers = [ "./auto-reply/reply.runtime.js", "./cli/prompt.js", @@ -15,32 +12,17 @@ const lazyRuntimeSpecifiers = [ function readLibraryModuleImports() { const sourceText = readFileSync(libraryPath, "utf8"); - const sourceFile = ts.createSourceFile(libraryPath, sourceText, ts.ScriptTarget.Latest, true); const staticImports = new Set(); const dynamicImports = new Set(); + const staticImportPattern = /(?:^|\n)\s*import\s+(?!type\b)[\s\S]*?\s+from\s+["']([^"']+)["']/g; + const dynamicImportPattern = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; - function visit(node: ts.Node) { - if ( - ts.isImportDeclaration(node) && - ts.isStringLiteral(node.moduleSpecifier) && - !node.importClause?.isTypeOnly - ) { - staticImports.add(node.moduleSpecifier.text); - } - - if ( - ts.isCallExpression(node) && - node.expression.kind === ts.SyntaxKind.ImportKeyword && - node.arguments.length === 1 && - ts.isStringLiteral(node.arguments[0]) - ) { - dynamicImports.add(node.arguments[0].text); - } - - ts.forEachChild(node, visit); + for (const match of sourceText.matchAll(staticImportPattern)) { + staticImports.add(match[1]); + } + for (const match of sourceText.matchAll(dynamicImportPattern)) { + dynamicImports.add(match[1]); } - - visit(sourceFile); return { dynamicImports, staticImports }; } diff --git a/src/utils.test.ts b/src/utils.test.ts index 5fa48d2166d..da8648bb4e9 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -25,10 +25,13 @@ describe("ensureDir", () => { describe("sleep", () => { it("resolves after delay using fake timers", async () => { vi.useFakeTimers(); - const promise = sleep(1000); - vi.advanceTimersByTime(1000); - await expect(promise).resolves.toBeUndefined(); - vi.useRealTimers(); + try { + const promise = sleep(1000); + vi.advanceTimersByTime(1000); + await expect(promise).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); }); @@ -65,10 +68,11 @@ describe("resolveHomeDir", () => { it("prefers OPENCLAW_HOME over HOME", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - - expect(resolveHomeDir()).toBe(path.resolve("/srv/openclaw-home")); - - vi.unstubAllEnvs(); + try { + expect(resolveHomeDir()).toBe(path.resolve("/srv/openclaw-home")); + } finally { + vi.unstubAllEnvs(); + } }); }); @@ -76,12 +80,13 @@ describe("shortenHomePath", () => { it("uses $OPENCLAW_HOME prefix when OPENCLAW_HOME is set", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - - expect(shortenHomePath(`${path.resolve("/srv/openclaw-home")}/.openclaw/openclaw.json`)).toBe( - "$OPENCLAW_HOME/.openclaw/openclaw.json", - ); - - vi.unstubAllEnvs(); + try { + expect(shortenHomePath(`${path.resolve("/srv/openclaw-home")}/.openclaw/openclaw.json`)).toBe( + "$OPENCLAW_HOME/.openclaw/openclaw.json", + ); + } finally { + vi.unstubAllEnvs(); + } }); }); @@ -89,12 +94,15 @@ describe("shortenHomeInString", () => { it("uses $OPENCLAW_HOME replacement when OPENCLAW_HOME is set", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - - expect( - shortenHomeInString(`config: ${path.resolve("/srv/openclaw-home")}/.openclaw/openclaw.json`), - ).toBe("config: $OPENCLAW_HOME/.openclaw/openclaw.json"); - - vi.unstubAllEnvs(); + try { + expect( + shortenHomeInString( + `config: ${path.resolve("/srv/openclaw-home")}/.openclaw/openclaw.json`, + ), + ).toBe("config: $OPENCLAW_HOME/.openclaw/openclaw.json"); + } finally { + vi.unstubAllEnvs(); + } }); }); @@ -116,10 +124,11 @@ describe("resolveUserPath", () => { it("prefers OPENCLAW_HOME for tilde expansion", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - - expect(resolveUserPath("~/openclaw")).toBe(path.resolve("/srv/openclaw-home", "openclaw")); - - vi.unstubAllEnvs(); + try { + expect(resolveUserPath("~/openclaw")).toBe(path.resolve("/srv/openclaw-home", "openclaw")); + } finally { + vi.unstubAllEnvs(); + } }); it("uses the provided env for tilde expansion", () => { diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index b59d2e10174..975f85404ee 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -62,19 +62,27 @@ export const forcedUnitFastTestFiles = [ "packages/memory-host-sdk/src/host/session-files.test.ts", "src/acp/client.test.ts", "src/acp/control-plane/manager.test.ts", + "src/acp/translator.cancel-scoping.test.ts", + "src/acp/translator.stop-reason.test.ts", "src/acp/persistent-bindings.test.ts", "src/acp/server.startup.test.ts", "src/acp/translator.session-rate-limit.test.ts", "src/browser-lifecycle-cleanup.test.ts", "src/crestodian/overview.test.ts", + "src/crestodian/crestodian.test.ts", "src/crestodian/operations.test.ts", "src/crestodian/rescue-message.test.ts", + "src/crestodian/tui-backend.test.ts", "src/flows/channel-setup.test.ts", "src/context-engine/context-engine.test.ts", + "src/canvas-host/server.state-dir.test.ts", "src/docker-image-digests.test.ts", + "src/dockerfile.test.ts", + "src/entry.test.ts", "src/i18n/registry.test.ts", "src/install-sh-version.test.ts", "src/logger.test.ts", + "src/library.test.ts", "src/memory-host-sdk/host/internal.test.ts", "src/memory-host-sdk/host/batch-http.test.ts", "src/memory-host-sdk/host/backend-config.test.ts", @@ -84,9 +92,13 @@ export const forcedUnitFastTestFiles = [ "src/mcp/channel-server.shutdown-unhandled-rejection.test.ts", "src/node-host/invoke-system-run-plan.test.ts", "src/node-host/invoke-system-run.test.ts", + "src/pairing/allow-from-store-read.test.ts", "src/pairing/pairing-store.test.ts", "src/plugin-sdk/memory-host-events.test.ts", + "src/proxy-capture/store.sqlite.test.ts", + "src/security/audit-exec-surface.test.ts", "src/security/audit-extra.async.test.ts", + "src/security/dm-policy-shared.test.ts", "src/security/audit-plugins-trust.test.ts", "src/security/audit-workspace-skill-escape.test.ts", "src/security/external-content.test.ts", @@ -94,10 +106,13 @@ export const forcedUnitFastTestFiles = [ "src/security/skill-scanner.test.ts", "src/security/windows-acl.test.ts", "src/realtime-transcription/websocket-session.test.ts", + "src/routing/resolve-route.test.ts", "src/trajectory/export.test.ts", "src/tts/provider-registry.test.ts", "src/tts/status-config.test.ts", "src/terminal/table.test.ts", + "src/test-helpers/state-dir-env.test.ts", + "src/utils.test.ts", "src/version.test.ts", ]; const forcedUnitFastTestFileSet = new Set(forcedUnitFastTestFiles);