diff --git a/src/entry.ts b/src/entry.ts index d8fd2bc718a..c38f69b1449 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,11 +3,12 @@ import { spawn } from "node:child_process"; import { enableCompileCache } from "node:module"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; +import { isRootHelpInvocation } from "./cli/argv.js"; import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; import { buildCliRespawnPlan } from "./entry.respawn.js"; +import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; @@ -99,29 +100,6 @@ if ( return true; } - function tryHandleRootVersionFastPath(argv: string[]): boolean { - if (resolveCliContainerTarget(argv)) { - return false; - } - if (!isRootVersionInvocation(argv)) { - return false; - } - Promise.all([import("./version.js"), import("./infra/git-commit.js")]) - .then(([{ VERSION }, { resolveCommitHash }]) => { - const commit = resolveCommitHash({ moduleUrl: import.meta.url }); - console.log(commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`); - process.exit(0); - }) - .catch((error) => { - console.error( - "[openclaw] Failed to resolve version:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); - return true; - } - process.argv = normalizeWindowsArgv(process.argv); if (!ensureCliRespawnReady()) { diff --git a/src/entry.version-fast-path.test.ts b/src/entry.version-fast-path.test.ts index 0edf58527ba..22eeabaf9d9 100644 --- a/src/entry.version-fast-path.test.ts +++ b/src/entry.version-fast-path.test.ts @@ -1,163 +1,80 @@ -import process from "node:process"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { importFreshModule } from "../test/helpers/import-fresh.js"; - -const applyCliProfileEnvMock = vi.hoisted(() => vi.fn()); -const attachChildProcessBridgeMock = vi.hoisted(() => vi.fn()); -const installProcessWarningFilterMock = vi.hoisted(() => vi.fn()); -const isMainModuleMock = vi.hoisted(() => vi.fn(() => true)); -const isRootHelpInvocationMock = vi.hoisted(() => vi.fn(() => false)); -const isRootVersionInvocationMock = vi.hoisted(() => vi.fn(() => true)); -const normalizeEnvMock = vi.hoisted(() => vi.fn()); -const normalizeWindowsArgvMock = vi.hoisted(() => vi.fn((argv: string[]) => argv)); -const parseCliProfileArgsMock = vi.hoisted(() => vi.fn((argv: string[]) => ({ ok: true, argv }))); -const resolveCliContainerTargetMock = vi.hoisted(() => vi.fn<() => string | null>(() => null)); -const resolveCommitHashMock = vi.hoisted(() => vi.fn<() => string | null>(() => "abc1234")); -const runCliMock = vi.hoisted(() => vi.fn(async () => {})); -const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => true)); +import { describe, expect, it, vi } from "vitest"; +import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js"; vi.mock("./cli/argv.js", () => ({ - isRootHelpInvocation: isRootHelpInvocationMock, - isRootVersionInvocation: isRootVersionInvocationMock, + isRootHelpInvocation: () => false, + isRootVersionInvocation: (argv: string[]) => argv.includes("--version"), })); vi.mock("./cli/container-target.js", () => ({ parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }), - resolveCliContainerTarget: resolveCliContainerTargetMock, + resolveCliContainerTarget: (argv: string[], env: NodeJS.ProcessEnv = process.env) => + argv.includes("--container") ? "demo" : (env.OPENCLAW_CONTAINER ?? null), })); -vi.mock("./cli/profile.js", () => ({ - applyCliProfileEnv: applyCliProfileEnvMock, - parseCliProfileArgs: parseCliProfileArgsMock, -})); - -vi.mock("./cli/run-main.js", () => ({ - runCli: runCliMock, -})); - -vi.mock("./cli/respawn-policy.js", () => ({ - shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock, -})); - -vi.mock("./cli/windows-argv.js", () => ({ - normalizeWindowsArgv: normalizeWindowsArgvMock, -})); - -vi.mock("./infra/env.js", () => ({ - isTruthyEnvValue: () => false, - normalizeEnv: normalizeEnvMock, -})); - -vi.mock("./infra/git-commit.js", () => ({ - resolveCommitHash: resolveCommitHashMock, -})); - -vi.mock("./infra/gaxios-fetch-compat.js", () => ({ - installGaxiosFetchCompat: vi.fn(async () => {}), -})); - -vi.mock("./infra/is-main.js", () => ({ - isMainModule: isMainModuleMock, -})); - -vi.mock("./infra/warning-filter.js", () => ({ - installProcessWarningFilter: installProcessWarningFilterMock, -})); - -vi.mock("./process/child-process-bridge.js", () => ({ - attachChildProcessBridge: attachChildProcessBridgeMock, -})); - -vi.mock("./version.js", () => ({ - VERSION: "9.9.9-test", -})); - -async function importEntry(scope: string) { - return await importFreshModule( - import.meta.url, - `./entry.js?scope=${scope}`, - ); -} - -async function flushEntrySideEffects() { +async function flushVersionFastPath() { await Promise.resolve(); await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); } describe("entry root version fast path", () => { - let originalArgv: string[]; - let originalGatewayToken: string | undefined; - let exitSpy: ReturnType; + it("prints version output and skips host handling when container-targeted", async () => { + const output = vi.fn(); + const exit = vi.fn(); + const resolveVersion = vi.fn(async () => ({ + VERSION: "9.9.9-test", + resolveCommitHash: vi.fn(() => "abc1234"), + })); - beforeEach(() => { - vi.clearAllMocks(); - originalArgv = [...process.argv]; - originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - process.argv = ["node", "openclaw", "--version"]; - exitSpy = vi - .spyOn(process, "exit") - .mockImplementation(((_code?: number) => undefined) as typeof process.exit); - }); + expect( + tryHandleRootVersionFastPath(["node", "openclaw", "--version"], { + output, + exit, + resolveVersion, + }), + ).toBe(true); + await flushVersionFastPath(); + expect(output).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exit).toHaveBeenCalledWith(0); - afterEach(() => { - process.argv = originalArgv; - if (originalGatewayToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; - } - exitSpy.mockRestore(); - }); + output.mockClear(); + exit.mockClear(); + resolveVersion.mockResolvedValueOnce({ + VERSION: "9.9.9-test", + resolveCommitHash: vi.fn(() => null), + }); - it("prints commit-tagged version output when commit metadata is available", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + expect( + tryHandleRootVersionFastPath(["node", "openclaw", "--version"], { + output, + exit, + resolveVersion, + }), + ).toBe(true); + await flushVersionFastPath(); + expect(output).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); + expect(exit).toHaveBeenCalledWith(0); - await importEntry("commit-tagged"); - await flushEntrySideEffects(); - expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); - expect(exitSpy).toHaveBeenCalledWith(0); + output.mockClear(); + exit.mockClear(); + expect( + tryHandleRootVersionFastPath(["node", "openclaw", "--container", "demo", "--version"], { + output, + exit, + resolveVersion, + }), + ).toBe(false); + expect(resolveVersion).toHaveBeenCalledTimes(2); + expect(output).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); - logSpy.mockRestore(); - }); - - it("falls back to plain version output when commit metadata is unavailable", async () => { - resolveCommitHashMock.mockReturnValueOnce(null); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - await importEntry("plain-version"); - await flushEntrySideEffects(); - expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); - expect(exitSpy).toHaveBeenCalledWith(0); - - logSpy.mockRestore(); - }); - - it("skips the host version fast path when a container target is active", async () => { - resolveCliContainerTargetMock.mockReturnValue("demo"); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - await importEntry("container-target"); - await flushEntrySideEffects(); - expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); - expect(logSpy).not.toHaveBeenCalled(); - expect(exitSpy).not.toHaveBeenCalled(); - - logSpy.mockRestore(); - }); - - it("allows root version container mode when gateway override env vars are set", async () => { - resolveCliContainerTargetMock.mockReturnValue("demo"); - process.env.OPENCLAW_GATEWAY_TOKEN = "demo-token"; - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - await importEntry("gateway-override"); - await flushEntrySideEffects(); - expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); - expect(errorSpy).not.toHaveBeenCalled(); - expect(exitSpy).not.toHaveBeenCalled(); - - errorSpy.mockRestore(); + expect( + tryHandleRootVersionFastPath(["node", "openclaw", "--version"], { + env: { OPENCLAW_CONTAINER: "demo" }, + output, + exit, + resolveVersion, + }), + ).toBe(false); }); }); diff --git a/src/entry.version-fast-path.ts b/src/entry.version-fast-path.ts new file mode 100644 index 00000000000..db99980ac33 --- /dev/null +++ b/src/entry.version-fast-path.ts @@ -0,0 +1,53 @@ +import { isRootVersionInvocation } from "./cli/argv.js"; +import { resolveCliContainerTarget } from "./cli/container-target.js"; + +export function tryHandleRootVersionFastPath( + argv: string[], + deps: { + env?: NodeJS.ProcessEnv; + moduleUrl?: string; + output?: (message: string) => void; + exit?: (code?: number) => void; + onError?: (error: unknown) => void; + resolveVersion?: () => Promise<{ + VERSION: string; + resolveCommitHash: (params: { moduleUrl: string }) => string | null; + }>; + } = {}, +): boolean { + if (resolveCliContainerTarget(argv, deps.env)) { + return false; + } + if (!isRootVersionInvocation(argv)) { + return false; + } + const output = deps.output ?? ((message: string) => console.log(message)); + const exit = deps.exit ?? ((code?: number) => process.exit(code)); + const onError = + deps.onError ?? + ((error: unknown) => { + console.error( + "[openclaw] Failed to resolve version:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); + const resolveVersion = + deps.resolveVersion ?? + (async () => { + const [{ VERSION }, { resolveCommitHash }] = await Promise.all([ + import("./version.js"), + import("./infra/git-commit.js"), + ]); + return { VERSION, resolveCommitHash }; + }); + + resolveVersion() + .then(({ VERSION, resolveCommitHash }) => { + const commit = resolveCommitHash({ moduleUrl: deps.moduleUrl ?? import.meta.url }); + output(commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`); + exit(0); + }) + .catch(onError); + return true; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index dbfe72462b7..e6d861efc67 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -16,6 +16,23 @@ vi.mock("../markdown.ts", () => ({ toSanitizedMarkdownHtml: (value: string) => value, })); +vi.mock("../chat/export.ts", () => ({ + exportChatMarkdown: vi.fn(), +})); + +vi.mock("../chat/speech.ts", () => ({ + isSttActive: () => false, + isSttSupported: () => false, + isTtsSpeaking: () => false, + isTtsSupported: () => false, + speakText: () => false, + startStt: () => false, + stopStt: () => undefined, + stopTts: () => undefined, +})); + +vi.mock("../components/resizable-divider.ts", () => ({})); + vi.mock("./markdown-sidebar.ts", async () => { const { html } = await import("lit"); return {