mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
64062af9a1
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI. Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor. Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
/**
|
|
* Launch-plumbing tests for the custom-reviews `reviewProfileId` field.
|
|
*
|
|
* These exercise the Bun POST /api/agents/jobs handler contract that the Pi
|
|
* mirror must match byte-for-byte:
|
|
* - `reviewProfileId` is parsed from the body and forwarded into buildCommand.
|
|
* - Unknown fields are rejected (fail loud, not silently ignored).
|
|
* - An absent id forwards no `reviewProfileId` (review.ts resolves that to
|
|
* builtin:default).
|
|
* - The launched job carries the reviewProfileId/Label stamped by buildCommand.
|
|
*
|
|
* `Bun.which` is mocked so capability detection reports providers available
|
|
* regardless of whether the host has the CLIs installed (CI parity).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
|
const realWhich = Bun.which;
|
|
beforeEach(() => {
|
|
// Make every provider "available" so the capability gate doesn't depend on
|
|
// the host having claude/codex installed.
|
|
(Bun as { which: typeof Bun.which }).which = (() => "/fake/bin") as typeof Bun.which;
|
|
});
|
|
afterEach(() => {
|
|
(Bun as { which: typeof Bun.which }).which = realWhich;
|
|
});
|
|
|
|
// Imported after the which-mock is in place at module top so the factory's
|
|
// one-time capability scan sees available providers. createAgentJobHandler reads
|
|
// Bun.which at call time (inside the factory), so import timing is irrelevant —
|
|
// but the per-test beforeEach guarantees availability anyway.
|
|
const { createAgentJobHandler } = await import("./agent-jobs");
|
|
|
|
function post(body: unknown): Request {
|
|
return new Request("http://localhost/api/agents/jobs", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
const JOBS_URL = new URL("http://localhost/api/agents/jobs");
|
|
|
|
describe("POST /api/agents/jobs — reviewProfileId launch plumbing", () => {
|
|
test("forwards reviewProfileId into buildCommand config", async () => {
|
|
let seenConfig: Record<string, unknown> | undefined;
|
|
const handler = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => "http://localhost:1234",
|
|
getCwd: () => "/tmp",
|
|
async buildCommand(_provider, config) {
|
|
seenConfig = config;
|
|
// Return a no-op command that won't actually spawn anything useful.
|
|
return { command: ["true"], reviewProfileId: "user:security", reviewProfileLabel: "Security" };
|
|
},
|
|
});
|
|
|
|
const res = await handler.handle(post({ provider: "codex", reviewProfileId: "user:security" }), JOBS_URL);
|
|
|
|
expect(res?.status).toBe(201);
|
|
expect(seenConfig?.reviewProfileId).toBe("user:security");
|
|
handler.killAll();
|
|
});
|
|
|
|
test("rejects unknown fields with 400", async () => {
|
|
const handler = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => "http://localhost:1234",
|
|
getCwd: () => "/tmp",
|
|
async buildCommand() {
|
|
return { command: ["true"] };
|
|
},
|
|
});
|
|
|
|
const res = await handler.handle(post({ provider: "codex", reviewPrompt: "inline" }), JOBS_URL);
|
|
|
|
expect(res?.status).toBe(400);
|
|
const json = await res!.json();
|
|
expect(json.error).toContain("reviewPrompt");
|
|
handler.killAll();
|
|
});
|
|
|
|
test("absent reviewProfileId forwards no reviewProfileId in config", async () => {
|
|
let seenConfig: Record<string, unknown> | undefined;
|
|
let called = false;
|
|
const handler = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => "http://localhost:1234",
|
|
getCwd: () => "/tmp",
|
|
async buildCommand(_provider, config) {
|
|
called = true;
|
|
seenConfig = config;
|
|
return { command: ["true"] };
|
|
},
|
|
});
|
|
|
|
const res = await handler.handle(post({ provider: "codex" }), JOBS_URL);
|
|
|
|
expect(res?.status).toBe(201);
|
|
expect(called).toBe(true);
|
|
// No config keys at all → buildCommand receives undefined (review.ts maps
|
|
// that to builtin:default).
|
|
expect(seenConfig?.reviewProfileId).toBeUndefined();
|
|
handler.killAll();
|
|
});
|
|
|
|
test("launched job carries reviewProfileId and reviewProfileLabel stamped by buildCommand", async () => {
|
|
const handler = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => "http://localhost:1234",
|
|
getCwd: () => "/tmp",
|
|
async buildCommand() {
|
|
return {
|
|
command: ["true"],
|
|
reviewProfileId: "user:api-contracts",
|
|
reviewProfileLabel: "API Contracts",
|
|
};
|
|
},
|
|
});
|
|
|
|
const res = await handler.handle(post({ provider: "claude", reviewProfileId: "user:api-contracts" }), JOBS_URL);
|
|
|
|
expect(res?.status).toBe(201);
|
|
const { job } = await res!.json();
|
|
expect(job.reviewProfileId).toBe("user:api-contracts");
|
|
expect(job.reviewProfileLabel).toBe("API Contracts");
|
|
handler.killAll();
|
|
});
|
|
|
|
test("a job launched without a profile omits the profile fields entirely", async () => {
|
|
const handler = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => "http://localhost:1234",
|
|
getCwd: () => "/tmp",
|
|
async buildCommand() {
|
|
return { command: ["true"] };
|
|
},
|
|
});
|
|
|
|
const res = await handler.handle(post({ provider: "codex" }), JOBS_URL);
|
|
|
|
expect(res?.status).toBe(201);
|
|
const { job } = await res!.json();
|
|
expect("reviewProfileId" in job).toBe(false);
|
|
expect("reviewProfileLabel" in job).toBe(false);
|
|
handler.killAll();
|
|
});
|
|
});
|
|
|
|
describe("POST /api/agents/jobs — guide launch review plumbing (portable export)", () => {
|
|
test("launchReview is handed to onJobComplete but never appears on the broadcast job", async () => {
|
|
const launchReview = { rawPatch: "diff --git a/x b/x\n", gitRef: "HEAD", source: { kind: "local" as const } };
|
|
let seenMeta: Record<string, unknown> | undefined;
|
|
let done: (() => void) | undefined;
|
|
const completed = new Promise<void>((resolve) => { done = resolve; });
|
|
const handler = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => "http://localhost:1234",
|
|
getCwd: () => "/tmp",
|
|
async buildCommand() {
|
|
return { command: ["true"], launchReview };
|
|
},
|
|
async onJobComplete(_job, meta) {
|
|
seenMeta = meta as Record<string, unknown>;
|
|
done?.();
|
|
},
|
|
});
|
|
|
|
const res = await handler.handle(post({ provider: "guide" }), JOBS_URL);
|
|
expect(res?.status).toBe(201);
|
|
const { job } = await res!.json();
|
|
// The patch must not ride the SSE-broadcast job object.
|
|
expect("launchReview" in job).toBe(false);
|
|
await completed;
|
|
expect(seenMeta?.launchReview).toEqual(launchReview);
|
|
handler.killAll();
|
|
});
|
|
});
|