// Write Cli Startup Metadata tests cover write cli startup metadata script behavior. import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { availableParallelism } from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; import { testing } from "../../scripts/write-cli-startup-metadata.ts"; import { waitForPidFile } from "../helpers/process-wait.js"; import { createScriptTestHarness } from "./test-helpers.js"; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: vi.fn(actual.spawn) }; }); // These subprocess tests use explicit ready/close signals; timeout only catches broken fixtures. const LOAD_SENSITIVE_PROCESS_TIMEOUT_MS = process.env.CI ? 30_000 : 15_000; const COMMAND_HELP_RENDER_CONCURRENCY = Math.min(8, Math.max(2, availableParallelism())); const DEFAULT_COMMAND_HELP_NAMES = [ "browser", "secrets", "nodes", "doctor", "gateway", "models", "plugins", "sessions", "tasks", ] as const; function writeFixtureFile(rootDir: string, relativePath: string, contents: string): void { const filePath = path.join(rootDir, relativePath); mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync(filePath, contents, "utf8"); } function writeStartupMetadataSourceSignatureFixture(rootDir: string): void { const fixtures = new Map([ ["extensions/browser/src/cli/browser-cli.ts", "export const browserHelp = 'browser';\n"], ["extensions/canvas/cli-metadata.ts", "export const canvasMetadata = 'canvas';\n"], ["extensions/canvas/index.ts", "export const canvasEntry = 'canvas';\n"], ["extensions/canvas/src/a2ui-jsonl.ts", "export const a2uiJsonl = 'canvas';\n"], ["extensions/canvas/src/cli-helpers.ts", "export const canvasHelpers = 'canvas';\n"], ["extensions/canvas/src/cli.ts", "export const canvasCliHelp = 'canvas';\n"], ["src/cli/banner.ts", "export const banner = 'openclaw';\n"], [ "src/cli/daemon-cli/register-service-commands.ts", "export const gatewayServiceCommands = 'gateway';\n", ], ["src/cli/gateway-cli.ts", "export const gatewayHelp = 'gateway';\n"], ["src/cli/gateway-cli/register.ts", "export const gatewayRegister = 'gateway';\n"], ["src/cli/gateway-cli/run-command.ts", "export const gatewayRun = 'gateway';\n"], ["src/cli/help-format.ts", "export const helpFormat = 'help';\n"], ["src/cli/models-cli.ts", "export const modelsHelp = 'models';\n"], ["src/cli/nodes-cli/register.ts", "export const nodesHelp = 'nodes';\n"], ["src/cli/program/register.maintenance.ts", "export const maintenanceHelp = 'maintenance';\n"], [ "src/cli/program/register.status-health-sessions.ts", "export const statusHealthSessionsHelp = 'sessions';\n", ], ["src/cli/program/context.ts", "export const context = 'context';\n"], ["src/cli/program/help.ts", "export const help = 'help';\n"], ["src/cli/plugins-cli.ts", "export const pluginsHelp = 'plugins';\n"], [ "src/plugins/register-plugin-cli-command-groups.ts", "export const pluginCommandGroups = 'plugins';\n", ], ["src/cli/secrets-cli.ts", "export const secretsHelp = 'secrets';\n"], ["packages/terminal-core/src/links.ts", "export const links = 'links';\n"], ["packages/terminal-core/src/theme.ts", "export const theme = 'theme';\n"], ]); for (const [relativePath, contents] of fixtures) { writeFixtureFile(rootDir, relativePath, contents); } } function processIsAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } function expectedTaskkillPath(): string { return resolveWindowsTaskkillPath(); } function createSpawnTextChild() { return Object.assign(new EventEmitter(), { kill: vi.fn((_signal?: NodeJS.Signals) => true), stderr: new PassThrough(), stdout: new PassThrough(), }); } async function waitForProcessExit( pid: number, timeoutMs = LOAD_SENSITIVE_PROCESS_TIMEOUT_MS, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!processIsAlive(pid)) { return; } await new Promise((resolve) => { setTimeout(resolve, 10); }); } throw new Error(`process ${pid} was still alive after ${timeoutMs}ms`); } async function waitForChildClose( child: ReturnType, timeoutMs = LOAD_SENSITIVE_PROCESS_TIMEOUT_MS, ): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { return await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("child did not close before timeout")); }, timeoutMs); child.once("close", (code, signal) => { clearTimeout(timeout); resolve({ code, signal }); }); }); } describe("write-cli-startup-metadata", () => { const { createTempDir } = createScriptTestHarness(); it("renders source root help without blocking sibling child events", async () => { const child = createSpawnTextChild(); const spawnMock = vi.mocked(spawn); spawnMock.mockImplementationOnce(() => child as unknown as ReturnType); let siblingEventObserved = false; const siblingEvent = new Promise((resolve) => { setImmediate(() => { siblingEventObserved = true; resolve(); }); }); const render = testing.renderSourceRootHelpText(); child.stdout.write("Usage: openclaw\n"); setImmediate(() => { child.emit("close", 0, null); }); await siblingEvent; expect(siblingEventObserved).toBe(true); await expect(render).resolves.toBe("Usage: openclaw\n"); expect(spawnMock).toHaveBeenCalledOnce(); expect(spawnMock.mock.calls[0]?.[1]).toEqual([ "--import", "tsx", "--input-type=module", "--eval", expect.any(String), ]); expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); }); it("fails command help rendering when captured output exceeds the byte limit", async () => { await expect( testing.spawnText(["--eval", "process.stdout.write('x'.repeat(2048))"], { cwd: process.cwd(), env: process.env, failureMessage: "render failed", killGraceMs: 25, maxOutputBytes: 1024, timeoutMs: 5_000, }), ).rejects.toThrow("render failed: output exceeded 1024 bytes"); }); it.each(["stdout", "stderr"] as const)( "fails command help rendering when %s emits a stream error", async (streamName) => { const child = createSpawnTextChild(); const spawnProcess = vi.fn(() => child as unknown as ReturnType); const streamError = new Error(`${streamName} pipe failed`); const render = testing.spawnText(["--help"], { cwd: process.cwd(), env: process.env, failureMessage: "render failed", killGraceMs: 25, maxOutputBytes: 1024, spawnProcess: spawnProcess as typeof spawn, timeoutMs: 5_000, }); child[streamName].emit("error", streamError); child.emit("close", null, "SIGTERM"); await expect(render).rejects.toMatchObject({ message: expect.stringContaining( `render failed: ${streamName} read error: ${streamName} pipe failed`, ), cause: streamError, }); expect(child.kill).toHaveBeenCalledWith("SIGTERM"); }, ); it("preserves an output-limit failure when shutdown also errors a stream", async () => { const child = createSpawnTextChild(); const spawnProcess = vi.fn(() => child as unknown as ReturnType); const render = testing.spawnText(["--help"], { cwd: process.cwd(), env: process.env, failureMessage: "render failed", killGraceMs: 25, maxOutputBytes: 5, spawnProcess: spawnProcess as typeof spawn, timeoutMs: 5_000, }); child.stdout.emit("data", "123456"); child.stdout.emit("error", new Error("pipe closed during shutdown")); child.emit("close", null, "SIGTERM"); await expect(render).rejects.toThrow("render failed: output exceeded 5 bytes"); }); it("aborts and drains the default command batch before removing shared state", async () => { const actualSpawn = ( await vi.importActual("node:child_process") ).spawn; const spawnMock = vi.mocked(spawn); const tempRoot = createTempDir("openclaw-startup-metadata-batch-failure-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const events: string[] = []; const children: Array & { commandName: string }> = []; const realRmSync = fs.rmSync.bind(fs); const removeState = vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { events.push("cleanup"); return realRmSync(target, options); }); writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); spawnMock.mockImplementation((_command, args) => { const commandName = String(args[1]); const child = Object.assign(createSpawnTextChild(), { commandName }); child.kill.mockImplementation((signal) => { events.push(`kill:${commandName}:${signal}`); queueMicrotask(() => { events.push(`close:${commandName}`); child.emit("close", null, signal); }); return true; }); children.push(child); return child as unknown as ReturnType; }); try { const writePromise = testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", }); const deadline = Date.now() + 1_000; while (children.length < COMMAND_HELP_RENDER_CONCURRENCY && Date.now() < deadline) { await new Promise((resolve) => setImmediate(resolve)); } expect(children.map((child) => child.commandName)).toEqual( DEFAULT_COMMAND_HELP_NAMES.slice(0, COMMAND_HELP_RENDER_CONCURRENCY), ); const browser = children[0]; expect(browser).toBeDefined(); browser?.stderr.write("browser renderer failed\n"); browser?.emit("close", 7, null); const error = await writePromise.then( () => undefined, (reason: unknown) => reason, ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("Failed to render source browser help"); expect((error as Error).message).toContain("browser renderer failed"); expect((error as Error).message).toMatch(/browser renderer failed \(elapsed \d+ms\)/u); expect(children.map((child) => child.commandName)).not.toContain("tasks"); for (const child of children.slice(1)) { expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(events).toContain(`close:${child.commandName}`); } expect(events.at(-1)).toBe("cleanup"); expect(existsSync(outputPath)).toBe(false); } finally { removeState.mockRestore(); spawnMock.mockImplementation(actualSpawn); } }); it.runIf(process.platform !== "win32")( "preserves shared state when a canceled process group cannot be proven dead", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-undrained-tree-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const child = Object.assign(createSpawnTextChild(), { pid: 123 }); const realProcessKill = process.kill.bind(process); const processKill = vi.spyOn(process, "kill").mockImplementation((pid, signal) => { if (pid === -123) { return true; } return realProcessKill(pid, signal); }); let renderStateDir = ""; writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); try { const writePromise = testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", renderSourceBrowserHelpText: (renderContext, taskContext) => { renderStateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; if (!taskContext) { throw new Error("missing render task context"); } return testing.spawnText(["openclaw.mjs", "browser", "--help"], { cwd: tempRoot, env: process.env, failureMessage: "browser render failed", killGraceMs: 10, maxOutputBytes: 1024, onTerminalFailure: taskContext.reportFailure, signal: taskContext.signal, spawnProcess: (() => child as unknown as ReturnType) as typeof spawn, timeoutMs: 5_000, }); }, renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }); await new Promise((resolve) => setImmediate(resolve)); child.stderr.write("primary browser failure\n"); child.emit("close", 7, null); const error = await writePromise.then( () => undefined, (reason: unknown) => reason, ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("primary browser failure"); expect((error as Error).message).toContain( `Preserved CLI startup metadata render state: ${renderStateDir}`, ); expect(error).toMatchObject({ preserveRenderState: true, processTreeCleanupFailure: { code: "EPROCESSGROUP_CLEANUP_FAILED", }, }); expect(existsSync(renderStateDir)).toBe(true); expect(existsSync(outputPath)).toBe(false); } finally { processKill.mockRestore(); if (renderStateDir) { fs.rmSync(renderStateDir, { force: true, recursive: true }); } } }, ); it.runIf(process.platform !== "win32")( "cancels a default-batch sibling process tree after another command fails", async () => { const actualSpawn = ( await vi.importActual("node:child_process") ).spawn; const spawnMock = vi.mocked(spawn); const tempRoot = createTempDir("openclaw-startup-metadata-batch-tree-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const grandchildPidPath = path.join(tempRoot, "grandchild.pid"); const startedCommands: string[] = []; const startedChildren: Array> = []; let grandchildPid = 0; writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); const failingScript = [ "const { existsSync } = await import('node:fs');", `const marker = ${JSON.stringify(grandchildPidPath)};`, "const timer = setInterval(() => {", " if (!existsSync(marker)) return;", " clearInterval(timer);", " process.stderr.write('browser sentinel failure\\n', () => process.exit(9));", "}, 5);", ].join("\n"); const grandchildScript = [ "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 50));", "setInterval(() => {}, 1000);", ].join("\n"); const siblingScript = [ "const { spawn } = await import('node:child_process');", "const { writeFileSync } = await import('node:fs');", `const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(grandchildScript)}], { stdio: "ignore" });`, `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`, "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", "setInterval(() => {}, 1000);", ].join("\n"); const idleScript = [ "process.on('SIGTERM', () => process.exit(0));", "setInterval(() => {}, 1000);", ].join("\n"); spawnMock.mockImplementation((_command, args, options) => { const commandName = String(args[1]); startedCommands.push(commandName); const script = commandName === "browser" ? failingScript : commandName === "secrets" ? siblingScript : idleScript; const child = actualSpawn( process.execPath, ["--input-type=module", "--eval", script], options, ); startedChildren.push(child); return child; }); try { const startedAt = Date.now(); const error = await testing .writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", }) .then( () => undefined, (reason: unknown) => reason, ); grandchildPid = await waitForPidFile(grandchildPidPath, LOAD_SENSITIVE_PROCESS_TIMEOUT_MS); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("browser sentinel failure"); expect(Date.now() - startedAt).toBeLessThan(LOAD_SENSITIVE_PROCESS_TIMEOUT_MS); expect(startedCommands).toHaveLength(COMMAND_HELP_RENDER_CONCURRENCY); expect(startedCommands).not.toContain("tasks"); await waitForProcessExit(grandchildPid); expect(existsSync(outputPath)).toBe(false); } finally { spawnMock.mockImplementation(actualSpawn); for (const child of startedChildren) { if (child.pid && processIsAlive(child.pid)) { try { process.kill(-child.pid, "SIGKILL"); } catch {} } } if (grandchildPid > 0 && processIsAlive(grandchildPid)) { try { process.kill(grandchildPid, "SIGKILL"); } catch {} } } }, ); it("signals Windows command help render process trees with taskkill", () => { const childKill = vi.fn(() => true); const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); testing.signalCliStartupMetadataProcessTree({ pid: 123, kill: childKill }, "SIGTERM", { platform: "win32", runTaskkill, }); expect(runTaskkill).toHaveBeenNthCalledWith(1, expectedTaskkillPath(), ["/PID", "123", "/T"], { stdio: "ignore", }); testing.signalCliStartupMetadataProcessTree({ pid: 123, kill: childKill }, "SIGKILL", { platform: "win32", runTaskkill, }); expect(runTaskkill).toHaveBeenNthCalledWith( 2, expectedTaskkillPath(), ["/PID", "123", "/T", "/F"], { stdio: "ignore", }, ); expect(childKill).not.toHaveBeenCalled(); }); it("force-kills Windows command help render process trees when graceful taskkill fails", () => { const childKill = vi.fn(() => true); const runTaskkill = vi .fn() .mockReturnValueOnce({ error: undefined, status: 1 }) .mockReturnValueOnce({ error: undefined, status: 0 }); testing.signalCliStartupMetadataProcessTree({ pid: 123, kill: childKill }, "SIGTERM", { platform: "win32", runTaskkill, }); expect(runTaskkill).toHaveBeenNthCalledWith(1, expectedTaskkillPath(), ["/PID", "123", "/T"], { stdio: "ignore", }); expect(runTaskkill).toHaveBeenNthCalledWith( 2, expectedTaskkillPath(), ["/PID", "123", "/T", "/F"], { stdio: "ignore", }, ); expect(childKill).not.toHaveBeenCalled(); }); it.runIf(process.platform !== "win32")( "kills descendant processes when command help rendering times out", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-timeout-"); const markerPath = path.join(tempRoot, "grandchild.pid"); const grandchildScript = [ "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", ].join("\n"); const parentScript = [ "const { spawn } = await import('node:child_process');", "const { writeFileSync } = await import('node:fs');", `const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(grandchildScript)}], { stdio: "ignore" });`, `writeFileSync(${JSON.stringify(markerPath)}, String(grandchild.pid));`, "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", ].join("\n"); await expect( testing.spawnText(["--input-type=module", "--eval", parentScript], { cwd: tempRoot, env: process.env, failureMessage: "render failed", killGraceMs: 25, maxOutputBytes: 1024, timeoutMs: 500, }), ).rejects.toThrow("render failed: timed out after 500ms"); const grandchildPid = await waitForPidFile(markerPath, LOAD_SENSITIVE_PROCESS_TIMEOUT_MS); await waitForProcessExit(grandchildPid); }, ); it.runIf(process.platform !== "win32")( "drains descendants when a command leader exits nonzero", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-nonzero-tree-"); const markerPath = path.join(tempRoot, "grandchild.pid"); const grandchildScript = [ "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", ].join("\n"); const parentScript = [ "const { spawn } = await import('node:child_process');", "const { writeFileSync } = await import('node:fs');", `const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(grandchildScript)}], { stdio: "ignore" });`, `writeFileSync(${JSON.stringify(markerPath)}, String(grandchild.pid));`, "process.stderr.write('leader failed\\n', () => process.exit(7));", ].join("\n"); await expect( testing.spawnText(["--input-type=module", "--eval", parentScript], { cwd: tempRoot, env: process.env, failureMessage: "render failed", killGraceMs: 25, maxOutputBytes: 1024, timeoutMs: 5_000, }), ).rejects.toThrow(/render failed: leader failed.*elapsed \d+ms/u); const grandchildPid = Number(readFileSync(markerPath, "utf8")); await waitForProcessExit(grandchildPid); }, ); it.runIf(process.platform !== "win32")( "waits for all command help descendants before re-raising parent signals", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-signal-"); const fastCommandPath = path.join(tempRoot, "fast-command.mjs"); const fastReadyPath = path.join(tempRoot, "fast-ready"); const commandPath = path.join(tempRoot, "command.mjs"); const runnerPath = path.join(tempRoot, "runner.mjs"); const grandchildPidPath = path.join(tempRoot, "grandchild.pid"); const renderStatePath = path.join(tempRoot, "render-state.txt"); const distDir = path.join(tempRoot, "dist"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const grandchildScript = [ "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", ].join("\n"); writeFixtureFile( tempRoot, "fast-command.mjs", [ "import { writeFileSync } from 'node:fs';", `writeFileSync(${JSON.stringify(fastReadyPath)}, "ready");`, "process.on('SIGTERM', () => process.exit(0));", "setInterval(() => {}, 1000);", ].join("\n"), ); writeFixtureFile( tempRoot, "command.mjs", [ "import { spawn } from 'node:child_process';", "import { writeFileSync } from 'node:fs';", `const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify( grandchildScript, )}], { stdio: "ignore" });`, `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`, "process.on('SIGTERM', () => process.exit(0));", "setInterval(() => {}, 1000);", ].join("\n"), ); writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); writeFixtureFile( tempRoot, "runner.mjs", [ `const { testing } = await import(${JSON.stringify( pathToFileURL(path.resolve("scripts/write-cli-startup-metadata.ts")).href, )});`, "const { writeFileSync } = await import('node:fs');", "const renderCommand = (commandPath, failureMessage) => (context, taskContext) => {", " if (!taskContext) throw new Error('missing render task context');", ` writeFileSync(${JSON.stringify(renderStatePath)}, context.env.OPENCLAW_STATE_DIR);`, " return testing.spawnText([commandPath], {", ` cwd: ${JSON.stringify(tempRoot)},`, " env: process.env,", " failureMessage,", " killGraceMs: 100,", " maxOutputBytes: 1024,", " onTerminalFailure: taskContext.reportFailure,", " signal: taskContext.signal,", " timeoutMs: 30_000,", " });", "};", "await testing.writeCliStartupMetadata({", ` distDir: ${JSON.stringify(distDir)},`, ` outputPath: ${JSON.stringify(outputPath)},`, ` extensionsDir: ${JSON.stringify(path.join(tempRoot, "extensions"))},`, ` sourceRootDir: ${JSON.stringify(tempRoot)},`, " renderBundledRootHelpText: async () => 'Usage: openclaw\\n',", ` renderSourceBrowserHelpText: renderCommand(${JSON.stringify(fastCommandPath)}, 'fast render failed'),`, ` renderSourceSecretsHelpText: renderCommand(${JSON.stringify(commandPath)}, 'render failed'),`, " renderSourceNodesHelpText: () => 'Usage: openclaw nodes\\n',", " renderSourceSubcommandHelpTextRecord: () => ({", " doctor: 'Usage: openclaw doctor\\n', gateway: 'Usage: openclaw gateway\\n',", " models: 'Usage: openclaw models\\n', plugins: 'Usage: openclaw plugins\\n',", " sessions: 'Usage: openclaw sessions\\n', tasks: 'Usage: openclaw tasks\\n',", " }),", "});", ].join("\n"), ); const runner = spawn(process.execPath, ["--import", "tsx", runnerPath], { cwd: process.cwd(), stdio: "ignore", }); let grandchildPid = 0; try { const deadline = Date.now() + LOAD_SENSITIVE_PROCESS_TIMEOUT_MS; grandchildPid = await waitForPidFile(grandchildPidPath, LOAD_SENSITIVE_PROCESS_TIMEOUT_MS); while (Date.now() < deadline) { let fastReady = false; try { fastReady = readFileSync(fastReadyPath, "utf8") === "ready"; } catch {} if (fastReady && grandchildPid > 0 && processIsAlive(grandchildPid)) { break; } await new Promise((resolve) => { setTimeout(resolve, 10); }); } expect(readFileSync(fastReadyPath, "utf8")).toBe("ready"); expect(grandchildPid).toBeGreaterThan(0); expect(processIsAlive(grandchildPid)).toBe(true); runner.kill("SIGTERM"); await expect(waitForChildClose(runner)).resolves.toEqual({ code: null, signal: "SIGTERM", }); await waitForProcessExit(grandchildPid); const renderStateDir = readFileSync(renderStatePath, "utf8"); expect(existsSync(renderStateDir)).toBe(false); } finally { if (runner.pid && processIsAlive(runner.pid)) { runner.kill("SIGKILL"); } if (grandchildPid > 0 && processIsAlive(grandchildPid)) { process.kill(grandchildPid, "SIGKILL"); } } }, ); it("writes startup metadata with populated root help text when dist falls back to source rendering", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); mkdirSync(distDir, { recursive: true }); mkdirSync(path.join(extensionsDir, "matrix"), { recursive: true }); writeFileSync( path.join(extensionsDir, "matrix", "package.json"), JSON.stringify({ openclaw: { channel: { id: "matrix", order: 120, label: "Matrix", }, }, }), "utf8", ); await testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, renderSourceRootHelpText: () => "Usage: openclaw\n", renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }); const written = JSON.parse(readFileSync(outputPath, "utf8")) as { browserHelpText: string; channelOptions: string[]; generatorSignature: string; nodesHelpText: string; rootHelpText: string; secretsHelpText: string; subcommandHelpText: { doctor: string; gateway: string; models: string; plugins: string; sessions: string; tasks: string; }; }; expect(written.channelOptions).toContain("matrix"); expect(written.generatorSignature).toMatch(/^[a-f0-9]{40}$/u); expect(written.browserHelpText).toContain("Usage:"); expect(written.browserHelpText).toContain("openclaw browser"); expect(written.secretsHelpText).toContain("Usage:"); expect(written.secretsHelpText).toContain("openclaw secrets"); expect(written.nodesHelpText).toContain("Usage:"); expect(written.nodesHelpText).toContain("openclaw nodes"); expect(written.rootHelpText).toContain("Usage:"); expect(written.rootHelpText).toContain("openclaw"); expect(written.subcommandHelpText.doctor).toContain("openclaw doctor"); expect(written.subcommandHelpText.gateway).toContain("openclaw gateway"); expect(written.subcommandHelpText.models).toContain("openclaw models"); expect(written.subcommandHelpText.plugins).toContain("openclaw plugins"); expect(written.subcommandHelpText.sessions).toContain("openclaw sessions"); expect(written.subcommandHelpText.tasks).toContain("openclaw tasks"); }); it("does not source-fallback a bundled root resource failure", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-root-resource-failure-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const renderSourceRootHelpText = vi.fn(() => "Usage: source fallback\n"); writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); const error = await testing .writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => { throw Object.assign(new Error("bundled root timed out"), { code: "ETIMEDOUT" }); }, renderSourceRootHelpText, renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }) .then( () => undefined, (reason: unknown) => reason, ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("bundled root timed out"); expect(renderSourceRootHelpText).not.toHaveBeenCalled(); expect(existsSync(outputPath)).toBe(false); }); it("selects the root-help bundle that exports the renderer", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-bundle-selection-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const renderSourceRootHelpText = vi.fn(() => "Usage: source fallback\n"); writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(tempRoot, "package.json", '{"type":"module"}\n'); writeFixtureFile( distDir, "root-help-live-config-fixture.js", "async function loadRootHelpRenderOptionsForConfigSensitivePlugins() { return null; }\nexport { loadRootHelpRenderOptionsForConfigSensitivePlugins };\n", ); writeFixtureFile( distDir, "root-help-renderer-fixture.js", "async function outputRootHelp() { process.stdout.write('Usage: bundled renderer\\n'); }\nexport { outputRootHelp };\n", ); await testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderSourceRootHelpText, renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }); const written = JSON.parse(readFileSync(outputPath, "utf8")) as { rootHelpText: string; }; expect(written.rootHelpText).toBe("Usage: bundled renderer\n"); expect(renderSourceRootHelpText).not.toHaveBeenCalled(); }); it("renders independent startup help snapshots concurrently", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-concurrency-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const started: string[] = []; const unblockers = new Map void>(); const expectedStarted = ["browser", "secrets", "nodes", "subcommands"]; mkdirSync(distDir, { recursive: true }); writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); const renderAfterUnblock = (label: string, output: string): (() => Promise) => { return async () => { started.push(label); await new Promise((resolve) => { unblockers.set(label, resolve); }); return output; }; }; const waitForAllStarted = async (): Promise => { const deadline = Date.now() + 1_000; while (Date.now() < deadline) { if (expectedStarted.every((label) => started.includes(label))) { return; } await new Promise((resolve) => { setTimeout(resolve, 5); }); } throw new Error(`startup help renderers did not start concurrently: ${started.join(", ")}`); }; const writePromise = testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", renderSourceBrowserHelpText: renderAfterUnblock("browser", "Usage: openclaw browser\n"), renderSourceSecretsHelpText: renderAfterUnblock("secrets", "Usage: openclaw secrets\n"), renderSourceNodesHelpText: renderAfterUnblock("nodes", "Usage: openclaw nodes\n"), renderSourceSubcommandHelpTextRecord: async () => { started.push("subcommands"); await new Promise((resolve) => { unblockers.set("subcommands", resolve); }); return { doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }; }, }); await waitForAllStarted(); for (const label of expectedStarted) { unblockers.get(label)?.(); } await writePromise; const written = JSON.parse(readFileSync(outputPath, "utf8")) as { browserHelpText: string; nodesHelpText: string; secretsHelpText: string; }; expect(written.browserHelpText).toContain("openclaw browser"); expect(written.secretsHelpText).toContain("openclaw secrets"); expect(written.nodesHelpText).toContain("openclaw nodes"); }); it.each([ { title: "after successful rendering", failRender: false }, { title: "when rendering fails", failRender: true }, ])("removes isolated root-help state $title", async ({ failRender }) => { const removeState = vi.spyOn(fs, "rmSync"); const tempRoot = createTempDir("openclaw-startup-metadata-cleanup-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); let stateDir = ""; let statePresentDuringSiblingRender = false; writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); const writeMetadata = testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", renderSourceBrowserHelpText: async (renderContext) => { stateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; const sqliteDir = path.join(stateDir, "state"); mkdirSync(sqliteDir, { recursive: true }); for (const suffix of ["", "-shm", "-wal"]) { writeFileSync(path.join(sqliteDir, `openclaw.sqlite${suffix}`), "fixture", "utf8"); } await new Promise((resolve) => setImmediate(resolve)); if (failRender) { throw new Error("browser help failed"); } return "Usage: openclaw browser\n"; }, renderSourceSecretsHelpText: async () => { await new Promise((resolve) => { setImmediate(resolve); }); statePresentDuringSiblingRender = existsSync(stateDir); return "Usage: openclaw secrets\n"; }, renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }); if (failRender) { await expect(writeMetadata).rejects.toThrow("browser help failed"); } else { await expect(writeMetadata).resolves.toBeUndefined(); } expect(stateDir).not.toBe(""); expect(statePresentDuringSiblingRender).toBe(true); expect(existsSync(stateDir)).toBe(false); expect(removeState).toHaveBeenCalledWith(stateDir, { force: true, recursive: true, maxRetries: 6, retryDelay: 25, }); removeState.mockRestore(); }); it("does not let shared-state cleanup mask the primary render failure", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-cleanup-failure-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const cleanupFailure = new Error("cleanup failed"); const realRmSync = fs.rmSync.bind(fs); let renderStateDir = ""; const removeState = vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { if (String(target) === renderStateDir) { throw cleanupFailure; } return realRmSync(target, options); }); writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); try { const error = await testing .writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", renderSourceBrowserHelpText: (renderContext) => { renderStateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; throw new Error("primary browser failure"); }, renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }) .then( () => undefined, (reason: unknown) => reason, ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("primary browser failure"); expect(error).toMatchObject({ cleanupError: cleanupFailure }); } finally { removeState.mockRestore(); if (renderStateDir) { realRmSync(renderStateDir, { force: true, recursive: true }); } } }); it("regenerates nodes help when bundled canvas CLI help sources change", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-signature-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); let nodesRenderCount = 0; writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); const writeMetadata = async (): Promise => { await testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", renderSourceNodesHelpText: () => { nodesRenderCount += 1; return `Usage: openclaw nodes ${nodesRenderCount}\n`; }, renderSourceSubcommandHelpTextRecord: () => ({ doctor: "Usage: openclaw doctor\n", gateway: "Usage: openclaw gateway\n", models: "Usage: openclaw models\n", plugins: "Usage: openclaw plugins\n", sessions: "Usage: openclaw sessions\n", tasks: "Usage: openclaw tasks\n", }), }); }; await writeMetadata(); await writeMetadata(); expect(nodesRenderCount).toBe(1); const staleGeneratorMetadata = JSON.parse(readFileSync(outputPath, "utf8")) as Record< string, unknown >; staleGeneratorMetadata.generatorSignature = "stale-generator"; writeFileSync(outputPath, `${JSON.stringify(staleGeneratorMetadata, null, 2)}\n`, "utf8"); await writeMetadata(); expect(nodesRenderCount).toBe(2); writeFixtureFile( tempRoot, "extensions/canvas/src/cli.ts", "export const canvasCliHelp = 'canvas changed help';\n", ); await writeMetadata(); const written = JSON.parse(readFileSync(outputPath, "utf8")) as { nodesHelpText: string; }; expect(nodesRenderCount).toBe(3); expect(written.nodesHelpText).toContain("openclaw nodes 3"); }); it("regenerates help when build version or commit changes", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-build-identity-"); const distDir = path.join(tempRoot, "dist"); const extensionsDir = path.join(tempRoot, "extensions"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); let renderCount = 0; let commandRenderCount = 0; const renderSubcommandHelp = () => { commandRenderCount += 1; const buildInfo = JSON.parse(readFileSync(path.join(distDir, "build-info.json"), "utf8")) as { commit: string; version: string; }; const banner = `OpenClaw ${buildInfo.version} (${buildInfo.commit.slice(0, 7)})`; return { doctor: `${banner}\nUsage: openclaw doctor\n`, gateway: `${banner}\nUsage: openclaw gateway\n`, models: `${banner}\nUsage: openclaw models\n`, plugins: `${banner}\nUsage: openclaw plugins\n`, sessions: `${banner}\nUsage: openclaw sessions\n`, tasks: `${banner}\nUsage: openclaw tasks\n`, }; }; writeStartupMetadataSourceSignatureFixture(tempRoot); writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); const writeMetadata = async (): Promise => { await testing.writeCliStartupMetadata({ distDir, outputPath, extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => { renderCount += 1; return `Usage: openclaw ${renderCount}\n`; }, renderSourceBrowserHelpText: () => { commandRenderCount += 1; return "Usage: openclaw browser\n"; }, renderSourceSecretsHelpText: () => { commandRenderCount += 1; return "Usage: openclaw secrets\n"; }, renderSourceNodesHelpText: () => { commandRenderCount += 1; return "Usage: openclaw nodes\n"; }, renderSourceSubcommandHelpTextRecord: renderSubcommandHelp, }); }; writeFixtureFile( distDir, "build-info.json", JSON.stringify({ version: "2026.7.2", commit: "a".repeat(40) }), ); await writeMetadata(); await writeMetadata(); expect(renderCount).toBe(1); expect(commandRenderCount).toBe(4); expect(readFileSync(outputPath, "utf8")).toContain("OpenClaw 2026.7.2 (aaaaaaa)"); writeFixtureFile( distDir, "build-info.json", JSON.stringify({ version: "2026.7.2", commit: "b".repeat(40) }), ); await writeMetadata(); expect(renderCount).toBe(2); expect(commandRenderCount).toBe(8); expect(readFileSync(outputPath, "utf8")).toContain("OpenClaw 2026.7.2 (bbbbbbb)"); writeFixtureFile( distDir, "build-info.json", JSON.stringify({ version: "2026.7.3", commit: "b".repeat(40) }), ); await writeMetadata(); expect(renderCount).toBe(3); expect(commandRenderCount).toBe(12); const written = JSON.parse(readFileSync(outputPath, "utf8")) as { subcommandHelpText: { models: string }; }; expect(written.subcommandHelpText.models).toContain("OpenClaw 2026.7.3 (bbbbbbb)"); }); });