Files
backnotprop__plannotator/apps/pi-extension/vendored-config-write.test.ts
Michael Ramos 271fcefded fix(server): live-proxy injection and config write hardening (#1364)
* fix(server): live-proxy injection and config write hardening

Four confirmed pre-release QA findings, each with a test that fails on
the pre-fix source.

live-proxy: the HTML injector scanned for head markers with no notion of
comments, so a codegen banner naming <head> before the real tag captured
the bridge script into a dead comment span: never executed, annotation
silently broken, no warning. The scanner now skips comments and the
'>'-terminated markup-declaration / bogus-comment spans (doctype,
CDATA-ish, <?...>) before matching, inside the same chunk-boundary state
machine. Raw-text element contents are still not tracked; that limit and
its degraded outcome are documented in the source.

live-proxy: new URL(req.url) ran before Host validation, so a Host-less
HTTP/1.0 request threw and served Bun's internal debug page with a stack
trace. Host validation now runs first, and URL construction takes the
same 403 path on failure.

live-proxy: the text/html content-type test was case-sensitive, so a
valid TEXT/HTML response skipped injection and the framing rewrites.

config: saveConfig was an unlocked read-merge-write, so two processes
sharing a data dir dropped each other's keys while both reported success.
The read-merge-write now runs under an O_EXCL advisory lockfile with a
bounded wait and stale takeover, degrading to the old behavior with a
warning rather than ever hanging, and the write itself is temp+rename so
lock-free readers cannot observe a torn file.

Also consolidates the duplicated agent-terminal side predicate onto the
single definition in @plannotator/core.

* fix(annotate): give live app sessions their own draft slot

mode "annotate-app" resolves markdown to "" by construction (the page
lives behind the proxy, not in a string the server holds), and the
autosave draft key was contentHash of that body. Every live session on
the machine therefore collapsed to the one hash of the empty string and
shared a single draft slot: two sessions against different dev servers
read and overwrote each other's in-progress annotations, deterministically.

A live session's identity is its target, exactly as a folder session's
identity is its folder path, so the key is now derived from the target
URL (normalized through the URL parser so the same dev server recovers
its draft when spelled with or without a trailing slash). Classic file
and folder keying is untouched.

Pi has no live app mode (no annotate-app, liveApp or live-proxy outside
its vendored generated/ tree), so there is nothing to mirror there.
2026-08-21 08:42:13 -07:00

69 lines
2.5 KiB
TypeScript

/**
* The Pi server shares one ~/.plannotator data dir with whatever Bun-runtime
* Plannotator session the user has open, and settles POST /api/config through
* the SAME saveConfig — but through the vendored copy in generated/, not the
* package under packages/shared. This asserts the write serialization is
* actually present on that side of the vendor boundary, so a vendor.sh rule
* that stopped copying it (or a Pi-only reimplementation) fails here rather
* than in a user's config file.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
loadConfig,
saveConfig,
getConfigLockPath,
isAgentTerminalSide,
__setConfigSaveMergeWindowHookForTest,
__setConfigLockTimingsForTest,
} from "./generated/config.ts";
describe("vendored config write path", () => {
const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "plannotator-pi-config-"));
process.env.PLANNOTATOR_DATA_DIR = tempDir;
});
afterEach(() => {
__setConfigSaveMergeWindowHookForTest(null);
__setConfigLockTimingsForTest(null);
if (originalDataDir !== undefined) {
process.env.PLANNOTATOR_DATA_DIR = originalDataDir;
} else {
delete process.env.PLANNOTATOR_DATA_DIR;
}
rmSync(tempDir, { recursive: true, force: true });
});
test("saveConfig holds the same advisory lock across its read-merge-write", () => {
const lockPath = getConfigLockPath();
expect(lockPath).toBe(join(tempDir, "config.json.lock"));
let lockedDuringMerge = false;
__setConfigSaveMergeWindowHookForTest(() => {
lockedDuringMerge = existsSync(lockPath);
});
saveConfig({ agentTerminalSide: "right" });
expect(lockedDuringMerge).toBe(true);
expect(existsSync(lockPath)).toBe(false);
expect(loadConfig().agentTerminalSide).toBe("right");
});
test("the side guard the annotate endpoint uses accepts exactly the core sides", () => {
// serverAnnotate.ts gates POST /api/config on this; it now delegates to
// the single definition vendored from @plannotator/core.
expect(isAgentTerminalSide("left")).toBe(true);
expect(isAgentTerminalSide("right")).toBe(true);
expect(isAgentTerminalSide("hidden")).toBe(true);
expect(isAgentTerminalSide("top")).toBe(false);
expect(isAgentTerminalSide(undefined)).toBe(false);
});
});