mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
ab8d2581eb
The bridge now names WHICH annotations have no live representation on the page (every target dead, or the restore never resolved), reported on change through a new validated message and the HtmlViewer onUnanchoredChange prop, delivered in readOnly mode too. Fail-closed anchors previously hid markers silently. AnnotationPanel readOnly no longer suppresses the renderCardFooter slot: its contents are host-owned and may be read affordances (replies, links), so the host gates what belongs in it. Built-in delete/edit and direct-edit discard stay hidden.
3668 lines
153 KiB
TypeScript
3668 lines
153 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
|
|
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 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("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);
|
|
});
|
|
});
|