Files
Michael Ramos 782d9740c1 feat(annotate): carry agent-facing element context on HTML and live-app pinpoints (#1517)
A pinpointed element exported as a one-word placeholder ('[element:
Navigation]') or its flattened textContent. The bridge now captures a
bounded description at click time (tag, id, classes, ancestor path, role,
accessible name, allowlisted attributes, rendered text, an adaptive HTML
skeleton, box, landmark, heading, component hint, live route) as
elementContext; the parent re-validates and re-caps it; the export prints
a fenced skeleton plus selector/path/role/name lines under the comment;
the annotation panel gains a per-row Copy for element-bearing cards; the
feedback archive records element identity. Additive: annotations without
the field export byte-identically, share links drop it, no protocol bump.
2026-09-12 16:20:39 -07:00

3946 lines
167 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Rendering-neutrality contract for the HTML viewer (see srcdoc.ts).
*
* Arbitrary customer HTML must render exactly as in a plain browser tab: the
* viewer writes NOTHING into the document's namespace — no bare CSS custom
* properties (a host `--muted` clobbering an author `--muted` visibly corrupts
* documents), no `color-scheme`, no root classes, no styling of author
* elements. Host tokens travel only under the viewer-owned `--pn-*` prefix
* unless the document opts in via <meta name="plannotator-theme" content="host">.
*
* These tests are the mutation guard: reintroducing any bare-token injection
* for non-opted-in documents must go red here.
*/
import { beforeAll, describe, expect, test } from "bun:test";
import { ANNOTATION_HIGHLIGHT_CSS, BRIDGE_SCRIPT } from "./bridge-script";
import {
DIFF_HIGHLIGHT_CSS,
buildSrcdocInjection,
buildThemeTokenPayload,
hasHostThemeOptIn,
injectIntoHead,
} from "./srcdoc";
const HOST_TOKENS = {
"--background": "oklch(0.15 0.02 260)",
"--muted": "oklch(0.26 0.02 260)",
"--border": "oklch(0.35 0.02 260)",
"--destructive": "oklch(0.65 0.20 25)",
"--focus-highlight": "#4493f8",
};
/** Matches a bare (non --pn-) custom-property declaration like `--muted:`. */
const BARE_TOKEN_DECL = /(^|[^-\w])--(?!pn-)[\w-]+\s*:/m;
describe("buildThemeTokenPayload", () => {
test("default (arbitrary document): every pushed property is --pn- prefixed", () => {
const payload = buildThemeTokenPayload(HOST_TOKENS, false);
expect(Object.keys(payload).length).toBe(Object.keys(HOST_TOKENS).length);
for (const key of Object.keys(payload)) {
expect(key.startsWith("--pn-")).toBe(true);
}
expect(payload["--pn-muted"]).toBe(HOST_TOKENS["--muted"]);
expect(payload["--muted"]).toBeUndefined();
});
test("host-theme opt-in: bare tokens ride along with the --pn- set", () => {
const payload = buildThemeTokenPayload(HOST_TOKENS, true);
expect(payload["--muted"]).toBe(HOST_TOKENS["--muted"]);
expect(payload["--pn-muted"]).toBe(HOST_TOKENS["--muted"]);
});
});
describe("buildSrcdocInjection", () => {
const base = { tokens: HOST_TOKENS, isLight: true, hostTheme: false, diffActive: false };
test("arbitrary document: no bare custom-property declarations reach the doc", () => {
const injection = buildSrcdocInjection(base);
const [themeBlock] = injection.split(ANNOTATION_HIGHLIGHT_CSS);
expect(themeBlock).toContain("--pn-muted:");
expect(BARE_TOKEN_DECL.test(themeBlock!.replace(/--pn-[\w-]+\s*:/g, ""))).toBe(false);
});
test("arbitrary document: no color-scheme injection in either host theme", () => {
expect(buildSrcdocInjection({ ...base, isLight: true })).not.toContain("color-scheme");
expect(buildSrcdocInjection({ ...base, isLight: false })).not.toContain("color-scheme");
});
test("host-theme opt-in: bare tokens and symmetric color-scheme are injected", () => {
const light = buildSrcdocInjection({ ...base, hostTheme: true, isLight: true });
expect(light).toContain("--muted:");
expect(light).toContain("color-scheme: light");
const dark = buildSrcdocInjection({ ...base, hostTheme: true, isLight: false });
expect(dark).toContain("color-scheme: dark");
});
test("diff CSS is absent on plain renders and scoped when active", () => {
expect(buildSrcdocInjection(base)).not.toContain("plannotator-diff");
const active = buildSrcdocInjection({ ...base, diffActive: true });
expect(active).toContain(DIFF_HIGHLIGHT_CSS);
// Scoped to diff-generated markup only — never bare ins/del selectors that
// would restyle author elements.
expect(DIFF_HIGHLIGHT_CSS).toContain("ins.plannotator-diff");
expect(DIFF_HIGHLIGHT_CSS).toContain("del.plannotator-diff");
expect(/(^|[}\s;])(ins|del)\s*\{/.test(DIFF_HIGHLIGHT_CSS)).toBe(false);
});
});
describe("viewer CSS/script namespace", () => {
test("annotation CSS reads only --pn- variables", () => {
expect(ANNOTATION_HIGHLIGHT_CSS).toContain("var(--pn-");
expect(/var\(--(?!pn-)/.test(ANNOTATION_HIGHLIGHT_CSS)).toBe(false);
expect(ANNOTATION_HIGHLIGHT_CSS).toContain("[data-plannotator-vim-reticle]");
expect(ANNOTATION_HIGHLIGHT_CSS).toContain("@media (prefers-reduced-motion: reduce)");
expect(BRIDGE_SCRIPT).toContain("return 'PREVIOUS BLOCK'");
expect(BRIDGE_SCRIPT).toContain("return 'NEXT BLOCK'");
expect(BRIDGE_SCRIPT).toContain("return 'SWAPPED ENDS'");
});
test("bridge script reads only --pn- variables and guards bare writes", () => {
expect(/var\(--(?!pn-)/.test(BRIDGE_SCRIPT)).toBe(false);
// The theme handler's non-opt-in guard: only --pn-* may be set on the root.
expect(BRIDGE_SCRIPT).toContain("key.indexOf('--pn-') !== 0");
});
});
describe("hasHostThemeOptIn", () => {
test("detects the meta tag across attribute order and quoting", () => {
expect(
hasHostThemeOptIn('<head><meta name="plannotator-theme" content="host"></head>'),
).toBe(true);
expect(
hasHostThemeOptIn("<head><meta content='host' name='plannotator-theme'/></head>"),
).toBe(true);
expect(hasHostThemeOptIn("<head><meta name=plannotator-theme content=host></head>")).toBe(
true,
);
});
test("does not trigger on absent, foreign, or mismatched metas", () => {
expect(hasHostThemeOptIn("<html><body><p>hi</p></body></html>")).toBe(false);
expect(hasHostThemeOptIn('<meta name="viewport" content="host">')).toBe(false);
expect(hasHostThemeOptIn('<meta name="plannotator-theme" content="self">')).toBe(false);
});
});
// Exercises the real bridge theme handler (the inline-setProperty site): on a
// host theme flip, nothing may land on the author's documentElement except
// --pn-* properties — no bare tokens, no `light` class — unless the document
// opted in to host theming. Requires DOM_TESTS=1 (happy-dom preload).
const hasDom = typeof document !== "undefined";
describe.if(hasDom)("bridge theme handler (DOM)", () => {
function bridgeMessageData(event: MessageEvent): Record<string, unknown> | null {
if (!event.data || typeof event.data !== "object") return null;
return event.data instanceof Object
? Object.fromEntries(Object.entries(event.data))
: null;
}
/** The bridge's page MutationObservers, captured at load: callback plus
* every observe() target. happy-dom stops delivering mutation records once
* the overlay host holds an SVG marker button (environment bug — see
* bumpDomGeneration), so observer-scope tests assert the observed target
* and drive the captured callback with synthetic records directly. */
const capturedObservers: Array<{
callback: (mutations: MutationRecord[], observer: MutationObserver) => void;
targets: Node[];
}> = [];
beforeAll(() => {
const RealMutationObserver = globalThis.MutationObserver;
(globalThis as unknown as { MutationObserver: unknown }).MutationObserver =
class CapturingMutationObserver extends RealMutationObserver {
private readonly entry: { callback: MutationCallback; targets: Node[] };
constructor(callback: MutationCallback) {
super(callback);
this.entry = { callback, targets: [] };
capturedObservers.push(this.entry as (typeof capturedObservers)[number]);
}
override observe(target: Node, options?: MutationObserverInit) {
this.entry.targets.push(target);
super.observe(target, options);
}
};
try {
new Function(BRIDGE_SCRIPT)();
} finally {
(globalThis as unknown as { MutationObserver: unknown }).MutationObserver =
RealMutationObserver;
}
});
/** Deliver synthetic mutation records straight to the bridge's page
* observer (bypassing happy-dom's broken delivery). */
function deliverPageMutations(records: Array<Partial<MutationRecord>>) {
const observer = capturedObservers[0];
if (!observer) throw new Error("bridge page observer was not captured");
observer.callback(records as MutationRecord[], null as unknown as MutationObserver);
}
function postBridge(data: Record<string, unknown>) {
window.dispatchEvent(
new MessageEvent("message", {
data,
source: window,
}),
);
}
/** find-and-mark renders through the rAF-coalesced overlay scheduler (a
* batch restore renders once instead of once per annotation); happy-dom's
* requestAnimationFrame is setImmediate-backed, so yielding one macrotask
* turn (the suite's standard flush) settles the pending overlay render. */
const flushOverlay = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/** Failed dead-target searches also carry a wall-clock backoff (300ms
* doubling to a 5s cap) on top of the generation gate. Tests asserting
* legitimate recovery after a new generation jump the bridge's monotonic
* clock (performance.now) past the cap instead of sleeping. */
let monotonicOffsetMs = 0;
beforeAll(() => {
const realNow = performance.now.bind(performance);
performance.now = () => realNow() + monotonicOffsetMs;
});
function advancePastDeadSearchBackoff() {
monotonicOffsetMs += 6000;
}
// --- Annotation overlay helpers -------------------------------------------
// Committed annotation visuals live in a shadow-rooted overlay host on the
// root element; nothing annotation-related is written into the page's DOM.
function overlayHost(): HTMLElement | null {
return document.querySelector<HTMLElement>("[data-plannotator-overlay-host]");
}
function overlayRoot(): ParentNode | null {
const host = overlayHost();
if (!host) return null;
return host.shadowRoot ?? host;
}
function allMarkers(): HTMLButtonElement[] {
const root = overlayRoot();
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLButtonElement>("button[data-plannotator-marker]"));
}
function visibleMarkers(): HTMLButtonElement[] {
return allMarkers().filter((b) => b.style.display !== "none");
}
function markersFor(id: string): HTMLButtonElement[] {
return visibleMarkers().filter((b) => b.getAttribute("data-annotation-id") === id);
}
function markerNumber(button: HTMLButtonElement): string {
return button.querySelector(".pn-marker-num")?.textContent ?? "";
}
function visibleHighlights(cls?: string): HTMLElement[] {
const root = overlayRoot();
if (!root) return [];
const selector = cls ? `.pn-hl.${cls}` : ".pn-hl";
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.style.display !== "none",
);
}
/** Committed range targets for one annotation id (cloned), via the
* bridge's test-only introspection hook. Range EXTENT — which exact text a
* committed annotation binds — has no other observable in the overlay
* model (happy-dom lays nothing out, so highlight geometry is all 0x0). */
function committedRanges(id: string): Range[] {
const internals = (window as unknown as {
__plannotatorBridgeInternals?: { committedRanges: (annId: string) => Range[] };
}).__plannotatorBridgeInternals;
return internals ? internals.committedRanges(id) : [];
}
/** Record every element scrollIntoView lands on across one action. */
function collectScrollTargets(action: () => void): Element[] {
const scrolled: Element[] = [];
const original = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = function (this: Element) {
scrolled.push(this);
};
try {
action();
} finally {
Element.prototype.scrollIntoView = original;
}
return scrolled;
}
test("author root only receives --pn-* on theme flip; opt-in restores bare push", () => {
const root = document.documentElement;
postBridge({
type: "plannotator-bridge-theme",
tokens: { "--pn-muted": "red", "--muted": "blue" },
isLight: true,
hostTheme: false,
});
expect(root.style.getPropertyValue("--pn-muted")).toBe("red");
expect(root.style.getPropertyValue("--muted")).toBe("");
expect(root.classList.contains("light")).toBe(false);
postBridge({
type: "plannotator-bridge-theme",
tokens: { "--pn-muted": "red", "--muted": "blue" },
isLight: true,
hostTheme: true,
});
expect(root.style.getPropertyValue("--muted")).toBe("blue");
expect(root.classList.contains("light")).toBe(true);
root.style.removeProperty("--pn-muted");
root.style.removeProperty("--muted");
root.classList.remove("light");
});
test("Vim navigation is block-first, focus-safe, and posts through the normal selection protocol", async () => {
document.body.innerHTML = [
"<h1>Keyboard document</h1>",
"<p>First paragraph</p>",
"<p>Second paragraph</p>",
'<a href="#destination">Native link</a>',
'<input value="native typing">',
].join("");
postBridge({
type: "plannotator-bridge-set-input-method",
method: "pinpoint",
});
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
const disabledMove = new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(disabledMove);
expect(disabledMove.defaultPrevented).toBe(false);
expect(document.querySelector("[data-plannotator-vim-badge]")).toBeNull();
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
});
postBridge({
type: "plannotator-bridge-focus-vim",
});
expect(document.body.getAttribute("tabindex")).toBe("-1");
expect(document.body.hasAttribute("data-plannotator-vim-focus-owner")).toBe(true);
const initial = document.querySelector(".plannotator-pinpoint-hover");
expect(initial?.textContent).toBe("Keyboard document");
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("BLOCK · PINPOINT");
const tab = new KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(tab);
expect(tab.defaultPrevented).toBe(false);
expect(document.querySelector(".plannotator-pinpoint-hover")?.textContent)
.toBe("Keyboard document");
const move = new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(move);
expect(move.defaultPrevented).toBe(true);
expect(document.querySelector(".plannotator-pinpoint-hover")?.textContent)
.toBe("First paragraph");
const bridgeMessages: Array<Record<string, unknown>> = [];
const capture = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (data?.type === "plannotator-bridge-selection") bridgeMessages.push(data);
};
window.addEventListener("message", capture);
const comment = new KeyboardEvent("keydown", {
key: "c",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(comment);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", capture);
expect(comment.defaultPrevented).toBe(true);
expect(bridgeMessages.at(-1)).toMatchObject({
type: "plannotator-bridge-selection",
text: "First paragraph",
modeOverride: "comment",
});
const input = document.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("Missing bridge input fixture");
const typing = new KeyboardEvent("keydown", {
key: "d",
bubbles: true,
cancelable: true,
});
input.dispatchEvent(typing);
expect(typing.defaultPrevented).toBe(false);
const link = document.querySelector<HTMLAnchorElement>("a");
if (!link) throw new Error("Missing bridge link fixture");
const activateLink = new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
});
link.dispatchEvent(activateLink);
expect(activateLink.defaultPrevented).toBe(false);
postBridge({
type: "plannotator-bridge-cancel-selection",
});
document.body.innerHTML = [
"<table><tbody>",
"<tr><td>A1</td><td>A2</td></tr>",
"<tr><td>B1</td><td>B2</td></tr>",
"</tbody></table>",
"<p>After table</p>",
].join("");
postBridge({
type: "plannotator-bridge-set-input-method",
method: "pinpoint",
});
for (const key of ["l", "l"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}
const a1 = document.querySelector(".plannotator-pinpoint-hover");
expect(a1?.tagName).toBe("TD");
expect(a1?.textContent).toBe("A1");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
}));
expect(document.querySelector(".plannotator-pinpoint-hover")?.textContent).toBe("A2");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "h",
bubbles: true,
cancelable: true,
}));
expect(document.querySelector(".plannotator-pinpoint-hover")?.tagName).toBe("TR");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
}));
expect(document.querySelector(".plannotator-pinpoint-hover")?.textContent).toBe("B1B2");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "h",
bubbles: true,
cancelable: true,
}));
expect(document.querySelector(".plannotator-pinpoint-hover")?.tagName).toBe("TABLE");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
}));
expect(document.querySelector(".plannotator-pinpoint-hover")?.textContent)
.toBe("After table");
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<p>Alpha <strong>bravo</strong> charlie</p>";
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
});
postBridge({
type: "plannotator-bridge-focus-vim",
});
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "l",
bubbles: true,
cancelable: true,
}));
expect(document.querySelector(".plannotator-pinpoint-hover")?.tagName).toBe("STRONG");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "v",
bubbles: true,
cancelable: true,
}));
for (const key of ["w", "e"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
expect(window.getSelection()?.toString()).toBe("bravo");
}
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<p>Alpha bravo charlie</p>";
postBridge({
type: "plannotator-bridge-set-input-method",
method: "drag",
});
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
});
postBridge({
type: "plannotator-bridge-focus-vim",
});
const visual = new KeyboardEvent("keydown", {
key: "v",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(visual);
const word = new KeyboardEvent("keydown", {
key: "w",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(word);
expect(window.getSelection()?.toString()).toBe("Alpha ");
const action = new KeyboardEvent("keydown", {
key: " ",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(action);
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("ACTION · SELECT");
postBridge({
type: "plannotator-bridge-cancel-selection",
});
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("VISUAL · SELECT");
expect(window.getSelection()?.toString()).toBe("Alpha ");
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<p>Collapsed text target</p>";
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
});
postBridge({
type: "plannotator-bridge-focus-vim",
});
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "l",
bubbles: true,
cancelable: true,
}));
const collapsedAction = new KeyboardEvent("keydown", {
key: "c",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(collapsedAction);
expect(collapsedAction.defaultPrevented).toBe(false);
const collapsedCopy = new KeyboardEvent("keydown", {
key: "y",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(collapsedCopy);
expect(collapsedCopy.defaultPrevented).toBe(false);
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("NORMAL · SELECT");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
}));
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
}));
const inactiveEscape = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(inactiveEscape);
expect(inactiveEscape.defaultPrevented).toBe(false);
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<p>Block one</p><p>Block two</p>";
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
});
postBridge({
type: "plannotator-bridge-focus-vim",
});
for (const key of ["V", "j"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}
expect(window.getSelection()?.toString()).toContain("Block two");
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "k",
bubbles: true,
cancelable: true,
}));
expect(window.getSelection()?.toString()).toBe("Block one");
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
expect(document.body.hasAttribute("tabindex")).toBe(false);
document.body.replaceChildren();
});
test("Vim HUD mode suppresses the iframe badge and emits handled command DTOs", async () => {
document.body.innerHTML = "<h1>First block</h1><p>Second block</p>";
const hudMessages: Array<Record<string, unknown>> = [];
const capture = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (
data
&& [
"plannotator-bridge-vim-command",
"plannotator-bridge-vim-state",
"plannotator-bridge-vim-help",
]
.includes(typeof data.type === "string" ? data.type : "")
) {
hudMessages.push(data);
}
};
window.addEventListener("message", capture);
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
hudEnabled: false,
});
postBridge({ type: "plannotator-bridge-focus-vim" });
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
}));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(hudMessages).toEqual([]);
expect(document.querySelector("[data-plannotator-vim-badge]")).not.toBeNull();
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<h1>First block</h1><p>Second block</p>";
postBridge({
type: "plannotator-bridge-set-input-method",
method: "pinpoint",
});
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
hudEnabled: true,
});
postBridge({ type: "plannotator-bridge-focus-vim" });
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "j",
bubbles: true,
cancelable: true,
}));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(document.querySelector("[data-plannotator-vim-badge]")).toBeNull();
expect(hudMessages).toContainEqual({
type: "plannotator-bridge-vim-state",
phase: "block",
});
expect(hudMessages).toContainEqual({
type: "plannotator-bridge-vim-command",
actionId: "moveDown",
key: "j",
context: "block",
});
const reticle = document.querySelector<HTMLElement>(
"[data-plannotator-vim-reticle]",
);
expect(reticle).not.toBeNull();
expect(reticle?.dataset.vimTargetPhase).toBe("block");
expect(reticle?.dataset.vimTargetLabel).toBe("BLOCK · PARAGRAPH");
expect(reticle?.querySelectorAll("[data-vim-reticle-corner]")).toHaveLength(4);
expect(document.querySelector(".plannotator-pinpoint-hover")).toBeNull();
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "?",
bubbles: true,
cancelable: true,
}));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(hudMessages).toContainEqual({
type: "plannotator-bridge-vim-help",
open: true,
});
expect(document.querySelector("[data-plannotator-vim-help]")).toBeNull();
postBridge({
type: "plannotator-bridge-set-vim-help",
open: false,
});
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(hudMessages).toContainEqual({
type: "plannotator-bridge-vim-help",
open: false,
});
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key: "l",
bubbles: true,
cancelable: true,
}));
expect(reticle?.dataset.vimTargetPhase).toBe("text");
expect(reticle?.dataset.vimTargetLabel).toBe("CURSOR · INLINE TEXT");
for (const key of ["v", "e"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}
expect(reticle?.dataset.vimTargetPhase).toBe("visual");
expect(reticle?.dataset.vimTargetLabel).toBe("VISUAL · EXACT TOKEN");
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
window.removeEventListener("message", capture);
document.body.replaceChildren();
});
test("routes Vim yank text to the trusted parent without sandbox clipboard access", async () => {
document.body.innerHTML = "<h1>Keyboard review fixture</h1><p>After</p>";
const copyMessages: Array<Record<string, unknown>> = [];
const capture = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (data?.type === "plannotator-bridge-vim-copy") {
copyMessages.push(data);
}
};
window.addEventListener("message", capture);
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
hudEnabled: true,
});
postBridge({ type: "plannotator-bridge-focus-vim" });
for (const key of ["V", "y"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", capture);
expect(copyMessages).toContainEqual({
type: "plannotator-bridge-vim-copy",
text: "Keyboard review fixture",
});
const reticle = document.querySelector<HTMLElement>(
"[data-plannotator-vim-reticle]",
);
expect(reticle?.dataset.vimTargetPhase).toBe("block");
expect(reticle?.dataset.vimTargetLabel).toBe("BLOCK · HEADING");
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.replaceChildren();
});
test("restores the committed Visual range after annotation markup mutates the DOM", () => {
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<p>Alpha bravo charlie</p>";
postBridge({
type: "plannotator-bridge-set-input-method",
method: "drag",
});
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
hudEnabled: false,
});
postBridge({ type: "plannotator-bridge-focus-vim" });
for (const key of ["v", "w", "c"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("ACTION · SELECT");
postBridge({
type: "plannotator-bridge-create-mark",
id: "vim-committed-range",
annotationType: "comment",
});
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("VISUAL · SELECT");
expect(window.getSelection()?.toString()).toBe("Alpha ");
// Committed range EXTENT: the record's range binds exactly the visual
// selection ("Alpha ") — the pre-overlay test verified the inline mark
// wrapped exactly this text.
const committedVisualRanges = committedRanges("vim-committed-range");
expect(committedVisualRanges.length).toBe(1);
expect(committedVisualRanges[0]!.toString()).toBe("Alpha ");
// The committed annotation renders as an overlay marker + highlight —
// never as inline markup inside the page.
expect(markersFor("vim-committed-range").length).toBe(1);
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "vim-committed-range",
),
).toBe(true);
expect(document.querySelector("[data-bind-id]")).toBeNull();
expect(document.querySelector("mark")).toBeNull();
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.replaceChildren();
});
test("restores a whole-block Visual range after annotation markup mutates the DOM", () => {
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.innerHTML = "<p>Whole block target</p><p>After</p>";
postBridge({
type: "plannotator-bridge-set-input-method",
method: "drag",
});
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: true,
hudEnabled: false,
});
postBridge({ type: "plannotator-bridge-focus-vim" });
for (const key of ["V", "c"]) {
document.body.dispatchEvent(new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
}));
}
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("ACTION · SELECT");
postBridge({
type: "plannotator-bridge-create-mark",
id: "vim-committed-block-range",
annotationType: "comment",
});
expect(document.querySelector("[data-plannotator-vim-badge]")?.textContent)
.toBe("VISUAL BLOCK · SELECT");
expect(window.getSelection()?.toString()).toBe("Whole block target");
// Committed range EXTENT: the whole-block commit binds exactly the
// block's text, not merely "some range that produced one marker".
const committedBlockRanges = committedRanges("vim-committed-block-range");
expect(committedBlockRanges.length).toBe(1);
expect(committedBlockRanges[0]!.toString()).toBe("Whole block target");
expect(markersFor("vim-committed-block-range").length).toBe(1);
expect(document.querySelector("[data-bind-id]")).toBeNull();
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
document.body.replaceChildren();
});
test("pinpoint click posts an anchored selection and never mutates the hovered element", async () => {
document.body.innerHTML = [
'<div id="hero"><p class="intro">Anchor target text</p><p>Second paragraph</p></div>',
].join("");
postBridge({
type: "plannotator-bridge-set-vim-mode",
enabled: false,
});
postBridge({
type: "plannotator-bridge-set-input-method",
method: "pinpoint",
});
// Mode affordance: crosshair cursor attribute while pinpoint is active.
expect(document.body.hasAttribute("data-plannotator-pinpoint-cursor")).toBe(true);
const target = document.querySelector<HTMLElement>("p.intro");
if (!target) throw new Error("target paragraph missing");
// Hover: the overlay box appears; the page element gains no class/style.
target.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true }));
const box = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-box]");
if (!box) throw new Error("pinpoint hover box missing");
expect(box.style.display).toBe("block");
expect(target.className).toBe("intro");
expect(target.getAttribute("style")).toBeNull();
// Click-to-pin: posts a selection carrying a validated anchor + pinpoint flag.
// Flush selection posts queued by earlier tests before collecting.
await new Promise((resolve) => setTimeout(resolve, 0));
const messages: Array<Record<string, unknown>> = [];
const collect = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (data?.type === "plannotator-bridge-selection") messages.push(data);
};
window.addEventListener("message", collect);
const click = new MouseEvent("click", { bubbles: true, cancelable: true });
target.dispatchEvent(click);
// postMessage delivery is task-queued — let it flush before collecting.
await new Promise((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", collect);
expect(click.defaultPrevented).toBe(true); // page behavior suppressed
expect(messages.length).toBe(1);
const posted = messages[0]!;
expect(posted.pinpoint).toBe(true);
expect(posted.text).toBe("Anchor target text");
const anchor = posted.anchor as { selector: string; tagName: string; text?: string };
expect(anchor.tagName).toBe("p");
expect(anchor.text).toBe("Anchor target text");
// The serialized selector must uniquely resolve back to the pinned element.
const matches = document.querySelectorAll(anchor.selector);
expect(matches.length).toBe(1);
expect(matches[0]).toBe(target);
postBridge({ type: "plannotator-bridge-cancel-selection" });
// Restoration writes NOTHING into the page: every restore below must
// leave the author DOM byte-identical.
const pageSnapshot = document.body.innerHTML;
// Anchor-first restore: the resolved element owns the placed marker and
// the scoped text range paints the overlay highlight.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "pin-restore",
originalText: "Anchor target text",
annotationType: "comment",
anchor,
});
await flushOverlay();
expect(markersFor("pin-restore").length).toBe(1);
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "pin-restore",
),
).toBe(true);
// Committed range EXTENT: the restored scoped text range binds INSIDE
// the anchored element and covers exactly the stored text. The scroll
// proxy below only checks the ELEMENT target — without this, the range
// could bind anywhere in the document and still pass.
const restoredRanges = committedRanges("pin-restore");
expect(restoredRanges.length).toBe(1);
expect(restoredRanges[0]!.toString()).toBe("Anchor target text");
expect(target.contains(restoredRanges[0]!.startContainer)).toBe(true);
expect(target.contains(restoredRanges[0]!.endContainer)).toBe(true);
// Scrolling the annotation lands on the anchored element itself.
const anchoredScroll = collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "pin-restore" });
});
expect(anchoredScroll[0]).toBe(target);
expect(document.body.innerHTML).toBe(pageSnapshot);
postBridge({ type: "plannotator-bridge-remove-mark", id: "pin-restore" });
expect(markersFor("pin-restore").length).toBe(0);
// Text drift under a STABLE anchor (#id): the element still identifies
// itself, so when the text is gone everywhere it gets a placed marker
// (still counts as restored).
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "pin-badge",
originalText: "Text that no longer exists anywhere",
annotationType: "comment",
anchor: { selector: "#hero", tagName: "div" },
});
await flushOverlay();
const driftMarkers = markersFor("pin-badge");
expect(driftMarkers.length).toBe(1);
expect(markerNumber(driftMarkers[0]!)).toBe("1");
expect(driftMarkers[0]!.getAttribute("aria-label")).toBe("Comment 1");
expect(driftMarkers[0]!.tagName).toBe("BUTTON");
expect(document.body.innerHTML).toBe(pageSnapshot);
// A weak anchor with NO text snapshot is rejected outright (a missing
// snapshot is a rejection, not an exemption): restoration falls back to
// the document-wide text search instead of trusting the element.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "pin-no-snapshot",
originalText: "Second paragraph",
annotationType: "comment",
anchor: { selector: anchor.selector, tagName: "p" },
});
await flushOverlay();
expect(markersFor("pin-no-snapshot").length).toBe(1);
const fallbackScroll = collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "pin-no-snapshot" });
});
// The text was found in the SECOND paragraph, not the anchored element.
expect(fallbackScroll[0]?.textContent).toBe("Second paragraph");
expect(fallbackScroll[0]).not.toBe(target);
postBridge({ type: "plannotator-bridge-remove-mark", id: "pin-no-snapshot" });
// Fail-closed anchors: a weak selector whose text snapshot no longer
// matches must not resolve (falls back to document-wide text search).
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "pin-stale",
originalText: "Second paragraph",
annotationType: "comment",
anchor: { selector: anchor.selector, tagName: "p", text: "Stale snapshot" },
});
await flushOverlay();
expect(markersFor("pin-stale").length).toBe(1);
const staleScroll = collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "pin-stale" });
});
expect(staleScroll[0]?.textContent).toBe("Second paragraph");
expect(staleScroll[0]).not.toBe(target);
expect(document.body.innerHTML).toBe(pageSnapshot);
postBridge({ type: "plannotator-bridge-clear-marks" });
expect(visibleMarkers().length).toBe(0);
postBridge({
type: "plannotator-bridge-set-input-method",
method: "drag",
});
expect(document.body.hasAttribute("data-plannotator-pinpoint-cursor")).toBe(false);
document.body.replaceChildren();
});
test("deeply nested targets get no anchor instead of a quadratic selector walk", async () => {
// Each ancestor step costs a document-wide uniqueness query against a
// growing selector, so unbounded depth freezes the tab on one click
// (measured ~58s at depth 800 pre-cap). Past MAX_ANCHOR_PATH_DEPTH the
// anchor is abandoned and restoration falls back to text search.
// Two structurally identical chains: every positional selector along the
// walk matches both branches, so uniqueness cannot short-circuit before
// the depth cap fires (the branch point sits above it).
const DEPTH = 60;
let chainA = "<p>Deeply buried text</p>";
let chainB = "<p>Other branch text</p>";
for (let i = 0; i < DEPTH; i++) {
chainA = `<div>${chainA}</div>`;
chainB = `<div>${chainB}</div>`;
}
document.body.innerHTML = chainA + chainB;
const target = document.querySelector<HTMLElement>("p");
if (!target || target.textContent !== "Deeply buried text") throw new Error("deep target missing");
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const messages: Array<Record<string, unknown>> = [];
const collect = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (data?.type === "plannotator-bridge-selection") messages.push(data);
};
window.addEventListener("message", collect);
const started = performance.now();
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
const elapsed = performance.now() - started;
await new Promise((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", collect);
expect(messages.length).toBe(1);
expect(messages[0]!.text).toBe("Deeply buried text");
expect((messages[0] as { anchor?: unknown }).anchor).toBeUndefined();
// Bounded work: the capped walk must complete in interactive time even
// under happy-dom's slow selector engine.
expect(elapsed).toBeLessThan(2000);
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("behavioral-attribute anchors never bypass the text check", async () => {
// Regenerated page: the button kept its role but its meaning flipped; the
// annotated text moved into a sibling paragraph. The role anchor must NOT
// resolve (role names behavior, not content) — the annotation follows the
// text via the document-wide search, and no pin lands on the button.
document.body.innerHTML = [
'<button role="button">Delete everything</button>',
"<p>Save draft</p>",
].join("");
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "role-anchor",
originalText: "Save draft",
annotationType: "comment",
anchor: { selector: 'button[role="button"]', tagName: "button", text: "Save draft" },
});
await flushOverlay();
expect(markersFor("role-anchor").length).toBe(1);
const roleScroll = collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "role-anchor" });
});
// The annotation followed the TEXT into the paragraph — never the button.
expect(roleScroll[0]?.tagName).toBe("P");
postBridge({ type: "plannotator-bridge-clear-marks" });
// data-* attributes ARE author-controlled identity: a data-testid anchor
// whose text drifted still resolves, and with the text gone everywhere the
// element gets the placed marker.
document.body.innerHTML = '<div data-testid="stats">New numbers</div>';
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "data-anchor",
originalText: "Old numbers",
annotationType: "comment",
anchor: { selector: 'div[data-testid="stats"]', tagName: "div", text: "Old numbers" },
});
await flushOverlay();
expect(markersFor("data-anchor").length).toBe(1);
const dataScroll = collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "data-anchor" });
});
expect(dataScroll[0]).toBe(document.querySelector('div[data-testid="stats"]'));
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("data-test-id/data-cy/data-qa are author-controlled identity attrs", async () => {
// Same trust class as data-testid: the anchor resolves without a text
// check even after the element's content drifted completely.
document.body.innerHTML = '<div data-cy="metrics">Fresh content</div>';
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "cy-anchor",
originalText: "Stale content gone from the page",
annotationType: "comment",
anchor: { selector: 'div[data-cy="metrics"]', tagName: "div", text: "Stale content gone from the page" },
});
await flushOverlay();
expect(markersFor("cy-anchor").length).toBe(1);
expect(document.querySelector("[data-bind-id]")).toBeNull();
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
// --- New hit-testing contract: hover targets the real element under the
// cursor (no tag whitelist, no has-text requirement). happy-dom has no
// layout, so document.elementFromPoint yields nothing and resolution runs
// from the event target — which in a real browser IS the deepest rendered
// element under the pointer, the exact geometry these tests model.
/** Signoff-page-shaped fixture: styled div/span chips, buttons, cards. */
const SIGNOFF_MARKUP = [
"<section><div class=\"frame\">",
"<div class=\"ihead\"><span class=\"dnum\">R1</span>",
"<span class=\"ibehav\">Empty library state</span></div>",
"<div class=\"rline\"><span class=\"rkey\">no-jargon</span>",
"<span class=\"rowchip\">adopted by 1</span></div>",
"<span class=\"btn primary\">Create</span>",
"</div></section>",
].join("");
function hoverAt(el: Element, x: number, y: number) {
el.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}));
}
async function clickAndCollectSelection(el: Element, x: number, y: number) {
await new Promise((resolve) => setTimeout(resolve, 0));
const messages: Array<Record<string, unknown>> = [];
const collect = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (data?.type === "plannotator-bridge-selection") messages.push(data);
};
window.addEventListener("message", collect);
const click = new MouseEvent("click", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
});
el.dispatchEvent(click);
await new Promise((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", collect);
return { click, messages };
}
test("chips and small buttons on div/span markup are individually targetable", async () => {
document.body.innerHTML = SIGNOFF_MARKUP;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const chip = document.querySelector<HTMLElement>("span.rowchip");
if (!chip) throw new Error("chip fixture missing");
// Hover resolves the chip itself — not the enclosing section.
hoverAt(chip, 100, 100);
const box = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-box]");
expect(box?.style.display).toBe("block");
expect(
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]")?.textContent,
).toBe("rowchip");
expect(chip.className).toBe("rowchip"); // page DOM untouched
const { click, messages } = await clickAndCollectSelection(chip, 100, 100);
expect(click.defaultPrevented).toBe(true);
expect(messages.length).toBe(1);
expect(messages[0]!.pinpoint).toBe(true);
expect(messages[0]!.text).toBe("adopted by 1");
const chipAnchor = messages[0]!.anchor as { selector: string; tagName: string; text?: string };
expect(chipAnchor.tagName).toBe("span");
const chipMatches = document.querySelectorAll(chipAnchor.selector);
expect(chipMatches.length).toBe(1);
expect(chipMatches[0]).toBe(chip);
postBridge({ type: "plannotator-bridge-cancel-selection" });
// The R1 chip and the small button resolve the same way.
const dnum = document.querySelector<HTMLElement>("span.dnum");
if (!dnum) throw new Error("dnum fixture missing");
hoverAt(dnum, 200, 100);
expect(
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]")?.textContent,
).toBe("dnum");
const dnumResult = await clickAndCollectSelection(dnum, 200, 100);
expect(dnumResult.messages.length).toBe(1);
expect(dnumResult.messages[0]!.text).toBe("R1");
postBridge({ type: "plannotator-bridge-cancel-selection" });
const btn = document.querySelector<HTMLElement>("span.btn");
if (!btn) throw new Error("button fixture missing");
hoverAt(btn, 300, 100);
expect(
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]")?.textContent,
).toBe("btn primary");
const btnResult = await clickAndCollectSelection(btn, 300, 100);
expect(btnResult.messages.length).toBe(1);
expect(btnResult.messages[0]!.text).toBe("Create");
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("pointing at a container's uncovered area selects the container", async () => {
// The geometric scope rule (matches the markdown surface): the deepest
// element under the pointer wins, so a pointer over the card's padding —
// where the card itself is the deepest rendered element — selects the
// card, no keyboard or cycling involved.
document.body.innerHTML = SIGNOFF_MARKUP;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const frame = document.querySelector<HTMLElement>("div.frame");
if (!frame) throw new Error("frame fixture missing");
hoverAt(frame, 400, 150);
expect(
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]")?.textContent,
).toBe("frame");
const { messages } = await clickAndCollectSelection(frame, 400, 150);
expect(messages.length).toBe(1);
expect((messages[0]!.anchor as { tagName: string }).tagName).toBe("div");
expect(String(messages[0]!.text)).toContain("no-jargon");
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("tiny elements promote to the nearest ancestor with a >=16px axis", () => {
document.body.innerHTML = '<div class="card"><span class="dot"></span></div>';
const card = document.querySelector<HTMLElement>("div.card");
const dot = document.querySelector<HTMLElement>("span.dot");
if (!card || !dot) throw new Error("promotion fixture missing");
const mockRect = (x: number, y: number, width: number, height: number) => ({
x, y, width, height,
top: y, left: x, right: x + width, bottom: y + height,
toJSON: () => ({}),
}) as DOMRect;
const originalBodyRect = document.body.getBoundingClientRect;
document.body.getBoundingClientRect = () => mockRect(0, 0, 800, 600);
dot.getBoundingClientRect = () => mockRect(10, 10, 8, 8);
card.getBoundingClientRect = () => mockRect(5, 5, 200, 100);
try {
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
hoverAt(dot, 12, 12);
const box = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-box]");
// The 8x8 dot is under 16px on both axes — the hover box outlines the
// 200x100 card instead (a floor, not a whitelist).
expect(box?.style.display).toBe("block");
expect(box?.style.left).toBe("5px");
expect(box?.style.width).toBe("200px");
expect(
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]")?.textContent,
).toBe("card");
} finally {
document.body.getBoundingClientRect = originalBodyRect;
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
}
});
test("generic-container labels follow the aria-label/role/class/text cascade", () => {
document.body.innerHTML = [
'<div class="stage">',
'<div id="l-aria" aria-label="Close dialog" class="rowchip">x</div>',
'<div id="l-role" role="tablist" class="abc12345">x</div>',
'<div id="l-class" class="styles_Card_a1b2c3">x</div>',
'<span id="l-text">R9</span>',
'<div id="l-container"><span>This text is far too long to serve as a hover label for anything</span></div>',
'<div id="l-long" class="extraverboseclasstokennameone extraverboseclasstokennametwo">x</div>',
"<p id=\"l-known\">Paragraph text</p>",
"</div>",
].join("");
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const label = () =>
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]")?.textContent;
const cases: Array<[string, string]> = [
["#l-aria", "Close dialog"], // aria-label beats classes
["#l-role", "tablist"], // role beats a hash-looking class
["#l-class", "styles Card"], // meaningful tokens, hash token stripped
["#l-text", "R9"], // short own text for class-less spans
["#l-container", "container"], // nothing meaningful -> container
// Class-token labels obey the 40-char cap like every other rung.
["#l-long", "extraverboseclasstokennameone extraverbo"],
["#l-known", "Paragraph"], // known tags keep their names
];
let x = 10;
for (const [selector, expected] of cases) {
const el = document.querySelector<HTMLElement>(selector);
if (!el) throw new Error(`label fixture ${selector} missing`);
hoverAt(el, (x += 50), 40);
expect(label()).toBe(expected);
}
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("text-less elements anchor only through a stable-identity rung", async () => {
// A text-less element has no snapshot to verify a weak anchor against, so
// it gets an anchor ONLY when the element itself carries stable identity
// (#id or an author-controlled data-* identity attribute). It is still
// annotatable either way — the selection describes the element.
document.body.innerHTML = [
'<div class="toolbar">',
'<span class="icon-close" data-testid="close-btn"></span>',
'<span class="icon-menu"></span>',
"<p>Sibling text</p></div>",
].join("");
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const close = document.querySelector<HTMLElement>("span.icon-close");
if (!close) throw new Error("icon fixture missing");
hoverAt(close, 60, 60);
const { click, messages } = await clickAndCollectSelection(close, 60, 60);
expect(click.defaultPrevented).toBe(true);
expect(messages.length).toBe(1);
// No text to quote: the posted selection describes the element.
expect(messages[0]!.text).toBe("[element: icon close]");
const anchor = messages[0]!.anchor as { selector: string; tagName: string; text?: string };
expect(anchor.selector).toBe('span[data-testid="close-btn"]');
expect(anchor.text).toBe("");
postBridge({ type: "plannotator-bridge-cancel-selection" });
// Round trip: the stable-identity anchor resolves without a text check,
// so the icon gets a placed marker even though text search can never
// succeed ("[element: icon close]" appears nowhere in the page).
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "identity-pin",
originalText: "[element: icon close]",
annotationType: "comment",
anchor,
});
await flushOverlay();
expect(markersFor("identity-pin").length).toBe(1);
expect(document.querySelector("[data-bind-id]")).toBeNull();
postBridge({ type: "plannotator-bridge-clear-marks" });
expect(visibleMarkers().length).toBe(0);
// A text-less element with only classes (weak selector) ships NO anchor:
// there is nothing to verify against, and a wrong-binding anchor is
// worse than no anchor.
const menu = document.querySelector<HTMLElement>("span.icon-menu");
if (!menu) throw new Error("menu icon fixture missing");
hoverAt(menu, 90, 60);
const menuResult = await clickAndCollectSelection(menu, 90, 60);
expect(menuResult.messages.length).toBe(1);
expect(menuResult.messages[0]!.text).toBe("[element: icon menu]");
expect(menuResult.messages[0]!.anchor).toBeUndefined();
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("identical text-less siblings fail closed instead of rebinding (D1)", async () => {
// Two structurally identical icons: any structure-derived signature
// (tag/classes/child count/size) is identical across them, so a
// positional selector whose sibling was removed would resolve to the
// WRONG element and still "verify". The contract is therefore: no anchor
// at all for weak text-less targets, and a crafted positional anchor
// with no snapshot never resolves.
document.body.innerHTML = [
'<div class="strip">',
'<span class="icon"></span>',
'<span class="icon"></span>',
"</div>",
].join("");
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const second = document.querySelectorAll<HTMLElement>("span.icon")[1];
if (!second) throw new Error("sibling fixture missing");
hoverAt(second, 120, 60);
const { messages } = await clickAndCollectSelection(second, 120, 60);
expect(messages.length).toBe(1);
expect(messages[0]!.anchor).toBeUndefined();
postBridge({ type: "plannotator-bridge-cancel-selection" });
// The old-world failure mode, replayed: after the first icon is removed,
// span.icon:nth-of-type(2) is gone and span.icon:nth-of-type(1) IS a
// different element. A crafted positional anchor (empty snapshot) must
// resolve nothing — no pin, no mark, anywhere.
second.parentElement!.removeChild(document.querySelectorAll("span.icon")[0]!);
for (const selector of ["div.strip > span:nth-of-type(2)", "div.strip > span:nth-of-type(1)"]) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: `rebind-${selector}`,
originalText: "[element: icon]",
annotationType: "comment",
anchor: { selector, tagName: "span", text: "" },
});
await flushOverlay();
}
expect(visibleMarkers().length).toBe(0);
expect(document.querySelector("[data-bind-id]")).toBeNull();
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
// --- Element context: the agent-facing description a pinpoint carries ---
// Failures to catch: silent regression to label-only capture; secrets or
// handlers leaking into feedback on disk; unbounded growth on a container
// click; the skeleton dropping the grep keys (id/class) an agent needs.
const CONTEXT_MARKUP = [
'<div id="root"><header class="site-header"><h1>Acme Analytics</h1>',
'<nav id="site-nav" class="site-nav sticky" aria-label="Primary" data-component="AppNav">',
'<ul class="nav-list"><li><a href="https://acme.test/home?token=abc#frag">Home</a></li>',
'<li><a href="/about?tab=2">About</a></li></ul>',
'<button class="nav-toggle" aria-label="Open menu" onclick="steal()" style="color:red" data-secret="s3cr3t"><svg></svg></button>',
"<script>var secret = 'do-not-export';</script>",
"</nav></header><main><h2>Usage</h2><p>Body</p></main></div>",
].join("");
test("a pinpointed <nav> carries an element context: identity, path, role, name, hooks, skeleton, surroundings", async () => {
document.body.innerHTML = CONTEXT_MARKUP;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const nav = document.querySelector<HTMLElement>("nav#site-nav")!;
hoverAt(nav, 30, 30);
const { messages } = await clickAndCollectSelection(nav, 30, 30);
expect(messages.length).toBe(1);
const context = messages[0]!.context as Record<string, unknown>;
expect(context).toBeDefined();
expect(context.tag).toBe("nav");
expect(context.id).toBe("site-nav");
expect(context.classes).toEqual(["site-nav", "sticky"]);
expect(context.path).toBe("body > div#root > header.site-header > nav#site-nav");
expect(context.role).toBe("navigation"); // implicit role, no role attribute
expect(context.name).toBe("Primary"); // aria-label wins the accessible name
expect(context.attrs).toEqual([["aria-label", "Primary"], ["data-component", "AppNav"]]);
expect(context.component).toBe("data-component=AppNav");
expect(context.landmark).toBe("header.site-header");
expect(context.heading).toBe('h1 "Acme Analytics"');
expect(context.children).toBe(2); // ul + button; the <script> is skipped
const outline = String(context.outline);
// The root tag prints its grep keys and its allowlisted attributes.
expect(outline.startsWith('<nav id="site-nav" class="site-nav sticky" aria-label="Primary" data-component="AppNav">')).toBe(true);
// Children as bare tags, leaves with their short text.
expect(outline).toContain('<ul class="nav-list">');
expect(outline).toContain("<li>Home</li>");
expect(outline).toContain('<button class="nav-toggle">');
expect(outline.endsWith("</nav>")).toBe(true);
// Never: script text, inline handlers, style, non-allowlisted data-*.
expect(JSON.stringify(context)).not.toContain("do-not-export");
expect(JSON.stringify(context)).not.toContain("steal");
expect(JSON.stringify(context)).not.toContain("color:red");
expect(JSON.stringify(context)).not.toContain("s3cr3t");
// Srcdoc sessions carry no page identity (that is live-mode only).
expect(context.page).toBeUndefined();
postBridge({ type: "plannotator-bridge-cancel-selection" });
// A text-less icon button: the quote stays the placeholder the export
// rewrites, and the context names the element the placeholder cannot.
const toggle = document.querySelector<HTMLElement>("button.nav-toggle")!;
hoverAt(toggle, 60, 30);
const button = await clickAndCollectSelection(toggle, 60, 30);
expect(button.messages[0]!.text).toBe("[element: Button]");
const buttonContext = button.messages[0]!.context as Record<string, unknown>;
expect(buttonContext.name).toBe("Open menu");
expect(buttonContext.role).toBe("button");
expect(buttonContext.landmark).toBe('nav#site-nav "Primary"');
expect(buttonContext.attrs).toEqual([["aria-label", "Open menu"]]);
postBridge({ type: "plannotator-bridge-cancel-selection" });
// Absolute URLs lose their query and fragment (tokens live there);
// relative ones are route state and stay whole.
const home = document.querySelector<HTMLElement>('a[href^="https://acme.test"]')!;
hoverAt(home, 10, 10);
const link = await clickAndCollectSelection(home, 10, 10);
const linkContext = link.messages[0]!.context as { attrs: Array<[string, string]>; role: string };
expect(linkContext.attrs).toEqual([["href", "https://acme.test/home?…"]]);
expect(linkContext.role).toBe("link");
expect(JSON.stringify(linkContext)).not.toContain("token=abc");
postBridge({ type: "plannotator-bridge-cancel-selection" });
// Fresh coordinates: the hover hit-test is keyed on the pointer position.
const about = document.querySelector<HTMLElement>('a[href="/about?tab=2"]')!;
hoverAt(about, 90, 12);
const rel = await clickAndCollectSelection(about, 90, 12);
expect((rel.messages[0]!.context as { attrs: Array<[string, string]> }).attrs).toEqual([["href", "/about?tab=2"]]);
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("a container with hundreds of children stays under the byte budget and collapses its skeleton", async () => {
const items: string[] = [];
for (let i = 0; i < 400; i++) items.push(`<li class="row">Row number ${i} with some words in it</li>`);
document.body.innerHTML = `<main><ul id="big" class="list">${items.join("")}</ul></main>`;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const list = document.querySelector<HTMLElement>("ul#big")!;
hoverAt(list, 30, 30);
const { messages } = await clickAndCollectSelection(list, 30, 30);
const context = messages[0]!.context as Record<string, unknown>;
expect(context.children).toBe(400);
expect(new TextEncoder().encode(JSON.stringify(context)).length).toBeLessThanOrEqual(2048);
const outline = String(context.outline);
expect(outline.length).toBeLessThanOrEqual(600);
// The adaptive skeleton shows a few children and counts the rest.
expect(outline).toContain("more: li×");
expect(String(context.text).length).toBeLessThanOrEqual(301); // 300 + ellipsis
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("marker click ownership is identity-gated, not selector-gated (D5)", async () => {
// A page element spoofing our marker attributes is NOT a viewer overlay:
// it hovers and annotates like any other element.
document.body.innerHTML = "<div data-plannotator-marker class=\"fake-marker\">7</div><p id=\"pin-me\">Pinned text</p>";
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const spoof = document.querySelector<HTMLElement>("div.fake-marker");
if (!spoof) throw new Error("spoof fixture missing");
hoverAt(spoof, 40, 40);
expect(
document.querySelector<HTMLElement>("[data-plannotator-pinpoint-box]")?.style.display,
).toBe("block");
const { click, messages } = await clickAndCollectSelection(spoof, 40, 40);
expect(click.defaultPrevented).toBe(true);
expect(messages.length).toBe(1);
expect(messages[0]!.text).toBe("7");
// A REAL placed marker owns its click: it posts mark-click (which the
// parent maps to focusing the annotation in the panel), not a selection.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "badge-owner",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#pin-me", tagName: "p" },
});
await flushOverlay();
const realMarker = markersFor("badge-owner")[0];
if (!realMarker) throw new Error("real marker missing");
const collected: Array<Record<string, unknown>> = [];
const collect = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (
data?.type === "plannotator-bridge-selection"
|| data?.type === "plannotator-bridge-mark-click"
) collected.push(data);
};
window.addEventListener("message", collect);
realMarker.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", collect);
expect(collected).toEqual([
{ type: "plannotator-bridge-mark-click", id: "badge-owner" },
]);
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
// --- Shift-click multi-select: several elements, ONE draft comment ---
const MULTI_MARKUP = [
'<div id="hero">',
'<p class="alpha">Alpha text</p>',
'<p class="beta">Beta text</p>',
'<p class="gamma">Gamma text</p>',
"</div>",
].join("");
type BridgeData = Record<string, unknown>;
/** Collect bridge messages of the given types across one synchronous action. */
async function collectMessages(
types: string[],
action: () => void,
): Promise<BridgeData[]> {
await new Promise((resolve) => setTimeout(resolve, 0)); // flush queued posts
const messages: BridgeData[] = [];
const collect = (event: MessageEvent) => {
const data = bridgeMessageData(event);
if (data && types.includes(String(data.type))) messages.push(data);
};
window.addEventListener("message", collect);
action();
await new Promise((resolve) => setTimeout(resolve, 0));
window.removeEventListener("message", collect);
return messages;
}
function clickAt(el: Element, x: number, y: number, shift: boolean) {
el.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
shiftKey: shift,
}));
}
function pinnedBoxCount(): number {
return document.querySelectorAll("[data-plannotator-pinpoint-box][data-pinned]").length;
}
async function startMultiDraft(options?: { arm?: boolean }): Promise<{
primaryKey: string;
keys: Map<string, string>; // className -> target key
}> {
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const alpha = document.querySelector<HTMLElement>("p.alpha")!;
const selections = await collectMessages(
["plannotator-bridge-selection"],
() => clickAt(alpha, 20, 20, false),
);
expect(selections.length).toBe(1);
expect(selections[0]!.pinpoint).toBe(true);
expect(typeof selections[0]!.targetKey).toBe("string");
expect(selections[0]!.targetLabel).toBe("Paragraph");
const primaryKey = String(selections[0]!.targetKey);
// The parent arms multi-select only when its comment composer mirrors the
// draft — replayed here unless the test wants an UNARMED draft.
if (options?.arm !== false) {
postBridge({ type: "plannotator-bridge-arm-multi-select", key: primaryKey });
}
const keys = new Map<string, string>();
keys.set("alpha", primaryKey);
return { primaryKey, keys };
}
test("shift-click additional targets carry their own (smaller) element context", async () => {
document.body.innerHTML = MULTI_MARKUP;
const { keys } = await startMultiDraft();
const beta = document.querySelector<HTMLElement>("p.beta")!;
const added = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(beta, 40, 40, true),
);
expect(added.length).toBe(1);
const context = added[0]!.context as Record<string, unknown>;
expect(context.tag).toBe("p");
expect(context.path).toBe("body > div#hero > p.beta");
expect(context.text).toBe("Beta text");
expect(new TextEncoder().encode(JSON.stringify(context)).length).toBeLessThanOrEqual(1024);
expect(keys.get("alpha")).toBeTruthy();
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("shift-click adds targets to the SAME draft and toggles them off again", async () => {
document.body.innerHTML = MULTI_MARKUP;
const { keys } = await startMultiDraft();
const beta = document.querySelector<HTMLElement>("p.beta")!;
const gamma = document.querySelector<HTMLElement>("p.gamma")!;
// Shift-click beta: joins the draft, posts a validated target DTO, and
// gets its own pinned outline box (primary keeps the main box).
const added = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(beta, 40, 40, true),
);
expect(added.length).toBe(1);
expect(added[0]!.label).toBe("Paragraph");
expect(added[0]!.text).toBe("Beta text");
const betaAnchor = added[0]!.anchor as { selector: string };
const betaMatches = document.querySelectorAll(betaAnchor.selector);
expect(betaMatches.length).toBe(1);
expect(betaMatches[0]).toBe(beta);
keys.set("beta", String(added[0]!.key));
expect(pinnedBoxCount()).toBe(2);
const addedGamma = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(gamma, 60, 60, true),
);
expect(addedGamma.length).toBe(1);
expect(pinnedBoxCount()).toBe(3);
// Shift-click beta AGAIN: toggle-off by DOM identity — the removal is
// echoed to the parent and beta's outline box disappears.
const removed = await collectMessages(
["plannotator-bridge-multi-target-added", "plannotator-bridge-multi-target-removed"],
() => clickAt(beta, 40, 40, true),
);
expect(removed.length).toBe(1);
expect(removed[0]!.type).toBe("plannotator-bridge-multi-target-removed");
expect(removed[0]!.key).toBe(keys.get("beta"));
expect(pinnedBoxCount()).toBe(2);
postBridge({ type: "plannotator-bridge-cancel-selection" });
expect(pinnedBoxCount()).toBe(0);
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("create-mark commits every draft target under ONE id with ONE badge number", async () => {
document.body.innerHTML = MULTI_MARKUP;
await startMultiDraft();
clickAt(document.querySelector("p.beta")!, 40, 40, true);
clickAt(document.querySelector("p.gamma")!, 60, 60, true);
await new Promise((resolve) => setTimeout(resolve, 0));
postBridge({
type: "plannotator-bridge-create-mark",
id: "multi-commit",
annotationType: "comment",
});
// Primary + beta + gamma all render placed markers under the SAME id —
// and every marker for this grouped annotation shows the SAME number.
// Numbering derives from the annotation collection, never target count.
const commitMarkers = markersFor("multi-commit");
expect(commitMarkers.length).toBeGreaterThanOrEqual(2);
for (const marker of commitMarkers) {
expect(markerNumber(marker)).toBe("1");
expect(marker.getAttribute("aria-label")).toBe("Comment 1");
}
expect(pinnedBoxCount()).toBe(0); // draft state fully cleared
// A SECOND annotation numbers 2 — multi-target markers consumed one slot.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "second-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#hero", tagName: "div" },
});
await flushOverlay();
const numbers = visibleMarkers().map((b) => markerNumber(b));
expect(numbers).toContain("2");
expect(numbers.filter((n) => n === "1").length).toBeGreaterThanOrEqual(2);
expect(numbers.filter((n) => n === "2").length).toBe(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("removing the primary promotes the next target; removing the last cancels", async () => {
document.body.innerHTML = MULTI_MARKUP;
const { primaryKey } = await startMultiDraft();
const beta = document.querySelector<HTMLElement>("p.beta")!;
clickAt(beta, 40, 40, true);
await new Promise((resolve) => setTimeout(resolve, 0));
// Shift-click the PRIMARY: it is removed and beta is promoted.
const removals = await collectMessages(
["plannotator-bridge-multi-target-removed"],
() => clickAt(document.querySelector("p.alpha")!, 20, 20, true),
);
expect(removals.length).toBe(1);
expect(removals[0]!.key).toBe(primaryKey);
expect(pinnedBoxCount()).toBe(1); // only the promoted primary's main box
// Committing now marks the PROMOTED element (beta), not alpha.
postBridge({
type: "plannotator-bridge-create-mark",
id: "promoted-commit",
annotationType: "comment",
});
const promotedMarkers = markersFor("promoted-commit");
expect(promotedMarkers.length).toBe(1);
const promotedScroll = collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "promoted-commit" });
});
expect(promotedScroll[0]).toBe(document.querySelector("p.beta"));
postBridge({ type: "plannotator-bridge-clear-marks" });
// Fresh draft with ONLY a primary: shift-clicking it cancels the draft —
// a later create-mark must commit nothing.
const again = await startMultiDraft();
const cancel = await collectMessages(
["plannotator-bridge-multi-target-removed"],
() => clickAt(document.querySelector("p.alpha")!, 20, 20, true),
);
expect(cancel.length).toBe(1);
expect(cancel[0]!.key).toBe(again.primaryKey);
expect(pinnedBoxCount()).toBe(0);
postBridge({
type: "plannotator-bridge-create-mark",
id: "cancelled-commit",
annotationType: "comment",
});
expect(markersFor("cancelled-commit").length).toBe(0);
expect(visibleMarkers().length).toBe(0);
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("UNARMED pinpoint drafts refuse the shift-toggle (D1: quickLabel-style modes)", async () => {
// The parent only mirrors targets while the comment composer owns the
// draft; quickLabel/redline drafts never send arm-multi-select. A
// shift-click on such a draft must NOT accumulate bridge-side targets the
// saved annotation would not carry — it behaves as a plain click.
document.body.innerHTML = MULTI_MARKUP;
await startMultiDraft({ arm: false });
const beta = document.querySelector<HTMLElement>("p.beta")!;
const messages = await collectMessages(
["plannotator-bridge-multi-target-added", "plannotator-bridge-selection"],
() => clickAt(beta, 40, 40, true),
);
// No multi-target-added; the shift-click replaced the draft instead.
expect(messages.length).toBe(1);
expect(messages[0]!.type).toBe("plannotator-bridge-selection");
expect(messages[0]!.text).toBe("Beta text");
expect(pinnedBoxCount()).toBe(1); // only the (new) primary's main box
// Committing registers nothing beyond the new primary — no orphan pins.
postBridge({
type: "plannotator-bridge-create-mark",
id: "unarmed-commit",
annotationType: "comment",
});
expect(markersFor("unarmed-commit").length).toBeLessThanOrEqual(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("a stale or mismatched arm key never arms a draft", async () => {
document.body.innerHTML = MULTI_MARKUP;
await startMultiDraft({ arm: false });
postBridge({ type: "plannotator-bridge-arm-multi-select", key: "not-the-primary" });
const messages = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(document.querySelector("p.beta")!, 40, 40, true),
);
expect(messages.length).toBe(0);
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("arming is per-draft: a NEW draft after an armed one starts unarmed (D1-R)", async () => {
// The stale-arm regression: comment-mode draft gets armed, the user
// switches to a mode the parent does not mirror (quick label posts
// nothing to the bridge), then starts a NEW draft with a plain click.
// Without a per-draft reset the old arm leaks into the new draft and the
// bridge accumulates outlines/pins the saved annotation will not carry.
document.body.innerHTML = MULTI_MARKUP;
const { primaryKey } = await startMultiDraft(); // armed comment draft
postBridge({ type: "plannotator-bridge-arm-multi-select", key: primaryKey });
// New draft via plain click (no cancel-selection, no re-arm) — exactly
// what a toolbar mode change followed by a pinpoint click produces.
const beta = document.querySelector<HTMLElement>("p.beta")!;
await collectMessages(["plannotator-bridge-selection"], () => clickAt(beta, 40, 40, false));
// Shift-click on the unarmed new draft must NOT add a target.
const added = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(document.querySelector("p.gamma") ?? document.querySelector("p.alpha")!, 40, 40, true),
);
expect(added.length).toBe(0);
// Committing the new draft registers at most its own primary: no orphans.
postBridge({
type: "plannotator-bridge-create-mark",
id: "stale-arm-commit",
annotationType: "comment",
});
expect(markersFor("stale-arm-commit").length).toBeLessThanOrEqual(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("remove-target is idempotent and resyncs a forged primary removal (D4)", async () => {
document.body.innerHTML = MULTI_MARKUP;
const { primaryKey } = await startMultiDraft();
const added = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(document.querySelector("p.beta")!, 40, 40, true),
);
const betaKey = String(added[0]!.key);
expect(pinnedBoxCount()).toBe(2);
// Forged-removal scenario: the parent believed the primary was removed
// (hostile multi-target-removed) and echoes remove-target back. The
// bridge, which never removed it, now performs the SAME promotion —
// both sides converge on beta as the primary.
postBridge({ type: "plannotator-bridge-remove-target", key: primaryKey });
expect(pinnedBoxCount()).toBe(1);
// Idempotency: the same removal again (double echo) is a no-op.
postBridge({ type: "plannotator-bridge-remove-target", key: primaryKey });
expect(pinnedBoxCount()).toBe(1);
// Committing pins the promoted element (beta), matching the parent model.
postBridge({
type: "plannotator-bridge-create-mark",
id: "resync-commit",
annotationType: "comment",
});
expect(markersFor("resync-commit").length).toBe(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
void betaKey;
document.body.replaceChildren();
});
test("parent-initiated remove-target mirrors without echoing back", async () => {
document.body.innerHTML = MULTI_MARKUP;
await startMultiDraft();
const added = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => clickAt(document.querySelector("p.beta")!, 40, 40, true),
);
const betaKey = String(added[0]!.key);
expect(pinnedBoxCount()).toBe(2);
const echoes = await collectMessages(
["plannotator-bridge-multi-target-removed"],
() => postBridge({ type: "plannotator-bridge-remove-target", key: betaKey }),
);
expect(echoes.length).toBe(0); // the parent already updated its own list
expect(pinnedBoxCount()).toBe(1);
// flash-target on the survivor must not throw or change draft state.
postBridge({ type: "plannotator-bridge-flash-target", key: betaKey });
expect(pinnedBoxCount()).toBe(1);
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("the multi-target draft is capped at 16 additional targets in the bridge", async () => {
const paragraphs: string[] = [];
for (let i = 0; i < 20; i++) paragraphs.push(`<p class="cap-${i}">Cap target ${i}</p>`);
document.body.innerHTML = `<div id="cap-stage">${paragraphs.join("")}</div>`;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const primarySelections = await collectMessages(
["plannotator-bridge-selection"],
() => clickAt(document.querySelector("p.cap-0")!, 10, 10, false), // primary
);
postBridge({
type: "plannotator-bridge-arm-multi-select",
key: String(primarySelections[0]!.targetKey),
});
const added = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => {
for (let i = 1; i < 20; i++) {
clickAt(document.querySelector(`p.cap-${i}`)!, 10 + i, 10, true);
}
},
);
expect(added.length).toBe(16);
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("arm-multi-select { max } lowers the draft cap for THAT draft only; absent max keeps 16", async () => {
// A host with a smaller product cap arms the draft with it so the in-page
// shift-toggle stops where the saved annotation would, instead of letting
// pins accumulate that the host will later trim. The cap is per draft:
// the next draft armed without one is back at the package's 16.
const paragraphs: string[] = [];
for (let i = 0; i < 20; i++) paragraphs.push(`<p class="hcap-${i}">Host cap target ${i}</p>`);
document.body.innerHTML = `<div id="hcap-stage">${paragraphs.join("")}</div>`;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const first = await collectMessages(
["plannotator-bridge-selection"],
() => clickAt(document.querySelector("p.hcap-0")!, 10, 10, false),
);
postBridge({
type: "plannotator-bridge-arm-multi-select",
key: String(first[0]!.targetKey),
max: 2,
});
const capped = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => {
for (let i = 1; i < 10; i++) clickAt(document.querySelector(`p.hcap-${i}`)!, 10 + i, 10, true);
},
);
expect(capped.length).toBe(2);
postBridge({ type: "plannotator-bridge-cancel-selection" });
// Next draft, armed without a max: the package cap applies again.
const second = await collectMessages(
["plannotator-bridge-selection"],
() => clickAt(document.querySelector("p.hcap-0")!, 10, 10, false),
);
postBridge({
type: "plannotator-bridge-arm-multi-select",
key: String(second[0]!.targetKey),
});
const uncapped = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => {
for (let i = 1; i < 20; i++) clickAt(document.querySelector(`p.hcap-${i}`)!, 10 + i, 10, true);
},
);
expect(uncapped.length).toBe(16);
// A max above the package cap, or garbage, never raises it.
postBridge({ type: "plannotator-bridge-cancel-selection" });
const third = await collectMessages(
["plannotator-bridge-selection"],
() => clickAt(document.querySelector("p.hcap-0")!, 10, 10, false),
);
postBridge({
type: "plannotator-bridge-arm-multi-select",
key: String(third[0]!.targetKey),
max: 999,
});
const clamped = await collectMessages(
["plannotator-bridge-multi-target-added"],
() => {
for (let i = 1; i < 20; i++) clickAt(document.querySelector(`p.hcap-${i}`)!, 10 + i, 10, true);
},
);
expect(clamped.length).toBe(16);
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
test("report-unanchored answers with the complete set once, empty included, after the restore batch", async () => {
// A fresh document whose restores all succeed never changes the set
// from its initial empty state, so change-only emission stays silent
// and a host could never learn that a previous orphan re-anchored. The
// parent's explicit request forces one emission after the next complete
// pass; ordinary change-only behavior resumes after it.
const reports: string[][] = [];
const listener = (e: MessageEvent) => {
const d = bridgeMessageData(e);
if (d && d.type === "plannotator-bridge-unanchored") reports.push(d.ids as string[]);
};
window.addEventListener("message", listener);
try {
document.body.innerHTML = "<p>Report target copy</p>";
postBridge({ type: "plannotator-bridge-clear-marks" });
await flushOverlay();
const baseline = reports.length;
// Everything restores: change-only would emit nothing.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "report-live",
originalText: "Report target copy",
annotationType: "comment",
});
postBridge({ type: "plannotator-bridge-report-unanchored" });
await flushOverlay();
expect(reports.length).toBe(baseline + 1);
expect(reports.at(-1)).toEqual([]);
// The request is consumed: an idle pass emits nothing more.
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
expect(reports.length).toBe(baseline + 1);
// With a failure in the batch the forced report carries it, once.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "report-ghost",
originalText: "Text this page never had",
annotationType: "comment",
});
postBridge({ type: "plannotator-bridge-report-unanchored" });
await flushOverlay();
expect(reports.at(-1)).toEqual(["report-ghost"]);
expect(reports.length).toBe(baseline + 2);
} finally {
window.removeEventListener("message", listener);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
}
});
test("scroll-to honors an explicit behavior and stays smooth without one", async () => {
// Reduced motion is not observable across the iframe boundary, so the
// parent passes it as behavior: 'auto'. Absent, the scroll must stay
// smooth: hosts that never pass it see no change.
document.body.innerHTML = "<p class=\"scroll-target\">Scroll behavior target</p>";
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "scroll-behavior",
originalText: "Scroll behavior target",
annotationType: "comment",
});
await flushOverlay();
const behaviors: Array<ScrollBehavior | undefined> = [];
const original = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = function (this: Element, arg?: boolean | ScrollIntoViewOptions) {
behaviors.push(typeof arg === "object" && arg ? arg.behavior : undefined);
};
try {
postBridge({ type: "plannotator-bridge-scroll-to", id: "scroll-behavior" });
postBridge({ type: "plannotator-bridge-scroll-to", id: "scroll-behavior", behavior: "auto" });
postBridge({ type: "plannotator-bridge-scroll-to", id: "scroll-behavior", behavior: "instant" });
} finally {
Element.prototype.scrollIntoView = original;
}
// Absent: smooth. 'auto': honored. Anything else: smooth (fail closed).
expect(behaviors).toEqual(["smooth", "auto", "smooth"]);
postBridge({ type: "plannotator-bridge-remove-mark", id: "scroll-behavior" });
document.body.replaceChildren();
});
test("find-and-mark restores additional anchors as same-numbered pins, fail-closed", async () => {
document.body.innerHTML = MULTI_MARKUP;
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "restore-multi",
originalText: "Alpha text",
annotationType: "comment",
anchor: { selector: "p.alpha", tagName: "p", text: "Alpha text" },
additionalAnchors: [
{ selector: "p.beta", tagName: "p", text: "Beta text" },
// Stale snapshot: this anchor must NOT resolve (no pin, no mis-mark).
{ selector: "p.gamma", tagName: "p", text: "Stale snapshot" },
],
});
await flushOverlay();
// Primary restored as an anchored element marker + overlay highlight;
// beta restored as a marker sharing the SAME number; gamma failed closed
// (stale snapshot resolves nothing — no marker, no mis-highlight).
const restoreMarkers = markersFor("restore-multi");
expect(restoreMarkers.length).toBe(2);
for (const marker of restoreMarkers) expect(markerNumber(marker)).toBe("1");
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "restore-multi",
),
).toBe(true);
expect(document.querySelector("[data-bind-id]")).toBeNull();
postBridge({ type: "plannotator-bridge-remove-mark", id: "restore-multi" });
expect(markersFor("restore-multi").length).toBe(0);
expect(
visibleHighlights().some(
(el) => el.getAttribute("data-annotation-id") === "restore-multi",
),
).toBe(false);
document.body.replaceChildren();
});
test("hover hit-testing never storms document-wide queries per mousemove", () => {
// The old hover path rebuilt the semantic target graph (three
// document-wide querySelectorAll sweeps) on every pointer frame. The new
// path is per-event hit-testing: element identity plus closest() walks,
// zero document-wide queries. Graph builds remain click/vim-time only.
document.body.innerHTML = SIGNOFF_MARKUP;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const targets = Array.from(document.querySelectorAll<HTMLElement>("span, div, section"));
const originalQsa = document.querySelectorAll.bind(document);
let documentWideQueries = 0;
(document as { querySelectorAll: typeof document.querySelectorAll }).querySelectorAll = ((
...args: Parameters<typeof document.querySelectorAll>
) => {
documentWideQueries += 1;
return originalQsa(...args);
}) as typeof document.querySelectorAll;
try {
let x = 0;
for (let i = 0; i < 30; i++) {
const el = targets[i % targets.length]!;
hoverAt(el, (x += 23), 90);
}
expect(documentWideQueries).toBe(0);
} finally {
(document as { querySelectorAll: typeof document.querySelectorAll }).querySelectorAll = originalQsa;
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
}
});
// --- Placed-marker overlay: geometry, lifecycle, and isolation ------------
const rectOf = (x: number, y: number, width: number, height: number) => ({
x, y, width, height,
top: y, left: x, right: x + width, bottom: y + height,
toJSON: () => ({}),
}) as DOMRect;
/** Enable "layout" in happy-dom: give the body a real rect. */
async function withLayout(run: () => void | Promise<void>) {
const originalBodyRect = document.body.getBoundingClientRect;
document.body.getBoundingClientRect = () => rectOf(0, 0, 1024, 768);
try {
await run();
} finally {
document.body.getBoundingClientRect = originalBodyRect;
}
}
test("annotation overlay is layout-neutral: page DOM untouched, host outside body, pointer-transparent", async () => {
document.body.innerHTML = [
'<div id="hero"><p class="intro">Anchor target text</p><p>Second paragraph</p></div>',
].join("");
const pageSnapshot = document.body.innerHTML;
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "neutral-a",
originalText: "Anchor target text",
annotationType: "comment",
anchor: { selector: "#hero", tagName: "div" },
});
await flushOverlay();
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "neutral-b",
originalText: "Second paragraph",
annotationType: "deletion",
});
await flushOverlay();
postBridge({
type: "plannotator-bridge-sync-annotations",
annotations: [{ id: "neutral-a", number: 1 }, { id: "neutral-b", number: 2 }],
});
postBridge({ type: "plannotator-bridge-focus-mark", id: "neutral-b" });
collectScrollTargets(() => {
postBridge({ type: "plannotator-bridge-scroll-to", id: "neutral-a" });
});
// The author DOM is byte-identical after restore + sync + focus + scroll.
expect(document.body.innerHTML).toBe(pageSnapshot);
// Overlay artifacts live OUTSIDE the page's layout, on the root element.
const host = overlayHost();
if (!host) throw new Error("overlay host missing");
expect(host.parentElement).toBe(document.documentElement);
expect(document.body.contains(host)).toBe(false);
expect(document.body.querySelector("[data-plannotator-marker]")).toBeNull();
expect(host.style.position).toBe("fixed");
expect(host.style.pointerEvents).toBe("none");
expect(host.style.zIndex).toBe("2147483647");
// Markers render (numbered, accessible buttons) despite touching nothing.
expect(markersFor("neutral-a").length).toBe(1);
expect(markersFor("neutral-b").length).toBe(1);
postBridge({ type: "plannotator-bridge-focus-mark", id: null });
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("marker reprojects the stored relative point against fresh geometry (no stored pixels)", async () => {
document.body.innerHTML = '<div id="geo">Geo target</div>';
const geo = document.querySelector<HTMLElement>("#geo")!;
await withLayout(async () => {
geo.getBoundingClientRect = () => rectOf(100, 50, 200, 100);
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "geo-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#geo", tagName: "div", point: { x: 0.25, y: 0.5 } },
});
await flushOverlay();
const first = markersFor("geo-ann");
expect(first.length).toBe(1);
expect(first[0]!.style.left).toBe("150px");
expect(first[0]!.style.top).toBe("100px");
// The element moves and resizes (responsive rerender): the marker
// reprojects the NORMALIZED point against the new rect — it never
// reuses the old pixels.
geo.getBoundingClientRect = () => rectOf(300, 50, 100, 100);
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
const moved = markersFor("geo-ann");
expect(moved.length).toBe(1);
expect(moved[0]!.style.left).toBe("325px");
expect(moved[0]!.style.top).toBe("100px");
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("pinpoint click captures the normalized selected point onto the posted anchor", async () => {
document.body.innerHTML = '<p id="cap">Capture me</p>';
const cap = document.querySelector<HTMLElement>("#cap")!;
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
const originalBodyRect = document.body.getBoundingClientRect;
document.body.getBoundingClientRect = () => rectOf(0, 0, 1024, 768);
cap.getBoundingClientRect = () => rectOf(100, 50, 200, 100);
try {
const { messages } = await clickAndCollectSelection(cap, 150, 100);
expect(messages.length).toBe(1);
const anchor = messages[0]!.anchor as { point?: { x: number; y: number } };
expect(anchor.point).toEqual({ x: 0.25, y: 0.5 });
// Committing places the marker at the captured point's projection.
postBridge({
type: "plannotator-bridge-create-mark",
id: "cap-ann",
annotationType: "comment",
});
const committed = markersFor("cap-ann");
expect(committed.length).toBe(1);
expect(committed[0]!.style.left).toBe("150px");
expect(committed[0]!.style.top).toBe("100px");
} finally {
document.body.getBoundingClientRect = originalBodyRect;
}
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.replaceChildren();
});
/** Advance the bridge's re-search generation like a real invalidation
* signal would. Dead-target re-resolution is generation-gated (M3): only
* text-capable signals (page mutations, settle events, frame loads) unlock
* it. happy-dom's MutationObserver stops delivering once the overlay host
* holds an SVG marker (environment bug — real browsers are unaffected), so
* tests use the settle-event signal, which is synchronous and equivalent. */
function bumpDomGeneration() {
document.body.dispatchEvent(new Event("animationend", { bubbles: true }));
}
test("unresolved targets omit their markers instead of guessing", async () => {
document.body.innerHTML = '<div data-testid="vanishing">Now you see me</div>';
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "vanish-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: 'div[data-testid="vanishing"]', tagName: "div" },
});
await flushOverlay();
expect(markersFor("vanish-ann").length).toBe(1);
// The page rerenders without the element: the anchor no longer resolves,
// so the marker is omitted — never left floating over unrelated content.
document.body.innerHTML = "<p>Completely different content</p>";
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("vanish-ann").length).toBe(0);
// The annotation record survives (it stays reachable via the panel), so
// a page that brings the element back re-resolves and re-renders it
// (once BOTH a new generation and the failure backoff have passed).
document.body.innerHTML = '<div data-testid="vanishing">Back again</div>';
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("vanish-ann").length).toBe(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("targets scrolled or clipped out of the viewport omit their markers", async () => {
document.body.innerHTML = '<div id="offscreen">Off screen</div>';
const el = document.querySelector<HTMLElement>("#offscreen")!;
await withLayout(async () => {
el.getBoundingClientRect = () => rectOf(100, -500, 200, 100); // above viewport
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "off-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#offscreen", tagName: "div" },
});
await flushOverlay();
expect(markersFor("off-ann").length).toBe(0);
el.getBoundingClientRect = () => rectOf(100, 2000, 200, 100); // below viewport
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("off-ann").length).toBe(0);
el.getBoundingClientRect = () => rectOf(100, 300, 200, 100); // scrolled back in
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("off-ann").length).toBe(1);
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("edge clamping keeps the full marker reachable; visually detached clamps are omitted", async () => {
document.body.innerHTML = '<div id="edge">Edge</div><div id="sliver">S</div>';
const edge = document.querySelector<HTMLElement>("#edge")!;
const sliver = document.querySelector<HTMLElement>("#sliver")!;
await withLayout(async () => {
// A point at the exact left edge clamps to the 29px inset and stays
// associated with its (40px-wide) target.
edge.getBoundingClientRect = () => rectOf(0, 40, 40, 40);
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "edge-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#edge", tagName: "div", point: { x: 0, y: 0.5 } },
});
await flushOverlay();
const clamped = markersFor("edge-ann");
expect(clamped.length).toBe(1);
expect(clamped[0]!.style.left).toBe("29px");
expect(clamped[0]!.style.top).toBe("60px");
// A fully visible 4px corner sliver still gets its marker: viewport
// edges CLAMP, never omit (per the contract only clip-container
// detachment omits). The raw point (2,4) sits inside the visible rect,
// so association holds and the 29px inset is rendering-only. The old
// behavior measured association AFTER clamping, creating a dead band
// where fully visible edge-flush elements lost their markers.
sliver.getBoundingClientRect = () => rectOf(0, 2, 4, 4);
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "sliver-ann",
originalText: "Text that exists nowhere on this page either",
annotationType: "comment",
anchor: { selector: "#sliver", tagName: "div" },
});
await flushOverlay();
const sliverMarkers = markersFor("sliver-ann");
expect(sliverMarkers.length).toBe(1);
expect(sliverMarkers[0]!.style.left).toBe("29px");
expect(sliverMarkers[0]!.style.top).toBe("29px");
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("coincident markers spread horizontally and deterministically", async () => {
document.body.innerHTML = [
'<div data-testid="co-a">A</div>',
'<div data-testid="co-b">B</div>',
].join("");
const a = document.querySelector<HTMLElement>('div[data-testid="co-a"]')!;
const b = document.querySelector<HTMLElement>('div[data-testid="co-b"]')!;
await withLayout(async () => {
a.getBoundingClientRect = () => rectOf(100, 100, 50, 50);
b.getBoundingClientRect = () => rectOf(100, 100, 50, 50);
for (const [id, selector] of [["co-1", 'div[data-testid="co-a"]'], ["co-2", 'div[data-testid="co-b"]']] as const) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id,
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector, tagName: "div" },
});
await flushOverlay();
}
// Same rounded point (125,125): the group spreads by 12.5px steps,
// centered on the shared point, ordered by comment number.
const one = markersFor("co-1");
const two = markersFor("co-2");
expect(one.length).toBe(1);
expect(two.length).toBe(1);
expect(one[0]!.style.left).toBe("118.75px");
expect(two[0]!.style.left).toBe("131.25px");
expect(one[0]!.style.top).toBe("125px");
expect(two[0]!.style.top).toBe("125px");
// Deterministic: a re-render yields identical placement.
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("co-1")[0]!.style.left).toBe("118.75px");
expect(markersFor("co-2")[0]!.style.left).toBe("131.25px");
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("parent-synced numbering overrides registration order and renumbers markers", async () => {
document.body.innerHTML = [
'<div data-testid="n-a">A</div>',
'<div data-testid="n-b">B</div>',
].join("");
for (const [id, selector] of [["n-1", 'div[data-testid="n-a"]'], ["n-2", 'div[data-testid="n-b"]']] as const) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id,
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector, tagName: "div" },
});
await flushOverlay();
}
// Pre-sync fallback: registration order.
expect(markerNumber(markersFor("n-1")[0]!)).toBe("1");
expect(markerNumber(markersFor("n-2")[0]!)).toBe("2");
// The parent's ordered collection is authoritative — numbers follow it.
postBridge({
type: "plannotator-bridge-sync-annotations",
annotations: [{ id: "n-2", number: 1 }, { id: "n-1", number: 2 }],
});
expect(markerNumber(markersFor("n-2")[0]!)).toBe("1");
expect(markersFor("n-2")[0]!.getAttribute("aria-label")).toBe("Comment 1");
expect(markerNumber(markersFor("n-1")[0]!)).toBe("2");
expect(markersFor("n-1")[0]!.getAttribute("aria-label")).toBe("Comment 2");
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("malformed sync payloads are ignored or skipped at the bridge boundary", async () => {
document.body.innerHTML = [
'<div data-testid="m-a">A</div>',
'<div data-testid="m-b">B</div>',
].join("");
for (const [id, selector] of [["m-1", 'div[data-testid="m-a"]'], ["m-2", 'div[data-testid="m-b"]']] as const) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id,
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector, tagName: "div" },
});
await flushOverlay();
}
postBridge({
type: "plannotator-bridge-sync-annotations",
annotations: [{ id: "m-1", number: 5 }, { id: "m-2", number: 6 }],
});
expect(markerNumber(markersFor("m-1")[0]!)).toBe("5");
// A completely malformed list leaves the previous numbering untouched.
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: 42 });
postBridge({ type: "plannotator-bridge-sync-annotations" });
expect(markerNumber(markersFor("m-1")[0]!)).toBe("5");
expect(markerNumber(markersFor("m-2")[0]!)).toBe("6");
// Junk entries are skipped; only well-formed (id, integer >= 1) apply.
postBridge({
type: "plannotator-bridge-sync-annotations",
annotations: [
{ id: "m-1", number: 7 },
{ id: "m-2", number: 0 },
{ id: "m-2", number: 2.5 },
{ id: "m-2", number: -3 },
{ id: 5, number: 2 },
"junk",
{ id: "x".repeat(300), number: 2 },
],
});
expect(markerNumber(markersFor("m-1")[0]!)).toBe("7");
// m-2 fell back to registration order (2), not a hostile number.
expect(markerNumber(markersFor("m-2")[0]!)).toBe("2");
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("focus highlight covers EVERY rect of a multi-paragraph selection (partial-blue regression)", async () => {
document.body.innerHTML = "<p>Alpha beta gamma delta</p>";
const originalGetClientRects = Range.prototype.getClientRects;
const originalBodyRect = document.body.getBoundingClientRect;
document.body.getBoundingClientRect = () => rectOf(0, 0, 1024, 768);
// A wrapped multi-line/multi-paragraph selection yields SEVERAL client
// rects. The old .focused class landed on querySelector's FIRST inline
// mark only; the overlay must cover every rect.
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(10, 10, 100, 20),
rectOf(10, 40, 80, 20),
];
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "focus-multi",
originalText: "Alpha beta gamma delta",
annotationType: "comment",
});
await flushOverlay();
// Persistent comment highlight paints both rects.
const commentRects = visibleHighlights("pn-hl-comment").filter(
(el) => el.getAttribute("data-annotation-id") === "focus-multi",
);
expect(commentRects.length).toBe(2);
postBridge({ type: "plannotator-bridge-focus-mark", id: "focus-multi" });
const focusRects = visibleHighlights("pn-hl-focus").filter(
(el) => el.getAttribute("data-annotation-id") === "focus-multi",
);
expect(focusRects.length).toBe(2);
expect(focusRects.map((el) => el.style.top).sort()).toEqual(["10px", "40px"]);
postBridge({ type: "plannotator-bridge-focus-mark", id: null });
expect(visibleHighlights("pn-hl-focus").length).toBe(0);
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
document.body.getBoundingClientRect = originalBodyRect;
}
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("draft selection highlight is overlay-projected and clears on cancel", async () => {
document.body.innerHTML = "<p>Draft highlight target</p>";
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
const p = document.querySelector("p")!;
const range = document.createRange();
range.selectNodeContents(p);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
p.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 25)); // handleSelection debounce
expect(visibleHighlights("pn-hl-draft").length).toBeGreaterThanOrEqual(1);
// Draft state never mutates the page.
expect(document.body.innerHTML).toBe("<p>Draft highlight target</p>");
postBridge({ type: "plannotator-bridge-cancel-selection" });
expect(visibleHighlights("pn-hl-draft").length).toBe(0);
document.body.replaceChildren();
});
test("hit-testing for new selections yields placed markers to reach the page beneath", async () => {
document.body.innerHTML = '<p id="beneath">Beneath text</p><div data-testid="hit">H</div>';
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
// Materialize the overlay host + a marker.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "hit-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: 'div[data-testid="hit"]', tagName: "div" },
});
await flushOverlay();
const host = overlayHost();
if (!host) throw new Error("overlay host missing");
const beneath = document.querySelector<HTMLElement>("#beneath")!;
const originalElementFromPoint = document.elementFromPoint;
// Simulate the real browser: the probe lands on the overlay host while
// markers accept hits, and on the page element once they are yielded.
(document as { elementFromPoint: typeof document.elementFromPoint }).elementFromPoint = () =>
host.hasAttribute("data-pn-hittest") ? beneath : host;
try {
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
hoverAt(beneath, 77, 77);
const label = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]");
expect(label?.style.display).toBe("block");
expect(label?.textContent).toBe("Paragraph");
const box = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-box]");
expect(box?.style.display).toBe("block");
// The yield is transient: hit-testing restored marker interactivity.
expect(host.hasAttribute("data-pn-hittest")).toBe(false);
} finally {
(document as { elementFromPoint: typeof document.elementFromPoint }).elementFromPoint =
originalElementFromPoint;
}
// String-level guard: happy-dom cannot honor pointer-events, and the
// elementFromPoint mock above IMPLEMENTS the yield keyed on the
// data-pn-hittest attribute — a typo in the CSS rule would still pass
// it. The exact rule must ship in the overlay CSS.
expect(BRIDGE_SCRIPT).toContain(
":host([data-pn-hittest]) .pn-marker, [data-plannotator-overlay-host][data-pn-hittest] .pn-marker { pointer-events: none !important; }",
);
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
// --- Fix-round regressions: clipping, visibility, search gating, print,
// --- click-to-select, containment, dedup, numbering, marker anchoring ----
test("highlight rects are clip-tested against inner scroll containers (M1)", async () => {
document.body.innerHTML =
'<div id="scroller"><p id="clipped-p">Clipped highlight text</p></div>';
const scroller = document.querySelector<HTMLElement>("#scroller")!;
const clippedP = document.querySelector<HTMLElement>("#clipped-p")!;
scroller.style.overflow = "hidden";
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
scroller.getBoundingClientRect = () => rectOf(0, 0, 200, 100);
clippedP.getBoundingClientRect = () => rectOf(0, 50, 200, 120);
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(0, 50, 200, 20), // fully inside the container
rectOf(0, 90, 200, 20), // straddles the container edge -> trimmed to 10px
rectOf(0, 150, 200, 20), // fully scrolled past the edge -> dropped
];
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "clip-hl",
originalText: "Clipped highlight text",
annotationType: "comment",
});
await flushOverlay();
const painted = visibleHighlights("pn-hl-comment")
.filter((el) => el.getAttribute("data-annotation-id") === "clip-hl")
.map((el) => ({ top: el.style.top, height: el.style.height }))
.sort((a, b) => parseFloat(a.top) - parseFloat(b.top));
expect(painted).toEqual([
{ top: "50px", height: "20px" },
{ top: "90px", height: "10px" },
]);
// The focus flash paints the same clip-tested rects.
postBridge({ type: "plannotator-bridge-focus-mark", id: "clip-hl" });
const focusRects = visibleHighlights("pn-hl-focus").filter(
(el) => el.getAttribute("data-annotation-id") === "clip-hl",
);
expect(focusRects.length).toBe(2);
postBridge({ type: "plannotator-bridge-focus-mark", id: null });
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("style-hidden targets are unresolved-for-display (M2 visibility gate)", async () => {
document.body.innerHTML = '<div id="slide">Slide content</div>';
const slide = document.querySelector<HTMLElement>("#slide")!;
await withLayout(async () => {
slide.getBoundingClientRect = () => rectOf(100, 100, 200, 100);
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "vis-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#slide", tagName: "div" },
});
await flushOverlay();
expect(markersFor("vis-ann").length).toBe(1);
// visibility:hidden keeps a full-size rect — the marker must not
// render over whatever visible content stacks in the same box.
slide.style.visibility = "hidden";
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("vis-ann").length).toBe(0);
slide.style.visibility = "";
// opacity:0 deliberately does NOT hide the marker: it is the standard
// invisible-hit-target pattern (transparent input over a styled
// control) where the annotation legitimately sits over the visible
// control — and computed opacity doesn't inherit, so an opacity gate
// wouldn't catch faded containers' descendants anyway.
slide.style.opacity = "0";
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("vis-ann").length).toBe(1);
slide.style.opacity = "";
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("vis-ann").length).toBe(1);
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("dead-target re-search is generation-gated, never per-render (M3)", async () => {
document.body.innerHTML = "<p>Ephemeral searchable text</p>";
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "gen-ann",
originalText: "Ephemeral searchable text",
annotationType: "comment",
});
await flushOverlay();
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "gen-ann",
),
).toBe(true);
// The text disappears; the next invalidation triggers ONE failed search.
document.body.innerHTML = "<p>Different content</p>";
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
// Repeated renders WITHOUT a new text-capable invalidation must not
// re-run the whole-document sweep (findTextRange = createTreeWalker).
const originalCreateTreeWalker = document.createTreeWalker.bind(document);
let sweeps = 0;
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker = ((
...args: Parameters<typeof document.createTreeWalker>
) => {
sweeps += 1;
return originalCreateTreeWalker(...args);
}) as typeof document.createTreeWalker;
try {
for (let i = 0; i < 5; i++) {
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
}
expect(sweeps).toBe(0);
// A text-capable invalidation (plus an elapsed failure backoff)
// unlocks exactly one more search…
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(sweeps).toBeGreaterThan(0);
const afterUnlock = sweeps;
// …whose failure is cached again for that generation.
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(sweeps).toBe(afterUnlock);
} finally {
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker =
originalCreateTreeWalker;
}
// The text returning re-resolves after the next invalidation (and an
// elapsed backoff).
document.body.innerHTML = "<p>Ephemeral searchable text</p>";
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "gen-ann",
),
).toBe(true);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("unanchored ids are reported on change: restore failures, late deaths, recovery, and clear", async () => {
// The fail-closed transparency contract (onUnanchoredChange): the bridge
// names WHICH annotations currently have no live representation — total
// restore failures (whose records are removed) and records whose every
// target died — re-reporting the full set only when it changes.
const reports: string[][] = [];
const listener = (e: MessageEvent) => {
const d = bridgeMessageData(e);
if (d && d.type === "plannotator-bridge-unanchored") reports.push(d.ids as string[]);
};
window.addEventListener("message", listener);
try {
document.body.innerHTML = "<p>Anchored copy</p>";
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "live-ann",
originalText: "Anchored copy",
annotationType: "comment",
});
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "ghost-ann",
originalText: "Text this page never contained",
annotationType: "comment",
});
await flushOverlay();
// Only the total restore failure reports; the resolved one does not.
expect(reports.at(-1)).toEqual(["ghost-ann"]);
// Change-only: an idle re-render must not repeat the report. (Bridge
// emissions deliver async like all postMessage traffic, so the flush
// guarantees any duplicate would have arrived before the count check.)
const countAfterRestore = reports.length;
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
expect(reports.length).toBe(countAfterRestore);
// A re-restore that now resolves clears the failed id.
document.body.innerHTML =
"<p>Anchored copy</p><p>Text this page never contained</p>";
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "ghost-ann",
originalText: "Text this page never contained",
annotationType: "comment",
});
await flushOverlay();
expect(reports.at(-1)).toEqual([]);
// Late death: both texts vanish, both records go dead after the
// generation-gated re-search fails.
document.body.innerHTML = "<p>Unrelated content</p>";
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
expect(reports.at(-1)).toEqual(["ghost-ann", "live-ann"]);
// Partial recovery: one text returns, the other stays dead.
document.body.innerHTML = "<p>Anchored copy</p>";
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
expect(reports.at(-1)).toEqual(["ghost-ann"]);
// clear-marks empties the report along with the records.
postBridge({ type: "plannotator-bridge-clear-marks" });
await flushOverlay();
expect(reports.at(-1)).toEqual([]);
} finally {
window.removeEventListener("message", listener);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
}
});
test("mutation-heavy pages: failed re-searches back off on the wall clock and are budgeted per pass (A)", async () => {
// A page that mutates every frame advances domGeneration every frame, so
// the generation gate alone would re-run the whole-document sweep per
// frame forever for permanently unresolvable targets. Failed searches
// must (1) run at most 2 per reconcile pass and (2) back off on the
// wall clock even across generation bumps.
document.body.innerHTML = [0, 1, 2, 3]
.map((i) => `<p>Backoff text ${i}</p>`)
.join("");
for (let i = 0; i < 4; i++) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: `boff-${i}`,
originalText: `Backoff text ${i}`,
annotationType: "comment",
});
}
await flushOverlay();
// Every target's text disappears: all four ranges are dead.
document.body.innerHTML = "<p>Rewritten content</p>";
const originalCreateTreeWalker = document.createTreeWalker.bind(document);
let sweeps = 0;
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker = ((
...args: Parameters<typeof document.createTreeWalker>
) => {
sweeps += 1;
return originalCreateTreeWalker(...args);
}) as typeof document.createTreeWalker;
try {
// Pass 1: only the per-pass budget (2) may search, even though all
// four targets are eligible.
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(sweeps).toBe(2);
// The skipped-but-eligible targets get a scheduled follow-up pass.
await flushOverlay();
expect(sweeps).toBe(4);
// No eligible targets remain: no further passes run searches.
await flushOverlay();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(sweeps).toBe(4);
// Mutation-heavy page: generation bumps every "frame". The wall-clock
// backoff must keep every failed target locked regardless.
for (let i = 0; i < 5; i++) {
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
}
expect(sweeps).toBe(4);
// Once the backoff elapses AND the generation has advanced, searching
// resumes (still budgeted per pass).
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(sweeps).toBe(6);
await flushOverlay();
expect(sweeps).toBe(8);
} finally {
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker =
originalCreateTreeWalker;
}
// A successful search resets the backoff: the text returning re-resolves
// on the next unlocked pass.
document.body.innerHTML = [0, 1, 2, 3]
.map((i) => `<p>Backoff text ${i}</p>`)
.join("");
advancePastDeadSearchBackoff();
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
expect(committedRanges("boff-0").length).toBe(1);
expect(committedRanges("boff-3").length).toBe(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("offscreen targets are culled before style/clip reads (B1)", async () => {
document.body.innerHTML = '<div id="cull-el">Cull element</div><p>Cull range text</p>';
const el = document.querySelector<HTMLElement>("#cull-el")!;
await withLayout(async () => {
// Element target far below the viewport (beyond the cull margin).
el.getBoundingClientRect = () => rectOf(100, 2000, 200, 100);
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "cull-el-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#cull-el", tagName: "div" },
});
await flushOverlay();
expect(markersFor("cull-el-ann").length).toBe(0);
// A re-render of the culled element does none of the per-target style
// work (targetStyleHidden / clipBoundsFor both read computed styles).
const realGetComputedStyle = window.getComputedStyle.bind(window);
let styleReads = 0;
(window as { getComputedStyle: typeof window.getComputedStyle }).getComputedStyle = ((
...args: Parameters<typeof window.getComputedStyle>
) => {
styleReads += 1;
return realGetComputedStyle(...args);
}) as typeof window.getComputedStyle;
try {
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("cull-el-ann").length).toBe(0);
expect(styleReads).toBe(0);
} finally {
(window as { getComputedStyle: typeof window.getComputedStyle }).getComputedStyle =
realGetComputedStyle;
}
// Scrolled back into view, the marker renders again.
el.getBoundingClientRect = () => rectOf(100, 300, 200, 100);
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("cull-el-ann").length).toBe(1);
postBridge({ type: "plannotator-bridge-remove-mark", id: "cull-el-ann" });
// Range targets cull on the range's bounding rect BEFORE collecting
// client rects (the per-rect clip/containment work never runs).
const originalGetClientRects = Range.prototype.getClientRects;
const originalRangeBounds = Range.prototype.getBoundingClientRect;
let rectCollections = 0;
(Range.prototype as unknown as { getBoundingClientRect: () => DOMRect })
.getBoundingClientRect = () => rectOf(10, 2000, 100, 20);
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => {
rectCollections += 1;
return [rectOf(10, 2000, 100, 20)];
};
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "cull-range-ann",
originalText: "Cull range text",
annotationType: "comment",
});
await flushOverlay();
expect(rectCollections).toBe(0);
expect(
visibleHighlights("pn-hl-comment").some(
(hl) => hl.getAttribute("data-annotation-id") === "cull-range-ann",
),
).toBe(false);
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
(Range.prototype as { getBoundingClientRect: typeof originalRangeBounds })
.getBoundingClientRect = originalRangeBounds;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("batch restore renders the overlay once, not once per annotation (B3)", async () => {
document.body.innerHTML =
'<div id="b3-0">A</div><div id="b3-1">B</div><div id="b3-2">C</div>';
const counts = [0, 0, 0];
for (let i = 0; i < 3; i++) {
const target = document.querySelector<HTMLElement>(`#b3-${i}`)!;
const rect = rectOf(50 + i * 100, 100, 50, 50);
target.getBoundingClientRect = () => {
counts[i] += 1;
return rect;
};
}
await withLayout(async () => {
for (let i = 0; i < 3; i++) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: `b3-ann-${i}`,
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: `#b3-${i}`, tagName: "div" },
});
}
// The projection is deferred: no render (no geometry reads) ran yet.
expect(counts).toEqual([0, 0, 0]);
await flushOverlay();
// ONE coalesced pass rendered the whole batch: each target's geometry
// was read exactly once (the old synchronous per-restore render read
// the first target three times: [3, 2, 1]).
expect(counts).toEqual([1, 1, 1]);
expect(visibleMarkers().length).toBe(3);
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("page mutations schedule no reconcile when the overlay has zero work (B4)", async () => {
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({ type: "plannotator-bridge-cancel-selection" });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
document.body.innerHTML = "<p>B4 idle text</p>";
await flushOverlay(); // drain anything pending before counting
const realRaf = window.requestAnimationFrame.bind(window);
let scheduled = 0;
(window as { requestAnimationFrame: typeof window.requestAnimationFrame })
.requestAnimationFrame = ((cb: FrameRequestCallback) => {
scheduled += 1;
return realRaf(cb);
}) as typeof window.requestAnimationFrame;
try {
// Zero records, no draft, pinpoint inactive: a page mutation bumps the
// generation but must not schedule the reconcile frame.
deliverPageMutations([{
type: "characterData",
target: document.body,
} as Partial<MutationRecord>]);
expect(scheduled).toBe(0);
// With a committed record, the same mutation schedules the reconcile.
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "b4-ann",
originalText: "B4 idle text",
annotationType: "comment",
});
await flushOverlay();
const before = scheduled;
deliverPageMutations([{
type: "characterData",
target: document.body,
} as Partial<MutationRecord>]);
expect(scheduled).toBeGreaterThan(before);
await flushOverlay();
} finally {
(window as { requestAnimationFrame: typeof window.requestAnimationFrame })
.requestAnimationFrame = realRaf;
}
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("hovering a committed highlight brightens its overlay rects without touching the page (D)", async () => {
document.body.innerHTML = "<p>Hover affordance text</p>";
const pageSnapshot = document.body.innerHTML;
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(100, 100, 200, 20),
];
try {
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "hover-ann",
originalText: "Hover affordance text",
annotationType: "comment",
});
await flushOverlay();
const painted = visibleHighlights("pn-hl-comment").filter(
(hl) => hl.getAttribute("data-annotation-id") === "hover-ann",
);
expect(painted.length).toBeGreaterThan(0);
expect(painted[0]!.classList.contains("pn-hl-hover")).toBe(false);
// Pointer over the painted rect: the hover class lands on the rect
// divs inside the shadow root; the page DOM stays byte-identical.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true,
clientX: 150,
clientY: 110,
}));
await flushOverlay();
expect(painted[0]!.classList.contains("pn-hl-hover")).toBe(true);
expect(document.body.innerHTML).toBe(pageSnapshot);
// Pointer off the rect clears the affordance.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true,
clientX: 600,
clientY: 500,
}));
await flushOverlay();
expect(painted[0]!.classList.contains("pn-hl-hover")).toBe(false);
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("switching to pinpoint before a scheduled hover hit test fires leaves no stale hover (1)", async () => {
document.body.innerHTML = "<p>Hover race text</p>";
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(100, 100, 200, 20),
];
try {
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "race-ann",
originalText: "Hover race text",
annotationType: "comment",
});
await flushOverlay();
const painted = visibleHighlights("pn-hl-comment").filter(
(hl) => hl.getAttribute("data-annotation-id") === "race-ann",
);
expect(painted.length).toBeGreaterThan(0);
// Schedule a hover hit test, then switch to pinpoint BEFORE the rAF
// fires: the pending callback must not re-apply the class, and no
// later re-render may resurrect it from stale hover state.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true,
clientX: 150,
clientY: 110,
}));
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
await flushOverlay();
expect(painted[0]!.classList.contains("pn-hl-hover")).toBe(false);
// Pinpoint-mode mousemoves skip the hover-clearing branch, so a
// re-render is where stale state would resurface — it must not.
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
await flushOverlay();
expect(painted[0]!.classList.contains("pn-hl-hover")).toBe(false);
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("print passes are never budget-starved: every dead-but-recoverable target re-searches (3)", async () => {
document.body.innerHTML = [0, 1, 2]
.map((i) => `<p>Print recover text ${i}</p>`)
.join("");
for (let i = 0; i < 3; i++) {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: `print-recover-${i}`,
originalText: `Print recover text ${i}`,
annotationType: "comment",
});
}
await flushOverlay();
// Recreate identical content: every range dies, but the text is still
// findable (dead-but-recoverable).
document.body.innerHTML = [0, 1, 2]
.map((i) => `<p>Print recover text ${i}</p>`)
.join("");
const originalGetClientRects = Range.prototype.getClientRects;
const originalCreateTreeWalker = document.createTreeWalker.bind(document);
let sweeps = 0;
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker = ((
...args: Parameters<typeof document.createTreeWalker>
) => {
sweeps += 1;
return originalCreateTreeWalker(...args);
}) as typeof document.createTreeWalker;
await withLayout(async () => {
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(10, 20, 100, 20),
];
try {
// Printing is a user-initiated one-shot with no follow-up pass: the
// 2-search reconcile budget must not apply, or a page with 3+ dead
// targets silently prints fewer highlights.
window.dispatchEvent(new Event("beforeprint"));
expect(sweeps).toBe(3);
const layer = document.querySelector<HTMLElement>("[data-plannotator-print-layer]");
if (!layer) throw new Error("print layer missing");
expect(layer.childNodes.length).toBe(3);
window.dispatchEvent(new Event("afterprint"));
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker =
originalCreateTreeWalker;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("printing re-projects committed highlights into a paginating absolute layer (M4)", async () => {
document.body.innerHTML = "<p>Printable highlight text</p>";
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(10, 20, 100, 20),
];
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "print-ann",
originalText: "Printable highlight text",
annotationType: "comment",
});
await flushOverlay();
window.dispatchEvent(new Event("beforeprint"));
const layer = document.querySelector<HTMLElement>(
"[data-plannotator-print-layer]",
);
if (!layer) throw new Error("print layer missing");
// On the ROOT element (not <body>): a page styling body
// { position: relative } would otherwise shift every stripe by
// body's document offset.
expect(layer.parentElement).toBe(document.documentElement);
// Absolute (not fixed) so it paginates with the content.
expect(layer.style.position).toBe("absolute");
const stripes = Array.from(layer.children) as HTMLElement[];
expect(stripes.length).toBe(1);
expect(stripes[0]!.style.position).toBe("absolute");
expect(stripes[0]!.style.left).toBe("10px");
expect(stripes[0]!.style.top).toBe("20px");
expect(stripes[0]!.style.width).toBe("100px");
expect(stripes[0]!.style.height).toBe("20px");
// Highlights print; markers don't (parity with pre-overlay badges).
expect(layer.querySelector("button")).toBeNull();
window.dispatchEvent(new Event("afterprint"));
expect(document.querySelector("[data-plannotator-print-layer]")).toBeNull();
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
// The injected CSS guards the layer against ever painting on screen.
expect(ANNOTATION_HIGHLIGHT_CSS).toContain("[data-plannotator-print-layer]");
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("clicking a committed highlight selects its annotation (M5 click-to-select)", async () => {
document.body.innerHTML = "<p>big highlight target</p><p>small nested note</p>";
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
const originalGetClientRects = Range.prototype.getClientRects;
const originalBodyRect = document.body.getBoundingClientRect;
document.body.getBoundingClientRect = () => rectOf(0, 0, 1024, 768);
(Range.prototype as unknown as { getClientRects: (this: Range) => DOMRect[] }).getClientRects =
function (this: Range) {
return this.toString().indexOf("small") >= 0
? [rectOf(40, 20, 60, 12)]
: [rectOf(10, 10, 300, 100)];
};
const clickBody = (x: number, y: number, shift = false) => {
document.body.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
shiftKey: shift,
}));
};
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "click-big",
originalText: "big highlight target",
annotationType: "comment",
});
await flushOverlay();
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "click-small",
originalText: "small nested note",
annotationType: "comment",
});
await flushOverlay();
// Inside both rects: the smallest highlight wins the overlap.
const overlapping = await collectMessages(
["plannotator-bridge-mark-click"],
() => clickBody(50, 25),
);
expect(overlapping).toEqual([
{ type: "plannotator-bridge-mark-click", id: "click-small" },
]);
// Inside only the big rect.
const bigOnly = await collectMessages(
["plannotator-bridge-mark-click"],
() => clickBody(200, 90),
);
expect(bigOnly).toEqual([
{ type: "plannotator-bridge-mark-click", id: "click-big" },
]);
// Outside every rect: page clicks pass through untouched.
const missed = await collectMessages(
["plannotator-bridge-mark-click"],
() => clickBody(600, 600),
);
expect(missed.length).toBe(0);
// Shift-clicks belong to multi-select, never click-to-select.
const shifted = await collectMessages(
["plannotator-bridge-mark-click"],
() => clickBody(50, 25, true),
);
expect(shifted.length).toBe(0);
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
document.body.getBoundingClientRect = originalBodyRect;
}
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("fixed-position targets ignore static overflow clippers (m3)", async () => {
document.body.innerHTML =
'<div id="wrap"><div id="fixed-el">Pinned banner</div></div>';
const wrap = document.querySelector<HTMLElement>("#wrap")!;
const fixedEl = document.querySelector<HTMLElement>("#fixed-el")!;
wrap.style.overflow = "hidden";
fixedEl.style.position = "fixed";
await withLayout(async () => {
wrap.getBoundingClientRect = () => rectOf(0, 0, 100, 50);
// Fully visible in the viewport but entirely outside the static
// clipper's box: overflow clipping does not apply to a fixed target
// whose containing-block chain skips the clipper.
fixedEl.getBoundingClientRect = () => rectOf(300, 300, 200, 50);
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "fixed-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#fixed-el", tagName: "div" },
});
await flushOverlay();
expect(markersFor("fixed-ann").length).toBe(1);
// Same geometry with static positioning: the clipper applies and the
// detached marker is omitted (clip-container omission preserved).
fixedEl.style.position = "";
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("fixed-ann").length).toBe(0);
// contain:layout makes the clipper a fixed containing block: its
// overflow clipping then APPLIES to the fixed target again (5a).
fixedEl.style.position = "fixed";
wrap.style.contain = "layout";
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("fixed-ann").length).toBe(0);
wrap.style.contain = "";
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("markers omit when the target scrolls out of its clip container (omission preserved)", async () => {
document.body.innerHTML =
'<div id="clipwrap"><div id="clipped-el">Clipped away</div></div>';
const clipwrap = document.querySelector<HTMLElement>("#clipwrap")!;
const clippedEl = document.querySelector<HTMLElement>("#clipped-el")!;
clipwrap.style.overflow = "hidden";
await withLayout(async () => {
clipwrap.getBoundingClientRect = () => rectOf(0, 0, 200, 100);
clippedEl.getBoundingClientRect = () => rectOf(0, 150, 200, 50); // scrolled past the edge
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "clip-omit",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#clipped-el", tagName: "div" },
});
await flushOverlay();
expect(markersFor("clip-omit").length).toBe(0);
clippedEl.getBoundingClientRect = () => rectOf(0, 20, 200, 50); // scrolled back in
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("clip-omit").length).toBe(1);
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("border boxes that duplicate line rects are dropped (m7 containment filter)", async () => {
document.body.innerHTML = "<p>Redline paragraph text</p>";
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
// Range.getClientRects() returns BOTH the contained element's border
// box AND its line boxes; painting all three double-paints the
// translucent fill and duplicates the strike line.
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () => [
rectOf(10, 10, 300, 60), // border box containing both lines
rectOf(10, 10, 300, 20), // line 1
rectOf(10, 40, 200, 20), // line 2
];
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "redline-ann",
originalText: "Redline paragraph text",
annotationType: "deletion",
});
await flushOverlay();
const painted = visibleHighlights("pn-hl-deletion").filter(
(el) => el.getAttribute("data-annotation-id") === "redline-ann",
);
expect(painted.length).toBe(2);
expect(painted.map((el) => el.style.height)).toEqual(["20px", "20px"]);
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("anchors re-resolving to one element render one marker (m10 refresh dedup)", async () => {
document.body.innerHTML =
'<div id="m10-a">First</div><div data-testid="m10-b">Second</div>';
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "dedup-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#m10-a", tagName: "div" },
additionalAnchors: [{ selector: 'div[data-testid="m10-b"]', tagName: "div" }],
});
await flushOverlay();
expect(markersFor("dedup-ann").length).toBe(2);
// The page rerenders so BOTH anchors resolve to the same element: only
// one marker may render (coincident same-number markers would spread).
document.body.innerHTML = '<div id="m10-a" data-testid="m10-b">Merged</div>';
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(markersFor("dedup-ann").length).toBe(1);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("clear-marks clears synced numbering (m11)", async () => {
document.body.innerHTML = '<div id="renum">Target</div>';
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "renum-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#renum", tagName: "div" },
});
await flushOverlay();
postBridge({
type: "plannotator-bridge-sync-annotations",
annotations: [{ id: "renum-ann", number: 7 }],
});
expect(markerNumber(markersFor("renum-ann")[0]!)).toBe("7");
// clear-marks then re-register the SAME id: the stale synced number must
// not leak — fallback numbering restarts at 1.
postBridge({ type: "plannotator-bridge-clear-marks" });
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "renum-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#renum", tagName: "div" },
});
await flushOverlay();
expect(markerNumber(markersFor("renum-ann")[0]!)).toBe("1");
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("marker anchors to the TRUE last client rect past the paint cap (m12)", async () => {
document.body.innerHTML = "<p>Long wrapped selection</p>";
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
const rects: DOMRect[] = [];
for (let i = 0; i < 60; i++) rects.push(rectOf(10, 10 + i * 5, 100, 5));
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () =>
rects;
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "long-ann",
originalText: "Long wrapped selection",
annotationType: "comment",
});
await flushOverlay();
// Painting caps at 48 rects…
const painted = visibleHighlights("pn-hl-comment").filter(
(el) => el.getAttribute("data-annotation-id") === "long-ann",
);
expect(painted.length).toBe(48);
// …but the marker sits at the selection TAIL (60th rect), not at the
// 48th rect mid-selection.
const longMarkers = markersFor("long-ann");
expect(longMarkers.length).toBe(1);
expect(longMarkers[0]!.style.left).toBe("110px");
expect(longMarkers[0]!.style.top).toBe("307.5px");
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("pinpoint hover over a placed marker advertises the marker, not the element beneath (m6)", async () => {
document.body.innerHTML = '<p id="under">Beneath paragraph</p><div id="m6-t">Marked</div>';
postBridge({ type: "plannotator-bridge-set-vim-mode", enabled: false });
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "hover-ann",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#m6-t", tagName: "div" },
});
await flushOverlay();
postBridge({
type: "plannotator-bridge-sync-annotations",
annotations: [{ id: "hover-ann", number: 3 }],
});
const markerBtn = markersFor("hover-ann")[0];
if (!markerBtn) throw new Error("marker missing");
const originalElementFromPoint = document.elementFromPoint;
(document as { elementFromPoint: typeof document.elementFromPoint }).elementFromPoint = () =>
markerBtn as unknown as Element;
try {
postBridge({ type: "plannotator-bridge-set-input-method", method: "pinpoint" });
hoverAt(document.body, 60, 60);
// Hover shows the MARKER's identity (the click selects its comment)…
const label = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-label]");
expect(label?.style.display).toBe("block");
expect(label?.textContent).toBe("Comment 3");
// …with no annotate affordance for the element beneath.
const box = document.querySelector<HTMLElement>("[data-plannotator-pinpoint-box]");
expect(box?.style.display ?? "none").toBe("none");
// m8: the label paints AFTER the overlay host (equal z-index resolves
// by DOM order), so marker bubbles can never occlude it.
const host = overlayHost()!;
expect(label!.parentElement).toBe(document.documentElement);
expect(
(label!.compareDocumentPosition(host) & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
).toBe(true);
// Moving off the bubble restores normal element hover/annotate.
const under = document.querySelector<HTMLElement>("#under")!;
(document as { elementFromPoint: typeof document.elementFromPoint }).elementFromPoint =
() => under;
hoverAt(under, 200, 200);
expect(label?.textContent).toBe("Paragraph");
} finally {
(document as { elementFromPoint: typeof document.elementFromPoint }).elementFromPoint =
originalElementFromPoint;
}
postBridge({ type: "plannotator-bridge-set-input-method", method: "drag" });
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("rect collection is capped at 48 with the containment filter, marker at the true tail (perf bound)", async () => {
document.body.innerHTML = "<p>Huge wrapped selection</p>";
const originalGetClientRects = Range.prototype.getClientRects;
await withLayout(async () => {
// 60 rects: index 0 is a border box strictly containing lines 1..10;
// only the first 48 may ever be materialized (a huge selection can
// yield thousands of rects per reconcile frame), the containment
// filter runs on that capped list, and the marker still anchors the
// TRUE final rect read by index off the live list.
const rects: DOMRect[] = [rectOf(10, 10, 100, 50)];
for (let i = 1; i < 60; i++) rects.push(rectOf(10, 10 + (i - 1) * 5, 100, 5));
(Range.prototype as unknown as { getClientRects: () => DOMRect[] }).getClientRects = () =>
rects;
try {
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "huge-ann",
originalText: "Huge wrapped selection",
annotationType: "comment",
});
await flushOverlay();
const painted = visibleHighlights("pn-hl-comment").filter(
(el) => el.getAttribute("data-annotation-id") === "huge-ann",
);
// 48 collected minus the containment-dropped border box.
expect(painted.length).toBe(47);
expect(painted.some((el) => el.style.height === "50px")).toBe(false);
// True tail: rects[59] (top 300, height 5) — not the 48th entry.
const hugeMarkers = markersFor("huge-ann");
expect(hugeMarkers.length).toBe(1);
expect(hugeMarkers[0]!.style.left).toBe("110px");
expect(hugeMarkers[0]!.style.top).toBe("302.5px");
} finally {
(Range.prototype as { getClientRects: typeof originalGetClientRects }).getClientRects =
originalGetClientRects;
}
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("the page observer watches documentElement; body swaps unlock re-search (M3 scope)", async () => {
const observer = capturedObservers[0];
if (!observer) throw new Error("bridge page observer was not captured");
// Scope: a body-scoped observer sees NO record when the page swaps the
// <body> element itself (documentElement.replaceChild), permanently
// locking dead-target re-search behind a generation that never advances.
expect(observer.targets).toContain(document.documentElement);
for (const observed of observer.targets) {
expect(observed.nodeName).not.toBe("BODY");
}
document.body.innerHTML = "<p>Body swap text</p>";
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "swap-ann",
originalText: "Body swap text",
annotationType: "comment",
});
await flushOverlay();
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "swap-ann",
),
).toBe(true);
// The body is swapped for an interim skeleton: the record lands on
// documentElement. The free retry runs against the skeleton and fails.
document.body.innerHTML = "<p>interim skeleton</p>";
deliverPageMutations([{
type: "childList",
target: document.documentElement,
addedNodes: [document.body] as unknown as NodeList,
removedNodes: [] as unknown as NodeList,
}]);
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "swap-ann",
),
).toBe(false);
// Overlay-only childList writes on the root (host/label/print-layer
// appends) must NOT bump the generation: no re-search may run.
const host = overlayHost();
if (!host) throw new Error("overlay host missing");
const originalCreateTreeWalker = document.createTreeWalker.bind(document);
let sweeps = 0;
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker = ((
...args: Parameters<typeof document.createTreeWalker>
) => {
sweeps += 1;
return originalCreateTreeWalker(...args);
}) as typeof document.createTreeWalker;
try {
deliverPageMutations([{
type: "childList",
target: document.documentElement,
addedNodes: [host] as unknown as NodeList,
removedNodes: [] as unknown as NodeList,
}]);
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(sweeps).toBe(0);
} finally {
(document as { createTreeWalker: typeof document.createTreeWalker }).createTreeWalker =
originalCreateTreeWalker;
}
// The text returns inside the (new) body — inside the documentElement
// observer's subtree — and the next real record (after the failure
// backoff) unlocks re-search.
document.body.innerHTML = "<p>Body swap text</p>";
advancePastDeadSearchBackoff();
deliverPageMutations([{
type: "childList",
target: document.documentElement,
addedNodes: [document.body] as unknown as NodeList,
removedNodes: [] as unknown as NodeList,
}]);
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
expect(
visibleHighlights("pn-hl-comment").some(
(el) => el.getAttribute("data-annotation-id") === "swap-ann",
),
).toBe(true);
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
test("dedup keeps the VISIBLE marker when a hidden sibling target shares the element (5b)", async () => {
document.body.innerHTML =
'<div id="m10-a">First</div><div data-testid="m10-b">Second</div>';
postBridge({
type: "plannotator-bridge-find-and-mark",
id: "dedup-vis",
originalText: "Text that exists nowhere on this page",
annotationType: "comment",
anchor: { selector: "#m10-a", tagName: "div", point: { x: 0.05, y: 0.5 } },
additionalAnchors: [
{ selector: 'div[data-testid="m10-b"]', tagName: "div", point: { x: 0.75, y: 0.5 } },
],
});
await flushOverlay();
// Both anchors re-resolve to ONE merged element inside a clipper that
// hides the FIRST target's point but not the second's: dedup must run
// among PLACED markers, keeping the visible one — not consume the
// element's slot on the hidden first target and render nothing.
document.body.innerHTML =
'<div id="m10-wrap"><div id="m10-a" data-testid="m10-b">Merged</div></div>';
const wrap = document.querySelector<HTMLElement>("#m10-wrap")!;
const merged = document.querySelector<HTMLElement>("#m10-a")!;
wrap.style.overflow = "hidden";
await withLayout(async () => {
wrap.getBoundingClientRect = () => rectOf(100, 0, 100, 100);
merged.getBoundingClientRect = () => rectOf(0, 0, 200, 50);
bumpDomGeneration();
postBridge({ type: "plannotator-bridge-sync-annotations", annotations: [] });
const dedupMarkers = markersFor("dedup-vis");
expect(dedupMarkers.length).toBe(1);
// The SECOND target's point (x 0.75 -> 150px) survived; y 25 clamps
// to the 29px viewport inset (rendering-only clamp).
expect(dedupMarkers[0]!.style.left).toBe("150px");
expect(dedupMarkers[0]!.style.top).toBe("29px");
});
postBridge({ type: "plannotator-bridge-clear-marks" });
document.body.replaceChildren();
});
});
describe("injectIntoHead", () => {
test("splices before </head> when present, else prepends", () => {
expect(injectIntoHead("<html><head><title>t</title></head><body/></html>", "[X]")).toBe(
"<html><head><title>t</title>[X]</head><body/></html>",
);
expect(injectIntoHead("<p>no head</p>", "[X]")).toBe("[X]<p>no head</p>");
});
test("removes document-authored CSP meta tags so the bridge can execute", () => {
// The exact tag Plannotator's own portable guided-review exports embed;
// with it present the inline bridge script is blocked and annotation dies.
const csp =
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; font-src \'none\'; connect-src \'none\'; media-src \'none\'; object-src \'none\'; base-uri \'none\'; form-action \'none\'; frame-src \'none\'">';
const out = injectIntoHead(`<html><head>${csp}<title>t</title></head><body/></html>`, "[X]");
expect(out).not.toMatch(/content-security-policy/i);
expect(out).toContain("[X]</head>");
expect(out).toContain("<title>t</title>");
// Attribute-order, quoting, casing, and self-closing variants.
const variants = [
"<meta content=\"default-src 'none'\" http-equiv=content-security-policy>",
"<META HTTP-EQUIV='Content-Security-Policy' CONTENT=\"script-src 'none'\" />",
"<meta http-equiv = \" content-security-policy \" content=\"default-src 'self'\">",
];
for (const tag of variants) {
expect(injectIntoHead(`<head>${tag}</head>`, "[X]")).not.toMatch(/http-equiv/i);
}
// Other meta tags survive untouched.
const keep = '<meta http-equiv="refresh" content="0"><meta charset="utf-8">';
expect(injectIntoHead(`<head>${keep}</head>`, "[X]")).toContain(keep);
});
});