mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
runBuiltCli spawned the built CLI with Node 24 defaults, so concurrent Sparkplug and Maglev stayed enabled in the child. Under CPU contention process.exit can then deadlock joining a background compiler that waits for a main-thread GC; spawnSync times out and reports status null after the CLI already printed its correct output. Gateway e2e shard 4/4 reproduced this in test/cli-json-stdout.gateway.e2e.test.ts. Prepend the shared resolveVitestNodeArgs policy (skipped under Bun), as runCliProcessChild already does, and assert in the gateway suite's preload that children run without concurrent Sparkplug.
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
import "../src/test-utils/prepare-compiled-subprocesses.js";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { withTempHome } from "openclaw/plugin-sdk/test-env";
|
|
import { describe, expect, it } from "vitest";
|
|
import { runBuiltCli } from "./cli-json-stdout.test-support.js";
|
|
|
|
describe("cli json stdout contract", () => {
|
|
it.each([
|
|
{
|
|
name: "invalid call params in JSON mode",
|
|
args: ["gateway", "call", "system-presence", "--params", "not-json", "--json"],
|
|
message: "--params must be valid JSON.",
|
|
},
|
|
{
|
|
name: "invalid call params in human mode",
|
|
args: ["gateway", "call", "system-presence", "--params", "not-json"],
|
|
message: "--params must be valid JSON.",
|
|
human: true,
|
|
},
|
|
{
|
|
name: "invalid call params through forced Commander",
|
|
args: ["gateway", "call", "system-presence", "--params", "not-json", "--json"],
|
|
message: "--params must be valid JSON.",
|
|
commander: true,
|
|
},
|
|
{
|
|
name: "invalid call params with dual TTYs",
|
|
args: ["gateway", "call", "system-presence", "--params", "not-json", "--json"],
|
|
message: "--params must be valid JSON.",
|
|
tty: true,
|
|
},
|
|
{
|
|
name: "contradictory usage options in JSON mode",
|
|
args: ["gateway", "usage-cost", "--agent", "alpha", "--all-agents", "--json"],
|
|
message: "Use --agent or --all-agents, not both",
|
|
},
|
|
{
|
|
name: "contradictory usage options in human mode",
|
|
args: ["gateway", "usage-cost", "--agent", "alpha", "--all-agents"],
|
|
message: "Use --agent or --all-agents, not both",
|
|
human: true,
|
|
},
|
|
{
|
|
name: "contradictory usage options through forced Commander",
|
|
args: ["gateway", "usage-cost", "--agent", "alpha", "--all-agents", "--json"],
|
|
message: "Use --agent or --all-agents, not both",
|
|
commander: true,
|
|
},
|
|
{
|
|
name: "contradictory usage options with dual TTYs",
|
|
args: ["gateway", "usage-cost", "--agent", "alpha", "--all-agents", "--json"],
|
|
message: "Use --agent or --all-agents, not both",
|
|
tty: true,
|
|
},
|
|
{
|
|
name: "routed Gateway health config failure in JSON mode",
|
|
args: ["gateway", "health", "--port", "29793", "--json"],
|
|
message: "AUTOQA_ROUTE_CONFIG_READ_FAILURE",
|
|
configReadFailure: true,
|
|
},
|
|
{
|
|
name: "Gateway health config failure in human mode",
|
|
args: ["gateway", "health", "--port", "29793"],
|
|
message: "AUTOQA_ROUTE_CONFIG_READ_FAILURE",
|
|
configReadFailure: true,
|
|
human: true,
|
|
},
|
|
{
|
|
name: "Gateway health config failure through forced Commander",
|
|
args: ["gateway", "health", "--port", "29793", "--json"],
|
|
message: "AUTOQA_ROUTE_CONFIG_READ_FAILURE",
|
|
configReadFailure: true,
|
|
commander: true,
|
|
},
|
|
{
|
|
name: "routed Gateway health config failure with dual TTYs",
|
|
args: ["gateway", "health", "--port", "29793", "--json"],
|
|
message: "AUTOQA_ROUTE_CONFIG_READ_FAILURE",
|
|
configReadFailure: true,
|
|
tty: true,
|
|
},
|
|
{
|
|
name: "specialized explicit Gateway authentication failure",
|
|
args: ["gateway", "call", "system-presence", "--url", "ws://127.0.0.1:29793", "--json"],
|
|
specializedAuth: true,
|
|
},
|
|
])("renders Gateway query failures through the Gateway owner for $name", async (testCase) => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const configPath = path.join(tempHome, "missing-openclaw.json");
|
|
const stateDir = path.join(tempHome, "isolated-state");
|
|
const gatewayError = "AUTOQA_INJECTED_GATEWAY_FAILURE";
|
|
const concurrentSparkplug = "AUTOQA_CONCURRENT_SPARKPLUG";
|
|
const preload = Buffer.from(
|
|
[
|
|
'import net from "node:net";',
|
|
`net.Socket.prototype.connect = function () { throw new Error(${JSON.stringify(gatewayError)}); };`,
|
|
`if (!process.versions.bun && !process.execArgv.includes("--no-concurrent-sparkplug")) process.stderr.write(${JSON.stringify(`${concurrentSparkplug}\n`)});`,
|
|
...("configReadFailure" in testCase
|
|
? [
|
|
'import fs from "node:fs";',
|
|
"Error.stackTraceLimit = 50;",
|
|
"const originalReadFileSync = fs.readFileSync;",
|
|
"fs.readFileSync = function (target, ...args) {",
|
|
' if (String(target) === process.env.OPENCLAW_CONFIG_PATH && new Error().stack?.includes("readNonObservingHealthConfig")) {',
|
|
` throw Object.assign(new Error(${JSON.stringify(testCase.message)}), { code: "EIO" });`,
|
|
" }",
|
|
" return originalReadFileSync.call(this, target, ...args);",
|
|
"};",
|
|
]
|
|
: []),
|
|
...("tty" in testCase
|
|
? [
|
|
'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });',
|
|
'Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });',
|
|
]
|
|
: []),
|
|
].join("\n"),
|
|
).toString("base64");
|
|
const result = runBuiltCli(
|
|
tempHome,
|
|
testCase.args,
|
|
{
|
|
OPENCLAW_CONFIG_PATH: configPath,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
...("commander" in testCase ? { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } : {}),
|
|
...("tty" in testCase ? { FORCE_COLOR: "1", NO_COLOR: undefined } : {}),
|
|
},
|
|
{ execArgv: [`--import=data:text/javascript;base64,${preload}`] },
|
|
);
|
|
|
|
// Concurrent Sparkplug can deadlock process.exit, so the child times out with status null.
|
|
expect(result.stderr).not.toContain(concurrentSparkplug);
|
|
expect(result.status, result.stderr).toBe(1);
|
|
expect(result.stdout, result.stderr).not.toContain("\u001B");
|
|
expect(result.stdout, result.stderr).not.toContain("\u0007");
|
|
expect(result.stdout).not.toContain(gatewayError);
|
|
expect(result.stderr).not.toContain(gatewayError);
|
|
if ("human" in testCase) {
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain(testCase.message);
|
|
} else if ("specializedAuth" in testCase) {
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
ok: false,
|
|
error: {
|
|
type: "gateway_credentials_required",
|
|
message: [
|
|
"gateway url override requires explicit credentials",
|
|
"Fix: pass --token or --password with --url (or gatewayToken in tools).",
|
|
"For the default local or SSH-tunneled Gateway, remove --url to use the configured target.",
|
|
`Config: ${configPath}`,
|
|
].join("\n"),
|
|
},
|
|
});
|
|
expect(result.stderr).toBe("");
|
|
} else {
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
ok: false,
|
|
error: {
|
|
type: "cli_error",
|
|
message:
|
|
"configReadFailure" in testCase
|
|
? `Config could not be read at ${configPath}:\n- <root>: read failed: Error: ${testCase.message}`
|
|
: testCase.message,
|
|
},
|
|
});
|
|
if ("configReadFailure" in testCase && !("commander" in testCase)) {
|
|
expect(result.stderr).toContain("[openclaw] The CLI command failed.");
|
|
}
|
|
expect(result.stderr).not.toContain(testCase.message);
|
|
}
|
|
if ("tty" in testCase) {
|
|
expect(result.stderr).toContain("\u001B[?25h");
|
|
}
|
|
await expect(fs.stat(stateDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
{ prefix: "openclaw-gateway-query-json-failure-e2e-" },
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "routed status with JSON before its timeout",
|
|
args: ["status", "--json", "--timeout", "nope"],
|
|
},
|
|
{
|
|
name: "routed health with JSON after its timeout",
|
|
args: ["health", "--timeout", "0", "--json"],
|
|
},
|
|
{
|
|
name: "Commander status with JSON after its timeout",
|
|
args: ["status", "--timeout", "nope", "--json"],
|
|
commander: true,
|
|
},
|
|
{
|
|
name: "Commander health with JSON before its timeout",
|
|
args: ["health", "--json", "--timeout", "0"],
|
|
commander: true,
|
|
},
|
|
{
|
|
name: "routed status through dual-TTY finalization",
|
|
args: ["status", "--json", "--timeout", "nope"],
|
|
tty: true,
|
|
},
|
|
])("renders invalid status/health timeouts as canonical JSON for $name", async (testCase) => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const preload = `data:text/javascript,${encodeURIComponent(
|
|
'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });',
|
|
)}`;
|
|
const result = runBuiltCli(
|
|
tempHome,
|
|
testCase.args,
|
|
{
|
|
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
|
|
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
|
|
OPENCLAW_GATEWAY_PORT: "29791",
|
|
...("commander" in testCase ? { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } : {}),
|
|
...("tty" in testCase ? { FORCE_COLOR: "1" } : {}),
|
|
},
|
|
{ execArgv: "tty" in testCase ? [`--import=${preload}`] : [] },
|
|
);
|
|
const message = "--timeout must be a positive integer (milliseconds)";
|
|
|
|
expect(result.status, result.stderr).toBe(1);
|
|
expect(result.stdout, result.stderr).not.toContain("\u001B");
|
|
expect(result.stdout, result.stderr).not.toContain("\u0007");
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
ok: false,
|
|
error: { type: "cli_error", message },
|
|
});
|
|
expect(result.stderr).toContain("[openclaw] The CLI command failed.");
|
|
expect(result.stderr).not.toContain(message);
|
|
if ("tty" in testCase) {
|
|
expect(result.stderr).toContain("\u001B[?25h");
|
|
}
|
|
},
|
|
{ prefix: "openclaw-status-health-json-timeout-e2e-" },
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "status with an invalid duration in human mode",
|
|
args: ["nodes", "status", "--last-connected", "not-a-duration"],
|
|
message: "Invalid --last-connected: Invalid duration",
|
|
human: true,
|
|
},
|
|
{
|
|
name: "status with JSON before its invalid duration",
|
|
args: ["nodes", "status", "--json", "--last-connected", "not-a-duration"],
|
|
message: "Invalid --last-connected: Invalid duration",
|
|
},
|
|
{
|
|
name: "status with JSON after its invalid duration",
|
|
args: ["nodes", "status", "--last-connected", "not-a-duration", "--json"],
|
|
message: "Invalid --last-connected: Invalid duration",
|
|
},
|
|
{
|
|
name: "list with an invalid duration in human mode",
|
|
args: ["nodes", "list", "--last-connected", "not-a-duration"],
|
|
message: "Invalid --last-connected: Invalid duration",
|
|
human: true,
|
|
},
|
|
{
|
|
name: "list with JSON before its invalid duration",
|
|
args: ["nodes", "list", "--json", "--last-connected", "not-a-duration"],
|
|
message: "Invalid --last-connected: Invalid duration",
|
|
},
|
|
{
|
|
name: "list with JSON after its invalid duration",
|
|
args: ["nodes", "list", "--last-connected", "not-a-duration", "--json"],
|
|
message: "Invalid --last-connected: Invalid duration",
|
|
},
|
|
{
|
|
name: "invoke with an explicitly JSON blank node",
|
|
args: ["nodes", "invoke", "--node", " ", "--command", "canvas.eval", "--json"],
|
|
message: "--node and --command required",
|
|
},
|
|
{
|
|
name: "invoke with an implicitly JSON blank node",
|
|
args: ["nodes", "invoke", "--node", " ", "--command", "canvas.eval"],
|
|
message: "--node and --command required",
|
|
},
|
|
{
|
|
name: "invoke with an explicitly JSON blank command",
|
|
args: ["nodes", "invoke", "--node", "mac-1", "--command", " ", "--json"],
|
|
message: "--node and --command required",
|
|
},
|
|
{
|
|
name: "invoke with an implicitly JSON blank command",
|
|
args: ["nodes", "invoke", "--node", "mac-1", "--command", " "],
|
|
message: "--node and --command required",
|
|
},
|
|
{
|
|
name: "rename with a blank name",
|
|
args: ["nodes", "rename", "--node", "mac-1", "--name", " ", "--json"],
|
|
message: "--name must not be empty",
|
|
},
|
|
])("renders nodes $name through the shared validation owner", async (testCase) => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const denyNetwork = Buffer.from(
|
|
`import net from "node:net";
|
|
net.Socket.prototype.connect = function () { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };
|
|
globalThis.fetch = async () => { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };`,
|
|
).toString("base64");
|
|
const result = runBuiltCli(
|
|
tempHome,
|
|
testCase.args,
|
|
{
|
|
// Startup probes SQLite in a worker; filesystem writes remain denied in the CLI.
|
|
NODE_OPTIONS: "--permission --allow-fs-read=* --allow-worker",
|
|
NODE_DISABLE_COMPILE_CACHE: "1",
|
|
OPENCLAW_NO_RESPAWN: "1",
|
|
OPENCLAW_LOG_LEVEL: "silent",
|
|
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
|
|
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
|
|
},
|
|
{ execArgv: [`--import=data:text/javascript;base64,${denyNetwork}`] },
|
|
);
|
|
|
|
expect(result.status, result.stderr).toBe(1);
|
|
if ("human" in testCase && testCase.human) {
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain(`nodes ${testCase.args[1]} failed:`);
|
|
expect(result.stderr).toContain(testCase.message);
|
|
} else {
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
ok: false,
|
|
error: {
|
|
type: "cli_error",
|
|
message: expect.stringContaining(testCase.message),
|
|
},
|
|
});
|
|
expect(result.stderr).toContain("[openclaw] The CLI command failed.");
|
|
expect(result.stderr).not.toContain(testCase.message);
|
|
}
|
|
expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN");
|
|
},
|
|
{ prefix: "openclaw-nodes-json-failure-e2e-" },
|
|
);
|
|
});
|
|
});
|