mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
* chore(release): prepare OpenRig 0.6.0 Add per-seat permission selection and typing protection, rig-wide Herdr entry, Node 22/24 support with SQLite 13, and first-use improvements. Label the optional Jev/Rig Stream classifier and Slack manifest setup assistance experimental. * docs: finalize 0.6.0 verification scope and release references Record the accepted Node 22 candidate install, retained Node 24 evidence, and unperformed native permission checks without broadening test claims. --------- Co-authored-by: dev-qa <[email protected]>
226 lines
9.4 KiB
TypeScript
226 lines
9.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { ProjectDeps } from "../src/commands/project.js";
|
|
import { createProgram } from "../src/index.js";
|
|
|
|
vi.mock("../src/daemon-lifecycle.js", async () => {
|
|
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
|
return {
|
|
...actual,
|
|
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
|
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
|
};
|
|
});
|
|
|
|
interface StubResponse { status: number; data: unknown }
|
|
|
|
function makeDeps(opts?: { routes?: Record<string, StubResponse> }): { deps: ProjectDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
|
|
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
|
const routes = opts?.routes ?? {};
|
|
return {
|
|
calls,
|
|
deps: {
|
|
lifecycleDeps: {} as ProjectDeps["lifecycleDeps"],
|
|
clientFactory: () => ({
|
|
get: vi.fn(async (path: string) => {
|
|
calls.push({ method: "GET", path });
|
|
return routes[`GET ${path}`] ?? { status: 200, data: {} };
|
|
}),
|
|
getText: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: "" }; }),
|
|
post: vi.fn(async (path: string, body: unknown) => {
|
|
calls.push({ method: "POST", path, body });
|
|
return routes[`POST ${path}`] ?? { status: 201, data: {} };
|
|
}),
|
|
delete: vi.fn(async (path: string) => { calls.push({ method: "DELETE", path }); return { status: 204, data: null }; }),
|
|
postText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
|
postExpectText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
|
}) as unknown as ReturnType<ProjectDeps["clientFactory"]>,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("rig project CLI (PL-004 Phase B)", () => {
|
|
let logs: string[];
|
|
let errors: string[];
|
|
|
|
beforeEach(() => {
|
|
logs = [];
|
|
errors = [];
|
|
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
|
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it("project is registered with all R1 subcommands", () => {
|
|
const { deps } = makeDeps();
|
|
const program = createProgram({ projectDeps: deps });
|
|
const cmd = program.commands.find((c) => c.name() === "project");
|
|
expect(cmd).toBeDefined();
|
|
const subs = cmd!.commands.map((c) => c.name()).sort();
|
|
expect(subs).toContain("lease-acquire");
|
|
expect(subs).toContain("lease-heartbeat");
|
|
expect(subs).toContain("lease-show");
|
|
expect(subs).toContain("reclaim-classifier");
|
|
expect(subs).toContain("classify");
|
|
expect(subs).toContain("list");
|
|
expect(subs).toContain("show");
|
|
});
|
|
|
|
it("lease-acquire POSTs /api/projects/lease/acquire with classifierSession", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/projects/lease/acquire": { status: 201, data: { state: "active" } } },
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "project", "lease-acquire", "--session", "alice@rig", "--json"]);
|
|
const call = calls.find((c) => c.path === "/api/projects/lease/acquire");
|
|
expect((call!.body as { classifierSession: string }).classifierSession).toBe("alice@rig");
|
|
});
|
|
|
|
it("classify POSTs /api/projects/project with stream-item-id + classification fields", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/projects/project": { status: 201, data: { projectId: "P-1" } } },
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "project", "classify", "stream-x",
|
|
"--session", "alice@rig",
|
|
"--lease-id", "L-1",
|
|
"--attempt-id", "A-1",
|
|
"--execution-id", "E-1",
|
|
"--type", "idea",
|
|
"--area", "coordination-stream-queue",
|
|
"--needs-human", "false",
|
|
"--urgency", "high",
|
|
"--destination", "planning@rig",
|
|
"--json",
|
|
]);
|
|
const call = calls.find((c) => c.path === "/api/projects/project");
|
|
const body = call!.body as Record<string, unknown>;
|
|
expect(body.streamItemId).toBe("stream-x");
|
|
expect(body.classifierSession).toBe("alice@rig");
|
|
expect(body.classificationType).toBe("idea");
|
|
expect(body.classificationUrgency).toBe("high");
|
|
expect(body.classificationDestination).toBe("planning@rig");
|
|
expect(body.leaseId).toBe("L-1");
|
|
expect(body.attemptId).toBe("A-1");
|
|
expect(body.executionId).toBe("E-1");
|
|
expect(body.area).toBe("coordination-stream-queue");
|
|
expect(body.needsHuman).toBe(false);
|
|
});
|
|
|
|
it("classify without --lease-id is refused by the CLI before any request", async () => {
|
|
const { deps, calls } = makeDeps({ routes: {} });
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
program.configureOutput({ writeErr: () => {} });
|
|
for (const sub of program.commands) {
|
|
sub.exitOverride();
|
|
sub.configureOutput({ writeErr: () => {} });
|
|
for (const leaf of sub.commands) { leaf.exitOverride(); leaf.configureOutput({ writeErr: () => {} }); }
|
|
}
|
|
await expect(program.parseAsync(["node", "rig", "project", "classify", "stream-x", "--session", "alice@rig"])).rejects.toThrow(/lease-id/);
|
|
expect(calls.find((c) => c.path === "/api/projects/project")).toBeUndefined();
|
|
});
|
|
|
|
it("classify omits needsHuman when unknown and rejects values other than true/false", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/projects/project": { status: 201, data: { projectId: "P-2" } } },
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "project", "classify", "stream-y", "--session", "alice@rig", "--lease-id", "L-1", "--json"]);
|
|
const body = calls.find((c) => c.path === "/api/projects/project")!.body as Record<string, unknown>;
|
|
expect(body.needsHuman).toBeUndefined();
|
|
const before = calls.length;
|
|
await program.parseAsync(["node", "rig", "project", "classify", "stream-y", "--session", "alice@rig", "--lease-id", "L-1", "--needs-human", "maybe"]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(calls.length).toBe(before);
|
|
});
|
|
|
|
it("R1 BLOCKER 1: classify with unknown_stream_item 400 surfaces error + non-zero exit", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: {
|
|
"POST /api/projects/project": {
|
|
status: 400,
|
|
data: {
|
|
error: "unknown_stream_item",
|
|
message: "stream_item_id stream-nonexistent does not exist in stream_items",
|
|
streamItemId: "stream-nonexistent",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "project", "classify", "stream-nonexistent",
|
|
"--session", "alice@rig",
|
|
"--lease-id", "L-1",
|
|
"--json",
|
|
]);
|
|
expect(process.exitCode).toBe(1);
|
|
const out = logs.join("\n");
|
|
expect(out).toContain("unknown_stream_item");
|
|
expect(out).toContain("does not exist");
|
|
});
|
|
|
|
it("classify with idempotency_violation 409 surfaces error + non-zero exit", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: {
|
|
"POST /api/projects/project": {
|
|
status: 409,
|
|
data: { error: "idempotency_violation", message: "stream-x already projected" },
|
|
},
|
|
},
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "project", "classify", "stream-x",
|
|
"--session", "alice@rig",
|
|
"--lease-id", "L-1",
|
|
"--json",
|
|
]);
|
|
expect(process.exitCode).toBe(1);
|
|
const out = logs.join("\n");
|
|
expect(out).toContain("idempotency_violation");
|
|
});
|
|
|
|
it("reclaim-classifier --if-dead passes ifDead: true", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/projects/reclaim-classifier": { status: 200, data: { state: "reclaimed" } } },
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "project", "reclaim-classifier",
|
|
"--session", "operator@rig",
|
|
"--if-dead",
|
|
"--reason", "alice unresponsive",
|
|
]);
|
|
const call = calls.find((c) => c.path === "/api/projects/reclaim-classifier");
|
|
const body = call!.body as Record<string, unknown>;
|
|
expect(body.byClassifierSession).toBe("operator@rig");
|
|
expect(body.ifDead).toBe(true);
|
|
expect(body.reason).toBe("alice unresponsive");
|
|
});
|
|
|
|
it("list constructs /api/projects/list with filter params", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/projects/list?classificationDestination=planning%40rig&limit=50": { status: 200, data: [] } },
|
|
});
|
|
const program = createProgram({ projectDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "project", "list",
|
|
"--destination", "planning@rig",
|
|
"--limit", "50",
|
|
"--json",
|
|
]);
|
|
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/projects/list"));
|
|
expect(call!.path).toContain("classificationDestination=planning%40rig");
|
|
expect(call!.path).toContain("limit=50");
|
|
});
|
|
});
|