Files
backnotprop__plannotator/apps/hook/server/annotate-resolution.test.ts
Michael Ramos d2d2dba7fa feat(annotate): configurable extra markdown extensions (#1309)
* feat(annotate): configurable extra markdown extensions (#1307)

Adds a config-only `markdownExtensions` key to ~/.plannotator/config.json,
e.g. { "markdownExtensions": [".livemd"] } for Livebook notebooks. A listed
extension is accepted everywhere .md is on the annotate path: CLI target
resolution, folder discovery and the file browser, /api/doc plus relative and
wiki-link navigation between sibling docs, the 2MB size cap, and per-file
version history. Listed extensions render as markdown with frontmatter
stripped, never as raw HTML, and they only widen the accepted set.

Design:
- packages/core/annotatable.ts stays browser-safe and zero-dep. Its regexes
  and predicates now take an optional, defaulted-empty list of extra
  extensions, plus a normalizer and regex builders.
- packages/shared/markdown-extensions.ts is the node-side seam: it reads
  config.json once per process through the existing loadConfig() and threads
  the normalized list into those pure functions. resolve-file re-exports the
  config-aware predicates so both runtimes pick them up; the Bun server, the
  Pi mirror, the OpenCode plugin and the CLI all go through them.
- The annotate /api/plan payload ships the resolved list so the renderer can
  linkify links to sibling documents (module-level UI registry, empty by
  default, so nothing changes without config).

Validation: entries must be dot-led, lowercase-normalized, and free of path
separators, globs and whitespace. Invalid entries are dropped silently,
built-ins are deduplicated, and `.env` is denylisted so config can never
register it (annotate copies file contents into the data dir).

Deliberately unchanged: the Pi plan-write allowlist (ALLOWED_PLAN_EXTENSIONS
in tool-scope.ts) and Edit Mode source save (SOURCE_SAVE_FILE_REGEX), which
keep their own narrower allowlists.

* fix(annotate): deny the dotenv family and sandbox config-aware tests

Review follow-ups on #1309:
- deny the whole dotenv family (.prod.env, .env.local, ...) in
  normalizeMarkdownExtensions, not just the exact .env name
- resolve config.json path per call instead of at module scope so
  PLANNOTATOR_DATA_DIR sandboxing works in single-process test runs
- stop resolve-file.test.ts reading the real user config: pure
  predicate imports plus pinned empty extras on every resolve call
- add the config.json -> memo -> predicate integration test using
  resetMarkdownExtensionsCache under a temp data dir

* test(call-flow): make the stale-read advert test self-sufficient

The read-only GET only probes the node runtime while Call flow is
enabled. The stale-read test relied on earlier tests' settings POSTs
leaking callFlow=true through the process-frozen config path; with lazy
config resolution each sandbox is genuinely isolated, so the test now
enables Call flow in its own data dir. Locally the dependency was
masked by an fnm-shimmed sem sidecar spawning node coincidentally.
2026-08-13 09:47:18 -07:00

155 lines
5.5 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { resolveAnnotateTarget } from "./annotate-resolution";
let root: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "plannotator-annotate-resolution-"));
mkdirSync(join(root, "docs"), { recursive: true });
mkdirSync(join(root, "notes"), { recursive: true });
mkdirSync(join(root, "empty"), { recursive: true });
writeFileSync(join(root, "plan.md"), "# Plan body");
writeFileSync(join(root, "docs/page.html"), "<p>hi</p>");
writeFileSync(join(root, "docs/dup.md"), "# A");
writeFileSync(join(root, "notes/dup.md"), "# B");
writeFileSync(join(root, "script.py"), "print()");
writeFileSync(join(root, "big.md"), "x".repeat(2 * 1024 * 1024 + 1));
mkdirSync(join(root, "notebooks"), { recursive: true });
writeFileSync(join(root, "notebooks/tour.livemd"), "# Livebook tour");
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
function resolve(
rawFilePath: string,
overrides: { renderMarkdown?: boolean; extraMarkdownExtensions?: readonly string[] } = {},
) {
return resolveAnnotateTarget({
rawFilePath,
projectRoot: root,
noJina: true,
renderMarkdown: overrides.renderMarkdown ?? false,
extraMarkdownExtensions: overrides.extraMarkdownExtensions ?? [],
log: () => {},
});
}
describe("resolveAnnotateTarget", () => {
test("resolves a markdown file and reads its content", async () => {
const result = await resolve("plan.md");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.absolutePath).toBe(join(root, "plan.md"));
expect(result.markdown).toBe("# Plan body");
expect(result.annotateMode).toBe("annotate");
expect(result.isUrl).toBe(false);
}
});
test("resolves a folder into folder mode", async () => {
const result = await resolve("docs");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.annotateMode).toBe("annotate-folder");
expect(result.folderPath).toBe(join(root, "docs"));
}
});
test("resolves an HTML file as raw HTML by default and markdown with --markdown", async () => {
const raw = await resolve("docs/page.html");
expect(raw.ok).toBe(true);
if (raw.ok) {
expect(raw.rawHtml).toBe("<p>hi</p>");
expect(raw.markdown).toBe("");
}
const converted = await resolve("docs/page.html", { renderMarkdown: true });
expect(converted.ok).toBe(true);
if (converted.ok) {
expect(converted.rawHtml).toBeUndefined();
expect(converted.sourceConverted).toBe(true);
}
});
// #1307: an extension listed in config.json's `markdownExtensions` must be
// accepted everywhere .md is — single file, folder discovery, and reading.
test("a configured extra extension opens as a document and its folder is annotatable", async () => {
const configured = { extraMarkdownExtensions: [".livemd"] };
const file = await resolve("notebooks/tour.livemd", configured);
expect(file.ok).toBe(true);
if (file.ok) {
expect(file.absolutePath).toBe(join(root, "notebooks/tour.livemd"));
expect(file.markdown).toBe("# Livebook tour");
expect(file.annotateMode).toBe("annotate");
}
const folder = await resolve("notebooks", configured);
expect(folder.ok).toBe(true);
if (folder.ok) expect(folder.annotateMode).toBe("annotate-folder");
});
test("without configuration the same file is an unsupported type and its folder is empty", async () => {
const file = await resolve("notebooks/tour.livemd");
expect(file.ok).toBe(false);
if (!file.ok) {
expect(file.notFound).toBe(false);
expect(file.message).toContain("File type not supported: .livemd");
}
const folder = await resolve("notebooks");
expect(folder.ok).toBe(false);
if (!folder.ok) expect(folder.message).toContain("No annotatable files");
});
test("only the missing-target terminal reports notFound", async () => {
const missing = await resolve("missing.md");
expect(missing.ok).toBe(false);
if (!missing.ok) {
expect(missing.notFound).toBe(true);
expect(missing.message).toBe("File not found: missing.md");
}
const word = await resolve("the");
expect(word.ok).toBe(false);
if (!word.ok) {
expect(word.notFound).toBe(true);
}
});
test("target-specific failures keep notFound false and their messages", async () => {
const ambiguous = await resolve("dup.md");
expect(ambiguous.ok).toBe(false);
if (!ambiguous.ok) {
expect(ambiguous.notFound).toBe(false);
expect(ambiguous.message).toContain('Ambiguous filename "dup.md"');
expect(ambiguous.message).toContain("2 matches");
}
const unsupported = await resolve("script.py");
expect(unsupported.ok).toBe(false);
if (!unsupported.ok) {
expect(unsupported.notFound).toBe(false);
expect(unsupported.message).toContain("File type not supported: .py");
}
const oversized = await resolve("big.md");
expect(oversized.ok).toBe(false);
if (!oversized.ok) {
expect(oversized.notFound).toBe(false);
expect(oversized.message).toContain("File too large to annotate (max 2MB)");
}
const emptyFolder = await resolve("empty");
expect(emptyFolder.ok).toBe(false);
if (!emptyFolder.ok) {
expect(emptyFolder.notFound).toBe(false);
expect(emptyFolder.message).toContain("No annotatable files");
}
});
});