mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 05:03:34 +00:00
* fix(workboard): hide archived cards in CLI list by default The `openclaw workboard list` CLI printed soft-archived cards, while the `workboard_list` agent tool and the `/workboard list` command both hide cards with `metadata.archivedAt` set unless archives are requested. Users who archived cards still saw them in CLI output and assumed archive failed. Filter archived cards by default in the CLI list handler and add an `--include-archived` flag mirroring the tool's `includeArchived` option, so all three list surfaces share one default. Docs updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(workboard): preserve json list archive visibility * fix(workboard): preserve json list archive visibility --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
// Workboard tests cover cli plugin behavior.
|
|
import { Command } from "commander";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { registerWorkboardCli } from "./cli.js";
|
|
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
|
|
|
const gatewayRuntime = vi.hoisted(() => ({
|
|
callGatewayFromCli: vi.fn(),
|
|
getRuntimeConfig: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/gateway-runtime")>(
|
|
"openclaw/plugin-sdk/gateway-runtime",
|
|
);
|
|
return {
|
|
...actual,
|
|
callGatewayFromCli: gatewayRuntime.callGatewayFromCli,
|
|
};
|
|
});
|
|
|
|
vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({
|
|
getRuntimeConfig: gatewayRuntime.getRuntimeConfig,
|
|
}));
|
|
|
|
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
|
const entries = new Map<string, T>();
|
|
return {
|
|
async register(key, value) {
|
|
entries.set(key, value);
|
|
},
|
|
async lookup(key) {
|
|
return entries.get(key);
|
|
},
|
|
async delete(key) {
|
|
return entries.delete(key);
|
|
},
|
|
async entries() {
|
|
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
|
},
|
|
};
|
|
}
|
|
|
|
function createProgram(store: WorkboardStore): Command {
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
program.configureOutput({
|
|
writeErr: () => {},
|
|
writeOut: () => {},
|
|
});
|
|
registerWorkboardCli({ program, store });
|
|
return program;
|
|
}
|
|
|
|
async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> {
|
|
const seen = new Map<string, string>();
|
|
for (let index = 0; index < 40; index += 1) {
|
|
const card = await store.create({ title: `Card ${index}` });
|
|
const prefix = card.id.slice(0, 1);
|
|
if (seen.has(prefix)) {
|
|
return prefix;
|
|
}
|
|
seen.set(prefix, card.id);
|
|
}
|
|
throw new Error("could not create cards with a shared prefix");
|
|
}
|
|
|
|
async function captureStdout(run: () => Promise<void>): Promise<string> {
|
|
const chunks: string[] = [];
|
|
const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => {
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
});
|
|
try {
|
|
await run();
|
|
return chunks.join("");
|
|
} finally {
|
|
write.mockRestore();
|
|
}
|
|
}
|
|
|
|
describe("registerWorkboardCli", () => {
|
|
beforeEach(() => {
|
|
gatewayRuntime.callGatewayFromCli.mockReset();
|
|
gatewayRuntime.getRuntimeConfig.mockReset();
|
|
gatewayRuntime.getRuntimeConfig.mockReturnValue({});
|
|
delete process.env.OPENCLAW_GATEWAY_URL;
|
|
});
|
|
|
|
it("redacts claim tokens from card JSON output", async () => {
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const card = await store.create({ title: "Claimed worker", status: "running" });
|
|
await store.claim(card.id, { ownerId: "worker", token: "secret-token" });
|
|
const program = createProgram(store);
|
|
|
|
const listOutput = await captureStdout(async () => {
|
|
await program.parseAsync(["workboard", "list", "--json"], { from: "user" });
|
|
});
|
|
const showOutput = await captureStdout(async () => {
|
|
await program.parseAsync(["workboard", "show", card.id, "--json"], { from: "user" });
|
|
});
|
|
|
|
expect(listOutput).not.toContain("secret-token");
|
|
expect(showOutput).not.toContain("secret-token");
|
|
expect(listOutput).toContain("[redacted]");
|
|
expect(showOutput).toContain("[redacted]");
|
|
});
|
|
|
|
it("hides archived cards from text output by default and reveals them with --include-archived", async () => {
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
await store.create({ title: "Active card" });
|
|
const archived = await store.create({ title: "Archived card" });
|
|
await store.archive(archived.id, true);
|
|
const program = createProgram(store);
|
|
|
|
const defaultOutput = await captureStdout(async () => {
|
|
await program.parseAsync(["workboard", "list"], { from: "user" });
|
|
});
|
|
const includeOutput = await captureStdout(async () => {
|
|
await program.parseAsync(["workboard", "list", "--include-archived"], { from: "user" });
|
|
});
|
|
|
|
expect(defaultOutput).toContain("Active card");
|
|
expect(defaultOutput).not.toContain("Archived card");
|
|
expect(includeOutput).toContain("Active card");
|
|
expect(includeOutput).toContain("Archived card");
|
|
});
|
|
|
|
it("preserves archived cards in JSON list output by default", async () => {
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const archived = await store.create({ title: "Archived card" });
|
|
await store.archive(archived.id, true);
|
|
const program = createProgram(store);
|
|
|
|
const output = await captureStdout(async () => {
|
|
await program.parseAsync(["workboard", "list", "--json"], { from: "user" });
|
|
});
|
|
|
|
expect(output).toContain(archived.id);
|
|
expect(output).toContain("archivedAt");
|
|
});
|
|
|
|
it("does not fall back to local dispatch for explicit gateway targets", async () => {
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const card = await store.create({ title: "Remote target", status: "ready" });
|
|
const program = createProgram(store);
|
|
gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce(
|
|
new Error("connect ECONNREFUSED 127.0.0.1:18789"),
|
|
);
|
|
|
|
await expect(
|
|
program.parseAsync(["workboard", "dispatch", "--url", "ws://remote"], { from: "user" }),
|
|
).rejects.toThrow("ECONNREFUSED");
|
|
|
|
const after = await store.get(card.id);
|
|
expect(after?.status).toBe("ready");
|
|
expect(after?.metadata?.automation?.dispatchCount).toBeUndefined();
|
|
});
|
|
|
|
it("does not fall back to local dispatch for configured remote gateways", async () => {
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const card = await store.create({ title: "Configured remote target", status: "ready" });
|
|
const program = createProgram(store);
|
|
gatewayRuntime.getRuntimeConfig.mockReturnValue({
|
|
gateway: { mode: "remote", remote: { url: "wss://gateway.example" } },
|
|
});
|
|
gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce(
|
|
new Error("connect ECONNREFUSED gateway.example:443"),
|
|
);
|
|
|
|
await expect(program.parseAsync(["workboard", "dispatch"], { from: "user" })).rejects.toThrow(
|
|
"ECONNREFUSED",
|
|
);
|
|
|
|
const after = await store.get(card.id);
|
|
expect(after?.status).toBe("ready");
|
|
expect(after?.metadata?.automation?.dispatchCount).toBeUndefined();
|
|
});
|
|
|
|
it("rejects ambiguous card id prefixes", async () => {
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const prefix = await createAmbiguousPrefix(store);
|
|
const program = createProgram(store);
|
|
|
|
await expect(
|
|
program.parseAsync(["workboard", "show", prefix], { from: "user" }),
|
|
).rejects.toThrow("Ambiguous card id prefix");
|
|
});
|
|
});
|