Files
backnotprop__plannotator/packages/server/annotate.test.ts
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00

2420 lines
92 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 { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "os";
import { dirname, join, resolve } from "path";
import { liveAppDraftIdentity, runGuardedShutdown, startAnnotateServer } from "./annotate";
import { getServerConfig, loadConfig } from "./config";
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/config favicon persistence", () => {
let savedPort: string | undefined;
let savedRemote: string | undefined;
let savedDataDir: string | undefined;
let tempDir: string;
beforeEach(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
delete process.env.PLANNOTATOR_PORT;
delete process.env.PLANNOTATOR_REMOTE;
tempDir = mkdtempSync(join(tmpdir(), "plannotator-annotate-config-test-"));
process.env.PLANNOTATOR_DATA_DIR = tempDir;
});
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 (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
rmSync(tempDir, { recursive: true, force: true });
});
test("persists classic favicon via POST /api/config and ignores unknown values", async () => {
const server = await startAnnotateServer({
markdown: "# Test",
filePath: join(tmpdir(), "test.md"),
htmlContent: MINIMAL_HTML,
});
try {
const validResponse = await fetch(`${server.url}/api/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favicon: "classic" }),
});
expect(validResponse.status).toBe(200);
expect(loadConfig().favicon).toBe("classic");
expect(getServerConfig(null).favicon).toBe("classic");
const invalidResponse = await fetch(`${server.url}/api/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favicon: "unknown" }),
});
expect(invalidResponse.status).toBe(200);
// "unknown" was not written into config, so "classic" is retained
expect(loadConfig().favicon).toBe("classic");
expect(getServerConfig(null).favicon).toBe("classic");
} 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();
}
});
});
// A local rendered-HTML root is served from its current bytes by both
// /api/plan (tab reload) and /api/share-html (share after Refresh), with the
// startup snapshot only as the deleted-file fallback. History lives in the
// real data dir (storage resolves it at import time), so every test uses its
// own project namespace, removed in afterAll.
describe("annotate server: local rendered-HTML root freshness", () => {
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";
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;
});
const mintedProjects: string[] = [];
function uniqueProject(label: string): string {
const project = `_annotate_root_html_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 });
}
});
const page = (marker: string) => `<html><body>${marker}</body></html>`;
// realpath so the deleted-file fallback is reachable: containment realpaths
// the root but keeps a missing target's lexical path, which on a symlinked
// tmpdir (macOS) would never match.
const freshDocDir = (label: string) => realpathSync(mkdtempSync(join(tmpdir(), `plannotator-root-html-${label}-`)));
test("/api/share-html shares the root document's current bytes after the file changes on disk", async () => {
const pagePath = join(freshDocDir("share"), "page.html");
writeFileSync(pagePath, page("STARTUP_VERSION"), "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: page("STARTUP_VERSION"),
renderHtml: true,
project: uniqueProject("share"),
});
try {
writeFileSync(pagePath, page("REFRESHED_VERSION"), "utf-8");
const refreshed = await (await fetch(
`${server.url}/api/share-html?path=${encodeURIComponent(pagePath)}`,
)).json() as { shareHtml: string };
expect(refreshed.shareHtml).toContain("REFRESHED_VERSION");
expect(refreshed.shareHtml).not.toContain("STARTUP_VERSION");
unlinkSync(pagePath);
const fallback = await (await fetch(`${server.url}/api/share-html`)).json() as { shareHtml: string };
expect(fallback.shareHtml).toContain("STARTUP_VERSION");
} finally {
server.stop();
}
});
// A tab reload after an agent edit must show the edited page (the draft
// annotations were placed on it) AND keep the version diff: the saved
// baseline is still the previous version, so the diff is recomputed
// against the served bytes rather than dropped (a reload used to lose the
// "Show changes" toggle for the rest of the session). Reads never write
// history.
test("/api/plan serves the root document's current bytes and recomputes the version diff against them", async () => {
const pagePath = join(freshDocDir("plan"), "page.html");
const project = uniqueProject("plan");
type PlanPayload = {
rawHtml?: string;
previousPlan?: string | null;
versionInfo?: { version: number };
diffCurrent?: string;
diffHtml?: string;
};
// Session 1 saves V1 as version 1 so session 2 has a baseline to diff.
writeFileSync(pagePath, page("V1"), "utf-8");
const seed = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: page("V1"),
renderHtml: true,
project,
});
seed.stop();
writeFileSync(pagePath, page("V2"), "utf-8");
const server = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: page("V2"),
renderHtml: true,
project,
});
const plan = async () => (await (await fetch(`${server.url}/api/plan`)).json()) as PlanPayload;
try {
const startup = await plan();
expect(startup.rawHtml).toContain("V2");
expect(startup.previousPlan).toBe(page("V1"));
expect(startup.versionInfo?.version).toBe(2);
expect(startup.diffHtml).toBeDefined();
writeFileSync(pagePath, page("V3"), "utf-8");
const reloaded = await plan();
expect(reloaded.rawHtml).toContain("V3");
expect(reloaded.rawHtml).not.toContain("V2");
// The baseline still names the saved previous version...
expect(reloaded.previousPlan).toBe(page("V1"));
expect(reloaded.versionInfo?.version).toBe(2);
// ...and the diff describes V1 -> V3, the page actually on screen.
expect(reloaded.diffCurrent).toBe(page("V3"));
expect(reloaded.diffHtml).toContain("<ins");
expect(reloaded.diffHtml).toContain("V3");
expect(reloaded.diffHtml).not.toContain("V2");
// The in-app Refresh reads the root through /api/doc: the same
// recomputed diff rides along for the ROOT document only.
const refreshed = (await (await fetch(
`${server.url}/api/doc?path=${encodeURIComponent(pagePath)}`,
)).json()) as PlanPayload & { renderAs?: string };
expect(refreshed.renderAs).toBe("html");
expect(refreshed.rawHtml).toContain("V3");
expect(refreshed.previousPlan).toBe(page("V1"));
expect(refreshed.versionInfo?.version).toBe(2);
expect(refreshed.diffHtml).toBe(reloaded.diffHtml);
// A sibling document served through /api/doc carries no version fields.
const siblingPath = join(dirname(pagePath), "sibling.html");
writeFileSync(siblingPath, page("SIBLING"), "utf-8");
const sibling = (await (await fetch(
`${server.url}/api/doc?path=${encodeURIComponent(siblingPath)}`,
)).json()) as PlanPayload;
expect(sibling.rawHtml).toContain("SIBLING");
expect(sibling.previousPlan).toBeUndefined();
expect(sibling.diffHtml).toBeUndefined();
// The saved history is untouched by reads: still exactly the two versions.
const versions = (await (await fetch(`${server.url}/api/plan/versions`)).json()) as { versions: unknown[] };
expect(versions.versions).toHaveLength(2);
unlinkSync(pagePath);
const fallback = await plan();
expect(fallback.rawHtml).toContain("V2");
expect(fallback.previousPlan).toBe(page("V1"));
expect(fallback.diffHtml).toBeDefined();
} finally {
server.stop();
}
});
// A root that exists but cannot be read is the missing-file fallback: the
// startup snapshot, with its version diff, and the share endpoint agrees.
// On Bun, Bun.file(dir).exists() is false, so a path replaced by a
// directory already took the missing path (the case guards the Pi mirror,
// where existsSync is true and the read throws); the chmod 000 case below
// is the one that made the Bun handler throw and answer 500.
async function seedTwoVersions(label: string): Promise<{ pagePath: string; project: string }> {
const pagePath = join(freshDocDir(label), "page.html");
const project = uniqueProject(label);
writeFileSync(pagePath, page("V1"), "utf-8");
const seed = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: page("V1"),
renderHtml: true,
project,
});
seed.stop();
writeFileSync(pagePath, page("V2"), "utf-8");
return { pagePath, project };
}
type FallbackPayload = { rawHtml?: string; previousPlan?: string | null; versionInfo?: { version: number }; diffHtml?: string };
test("/api/plan falls back to the startup snapshot (with its version diff) when the root path becomes a directory", async () => {
const { pagePath, project } = await seedTwoVersions("dir");
const server = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: page("V2"),
renderHtml: true,
project,
});
try {
unlinkSync(pagePath);
mkdirSync(pagePath);
const res = await fetch(`${server.url}/api/plan`);
expect(res.status).toBe(200);
const fallback = (await res.json()) as FallbackPayload;
expect(fallback.rawHtml).toContain("V2");
expect(fallback.previousPlan).toBe(page("V1"));
expect(fallback.versionInfo?.version).toBe(2);
expect(fallback.diffHtml).toBeDefined();
const share = await fetch(`${server.url}/api/share-html`);
expect(share.status).toBe(200);
expect(((await share.json()) as { shareHtml: string }).shareHtml).toContain("V2");
} finally {
server.stop();
}
});
// chmod 000 is not a restriction for root, so the check is skipped there.
const canRevokeRead = process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() !== 0;
test.skipIf(!canRevokeRead)("/api/plan falls back to the startup snapshot when the root file is unreadable", async () => {
const { pagePath, project } = await seedTwoVersions("perm");
const server = await startAnnotateServer({
markdown: "",
filePath: pagePath,
htmlContent: MINIMAL_HTML,
rawHtml: page("V2"),
renderHtml: true,
project,
});
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
try {
writeFileSync(pagePath, page("V3"), "utf-8");
chmodSync(pagePath, 0o000);
const res = await fetch(`${server.url}/api/plan`);
expect(res.status).toBe(200);
const fallback = (await res.json()) as FallbackPayload;
expect(fallback.rawHtml).toContain("V2");
expect(fallback.rawHtml).not.toContain("V3");
expect(fallback.previousPlan).toBe(page("V1"));
expect(fallback.diffHtml).toBeDefined();
// The fallback is silent to the reviewer, so the reason is logged once
// per process (path and error), not once per read.
await fetch(`${server.url}/api/plan`);
const rootWarnings = warnings.filter((w) => w.includes("could not read the HTML root"));
expect(rootWarnings).toHaveLength(1);
expect(rootWarnings[0]).toContain(pagePath);
} finally {
console.warn = originalWarn;
chmodSync(pagePath, 0o644);
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 });
}
});
});
describe("annotate server: live app mode (annotate-app)", () => {
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;
});
function startFakeApp() {
return Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () =>
new Response("<html><head><title>app</title></head><body>app</body></html>", {
headers: { "Content-Type": "text/html" },
}),
});
}
async function startLiveServer(targetUrl: string, extra?: { tailnetPublished?: boolean }) {
return startAnnotateServer({
markdown: "",
filePath: targetUrl,
htmlContent: MINIMAL_HTML,
mode: "annotate-app",
sourceInfo: targetUrl,
liveApp: {
targetUrl,
bridgeScript: "/* bridge body */",
bridgeBootstrap: "/* bootstrap body */",
annotationCss: ".pn-live {}",
},
...extra,
});
}
test("/api/plan returns the live payload and the proxy serves the composed bridge", async () => {
const app = startFakeApp();
const targetUrl = `http://127.0.0.1:${app.port}`;
const server = await startLiveServer(targetUrl);
try {
const plan = (await (await fetch(`${server.url}/api/plan`)).json()) as Record<string, unknown>;
expect(plan.mode).toBe("annotate-app");
expect(plan.filePath).toBe(targetUrl);
expect(plan.targetUrl).toBe(targetUrl);
expect(plan.liveToken).toMatch(/^[0-9a-f]{32}$/);
expect(plan.sharingEnabled).toBe(false);
expect(plan.convertHtml).toBe(false);
// appUrl is the live loopback proxy under its LOCALHOST spelling (so
// the framed app is same-site with the editor and shares the dev
// app's host-only localhost cookies), never an advertised-host URL.
expect(plan.appUrl).toMatch(/^http:\/\/localhost:\d+\/$/);
// No srcdoc payloads, no version fields.
expect(plan.rawHtml).toBeUndefined();
expect(plan.renderAs).toBeUndefined();
expect(plan.previousPlan).toBeUndefined();
expect(plan.versionInfo).toBeUndefined();
expect(plan.diffCurrent).toBeUndefined();
// Agent terminal stays unavailable for live sessions.
expect((plan.agentTerminal as { enabled: boolean }).enabled).toBe(false);
// The proxy serves the composed bridge body: config prelude with the
// session token and both editor origin forms (localhost first), then
// bootstrap, then bridge.
const appUrl = plan.appUrl as string;
const bridge = await (await fetch(`${appUrl}__plannotator__/bridge.js`)).text();
expect(bridge).toContain(String(plan.liveToken));
const localhostAt = bridge.indexOf(`http://localhost:${server.port}`);
const loopbackAt = bridge.indexOf(`http://127.0.0.1:${server.port}`);
expect(localhostAt).toBeGreaterThanOrEqual(0);
expect(loopbackAt).toBeGreaterThan(localhostAt);
expect(bridge).toContain(".pn-live {}");
expect(bridge.indexOf("/* bootstrap body */")).toBeLessThan(bridge.indexOf("/* bridge body */"));
// The proxied page carries the injected bridge script tag.
const page = await (await fetch(appUrl)).text();
expect(page).toContain('<script src="/__plannotator__/bridge.js"></script>');
} finally {
server.stop();
app.stop(true);
}
});
test("a pathful target URL keeps its path and query in appUrl", async () => {
// Annotating http://localhost:5173/admin/settings must open that page,
// not the app root.
const app = startFakeApp();
const targetUrl = `http://127.0.0.1:${app.port}/admin/settings?tab=2`;
const server = await startLiveServer(targetUrl);
try {
const plan = (await (await fetch(`${server.url}/api/plan`)).json()) as { appUrl: string };
expect(plan.appUrl).toMatch(/^http:\/\/localhost:\d+\/admin\/settings\?tab=2$/);
// The advertised page is reachable through the proxy under the
// localhost Host spelling.
const res = await fetch(plan.appUrl);
expect(res.status).toBe(200);
} finally {
server.stop();
app.stop(true);
}
});
test("version endpoints report no history for live sessions", async () => {
const app = startFakeApp();
const server = await startLiveServer(`http://127.0.0.1:${app.port}`);
try {
const versions = (await (await fetch(`${server.url}/api/plan/versions`)).json()) as {
slug: string | null;
versions: unknown[];
};
expect(versions.slug).toBeNull();
expect(versions.versions).toEqual([]);
const version = await fetch(`${server.url}/api/plan/version?v=1`);
expect(version.status).toBe(404);
} finally {
server.stop();
app.stop(true);
}
});
test("stop() closes the proxy port with the server", async () => {
const app = startFakeApp();
const server = await startLiveServer(`http://127.0.0.1:${app.port}`);
const plan = (await (await fetch(`${server.url}/api/plan`)).json()) as { appUrl: string };
// Reachable while running.
expect((await fetch(plan.appUrl)).status).toBe(200);
server.stop();
await Bun.sleep(50);
let closed = false;
try {
await fetch(plan.appUrl, { signal: AbortSignal.timeout(1000) });
} catch {
closed = true;
}
expect(closed).toBe(true);
app.stop(true);
});
describe("draft isolation between live sessions", () => {
// A live session holds no document text (markdown is "" by construction),
// so keying its draft by content gave every live session on the machine
// the one hash of the empty string: two sessions against different dev
// servers shared a single draft slot and overwrote each other.
const savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
let draftDataDir: string;
beforeEach(() => {
draftDataDir = mkdtempSync(join(tmpdir(), "plannotator-live-draft-"));
process.env.PLANNOTATOR_DATA_DIR = draftDataDir;
});
afterEach(() => {
if (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
rmSync(draftDataDir, { recursive: true, force: true });
});
async function saveDraft(server: { url: string }, feedback: string): Promise<void> {
const res = await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback, annotations: [] }),
});
expect(res.status).toBe(200);
}
async function loadDraft(server: { url: string }): Promise<{ feedback?: string } | null> {
const res = await fetch(`${server.url}/api/draft`);
if (res.status === 404) return null;
expect(res.status).toBe(200);
return (await res.json()) as { feedback?: string };
}
test("two live sessions on different targets keep independent drafts", async () => {
const appX = startFakeApp();
const appY = startFakeApp();
const serverX = await startLiveServer(`http://127.0.0.1:${appX.port}`);
const serverY = await startLiveServer(`http://127.0.0.1:${appY.port}`);
try {
await saveDraft(serverX, "notes for X");
await saveDraft(serverY, "notes for Y");
// Neither session sees the other's text, in either direction.
expect((await loadDraft(serverX))?.feedback).toBe("notes for X");
expect((await loadDraft(serverY))?.feedback).toBe("notes for Y");
expect(readdirSync(join(draftDataDir, "drafts")).length).toBe(2);
} finally {
serverX.stop();
serverY.stop();
appX.stop(true);
appY.stop(true);
}
});
test("the same target recovers its draft after a restart", async () => {
const app = startFakeApp();
const targetUrl = `http://127.0.0.1:${app.port}`;
const first = await startLiveServer(targetUrl);
try {
await saveDraft(first, "survives the crash");
} finally {
first.stop();
}
// Same target, spelled with a trailing slash the way a browser would
// hand it back: the draft is the point of the key, so it must survive.
const second = await startLiveServer(`${targetUrl}/`);
try {
expect((await loadDraft(second))?.feedback).toBe("survives the crash");
} finally {
second.stop();
app.stop(true);
}
});
test("live identities separate distinct targets and are stable across spellings", () => {
const a = liveAppDraftIdentity("http://127.0.0.1:5173");
expect(liveAppDraftIdentity("http://127.0.0.1:5173/")).toBe(a);
expect(liveAppDraftIdentity("http://127.0.0.1:5174")).not.toBe(a);
// Different pages of one app are different targets, and stay so.
expect(liveAppDraftIdentity("http://127.0.0.1:5173/admin")).not.toBe(a);
expect(liveAppDraftIdentity("http://127.0.0.1:5173/admin/")).toBe(
liveAppDraftIdentity("http://127.0.0.1:5173/admin"),
);
// Unparseable input still yields a per-target value rather than throwing.
expect(liveAppDraftIdentity("not a url")).toBe("not a url");
});
});
test("classic file annotate still keys its draft by content", async () => {
// The live fix must not move any existing draft: a file session's key is
// the hash of its markdown, exactly as before, so drafts written by an
// earlier release are still found.
const savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
const dataDir = mkdtempSync(join(tmpdir(), "plannotator-file-draft-"));
process.env.PLANNOTATOR_DATA_DIR = dataDir;
const markdown = "# Doc\n\nbody text\n";
const server = await startAnnotateServer({
markdown,
filePath: join(dataDir, "doc.md"),
htmlContent: MINIMAL_HTML,
mode: "annotate",
});
try {
const res = await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedback: "file note", annotations: [] }),
});
expect(res.status).toBe(200);
const expectedKey = createHash("sha256").update(markdown).digest("hex").slice(0, 16);
expect(existsSync(join(dataDir, "drafts", `${expectedKey}.json`))).toBe(true);
} finally {
server.stop();
if (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
rmSync(dataDir, { recursive: true, force: true });
}
});
test("remote mode rejects live app sessions outright", async () => {
process.env.PLANNOTATOR_REMOTE = "1";
await expect(startLiveServer("http://127.0.0.1:65500")).rejects.toThrow(
"Live app annotation is unavailable in remote mode",
);
});
test("tailnet-published sessions reject live app sessions outright", async () => {
// --tailscale keeps the annotate server loopback-bound but publishes it
// across the tailnet through the serve proxy; a live proxy would relay
// the user's authenticated dev app to every tailnet peer, so it is the
// same hard-off as remote mode, keyed on tailnetPublished.
await expect(
startLiveServer("http://127.0.0.1:65500", { tailnetPublished: true }),
).rejects.toThrow("Live app annotation is unavailable in tailnet-published sessions");
});
});
describe("annotate server: guarded shutdown (runGuardedShutdown)", () => {
// The live-proxy leak this guards: stop() disposes the agent terminal
// BEFORE the live proxy, and agent-terminal teardown is historically
// fragile (#1314). In a flat sequence a throw there orphaned the proxy's
// listener and upstream WebSockets. Each step must run even when an
// earlier one throws, and the listener close must run regardless.
test("a throwing disposer does not skip later steps or the listener close", () => {
const ran: string[] = [];
const logged: string[] = [];
runGuardedShutdown(
[
["agent terminal", () => {
ran.push("agent terminal");
throw new Error("pty teardown exploded");
}],
["live proxy", () => ran.push("live proxy")],
],
() => ran.push("listener"),
(message) => logged.push(message),
);
expect(ran).toEqual(["agent terminal", "live proxy", "listener"]);
// The failure is reported, named after the step that threw.
expect(logged.some((line) => line.includes("agent terminal"))).toBe(true);
});
test("all steps clean: everything runs once in order, nothing is logged", () => {
const ran: string[] = [];
const logged: string[] = [];
runGuardedShutdown(
[
["a", () => ran.push("a")],
["b", () => ran.push("b")],
],
() => ran.push("listener"),
(message) => logged.push(message),
);
expect(ran).toEqual(["a", "b", "listener"]);
expect(logged).toEqual([]);
});
});