Files
backnotprop__plannotator/packages/server/annotate.test.ts
Michael Ramos ed6f44bf2e fix(release): tailscale gate exit codes and lease gating, conditional SIGHUP, informative guide validation error (#1286)
- annotate --tailscale publish failures now exit through
  annotateStartupFailureExitCode: exit 2 under a strict gate
  (--require-approval / --result-file), where exit 1 is reserved for "the
  reviewer did not approve, decision record published". Non-strict annotate
  and review keep the documented exit 1.
- the annotate client lease (auto-dismiss on abandonment) is forced off
  while tailnetPublished is set: --tailscale reads as local to the CLI
  predicate, but clients connect through the serve proxy, and a proxy
  disconnect longer than the grace would dismiss a live review. Same
  rationale as remote/shared sessions; decided at the single point both
  the /api/plan advert and the SSE endpoint read.
- the SIGHUP-to-process.exit route moved from an unconditional CLI-entry
  listener into enableTailscaleServe's success path, installed only once a
  serve mapping exists. Any SIGHUP listener overrides the ignored
  disposition nohup depends on, so plain sessions now keep zero listeners
  and "nohup plannotator review &" survives terminal close again;
  --tailscale sessions still tear their mapping down on HUP (exit 129).
- validateGuideOutput explains a fully-invalidated guide whose refs named
  files outside the changeset (count plus up to 3 example paths, with a
  pointer to the Commits panel) instead of the bare generic message; the
  generic message stays for genuinely structural emptiness. The informative
  error now flows through onJobComplete to the job failure card; Pi picks
  the change up via the vendored guide-review copy.
2026-08-12 14:04:59 -07:00

1765 lines
66 KiB
TypeScript

/**
* Annotate Server — end-to-end route wiring
*
* Boots the real annotate server and exercises /api/save-notes over HTTP. This
* is the regression guard for the original bug (#844): the route was missing
* from the annotate server, so POSTs fell through to the SPA HTML catch-all and
* the "Save to Obsidian" button silently failed. handleSaveNotes is unit-tested
* in shared-handlers.test.ts; this proves it is actually wired into the server
* and answers with JSON rather than the HTML page.
*
* NOTE: this can only run because apps/opencode-plugin/commands.test.ts injects
* its annotate-server stub via CommandDeps instead of a global `mock.module`.
* A module mock there would leak the stub into this file (Bun module mocks are
* process-global and cannot be unset).
*/
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "os";
import { join, resolve } from "path";
import { startAnnotateServer } from "./annotate";
import { deriveAnnotateHistorySlug } from "@plannotator/shared/annotate-history";
import { getPlannotatorDataDir } from "@plannotator/shared/data-dir";
const MINIMAL_HTML = "<html><body>Plannotator</body></html>";
describe("annotate server: /api/save-notes wiring", () => {
// Bind a random local port regardless of env left behind by sibling suites.
let savedPort: string | undefined;
let savedRemote: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
});
test("POST is served as JSON by the route, not the SPA HTML catch-all", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "test.md"),
htmlContent: MINIMAL_HTML,
});
try {
// Empty body keeps this focused on wiring; handler behaviour with real
// integrations is unit-tested in shared-handlers.test.ts. If the route
// were missing, this POST would fall to the catch-all and return the
// 200 text/html SPA page instead of JSON.
const response = await fetch(`${server.url}/api/save-notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
const json = await response.json();
expect(json).toHaveProperty("ok", true);
expect(json.results).toEqual({});
} finally {
server.stop();
}
});
test("an unmatched path still falls through to the SPA HTML", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "test.md"),
htmlContent: MINIMAL_HTML,
});
try {
const response = await fetch(`${server.url}/not-a-real-route`);
expect(response.headers.get("content-type")).toContain("text/html");
expect(await response.text()).toContain("Plannotator");
} finally {
server.stop();
}
});
});
describe("annotate server: /api/share-html symlink containment", () => {
let savedPort: string | undefined;
let savedRemote: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
});
// Regression: /api/share-html read the requested file through a lexical-only
// containment check, so a symlinked *.html inside the doc directory pointing
// outside it leaked the target's contents into the share payload. (Completes
// the #927 symlink fix, which hardened the asset sinks but missed this one.)
test("rejects a symlinked .html that escapes the document directory", async () => {
const docDir = mkdtempSync(join(tmpdir(), "plannotator-sharehtml-"));
const secretDir = mkdtempSync(join(tmpdir(), "plannotator-secret-"));
const secretPath = join(secretDir, "secret.html");
writeFileSync(secretPath, "SECRET_OUTSIDE_CONTENT", "utf-8");
symlinkSync(secretPath, join(docDir, "evil.html"));
const pagePath = join(docDir, "page.html");
writeFileSync(pagePath, MINIMAL_HTML, "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: MINIMAL_HTML,
renderHtml: true,
});
try {
const response = await fetch(
`${server.url}/api/share-html?path=${encodeURIComponent(join(docDir, "evil.html"))}`,
);
expect(response.status).toBe(403);
expect(await response.text()).not.toContain("SECRET_OUTSIDE_CONTENT");
} finally {
server.stop();
}
});
});
describe("annotate server: source save", () => {
let savedPort: string | undefined;
let savedRemote: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
});
test("recreates a deleted single-file source on save", async () => {
const docDir = mkdtempSync(join(tmpdir(), "plannotator-source-save-"));
const sourcePath = join(docDir, "source.md");
writeFileSync(sourcePath, "Before\r\n", "utf-8");
const server = await startAnnotateServer({
markdown: "Before\r\n",
filePath: sourcePath,
htmlContent: MINIMAL_HTML,
});
try {
const planResponse = await fetch(`${server.url}/api/plan`);
const plan = await planResponse.json() as { sourceSave?: { hash: string; mtimeMs: number; eol: "lf" | "crlf" | "mixed" | "none" } };
if (!plan.sourceSave) throw new Error("expected source save metadata");
unlinkSync(sourcePath);
const response = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: "After\n",
baseHash: plan.sourceSave.hash,
baseMtimeMs: plan.sourceSave.mtimeMs,
baseEol: plan.sourceSave.eol,
allowMissingBase: true,
}),
});
expect(response.status).toBe(200);
expect(readFileSync(sourcePath, "utf-8")).toBe("After\r\n");
} finally {
server.stop();
}
});
test("recreates a missing single-file source when the session started for that path", async () => {
const docDir = mkdtempSync(join(tmpdir(), "plannotator-source-save-missing-start-"));
const sourcePath = join(docDir, "source.md");
const server = await startAnnotateServer({
markdown: "Recovered\n",
filePath: sourcePath,
htmlContent: MINIMAL_HTML,
});
try {
const planResponse = await fetch(`${server.url}/api/plan`);
const plan = await planResponse.json() as {
plan?: string;
sourceSave?: {
enabled?: boolean;
path?: string;
hash: string;
mtimeMs: number;
eol: "lf" | "crlf" | "mixed" | "none";
};
};
expect(plan.plan).toBe("Recovered\n");
expect(plan.sourceSave?.enabled).toBe(true);
expect(plan.sourceSave?.path).toBe(join(realpathSync(docDir), "source.md"));
const response = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: "Recovered\n",
baseHash: plan.sourceSave!.hash,
baseMtimeMs: plan.sourceSave!.mtimeMs,
baseEol: plan.sourceSave!.eol,
allowMissingBase: true,
}),
});
expect(response.status).toBe(200);
expect(readFileSync(sourcePath, "utf-8")).toBe("Recovered\n");
} finally {
server.stop();
}
});
test("verifies a saved single-file source opened through a symlink", async () => {
const linkDir = mkdtempSync(join(tmpdir(), "plannotator-source-link-"));
const realDir = mkdtempSync(join(tmpdir(), "plannotator-source-real-"));
const realPath = join(realDir, "AGENTS.md");
const linkPath = join(linkDir, "CLAUDE.md");
writeFileSync(realPath, "Before\n", "utf-8");
symlinkSync(realPath, linkPath);
const server = await startAnnotateServer({
markdown: "Before\n",
filePath: linkPath,
htmlContent: MINIMAL_HTML,
});
try {
const planResponse = await fetch(`${server.url}/api/plan`);
const plan = await planResponse.json() as {
sourceSave?: {
enabled?: boolean;
path?: string;
hash: string;
mtimeMs: number;
eol: "lf" | "crlf" | "mixed" | "none";
};
};
expect(plan.sourceSave?.enabled).toBe(true);
expect(plan.sourceSave?.path).toBe(realpathSync(realPath));
const saveResponse = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: "After\n",
baseHash: plan.sourceSave!.hash,
baseMtimeMs: plan.sourceSave!.mtimeMs,
baseEol: plan.sourceSave!.eol,
allowMissingBase: true,
}),
});
expect(saveResponse.status).toBe(200);
const probeResponse = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(plan.sourceSave!.path!)}`);
expect(probeResponse.status).toBe(200);
const probe = await probeResponse.json() as { markdown?: string; sourceSave?: { enabled?: boolean; path?: string } };
expect(probe.markdown).toBe("After\n");
expect(probe.sourceSave?.enabled).toBe(true);
expect(probe.sourceSave?.path).toBe(realpathSync(realPath));
} finally {
server.stop();
}
});
test("recreates a deleted folder source only after Plannotator opened it", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-source-save-"));
const openedPath = join(folderPath, "opened.md");
const neverOpenedPath = join(folderPath, "never-opened.md");
writeFileSync(openedPath, "Before\n", "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
});
try {
const docResponse = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(openedPath)}`);
const doc = await docResponse.json() as { sourceSave?: { path: string; hash: string; mtimeMs: number; eol: "lf" | "crlf" | "mixed" | "none" } };
if (!doc.sourceSave) throw new Error("expected folder source save metadata");
unlinkSync(openedPath);
const recreateOpened = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: doc.sourceSave.path,
text: "After\n",
baseHash: doc.sourceSave.hash,
baseMtimeMs: doc.sourceSave.mtimeMs,
baseEol: doc.sourceSave.eol,
allowMissingBase: true,
}),
});
expect(recreateOpened.status).toBe(200);
expect(readFileSync(openedPath, "utf-8")).toBe("After\n");
const recreateNeverOpened = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: neverOpenedPath,
text: "Nope\n",
baseHash: "sha256:not-a-real-opened-file",
allowMissingBase: true,
}),
});
expect(recreateNeverOpened.status).toBe(403);
} finally {
server.stop();
}
});
test("recreates a deleted folder source opened through a relative base link", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-relative-source-save-"));
const subDir = join(folderPath, "sub");
mkdirSync(subDir, { recursive: true });
const linkedPath = join(folderPath, "linked.md");
writeFileSync(join(subDir, "a.md"), "[linked](../linked.md)\n", "utf-8");
writeFileSync(linkedPath, "Before\n", "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
});
try {
const docResponse = await fetch(
`${server.url}/api/doc?path=${encodeURIComponent("../linked.md")}&base=${encodeURIComponent(subDir)}`,
);
const doc = await docResponse.json() as { sourceSave?: { path: string; hash: string; mtimeMs: number; eol: "lf" | "crlf" | "mixed" | "none" } };
if (!doc.sourceSave) throw new Error("expected folder source save metadata");
unlinkSync(linkedPath);
const response = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: doc.sourceSave.path,
text: "After\n",
baseHash: doc.sourceSave.hash,
baseMtimeMs: doc.sourceSave.mtimeMs,
baseEol: doc.sourceSave.eol,
allowMissingBase: true,
}),
});
expect(response.status).toBe(200);
expect(readFileSync(linkedPath, "utf-8")).toBe("After\n");
} finally {
server.stop();
}
});
test("serves a folder source through the real root when the folder is symlinked", async () => {
const realFolder = mkdtempSync(join(tmpdir(), "plannotator-folder-real-"));
const linkParent = mkdtempSync(join(tmpdir(), "plannotator-folder-link-"));
const linkFolder = join(linkParent, "docs");
const realPath = join(realFolder, "note.md");
writeFileSync(realPath, "Before\n", "utf-8");
symlinkSync(realFolder, linkFolder);
const server = await startAnnotateServer({
markdown: "",
filePath: linkFolder,
folderPath: linkFolder,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
});
try {
const docResponse = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(realpathSync(realPath))}`);
expect(docResponse.status).toBe(200);
const doc = await docResponse.json() as { markdown?: string; sourceSave?: { enabled?: boolean; path?: string } };
expect(doc.markdown).toBe("Before\n");
expect(doc.sourceSave?.enabled).toBe(true);
expect(doc.sourceSave?.path).toBe(realpathSync(realPath));
} finally {
server.stop();
}
});
test("folder annotate doc lookup stays scoped to the selected folder", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-doc-scope-"));
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent("package.json")}&base=${encodeURIComponent(folderPath)}`);
expect(response.status).toBe(404);
const existsResponse = await fetch(`${server.url}/api/doc/exists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: ["package.json"], base: folderPath }),
});
expect(existsResponse.status).toBe(200);
const existsData = await existsResponse.json() as { results?: Record<string, { status?: string }> };
expect(existsData.results?.["package.json"]?.status).toBe("missing");
} finally {
server.stop();
}
});
test("does not recreate a deleted folder source from draft state alone", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-draft-source-save-"));
const deletedPath = join(realpathSync(folderPath), "deleted.md");
const sourceSave = {
enabled: true,
kind: "local-text-file",
scope: "folder-file",
path: deletedPath,
basename: "deleted.md",
language: "markdown",
hash: "sha256:draft-base",
mtimeMs: 0,
size: 0,
eol: "lf",
};
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
});
try {
const draftResponse = await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
annotations: [],
globalAttachments: [],
editedDocuments: [{
key: `file:${deletedPath}`,
sourceSave,
sessionOpenText: "",
diskBaseline: "",
currentText: "Recovered\n",
}],
ts: Date.now(),
}),
});
expect(draftResponse.status).toBe(200);
const response = await fetch(`${server.url}/api/source/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: deletedPath,
text: "Recovered\n",
baseHash: sourceSave.hash,
baseEol: "lf",
allowMissingBase: true,
}),
});
expect(response.status).toBe(403);
expect(existsSync(deletedPath)).toBe(false);
} finally {
await fetch(`${server.url}/api/draft`, { method: "DELETE" }).catch(() => {});
server.stop();
}
});
});
describe("annotate server: folder annotate history", () => {
let savedPort: string | undefined;
let savedRemote: string | undefined;
let savedHistoryFlag: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
savedHistoryFlag = process.env.PLANNOTATOR_ANNOTATE_HISTORY;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
// Force the toggle on for every test but the one that explicitly flips it
// off — a real ~/.plannotator/config.json on the machine running these
// tests must never change the outcome.
process.env.PLANNOTATOR_ANNOTATE_HISTORY = "1";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
if (savedHistoryFlag === undefined) delete process.env.PLANNOTATOR_ANNOTATE_HISTORY;
else process.env.PLANNOTATOR_ANNOTATE_HISTORY = savedHistoryFlag;
});
// Every test uses its own project namespace (history lives in the real
// ~/.plannotator data dir, same as storage.test.ts) so runs never collide.
// Every minted name is tracked and its history directory removed in
// afterAll below — this suite must never leave residue in the real data
// dir (including the stray non-directory file the "unwritable data dir"
// test deliberately plants inside its own project's history dir; removing
// the project dir recursively takes that with it).
const mintedProjects: string[] = [];
function uniqueProject(label: string): string {
const project = `_annotate_history_test_${label}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
mintedProjects.push(project);
return project;
}
afterAll(() => {
const historyDir = join(getPlannotatorDataDir(), "history");
for (const project of mintedProjects) {
rmSync(join(historyDir, project), { recursive: true, force: true });
}
});
test("first open mints one version; reopening in the same session is memoized (no re-snapshot even if the file changes on disk)", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-first-open-"));
const docPath = join(folderPath, "note.md");
writeFileSync(docPath, "V1\n", "utf-8");
const project = uniqueProject("first-open");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const first = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const firstJson = await first.json() as {
markdown?: string;
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
expect(firstJson.markdown).toBe("V1\n");
expect(firstJson.previousPlan).toBeNull();
expect(firstJson.versionInfo).toEqual({ version: 1, totalVersions: 1, project });
// diffCurrent is intentionally not propagated on the folder /api/doc
// path — it always equals the doc's own markdown and the client never
// reads it (unlike single-file /api/plan, which keeps it for shape parity).
expect("diffCurrent" in firstJson).toBe(false);
// Change the file on disk between opens — a re-run of the pipeline
// would mint version 2. Memoization must prevent that.
writeFileSync(docPath, "V2\n", "utf-8");
const second = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const secondJson = await second.json() as {
markdown?: string;
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
// The live document content is always read fresh from disk...
expect(secondJson.markdown).toBe("V2\n");
// ...but the history snapshot/diff fields stay exactly what first-open computed.
expect(secondJson.previousPlan).toBeNull();
expect(secondJson.versionInfo).toEqual({ version: 1, totalVersions: 1, project });
const versions = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
const versionsJson = await versions.json() as { versions: unknown[] };
expect(versionsJson.versions).toHaveLength(1);
} finally {
server.stop();
}
});
test("content matching the latest stored version dedupes (mints nothing) and still serves correct previous-version fields", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-dedupe-"));
const docPath = join(folderPath, "note.md");
const project = uniqueProject("dedupe");
// Seed history for this exact path via the single-file flow, then make
// the folder file's on-disk content match that stored version exactly.
const seedServer = await startAnnotateServer({
markdown: "Same\n",
filePath: docPath,
htmlContent: MINIMAL_HTML,
mode: "annotate",
project,
});
seedServer.stop();
writeFileSync(docPath, "Same\n", "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const json = await response.json() as {
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
// Dedup keeps it at version 1 — the folder open did not mint version 2.
expect(json.versionInfo).toEqual({ version: 1, totalVersions: 1, project });
expect(json.previousPlan).toBeNull();
const versions = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
const versionsJson = await versions.json() as { versions: unknown[] };
expect(versionsJson.versions).toHaveLength(1);
} finally {
server.stop();
}
});
test("cross-mode slug continuity: a version saved via single-file flow is served as the baseline when a folder session opens the same path", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-cross-mode-"));
const docPath = join(folderPath, "note.md");
const project = uniqueProject("cross-mode");
// Single-file session saves "V1" as version 1 for this exact resolved path.
const seedServer = await startAnnotateServer({
markdown: "V1\n",
filePath: docPath,
htmlContent: MINIMAL_HTML,
mode: "annotate",
project,
});
seedServer.stop();
// The folder session reads different content off disk, so it mints version 2.
writeFileSync(docPath, "V2\n", "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const json = await response.json() as {
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
expect(json.previousPlan).toBe("V1\n");
expect(json.versionInfo).toEqual({ version: 2, totalVersions: 2, project });
const versionOne = await fetch(`${server.url}/api/plan/version?path=${encodeURIComponent(docPath)}&v=1`);
const versionOneJson = await versionOne.json() as { plan?: string };
expect(versionOneJson.plan).toBe("V1\n");
} finally {
server.stop();
}
});
test("first-ever open of a never-annotated path carries no previous version but does report version 1 of 1 (parity with single-file)", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-never-seen-"));
const docPath = join(folderPath, "note.md");
writeFileSync(docPath, "Fresh\n", "utf-8");
const project = uniqueProject("never-seen");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const json = await response.json() as {
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
expect(json.previousPlan).toBeNull();
expect(json.versionInfo).toEqual({ version: 1, totalVersions: 1, project });
// diffCurrent is intentionally not propagated on the folder /api/doc path.
expect("diffCurrent" in json).toBe(false);
} finally {
server.stop();
}
});
test("config toggle off: no snapshot, no diff fields, doc still serves", async () => {
process.env.PLANNOTATOR_ANNOTATE_HISTORY = "0";
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-toggle-off-"));
const docPath = join(folderPath, "note.md");
writeFileSync(docPath, "Content\n", "utf-8");
const project = uniqueProject("toggle-off");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
expect(response.status).toBe(200);
const json = await response.json() as Record<string, unknown>;
expect(json.markdown).toBe("Content\n");
expect("previousPlan" in json).toBe(false);
expect("versionInfo" in json).toBe(false);
expect("diffCurrent" in json).toBe(false);
const versions = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
const versionsJson = await versions.json() as { slug: string | null; versions: unknown[] };
expect(versionsJson).toEqual({ project, slug: null, versions: [] });
} finally {
server.stop();
}
});
test("ineligible file type (HTML) serves as today with no snapshot", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-html-"));
const docPath = join(folderPath, "page.html");
writeFileSync(docPath, "<html><body>Hi</body></html>", "utf-8");
const project = uniqueProject("html");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
expect(response.status).toBe(200);
const json = await response.json() as Record<string, unknown>;
expect(json.renderAs).toBe("html");
expect("previousPlan" in json).toBe(false);
expect("versionInfo" in json).toBe(false);
expect("diffCurrent" in json).toBe(false);
const versions = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
const versionsJson = await versions.json() as { slug: string | null; versions: unknown[] };
expect(versionsJson).toEqual({ project, slug: null, versions: [] });
} finally {
server.stop();
}
});
test("eligibility matches the single-file plain-text set: .mdx mints a snapshot on first open", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-mdx-"));
const docPath = join(folderPath, "note.mdx");
writeFileSync(docPath, "MDX content\n", "utf-8");
const project = uniqueProject("mdx");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const json = await response.json() as {
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
expect(json.previousPlan).toBeNull();
expect(json.versionInfo).toEqual({ version: 1, totalVersions: 1, project });
} finally {
server.stop();
}
});
test("cross-mode continuity for config formats: a .yaml with single-file history diffs when opened via its folder", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-yaml-"));
const docPath = join(folderPath, "config.yaml");
const project = uniqueProject("yaml");
// Single-file session saves "a: 1" as version 1 for this exact path.
const seedServer = await startAnnotateServer({
markdown: "a: 1\n",
filePath: docPath,
htmlContent: MINIMAL_HTML,
mode: "annotate",
project,
});
seedServer.stop();
// The folder session reads different content off disk, so it mints version 2.
writeFileSync(docPath, "a: 2\n", "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
// `doc=1` mirrors the file browser: it forces annotatable plain-text
// rendering for extensions that overlap CODE_FILE_REGEX (.yaml, .json…).
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}&doc=1`);
const json = await response.json() as {
previousPlan?: string | null;
versionInfo?: { version: number; totalVersions: number; project: string };
};
expect(json.previousPlan).toBe("a: 1\n");
expect(json.versionInfo).toEqual({ version: 2, totalVersions: 2, project });
} finally {
server.stop();
}
});
test(".env stays ineligible: no snapshot is minted even though .env.example would be", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-env-"));
const docPath = join(folderPath, ".env");
writeFileSync(docPath, "SECRET=1\n", "utf-8");
const project = uniqueProject("env");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}&doc=1`);
const json = await response.json() as Record<string, unknown>;
// Whatever shape /api/doc answers with (.env is not annotatable, so it
// is never served as a document), no history fields may appear and no
// snapshot may be written.
expect("previousPlan" in json).toBe(false);
expect("versionInfo" in json).toBe(false);
const versions = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
const versionsJson = await versions.json() as { slug: string | null; versions: unknown[] };
expect(versionsJson).toEqual({ project, slug: null, versions: [] });
} finally {
server.stop();
}
});
test("an unwritable history directory degrades to a plain render, no error propagates", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-unwritable-"));
const docPath = join(folderPath, "note.md");
writeFileSync(docPath, "Content\n", "utf-8");
const project = uniqueProject("unwritable");
// Block the exact history directory the pipeline will try to mkdir by
// pre-creating a plain FILE at that path — mkdirSync(recursive) throws
// when a target segment exists and is not a directory, on every platform.
const slug = deriveAnnotateHistorySlug(docPath);
const historyProjectDir = join(getPlannotatorDataDir(), "history", project);
mkdirSync(historyProjectDir, { recursive: true });
writeFileSync(join(historyProjectDir, slug), "not a directory", "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
const response = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
expect(response.status).toBe(200);
const json = await response.json() as Record<string, unknown>;
expect(json.markdown).toBe("Content\n");
expect("previousPlan" in json).toBe(false);
expect("versionInfo" in json).toBe(false);
expect("diffCurrent" in json).toBe(false);
} finally {
server.stop();
}
});
test("version endpoints: path param serves that file's versions; without path, single-session binding is unchanged", async () => {
const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-history-endpoints-"));
const docPath = join(folderPath, "note.md");
writeFileSync(docPath, "V1\n", "utf-8");
const project = uniqueProject("endpoints");
const server = await startAnnotateServer({
markdown: "",
filePath: folderPath,
folderPath,
mode: "annotate-folder",
htmlContent: MINIMAL_HTML,
project,
});
try {
// No history yet for this path: version endpoints report empty, not an error.
const versionsBefore = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
expect(await versionsBefore.json()).toEqual({ project, slug: null, versions: [] });
const versionBefore = await fetch(`${server.url}/api/plan/version?path=${encodeURIComponent(docPath)}&v=1`);
expect(versionBefore.status).toBe(404);
expect(await versionBefore.json()).toEqual({ error: "No version history" });
// Open the file so history is initialized this session.
await fetch(`${server.url}/api/doc?path=${encodeURIComponent(docPath)}`);
const versionsAfter = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(docPath)}`);
const versionsAfterJson = await versionsAfter.json() as { slug: string | null; versions: { version: number }[] };
expect(versionsAfterJson.slug).not.toBeNull();
expect(versionsAfterJson.versions).toHaveLength(1);
const versionAfter = await fetch(`${server.url}/api/plan/version?path=${encodeURIComponent(docPath)}&v=1`);
expect(versionAfter.status).toBe(200);
expect(await versionAfter.json()).toEqual({ plan: "V1\n", version: 1 });
// A path outside the folder root is rejected the same way /api/doc rejects it.
const outsidePath = join(realpathSync(tmpdir()), "outside.md");
const deniedVersions = await fetch(`${server.url}/api/plan/versions?path=${encodeURIComponent(outsidePath)}`);
expect(deniedVersions.status).toBe(403);
const deniedVersion = await fetch(`${server.url}/api/plan/version?path=${encodeURIComponent(outsidePath)}&v=1`);
expect(deniedVersion.status).toBe(403);
// Without a path param at all, behavior is exactly today's: this
// session has no single-file annotateHistory binding (it's a folder
// session), so both endpoints report "no history" as before.
const noPathVersions = await fetch(`${server.url}/api/plan/versions`);
expect(await noPathVersions.json()).toEqual({ project, slug: null, versions: [] });
const noPathVersion = await fetch(`${server.url}/api/plan/version?v=1`);
expect(noPathVersion.status).toBe(404);
} finally {
server.stop();
}
});
});
describe("annotate server: approval notes", () => {
let savedPort: string | undefined;
let savedRemote: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
});
test("returns the explicit approval-notes capability", async () => {
for (const approvalNotesSupported of [true, false]) {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "approval-capability.md"),
htmlContent: MINIMAL_HTML,
approvalNotesSupported,
});
try {
const response = await fetch(`${server.url}/api/plan`);
const plan = await response.json() as { approvalNotesSupported?: boolean };
expect(plan.approvalNotesSupported).toBe(approvalNotesSupported);
} finally {
server.stop();
}
}
});
test("preserves feedback and annotations on approval", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "approval-notes.md"),
htmlContent: MINIMAL_HTML,
approvalNotesSupported: true,
});
try {
const response = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
feedback: "Keep the retry bounded.",
annotations: [{ id: "a1" }],
draftGeneration: 3,
}),
});
expect(response.status).toBe(200);
expect(await server.waitForDecision()).toEqual({
approved: true,
feedback: "Keep the retry bounded.",
annotations: [{ id: "a1" }],
});
} finally {
server.stop();
}
});
// Approve-with-notes must anchor exactly where Send Feedback would. Dropping
// the message scope made the notes land on the last message rather than the
// one the reviewer picked in a multi-message annotate-last session.
test("forwards the message scope on approval", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "approval-message-scope.md"),
htmlContent: MINIMAL_HTML,
approvalNotesSupported: true,
});
try {
const response = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
feedback: "Scope this to the picked message.",
annotations: [],
selectedMessageId: "message-2",
feedbackScope: "messages",
}),
});
expect(response.status).toBe(200);
expect(await server.waitForDecision()).toEqual({
approved: true,
feedback: "Scope this to the picked message.",
annotations: [],
selectedMessageId: "message-2",
feedbackScope: "messages",
});
} finally {
server.stop();
}
});
test("keeps bodyless approval compatible", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "approval-bodyless.md"),
htmlContent: MINIMAL_HTML,
});
try {
const response = await fetch(`${server.url}/api/approve`, { method: "POST" });
expect(response.status).toBe(200);
expect(await server.waitForDecision()).toEqual({
approved: true,
feedback: "",
annotations: [],
});
} finally {
server.stop();
}
});
test("rejects malformed or wrong-type approval bodies without resolving", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "approval-invalid.md"),
htmlContent: MINIMAL_HTML,
});
try {
const decision = server.waitForDecision();
const malformed = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{",
});
expect(malformed.status).toBe(400);
expect(await Promise.race([decision.then(() => "resolved"), Bun.sleep(25).then(() => "pending")])).toBe("pending");
const wrongType = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: 42, annotations: [] }),
});
expect(wrongType.status).toBe(400);
expect(await Promise.race([decision.then(() => "resolved"), Bun.sleep(25).then(() => "pending")])).toBe("pending");
await fetch(`${server.url}/api/approve`, { method: "POST" });
expect(await decision).toEqual({ approved: true, feedback: "", annotations: [] });
} finally {
server.stop();
}
});
});
describe("annotate server: client lease", () => {
let savedPort: string | undefined;
let savedRemote: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
});
/**
* Connect to the client-lease stream and wait for its first byte (the
* ready comment). Returns a `disconnect()` that aborts the underlying
* fetch — plain `reader.cancel()` only stops local reads and does not
* propagate a close to the server's `ReadableStream.cancel()`, whereas
* aborting the request closes the connection the way an abandoned browser
* tab actually would.
*/
async function connectClientLease(url: string): Promise<{ disconnect: () => Promise<void> }> {
const controller = new AbortController();
const response = await fetch(`${url}/api/annotate/client-lease`, { signal: controller.signal });
expect(response.status).toBe(200);
const reader = response.body!.getReader();
const first = await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Timed out waiting for ready comment")), 1000);
}),
]);
expect(first.done).toBe(false);
return {
disconnect: async () => {
controller.abort();
await reader.cancel().catch(() => {});
},
};
}
/**
* Track whether a promise has settled without racing it against a timer —
* a `Promise.race` between an already-resolved sentinel and a promise that
* may or may not have settled is nondeterministic. Attaching `.then` up
* front and reading a flag afterward is reliable regardless of timing.
*/
function trackSettled<T>(promise: Promise<T>): () => boolean {
let settled = false;
promise.then(() => {
settled = true;
});
return () => settled;
}
test("advertises the effective client-lease capability in /api/plan", async () => {
for (const clientLeaseSupported of [true, false]) {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-capability.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported,
});
try {
const response = await fetch(`${server.url}/api/plan`);
const plan = await response.json() as { clientLease?: { enabled: boolean; reconnectGraceMs?: number } };
if (clientLeaseSupported) {
expect(plan.clientLease).toEqual({ enabled: true, reconnectGraceMs: 30_000 });
} else {
expect(plan.clientLease).toEqual({ enabled: false });
}
} finally {
server.stop();
}
}
});
test("a tailnet-published session neither advertises nor serves the lease even when the CLI predicate allowed it", async () => {
// --tailscale forces local mode, so the CLI-side predicate reads the
// session as local and passes clientLeaseSupported: true — but clients
// reach it through the serve proxy, and a proxy disconnect longer than
// the grace would auto-dismiss a live review. The server must force the
// capability off, exactly like a remote session.
const savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
const sandboxDataDir = mkdtempSync(join(tmpdir(), "plannotator-lease-tailnet-"));
process.env.PLANNOTATOR_DATA_DIR = sandboxDataDir;
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-tailnet.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported: true,
tailnetPublished: true,
});
try {
const response = await fetch(`${server.url}/api/plan`);
const plan = await response.json() as { clientLease?: { enabled: boolean } };
expect(plan.clientLease).toEqual({ enabled: false });
const stream = await fetch(`${server.url}/api/annotate/client-lease`);
expect(stream.status).toBe(404);
} finally {
server.stop();
if (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
rmSync(sandboxDataDir, { recursive: true, force: true });
}
});
test("returns 404 for the client-lease stream when the capability is disabled", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-disabled.md"),
htmlContent: MINIMAL_HTML,
});
try {
const response = await fetch(`${server.url}/api/annotate/client-lease`);
expect(response.status).toBe(404);
} finally {
server.stop();
}
});
test("resolves the decision as dismissed after the last client disconnects and the grace period elapses", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-expiry.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported: true,
clientLeaseTestOverrides: { graceMs: 50 },
});
try {
const decision = server.waitForDecision();
const isSettled = trackSettled(decision);
const client = await connectClientLease(server.url);
// Still connected — no expiry.
await Bun.sleep(20);
expect(isSettled()).toBe(false);
await client.disconnect();
expect(await decision).toEqual({ feedback: "", annotations: [], exit: true });
} finally {
server.stop();
}
});
test("a reconnect before the grace deadline cancels the pending expiry", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-reconnect.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported: true,
clientLeaseTestOverrides: { graceMs: 80 },
});
try {
const decision = server.waitForDecision();
const isSettled = trackSettled(decision);
const firstClient = await connectClientLease(server.url);
await firstClient.disconnect();
// Reconnect well before the 80ms grace deadline.
await Bun.sleep(20);
const secondClient = await connectClientLease(server.url);
// Even past the original deadline, the reconnect cancelled the pending expiry.
await Bun.sleep(100);
expect(isSettled()).toBe(false);
// A fresh disconnect starts its own full grace window.
await secondClient.disconnect();
expect(await decision).toEqual({ feedback: "", annotations: [], exit: true });
} finally {
server.stop();
}
});
test("an explicit approval wins over a later client-lease expiry", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-explicit-decision.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported: true,
clientLeaseTestOverrides: { graceMs: 60 },
});
try {
const client = await connectClientLease(server.url);
const approve = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "Looks good.", annotations: [] }),
});
expect(approve.status).toBe(200);
expect(await server.waitForDecision()).toEqual({
approved: true,
feedback: "Looks good.",
annotations: [],
});
// Disconnecting after the explicit decision must not overwrite it once
// the grace period elapses — the approval already cancelled tracking.
await client.disconnect();
await Bun.sleep(120);
expect(await server.waitForDecision()).toEqual({
approved: true,
feedback: "Looks good.",
annotations: [],
});
} finally {
server.stop();
}
});
test("a decision arriving after the lease expired is rejected instead of reported as applied", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-late-decision.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported: true,
clientLeaseTestOverrides: { graceMs: 30 },
});
try {
const client = await connectClientLease(server.url);
await client.disconnect();
expect(await server.waitForDecision()).toEqual({
feedback: "",
annotations: [],
exit: true,
});
// A tab that never saw the dismissal must not be told its decision was
// applied: the caller already received `dismissed`.
for (const [path, init] of [
[
"/api/approve",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "Looks good.", annotations: [] }),
},
],
[
"/api/feedback",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "Please change this.", annotations: [] }),
},
],
["/api/exit", { method: "POST" }],
] as const) {
const response = await fetch(`${server.url}${path}`, init);
expect(response.status).toBe(409);
}
expect(await server.waitForDecision()).toEqual({
feedback: "",
annotations: [],
exit: true,
});
} finally {
server.stop();
}
});
test("stopping the server ends live lease streams", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "client-lease-stop.md"),
htmlContent: MINIMAL_HTML,
gate: true,
approvalNotesSupported: true,
clientLeaseSupported: true,
});
const response = await fetch(`${server.url}/api/annotate/client-lease`);
expect(response.status).toBe(200);
const reader = response.body!.getReader();
const first = await reader.read();
expect(new TextDecoder().decode(first.value)).toBe(": ready\n\n");
server.stop();
// The stream must complete rather than stay open on a server that is gone.
const next = await reader.read();
expect(next.done).toBe(true);
});
});
describe("annotate server: durable submit records (#678)", () => {
// The decision promise's consumer (the invoking CLI/agent) can time out
// before the reviewer submits; the submit then settled the promise with
// nobody listening, deleted the draft, and the feedback existed nowhere.
// These tests pin the fix: a durable record is written to
// history/{project}/{slug}/submissions/ BEFORE the draft is deleted, the
// annotate-history opt-out suppresses the record (stateless sessions keep
// legacy behavior), and a failed durable write keeps the draft behind as
// the recovery copy.
let savedPort: string | undefined;
let savedRemote: string | undefined;
let savedHistoryFlag: string | undefined;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
savedHistoryFlag = process.env.PLANNOTATOR_ANNOTATE_HISTORY;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
// Force the toggle on unless a test explicitly flips it off — a real
// ~/.plannotator/config.json must never change the outcome.
process.env.PLANNOTATOR_ANNOTATE_HISTORY = "1";
});
afterEach(() => {
if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = savedRemote;
if (savedHistoryFlag === undefined) delete process.env.PLANNOTATOR_ANNOTATE_HISTORY;
else process.env.PLANNOTATOR_ANNOTATE_HISTORY = savedHistoryFlag;
});
// History lives in the real data dir (DATA_DIR is cached at module import),
// so each test uses a unique project namespace and afterAll removes it.
const mintedProjects: string[] = [];
function uniqueProject(label: string): string {
const project = `_annotate_submission_test_${label}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
mintedProjects.push(project);
return project;
}
afterAll(() => {
const historyDir = join(getPlannotatorDataDir(), "history");
for (const project of mintedProjects) {
rmSync(join(historyDir, project), { recursive: true, force: true });
}
});
function submissionsDir(project: string, docPath: string): string {
return join(
getPlannotatorDataDir(),
"history",
project,
deriveAnnotateHistorySlug(resolve(docPath)),
"submissions",
);
}
// The project name is baked into the markdown so every test gets a unique
// content-hashed draft key — drafts live in the real data dir and identical
// markdown across tests would collide on one draft file.
async function startServer(project: string, docPath: string) {
const markdown = `# Doc ${project}\n\nBody\n`;
writeFileSync(docPath, markdown, "utf-8");
return startAnnotateServer({
markdown,
filePath: docPath,
htmlContent: MINIMAL_HTML,
project,
});
}
test("feedback submit writes a durable record and only then deletes the draft", async () => {
const dir = mkdtempSync(join(tmpdir(), "plannotator-submit-durable-"));
const docPath = join(dir, "doc.md");
const project = uniqueProject("feedback");
const server = await startServer(project, docPath);
try {
// Auto-saved draft exists before submit (the recovery copy).
const saved = await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotations: [{ id: "a1" }] }),
});
expect(saved.status).toBe(200);
const response = await fetch(`${server.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
feedback: "## Feedback\n\nPlease fix X in the second paragraph.",
annotations: [{ id: "a1" }],
}),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
// Durable record: one markdown file next to the file's version history.
const recordDir = submissionsDir(project, docPath);
const records = readdirSync(recordDir).filter((f) => f.endsWith(".md"));
expect(records.length).toBe(1);
const content = readFileSync(join(recordDir, records[0]), "utf-8");
expect(content).toContain("Please fix X in the second paragraph.");
expect(content).toContain("- Decision: feedback");
expect(content).toContain(`- Source: ${resolve(docPath)}`);
// Draft is gone AFTER the record exists.
const draft = await fetch(`${server.url}/api/draft`);
expect(draft.status).toBe(404);
} finally {
server.stop();
rmSync(dir, { recursive: true, force: true });
}
});
test("approve with notes persists a record; a bare approve writes nothing", async () => {
const dir = mkdtempSync(join(tmpdir(), "plannotator-submit-approve-"));
// Approve-with-notes carries user content -> record.
const notesDoc = join(dir, "notes.md");
const notesProject = uniqueProject("approve-notes");
const notesServer = await startServer(notesProject, notesDoc);
try {
const response = await fetch(`${notesServer.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "LGTM, but rename the helper.", annotations: [] }),
});
expect(response.status).toBe(200);
const recordDir = submissionsDir(notesProject, notesDoc);
const records = readdirSync(recordDir).filter((f) => f.endsWith(".md"));
expect(records.length).toBe(1);
const content = readFileSync(join(recordDir, records[0]), "utf-8");
expect(content).toContain("LGTM, but rename the helper.");
expect(content).toContain("- Decision: approved (with notes)");
} finally {
notesServer.stop();
}
// Bare approve is contentless -> nothing to persist.
const bareDoc = join(dir, "bare.md");
const bareProject = uniqueProject("approve-bare");
const bareServer = await startServer(bareProject, bareDoc);
try {
const response = await fetch(`${bareServer.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(existsSync(submissionsDir(bareProject, bareDoc))).toBe(false);
} finally {
bareServer.stop();
rmSync(dir, { recursive: true, force: true });
}
});
test("annotateHistory disabled: no content is written and the draft is deleted (legacy behavior)", async () => {
process.env.PLANNOTATOR_ANNOTATE_HISTORY = "0";
const dir = mkdtempSync(join(tmpdir(), "plannotator-submit-optout-"));
const docPath = join(dir, "doc.md");
const project = uniqueProject("opt-out");
const server = await startServer(project, docPath);
try {
await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotations: [{ id: "a1" }] }),
});
const response = await fetch(`${server.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "Secret excerpt", annotations: [{ id: "a1" }] }),
});
expect(response.status).toBe(200);
// The opt-out means "no annotate content in the data dir": no version
// snapshot AND no submission record — the project dir never appears.
expect(existsSync(join(getPlannotatorDataDir(), "history", project))).toBe(false);
// Legacy behavior preserved: the draft is still deleted on submit.
const draft = await fetch(`${server.url}/api/draft`);
expect(draft.status).toBe(404);
} finally {
server.stop();
rmSync(dir, { recursive: true, force: true });
}
});
test("previously-stateless modes stay stateless: annotate-last and URL sessions write no record", async () => {
// Before #678 these modes never touched the data dir; the durable record
// must not widen the documented annotateHistory contract to them — their
// submissions quote agent messages or fetched pages.
const lastProject = uniqueProject("last-message");
const lastServer = await startAnnotateServer({
markdown: `# Agent message ${lastProject}\n\nQuoted agent output.\n`,
filePath: "last-message",
htmlContent: MINIMAL_HTML,
project: lastProject,
mode: "annotate-last",
});
try {
const response = await fetch(`${lastServer.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "Quoting the agent: do Y instead.", annotations: [] }),
});
expect(response.status).toBe(200);
expect(existsSync(join(getPlannotatorDataDir(), "history", lastProject))).toBe(false);
} finally {
lastServer.stop();
}
const urlProject = uniqueProject("url");
const urlServer = await startAnnotateServer({
markdown: `# Fetched page ${urlProject}\n\nPage content.\n`,
filePath: "https://example.com/some/page",
htmlContent: MINIMAL_HTML,
project: urlProject,
});
try {
const response = await fetch(`${urlServer.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "The fetched page says Z.", annotations: [] }),
});
expect(response.status).toBe(200);
expect(existsSync(join(getPlannotatorDataDir(), "history", urlProject))).toBe(false);
} finally {
urlServer.stop();
}
});
test("a malformed feedback body degrades to legacy behavior, never a 500", async () => {
// /api/feedback does no body type validation; pre-#678 a non-string
// feedback flowed through settle() untouched and returned 200. The
// durable-record guard must not turn that into a thrown 500.
const dir = mkdtempSync(join(tmpdir(), "plannotator-submit-malformed-"));
const docPath = join(dir, "doc.md");
const project = uniqueProject("malformed");
const server = await startServer(project, docPath);
try {
await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotations: [{ id: "a1" }] }),
});
const response = await fetch(`${server.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: 42, annotations: [] }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
// Legacy behavior: draft deleted, no record (nothing persistable).
const draft = await fetch(`${server.url}/api/draft`);
expect(draft.status).toBe(404);
expect(existsSync(submissionsDir(project, docPath))).toBe(false);
} finally {
server.stop();
rmSync(dir, { recursive: true, force: true });
}
});
test("a failed durable write keeps the draft as the recovery copy", async () => {
const dir = mkdtempSync(join(tmpdir(), "plannotator-submit-unwritable-"));
const docPath = join(dir, "doc.md");
const project = uniqueProject("unwritable");
// Plant a FILE where the project's history directory must go: every
// mkdir under it fails, so both the startup snapshot and the submission
// write degrade. (afterAll's recursive+force rm removes the file too.)
const historyRoot = join(getPlannotatorDataDir(), "history");
mkdirSync(historyRoot, { recursive: true });
writeFileSync(join(historyRoot, project), "not a directory", "utf-8");
const server = await startServer(project, docPath);
try {
await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotations: [{ id: "a1" }] }),
});
const response = await fetch(`${server.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "Please fix X", annotations: [{ id: "a1" }] }),
});
// The decision itself still succeeds — persistence is an enhancement.
expect(response.status).toBe(200);
// But the draft survives: with no durable record written, it is the
// only remaining copy of the reviewer's work.
const draft = await fetch(`${server.url}/api/draft`);
expect(draft.status).toBe(200);
// Cleanup: don't leave this test's draft behind in the real data dir.
await fetch(`${server.url}/api/draft`, { method: "DELETE" });
} finally {
server.stop();
rmSync(dir, { recursive: true, force: true });
}
});
});