mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
rig transcript --tail and rig capture --lines now send the same line count on every path. A count of 0, a negative number or a non-numeric value falls back to the documented default of 20 instead of reaching the daemon as zero, so rig capture --lines 0 shows the default view (20 history lines plus the visible pane) rather than the visible pane alone, and rig transcript --tail 0 returns the default 50 lines. A positive count is unchanged. Over SSH, a value parseInt reads as a positive count is forwarded as typed, so the remote CLI applies the same rule, and the local, cross-host HTTP and SSH paths agree. 6 files, +70 -4 (source +13 -4, tests +57). No new refusals.
205 lines
8.9 KiB
TypeScript
205 lines
8.9 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
|
import http from "node:http";
|
|
import { Command } from "commander";
|
|
import { transcriptCommand } from "../src/commands/transcript.js";
|
|
import { DaemonClient } from "../src/client.js";
|
|
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
|
import type { StatusDeps } from "../src/commands/status.js";
|
|
|
|
function mockLifecycleDeps(): 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(() => null),
|
|
writeFile: vi.fn(),
|
|
removeFile: vi.fn(),
|
|
exists: vi.fn(() => false),
|
|
mkdirp: vi.fn(),
|
|
openForAppend: vi.fn(() => 3),
|
|
isProcessAlive: vi.fn(() => true),
|
|
};
|
|
}
|
|
|
|
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
|
return new Promise(async (resolve) => {
|
|
const logs: string[] = [];
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
const origExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
|
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
|
|
const exitCode = process.exitCode;
|
|
process.exitCode = origExitCode;
|
|
resolve({ logs, exitCode });
|
|
});
|
|
}
|
|
|
|
function runningDeps(port: number): StatusDeps {
|
|
return {
|
|
lifecycleDeps: {
|
|
...mockLifecycleDeps(),
|
|
exists: vi.fn((p: string) => p === STATE_FILE),
|
|
readFile: vi.fn((p: string) => {
|
|
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-03-31T00:00:00Z" } as DaemonState);
|
|
return null;
|
|
}),
|
|
fetch: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
|
};
|
|
}
|
|
|
|
describe("Transcript CLI", () => {
|
|
let server: http.Server;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
server = http.createServer((req, res) => {
|
|
const url = decodeURIComponent(req.url ?? "");
|
|
if (url.startsWith("/api/transcripts/dev-impl@my-rig/tail")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ session: "dev-impl@my-rig", lines: 10, content: "line1\nline2\nline3\n" }));
|
|
} else if (url.startsWith("/api/transcripts/dev-impl@thick-rig/tail")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
const many = Array.from({ length: 12 }, (_, i) => `content line ${i}`).join("\n");
|
|
res.end(JSON.stringify({ session: "dev-impl@thick-rig", lines: 12, content: many }));
|
|
} else if (url.startsWith("/api/transcripts/dev-impl@long-rig/tail")) {
|
|
// A long transcript: the tail route returns the last `lines` lines it was asked for.
|
|
const lines = Number(new URL(url, "http://x").searchParams.get("lines"));
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ content: Array.from({ length: lines }, (_, i) => `long line ${i}`).join("\n") + "\n" }));
|
|
} else if (url.startsWith("/api/transcripts/dev-impl@paragraph-rig/tail")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ content: "\n# Result\n\nFirst paragraph.\n\n\nSecond paragraph.\n```python\nx = 1\n\nprint(x)\n```\nDone.\n" }));
|
|
} else if (url.startsWith("/api/transcripts/dev-impl@my-rig/grep")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ session: "dev-impl@my-rig", pattern: "decision", matches: ["decision made", "decision final"] }));
|
|
} else if (url.startsWith("/api/transcripts/nonexistent/")) {
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Session 'nonexistent' not found. Check session names with: rig ps --nodes" }));
|
|
} else {
|
|
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;
|
|
});
|
|
|
|
afterAll(() => { server.close(); });
|
|
|
|
function makeCmd(): Command {
|
|
const prog = new Command();
|
|
prog.exitOverride();
|
|
prog.addCommand(transcriptCommand(runningDeps(port)));
|
|
return prog;
|
|
}
|
|
|
|
it("--tail prints tail content", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@my-rig", "--tail", "10"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("line1");
|
|
expect(output).toContain("line2");
|
|
});
|
|
|
|
it("preserves leading, consecutive and interior transcript blank lines", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@paragraph-rig"]);
|
|
});
|
|
expect(logs.join("\n") + "\n").toBe("\n# Result\n\nFirst paragraph.\n\n\nSecond paragraph.\n```python\nx = 1\n\nprint(x)\n```\nDone.\n");
|
|
});
|
|
|
|
it("thin transcript emits the fullscreen-renderer point-of-use note (OPR.0.5.3.1 item 4)", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@my-rig", "--tail", "10"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("line1"); // content still printed
|
|
expect(output).toContain("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"); // names the lever
|
|
expect(output.toLowerCase()).toContain("fullscreen renderer"); // names the cause
|
|
});
|
|
|
|
it("a healthy (non-thin) transcript does NOT emit the renderer note", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@thick-rig", "--tail", "10"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("content line 0");
|
|
expect(output).not.toContain("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN");
|
|
});
|
|
|
|
it("a small --tail of a long transcript does NOT emit the renderer note", async () => {
|
|
for (const tail of ["5", "3", "1"]) {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@long-rig", "--tail", tail]);
|
|
});
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("long line 0");
|
|
expect(output).not.toContain("line(s) captured");
|
|
}
|
|
});
|
|
|
|
it("a thin transcript read with the default tail still emits the renderer note", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@my-rig"]);
|
|
});
|
|
expect(logs.join("\n")).toContain("only 3 line(s) captured");
|
|
});
|
|
|
|
it("--grep prints matching lines", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@my-rig", "--grep", "decision"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("decision made");
|
|
expect(output).toContain("decision final");
|
|
});
|
|
|
|
it("--json prints raw JSON", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@my-rig", "--tail", "10", "--json"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
const parsed = JSON.parse(output);
|
|
expect(parsed.session).toBe("dev-impl@my-rig");
|
|
expect(parsed.content).toContain("line1");
|
|
});
|
|
|
|
it("404 response prints guidance and exits non-zero", async () => {
|
|
const { logs, exitCode } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "nonexistent", "--tail", "10"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("not found");
|
|
expect(output).toContain("rig ps");
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
|
|
it("--tail and --grep together uses grep (precedence)", async () => {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@my-rig", "--tail", "10", "--grep", "decision"]);
|
|
});
|
|
const output = logs.join("\n");
|
|
// grep mode: should show matched lines, not tail content
|
|
expect(output).toContain("decision made");
|
|
});
|
|
|
|
it("sends exactly 50 lines for non-positive --tail values", async () => {
|
|
for (const tail of ["0", "-1"]) {
|
|
const { logs } = await captureLogs(async () => {
|
|
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@long-rig", `--tail=${tail}`]);
|
|
});
|
|
// The fixture generates exactly as many lines as the request asks for:
|
|
// line 49 present and line 50 absent pins the wire value at lines=50.
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("long line 49");
|
|
expect(output).not.toContain("long line 50");
|
|
}
|
|
});
|
|
});
|