Files
openclaw/test/cli-json-stdout.e2e.test.ts
T
Peter Steinberger 6c66f48a7c fix(cli): emit one JSON failure contract for --json invocations (#124849)
* fix(cli): unify JSON failure output

* test(cli): update skills verify failure envelope
2026-08-16 15:09:58 -07:00

435 lines
15 KiB
TypeScript

// CLI JSON stdout E2E tests validate machine-readable CLI output.
import { spawnSync } from "node:child_process";
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";
function runSourceCli(tempHome: string, args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: tempHome,
USERPROFILE: tempHome,
OPENCLAW_TEST_FAST: "1",
};
delete env.OPENCLAW_HOME;
delete env.OPENCLAW_STATE_DIR;
delete env.OPENCLAW_CONFIG_PATH;
delete env.VITEST;
Object.assign(env, envOverrides);
const entry = path.resolve(process.cwd(), "src/entry.ts");
return spawnSync(process.execPath, ["--import", "tsx", entry, ...args], {
cwd: process.cwd(),
env,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
timeout: 60_000,
});
}
describe("cli json stdout contract", () => {
it.each([
{
name: "routed config get",
args: ["config", "get", "gateway.port", "--json"],
overrides: {},
},
{
name: "Commander config get",
args: ["config", "get", "gateway.port", "--json"],
overrides: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" },
},
{
name: "Nix config get",
args: ["config", "get", "gateway.port", "--json"],
overrides: { OPENCLAW_NIX_MODE: "1" },
},
{ name: "config schema", args: ["config", "schema"], overrides: {} },
{
name: "Nix config schema",
args: ["config", "schema"],
overrides: { OPENCLAW_NIX_MODE: "1" },
},
{ name: "config validate", args: ["config", "validate", "--json"], overrides: {} },
{
name: "Nix config validate",
args: ["config", "validate", "--json"],
overrides: { OPENCLAW_NIX_MODE: "1" },
},
])("does not initialize shared SQLite for $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
const stateDir = path.join(tempHome, "read-only-state");
const configPath = path.join(tempHome, "read-only-openclaw.json");
await fs.writeFile(
configPath,
`${JSON.stringify({ gateway: { mode: "local", port: 18789 } })}\n`,
"utf8",
);
const result = runSourceCli(tempHome, testCase.args, {
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
...testCase.overrides,
});
expect(result.status, result.stderr).toBe(0);
expect(() => JSON.parse(result.stdout)).not.toThrow();
await expect(
fs.access(path.join(stateDir, "state", "openclaw.sqlite")),
).rejects.toMatchObject({
code: "ENOENT",
});
},
{ prefix: "openclaw-read-only-config-e2e-" },
);
});
it.each([
{ name: "routed malformed config get", overrides: {} },
{
name: "Commander malformed config get",
overrides: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" },
},
])("returns actionable JSON without creating state for $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
const stateDir = path.join(tempHome, "read-only-state");
const configPath = path.join(tempHome, "read-only-openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf8");
const result = runSourceCli(
tempHome,
["config", "get", "gateway.__proto__.token", "--json"],
{
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
...testCase.overrides,
},
);
expect(result.status, result.stderr).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
ok: false,
error: {
type: "cli_error",
message: expect.stringContaining("Invalid path segment: __proto__"),
},
});
expect(result.stderr).toBe("");
await expect(
fs.access(path.join(stateDir, "state", "openclaw.sqlite")),
).rejects.toMatchObject({
code: "ENOENT",
});
},
{ prefix: "openclaw-read-only-invalid-config-e2e-" },
);
});
it.each([
{ name: "routed invalid config get", overrides: {} },
{
name: "Commander invalid config get",
overrides: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" },
},
])("reports invalid configuration as JSON without creating state for $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
const stateDir = path.join(tempHome, "read-only-state");
const configPath = path.join(tempHome, "read-only-openclaw.json");
await fs.writeFile(
configPath,
`${JSON.stringify({ gateway: { bind: "not-a-supported-mode" } })}\n`,
"utf8",
);
const result = runSourceCli(tempHome, ["config", "get", "gateway.port", "--json"], {
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
...testCase.overrides,
});
expect(result.status, result.stderr).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
ok: false,
error: {
type: "cli_error",
message: expect.stringContaining("OpenClaw config is invalid"),
},
issues: expect.arrayContaining([
expect.objectContaining({ path: "gateway.bind", message: expect.any(String) }),
]),
});
expect(result.stderr).toBe("");
await expect(
fs.access(path.join(stateDir, "state", "openclaw.sqlite")),
).rejects.toMatchObject({
code: "ENOENT",
});
},
{ prefix: "openclaw-read-only-invalid-snapshot-e2e-" },
);
});
it.each([
{ name: "default service", inheritedProfile: undefined, inheritedStateName: ".openclaw" },
{ name: "named service", inheritedProfile: "main", inheritedStateName: ".openclaw-main" },
])("resolves the requested profile from inherited $name state", async (inherited) => {
await withTempHome(
async (tempHome) => {
const inheritedStateDir = path.join(tempHome, inherited.inheritedStateName);
const result = runSourceCli(tempHome, ["--profile", "work", "config", "file"], {
OPENCLAW_PROFILE: inherited.inheritedProfile,
OPENCLAW_STATE_DIR: inheritedStateDir,
OPENCLAW_CONFIG_PATH: path.join(inheritedStateDir, "openclaw.json"),
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(path.join(tempHome, ".openclaw-work", "openclaw.json"));
await expect(fs.access(path.join(tempHome, ".openclaw-work"))).rejects.toMatchObject({
code: "ENOENT",
});
},
{ prefix: "openclaw-profile-isolation-e2e-" },
);
});
it("keeps default-profile exec approvals untouched for a scratch-state config query", async () => {
await withTempHome(
async (tempHome) => {
const defaultStateDir = path.join(tempHome, ".openclaw");
const scratchStateDir = path.join(tempHome, "scratch-state");
const approvalsPath = path.join(defaultStateDir, "exec-approvals.json");
const approvals = '{"version":1,"approvals":{"demo":true}}\n';
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.mkdir(scratchStateDir, { recursive: true });
await fs.writeFile(approvalsPath, approvals, "utf8");
const result = runSourceCli(tempHome, ["config", "file"], {
OPENCLAW_STATE_DIR: scratchStateDir,
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(path.join(scratchStateDir, "openclaw.json"));
await expect(fs.readFile(approvalsPath, "utf8")).resolves.toBe(approvals);
await expect(fs.access(`${approvalsPath}.migrated`)).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
fs.access(path.join(scratchStateDir, "exec-approvals.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(
fs.access(path.join(scratchStateDir, "state", "openclaw.sqlite")),
).rejects.toMatchObject({ code: "ENOENT" });
},
{ prefix: "openclaw-read-only-state-e2e-" },
);
});
it("keeps `update status --json` stdout parseable even with legacy doctor preflight inputs", async () => {
await withTempHome(
async (tempHome) => {
const legacyDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(legacyDir, { recursive: true });
await fs.writeFile(path.join(legacyDir, "clawdbot.json"), "{}", "utf8");
const result = runSourceCli(tempHome, ["update", "status", "--json", "--timeout", "1"]);
expect(result.status).toBe(0);
const stdout = result.stdout.trim();
expect(stdout.length).toBeGreaterThan(0);
const parsed = JSON.parse(stdout) as unknown;
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Expected JSON object stdout, got: ${stdout}`);
}
expect(Object.keys(parsed).toSorted((a, b) => a.localeCompare(b))).toEqual([
"availability",
"channel",
"update",
]);
expect(stdout).not.toContain("Doctor warnings");
expect(stdout).not.toContain("Doctor changes");
expect(stdout).not.toContain("Config invalid");
},
{ prefix: "openclaw-json-e2e-" },
);
});
it("rejects an explicitly empty update status timeout before emitting JSON", async () => {
await withTempHome(
async (tempHome) => {
const result = runSourceCli(tempHome, ["update", "status", "--json", "--timeout", ""]);
expect(result.status, result.stderr).toBe(1);
expect(JSON.parse(result.stdout)).toEqual({
ok: false,
error: {
type: "cli_error",
message: "--timeout must be a positive integer (seconds)",
},
});
expect(result.stderr).toContain("--timeout must be a positive integer (seconds)");
},
{ prefix: "openclaw-update-empty-timeout-e2e-" },
);
});
it("returns one canonical document for a command that previously failed on stderr only", async () => {
await withTempHome(
async (tempHome) => {
const missingArchive = path.join(tempHome, "missing-backup.tar.gz");
const result = runSourceCli(tempHome, ["backup", "verify", missingArchive, "--json"]);
expect(result.status).toBe(1);
expect(JSON.parse(result.stdout)).toEqual({
ok: false,
error: {
type: "cli_error",
message: expect.stringContaining("missing-backup.tar.gz"),
},
});
},
{ prefix: "openclaw-json-failure-e2e-" },
);
});
it("keeps Commander parse failures machine-readable in JSON mode", async () => {
await withTempHome(
async (tempHome) => {
const result = runSourceCli(tempHome, [
"config",
"get",
"gateway.port",
"--json",
"--not-a-real-option",
]);
expect(result.status).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
ok: false,
error: {
type: "cli_error",
message: expect.stringContaining("--not-a-real-option"),
},
});
expect(result.stderr).toContain("--not-a-real-option");
},
{ prefix: "openclaw-json-parse-failure-e2e-" },
);
});
it("keeps representative success payload bytes unchanged", async () => {
await withTempHome(
async (tempHome) => {
const configPath = path.join(tempHome, "openclaw.json");
await fs.writeFile(configPath, '{"gateway":{"port":28789}}\n', "utf8");
const env = { OPENCLAW_CONFIG_PATH: configPath };
const getResult = runSourceCli(tempHome, ["config", "get", "gateway.port", "--json"], env);
const validateResult = runSourceCli(tempHome, ["config", "validate", "--json"], env);
expect(getResult.status, getResult.stderr).toBe(0);
expect(getResult.stdout).toBe("28789\n");
expect(validateResult.status, validateResult.stderr).toBe(0);
expect(validateResult.stdout).toBe(
`${JSON.stringify({ valid: true, path: configPath, warnings: [] })}\n`,
);
},
{ prefix: "openclaw-json-success-bytes-e2e-" },
);
});
it("keeps `config schema` stdout parseable at debug log level", async () => {
await withTempHome(
async (tempHome) => {
const result = runSourceCli(tempHome, ["config", "schema"], {
OPENCLAW_LOG_LEVEL: "debug",
});
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout) as {
properties?: Record<string, unknown>;
};
expect(parsed.properties?.$schema).toEqual({ type: "string" });
expect(result.stdout).not.toContain("possibly sensitive key found");
expect(result.stderr).not.toContain("possibly sensitive key found");
},
{ prefix: "openclaw-config-schema-json-e2e-" },
);
});
it("keeps `config validate --json` stdout parseable at debug log level", async () => {
await withTempHome(
async (tempHome) => {
const configPath = path.join(tempHome, "openclaw.json");
await fs.writeFile(configPath, "{}", "utf8");
const result = runSourceCli(tempHome, ["config", "validate", "--json"], {
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_LOG_LEVEL: "debug",
});
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
valid: true,
path: configPath,
});
expect(result.stdout).not.toContain("possibly sensitive key found");
},
{ prefix: "openclaw-config-validate-json-e2e-" },
);
});
it("returns structured Doctor lint output when llama.cpp is not bundled", async () => {
await withTempHome(
async (tempHome) => {
const bundledPluginsDir = path.join(tempHome, "packaged-extensions");
const memoryCoreDir = path.join(bundledPluginsDir, "memory-core");
await fs.mkdir(memoryCoreDir, { recursive: true });
await fs.writeFile(
path.join(memoryCoreDir, "api.js"),
[
"export function registerMemoryCoreDoctorChecks(host) {",
" host.registerHealthCheck({",
' id: "memory-core/managed-local-embedding-setup",',
' kind: "plugin",',
' source: "memory-core",',
' description: "packaged Memory Core readiness fixture",',
" async detect() { return []; },",
" });",
"}",
"",
].join("\n"),
"utf8",
);
const result = runSourceCli(
tempHome,
[
"doctor",
"--lint",
"--only",
"memory-core/managed-local-embedding-setup",
"--severity-min",
"error",
"--json",
],
{
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir,
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
},
);
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
ok: true,
checksRun: 1,
findings: [],
});
},
{ prefix: "openclaw-doctor-packaged-json-e2e-" },
);
});
});