openclaw/test/cli-json-stdout.sessions.e2e.test.ts

458 lines
18 KiB
TypeScript

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";
async function seedTrajectorySession(tempHome: string, sessionKey: string) {
const stateDir = path.join(tempHome, "isolated-state");
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: tempHome,
USERPROFILE: tempHome,
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_STATE_DIR: stateDir,
};
delete env.OPENCLAW_HOME;
const [{ upsertSessionEntryCore }, { closeOpenClawAgentDatabaseByPath }] = await Promise.all([
import("../src/config/sessions/session-accessor.js"),
import("../src/state/openclaw-agent-db.js"),
]);
await upsertSessionEntryCore(
{ agentId: "main", env, sessionKey },
{ sessionId: "trajectory-process-session", updatedAt: 1 },
);
closeOpenClawAgentDatabaseByPath(
path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"),
);
}
describe("cli json stdout contract", () => {
it.each([
{
name: "bare list active filter in human mode",
args: ["sessions", "--active", "0"],
message: "--active must be a positive number of minutes, for example --active 30.",
human: true,
},
{
name: "bare list limit in human mode through forced Commander",
args: ["sessions", "--limit", "0"],
message: '--limit must be a positive integer or "all", for example --limit 25.',
human: true,
commander: true,
},
{
name: "routed bare list active filter with JSON before its option",
args: ["sessions", "--json", "--active", "0"],
message: "--active must be a positive number of minutes, for example --active 30.",
},
{
name: "routed bare list limit with JSON after its option",
args: ["sessions", "--limit", "0", "--json"],
message: '--limit must be a positive integer or "all", for example --limit 25.',
},
{
name: "Commander bare list active filter with JSON after its option",
args: ["sessions", "--active", "0", "--json"],
message: "--active must be a positive number of minutes, for example --active 30.",
commander: true,
},
{
name: "Commander bare list limit with JSON before its option",
args: ["sessions", "--json", "--limit", "0"],
message: '--limit must be a positive integer or "all", for example --limit 25.',
commander: true,
},
{
name: "list alias active filter with inherited parent JSON",
args: ["sessions", "--json", "list", "--active", "0"],
message: "--active must be a positive number of minutes, for example --active 30.",
},
{
name: "list alias limit with leaf JSON",
args: ["sessions", "list", "--limit", "0", "--json"],
message: '--limit must be a positive integer or "all", for example --limit 25.',
},
{
name: "bare list active filter before an invalid limit",
args: ["sessions", "--json", "--limit", "0", "--active", "0"],
message: "--active must be a positive number of minutes, for example --active 30.",
},
{
name: "routed bare list active filter through dual-TTY finalization",
args: ["sessions", "--json", "--active", "0"],
message: "--active must be a positive number of minutes, for example --active 30.",
tty: true,
},
{
name: "Commander bare list limit through dual-TTY finalization",
args: ["sessions", "--limit", "0", "--json"],
message: '--limit must be a positive integer or "all", for example --limit 25.',
commander: true,
tty: true,
},
{
name: "cleanup with an inherited filter in human mode",
args: ["sessions", "--active", "5", "cleanup"],
message:
"`sessions cleanup` does not support the parent `sessions` option --active; session-list filters cannot scope session maintenance.",
human: true,
},
{
name: "bare list unknown agent",
args: ["sessions", "--agent", "unknown-agent", "--json"],
message:
'Unknown agent id "unknown-agent". Run openclaw agents list to see configured agents.',
},
{
name: "Commander list unknown agent through dual-TTY finalization",
args: ["sessions", "--json", "--agent", "unknown-agent"],
message:
'Unknown agent id "unknown-agent". Run openclaw agents list to see configured agents.',
commander: true,
tty: true,
},
{
name: "list alias blank store with inherited parent JSON",
args: ["sessions", "--json", "list", "--store", ""],
message: "--store must not be blank",
},
{
name: "bare list missing explicit store",
args: ["sessions", "--store", "$MISSING_STORE", "--json"],
message:
"Session store target does not exist: $MISSING_STORE. Pass a selector whose resolved SQLite target exists.",
},
{
name: "cleanup missing explicit store",
args: ["sessions", "cleanup", "--dry-run", "--store", "$MISSING_STORE", "--json"],
message:
"Session store target does not exist: $MISSING_STORE. Pass a selector whose resolved SQLite target exists.",
},
{
name: "list unknown agent in human mode",
args: ["sessions", "--agent", "unknown-agent"],
message:
'Unknown agent id "unknown-agent". Run openclaw agents list to see configured agents.',
human: true,
},
{
name: "tail blank explicit agent",
args: ["sessions", "tail", "--agent", ""],
message: "--agent must not be blank",
human: true,
},
{
name: "tail whitespace parent agent with a session key",
args: ["sessions", "--agent", " ", "tail", "--session-key", "agent:main:test"],
message: "--agent must not be blank",
human: true,
},
{
name: "cleanup inherited filter with leaf JSON",
args: ["sessions", "--active", "5", "cleanup", "--json"],
message:
"`sessions cleanup` does not support the parent `sessions` option --active; session-list filters cannot scope session maintenance.",
},
{
name: "cleanup inherited limit with parent JSON",
args: ["sessions", "--json", "--limit", "1", "cleanup"],
message:
"`sessions cleanup` does not support the parent `sessions` option --limit; session-list filters cannot scope session maintenance.",
},
{
name: "trajectory export inherited all-agent scope",
args: [
"sessions",
"--all-agents",
"export-trajectory",
"--session-key",
"agent:main:main",
"--json",
],
message:
"`sessions export-trajectory` does not support the parent `sessions` option --all-agents; trajectory export targets one session and cannot apply session-list filters.",
},
{
name: "trajectory export missing session key in human mode",
args: ["sessions", "export-trajectory"],
message: "--session-key is required. Run openclaw sessions to choose a session.",
human: true,
},
{
name: "trajectory export missing session key with leaf JSON",
args: ["sessions", "export-trajectory", "--json"],
message: "--session-key is required. Run openclaw sessions to choose a session.",
},
{
name: "trajectory export missing session key with parent JSON through forced Commander",
args: ["sessions", "--json", "export-trajectory"],
message: "--session-key is required. Run openclaw sessions to choose a session.",
commander: true,
},
{
name: "trajectory export malformed encoded request",
args: [
"sessions",
"export-trajectory",
"--request-json-base64",
Buffer.from("not json", "utf8").toString("base64url"),
"--json",
],
message:
"Failed to decode trajectory export request: Encoded trajectory export request is invalid JSON",
},
{
name: "trajectory export noncanonical encoded request with parent JSON",
args: [
"sessions",
"--json",
"export-trajectory",
"--request-json-base64",
` ${Buffer.from(JSON.stringify({ sessionKey: "agent:main:test" })).toString("base64url")} `,
],
message:
"Failed to decode trajectory export request: Encoded trajectory export request is invalid",
},
{
name: "trajectory export blank explicit agent",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:test",
"--agent",
"",
"--json",
],
message: "--agent must not be blank",
},
{
name: "trajectory export unconfigured explicit agent",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:test",
"--agent",
"unknown-agent",
"--json",
],
message:
'Unknown agent id "unknown-agent". Run openclaw agents list to see configured agents.',
},
{
name: "trajectory export missing session through dual-TTY finalization",
args: ["sessions", "export-trajectory", "--session-key", "agent:main:missing", "--json"],
message:
"Session not found: agent:main:missing. Run openclaw sessions to see available sessions.",
tty: true,
},
{
name: "trajectory export invalid explicit store",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:trajectory-process",
"--store",
"$MISSING_STORE",
"--json",
],
message:
"Session store target does not exist: $MISSING_STORE. Pass a selector whose resolved SQLite target exists.",
},
{
name: "trajectory exporter operational failure",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:trajectory-process",
"--workspace",
"$TRAJECTORY_WORKSPACE",
"--json",
],
message: "Failed to export trajectory: injected trajectory exporter failure",
exporterFailure: true,
},
{
name: "archive inherited store with leaf JSON",
args: ["sessions", "--store", "/tmp/other.sqlite", "archive", "agent:main:test", "--json"],
message:
"`sessions archive` does not support the parent `sessions` option --store; the gateway resolves target stores from each key and --agent.",
},
{
name: "archive invalid timeout with parent JSON",
args: ["sessions", "--json", "archive", "agent:main:test", "--timeout", "0"],
message: "--timeout must be a positive integer (milliseconds).",
},
{
name: "delete inherited all-agent scope",
args: ["sessions", "--all-agents", "delete", "agent:main:test", "--yes", "--json"],
message:
"`sessions delete` does not support the parent `sessions` option --all-agents; the gateway resolves target stores from each key and --agent.",
},
{
name: "delete invalid timeout with leaf JSON",
args: ["sessions", "delete", "agent:main:test", "--timeout", "nope", "--yes", "--json"],
message: "--timeout must be a positive integer (milliseconds).",
},
{
name: "compact inherited all-agent scope",
args: ["sessions", "--all-agents", "compact", "agent:main:test", "--json"],
message:
"`sessions compact` does not support the parent `sessions` option --all-agents; the gateway resolves the target store from <key> and --agent.",
},
{
name: "compact invalid max-lines with leaf JSON",
args: ["sessions", "compact", "agent:main:test", "--max-lines", "0", "--json"],
message: "--max-lines must be a positive integer.",
},
{
name: "compact invalid timeout with parent JSON",
args: ["sessions", "--json", "compact", "agent:main:test", "--timeout", "0"],
message: "--timeout must be a positive integer (milliseconds).",
},
{
name: "human-only tail rejecting inherited JSON",
args: ["sessions", "--json", "tail"],
message:
"`sessions tail` does not support the parent `sessions` option --json; trajectory tail emits human-readable progress and selects sessions separately.",
},
{
name: "cleanup inherited filter through forced Commander",
args: ["sessions", "--active", "5", "cleanup", "--json"],
message:
"`sessions cleanup` does not support the parent `sessions` option --active; session-list filters cannot scope session maintenance.",
commander: true,
},
{
name: "compact invalid max-lines through dual-TTY finalization",
args: ["sessions", "compact", "agent:main:test", "--max-lines", "0", "--json"],
message: "--max-lines must be a positive integer.",
tty: true,
},
])("renders sessions list and registration validation failures for $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
if ("exporterFailure" in testCase) {
await seedTrajectorySession(tempHome, "agent:main:trajectory-process");
}
const preload = 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"); };',
...("exporterFailure" in testCase
? [
'import fs from "node:fs/promises";',
"const originalRealpath = fs.realpath;",
`fs.realpath = async (target, ...args) => { if (target === ${JSON.stringify(tempHome)}) { throw new Error("injected trajectory exporter failure"); } return originalRealpath(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 missingStore = path.join(tempHome, "missing-store.sqlite");
const args = testCase.args.map((arg) =>
arg === "$TRAJECTORY_WORKSPACE"
? tempHome
: arg === "$MISSING_STORE"
? missingStore
: arg,
);
const message = testCase.message.replace("$MISSING_STORE", missingStore);
const result = runBuiltCli(tempHome, args, {
NODE_OPTIONS: `--import=data:text/javascript;base64,${preload}`,
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_GATEWAY_PORT: "29791",
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
...("commander" in testCase ? { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } : {}),
...("tty" in testCase ? { FORCE_COLOR: "1" } : {}),
});
expect(result.status, result.stderr).toBe(1);
expect(result.stdout, result.stderr).not.toContain("\u001B");
expect(result.stdout, result.stderr).not.toContain("\u0007");
if ("human" in testCase) {
expect(result.stdout).toBe("");
} else {
expect(JSON.parse(result.stdout)).toEqual({
ok: false,
error: { type: "cli_error", message },
});
}
expect(result.stderr).toContain(message);
expect(result.stderr.split(message)).toHaveLength(2);
expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN");
if ("tty" in testCase) {
expect(result.stderr).toContain("\u001B[?25h");
}
},
{ prefix: "openclaw-sessions-registration-json-failure-e2e-" },
);
});
it.each([
{ name: "direct JSON export", encoded: false, json: true },
{ name: "encoded request precedence with plain output", encoded: true, json: false },
])("preserves successful trajectory $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
const sessionKey = "agent:main:trajectory-process";
await seedTrajectorySession(tempHome, sessionKey);
const output = testCase.encoded ? "encoded-export" : "direct-export";
const args = [
"sessions",
"export-trajectory",
"--session-key",
testCase.encoded ? "agent:main:missing" : sessionKey,
"--output",
"direct-export",
"--workspace",
tempHome,
];
if (testCase.encoded) {
args.push(
"--request-json-base64",
Buffer.from(JSON.stringify({ sessionKey, output }), "utf8").toString("base64url"),
);
}
if (testCase.json) {
args.push("--json");
}
const result = runBuiltCli(tempHome, args, {
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_GATEWAY_PORT: "29791",
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
});
expect(result.status, result.stderr).toBe(0);
if (testCase.json) {
expect(JSON.parse(result.stdout)).toMatchObject({
displayPath: `.openclaw/trajectory-exports/${output}`,
sessionId: "trajectory-process-session",
});
} else {
expect(result.stdout).toContain("✅ Trajectory exported!");
expect(result.stdout).toContain(`.openclaw/trajectory-exports/${output}`);
expect(result.stdout).toContain("trajectory-process-session");
}
await expect(
fs.access(
path.join(tempHome, ".openclaw", "trajectory-exports", output, "manifest.json"),
),
).resolves.toBeUndefined();
},
{ prefix: "openclaw-trajectory-success-e2e-" },
);
});
});