mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
test(S5b): cover all import running-name refusal surfaces
This commit is contained in:
1 parent
279e98e5d3
commit
a0ce8763dd
2 files changed
+239
No files matched your search
@@ -0,0 +1,113 @@
|
||||
// S5b final-fix round 2 (r2 artifact 8d9ec788) — every CLI consumer of the
|
||||
// direct instantiate/materialize routes must render the locked rig_name_running
|
||||
// teaching refusal VERBATIM, not wrap it in check-your-spec/validate noise.
|
||||
// The stub serves the daemon's post-fix 409 outcome shape.
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import * as http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { adoptCommand } from "../src/commands/adopt.js";
|
||||
import { importCommand } from "../src/commands/import.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type DaemonState, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
const TEACHING =
|
||||
'A rig named "dupe-cli" is already RUNNING: 01RIGID with 1 running session(s) ' +
|
||||
"(checked: existing rigs with this name for sessions in status 'running'). " +
|
||||
"Nothing was created or launched. Alternatives: work with the running rig " +
|
||||
"(rig ps --nodes / rig send), stop it first with 'rig down dupe-cli', " +
|
||||
"or launch this spec under a different name.";
|
||||
|
||||
function mockLifecycleDeps(port: number): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) {
|
||||
return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" } as DaemonState);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
describe("rig import rendering — rig_name_running teaching refusal (S5b r2)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let specPath: string;
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer(async (req, res) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if ((req.url === "/api/rigs/import" || req.url === "/api/rigs/import/materialize") && req.method === "POST") {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
ok: false,
|
||||
code: "rig_name_running",
|
||||
error: TEACHING,
|
||||
runningRig: { id: "01RIGID", name: "dupe-cli", runningSessionCount: 1 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, resolve); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "import-rnr-"));
|
||||
specPath = path.join(tempDir, "rig.yaml");
|
||||
fs.writeFileSync(specPath, 'version: "0.2"\nname: dupe-cli\npods: []\nedges: []\n');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function run(mode: "--instantiate" | "--materialize-only" | "adopt"): Promise<{ out: string; exitCode: number | undefined }> {
|
||||
const captured: string[] = [];
|
||||
const originalExit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => captured.push(args.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => captured.push(args.join(" ")));
|
||||
try {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
const deps = {
|
||||
lifecycleDeps: mockLifecycleDeps(port),
|
||||
clientFactory: (baseUrl: string) => new DaemonClient(baseUrl),
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
};
|
||||
const args = mode === "adopt"
|
||||
? ["node", "rig", "adopt", specPath, "--bind", "crew.a=existing-session"]
|
||||
: ["node", "rig", "import", specPath, mode];
|
||||
prog.addCommand(mode === "adopt" ? adoptCommand(deps as never) : importCommand(deps as never));
|
||||
await prog.parseAsync(args).catch(() => {});
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExit;
|
||||
return { out: captured.join("\n"), exitCode };
|
||||
}
|
||||
|
||||
for (const mode of ["--instantiate", "--materialize-only", "adopt"] as const) {
|
||||
it(`${mode}: renders the teaching refusal verbatim — no spec-blaming or daemon-logs noise`, async () => {
|
||||
const { out, exitCode } = await run(mode);
|
||||
|
||||
expect(out).toBe(TEACHING);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// S5b final-fix round 2 (r2 artifact 8d9ec788) — the WHOLE import/materialize
|
||||
// delivery class, table-shaped so the typed rig_name_running variant cannot
|
||||
// drift among sibling handlers: pod-aware import (fixed round 1, control),
|
||||
// legacy import and materialize-only (the two 500-mapping siblings).
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
|
||||
const POD_YAML = (name: string) => [
|
||||
'version: "0.2"',
|
||||
`name: ${name}`,
|
||||
"pods:",
|
||||
" - id: crew",
|
||||
" label: Crew",
|
||||
" members:",
|
||||
" - id: a",
|
||||
' agent_ref: "builtin:terminal"',
|
||||
' profile: "none"',
|
||||
" runtime: terminal",
|
||||
" cwd: /",
|
||||
" edges: []",
|
||||
"edges: []",
|
||||
].join("\n");
|
||||
|
||||
const LEGACY_YAML = (name: string) => [
|
||||
"schema_version: 1",
|
||||
`name: ${name}`,
|
||||
'version: "1.0"',
|
||||
"nodes:",
|
||||
" - id: solo",
|
||||
" runtime: claude-code",
|
||||
" cwd: /",
|
||||
"edges: []",
|
||||
].join("\n");
|
||||
|
||||
describe("import/materialize routes — rig_name_running is a 409 conflict on EVERY sibling (S5b r2)", () => {
|
||||
let db: Database.Database;
|
||||
let setup: ReturnType<typeof createTestApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
setup = createTestApp(db);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
function seedRunningRig(name: string) {
|
||||
const rig = setup.rigRepo.createRig(name);
|
||||
const node = setup.rigRepo.addNode(rig.id, "crew.a", { runtime: "claude-code", cwd: "/" });
|
||||
const session = setup.sessionRegistry.registerSession(node.id, `crew-a@${name}`);
|
||||
setup.sessionRegistry.updateStatus(session.id, "running");
|
||||
return rig;
|
||||
}
|
||||
|
||||
function rigCount(name: string): number {
|
||||
return (db.prepare("SELECT COUNT(*) AS c FROM rigs WHERE name = ?").get(name) as { c: number }).c;
|
||||
}
|
||||
|
||||
// The table: every production route that surfaces an instantiate/materialize
|
||||
// outcome, driven identically. Adding a sibling handler without adding a row
|
||||
// here is the drift this test exists to catch.
|
||||
const ROUTES: Array<{
|
||||
label: string;
|
||||
path: string;
|
||||
yaml: (name: string) => string;
|
||||
headers: (name: string) => Record<string, string>;
|
||||
}> = [
|
||||
{
|
||||
label: "pod-aware POST /api/rigs/import (round-1 control)",
|
||||
path: "/api/rigs/import",
|
||||
yaml: POD_YAML,
|
||||
headers: () => ({ "Content-Type": "text/yaml", "X-Rig-Root": "/tmp" }),
|
||||
},
|
||||
{
|
||||
label: "legacy POST /api/rigs/import",
|
||||
path: "/api/rigs/import",
|
||||
yaml: LEGACY_YAML,
|
||||
headers: () => ({ "Content-Type": "text/yaml" }),
|
||||
},
|
||||
{
|
||||
label: "POST /api/rigs/import/materialize",
|
||||
path: "/api/rigs/import/materialize",
|
||||
yaml: POD_YAML,
|
||||
headers: () => ({ "Content-Type": "text/yaml", "X-Rig-Root": "/tmp" }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const route of ROUTES) {
|
||||
it(`${route.label}: RUNNING same-name rig -> 409, top-level code, teaching text, nothing created or launched`, async () => {
|
||||
const name = "dupe-import";
|
||||
const rig = seedRunningRig(name);
|
||||
const createSession = (setup.tmuxAdapter as unknown as { createSession: ReturnType<typeof vi.fn> }).createSession;
|
||||
|
||||
const res = await setup.app.request(route.path, {
|
||||
method: "POST",
|
||||
headers: route.headers(name),
|
||||
body: route.yaml(name),
|
||||
});
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
|
||||
expect(res.status, `${route.label} status`).toBe(409);
|
||||
expect(body["code"], `${route.label} top-level code`).toBe("rig_name_running");
|
||||
const teaching = String(body["error"] ?? "");
|
||||
expect(body["message"], `${route.label} message/error parity`).toBe(teaching);
|
||||
expect(teaching).toContain(name);
|
||||
expect(teaching).toContain(rig.id);
|
||||
expect(teaching).toMatch(/nothing was created or launched/i);
|
||||
expect(teaching).toMatch(/rig down/);
|
||||
|
||||
expect(rigCount(name), `${route.label} rig count`).toBe(1);
|
||||
expect(createSession, `${route.label} launches`).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("control: materialize with X-Target-Rig-Id (adopt/expand family) targets the EXISTING rig — guard never fires", async () => {
|
||||
const rig = seedRunningRig("target-mat");
|
||||
const res = await setup.app.request("/api/rigs/import/materialize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/yaml", "X-Rig-Root": "/tmp", "X-Target-Rig-Id": rig.id },
|
||||
body: POD_YAML("target-mat"),
|
||||
});
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
expect(body["code"]).not.toBe("rig_name_running");
|
||||
expect(rigCount("target-mat")).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user