Files
backnotprop__plannotator/packages/ui/components/html-viewer/annotationNumbering.test.ts
Michael Ramos 121082430e fix: QA-gate hardening for the v0.26.8 feature set (overlay perf, numbering, OpenCode 2 parity) (#1258)
* fix(opencode): consolidate V2 system parts into one composed prompt (#1114)

The OpenCode 2 adapter still shipped the pre-#1114 multi-part system
injection: replacePlanningSystemParts kept one part per source and the
generic reminder pushed a separate part, so Qwen3.x Jinja template
corruption persisted for OpenCode 2 users. Mirror the V1 entry exactly:
compose the stripped existing text plus additions into a single system
part via composeSystemPrompt, and compose the generic reminder into the
existing text instead of appending a second part.

Also adds the regression tests for the bug class flagged in #1114's
review: both helpers must read/compose the existing system text BEFORE
truncating the array (a reorder to 'system.length = 0' first drops the
host prompt and goes red here).

* perf(annotate): harden the raw-HTML overlay reconcile (dead-target backoff, cull, batching)

Bridge-script hardening for mutation-heavy pages and large annotation
sets, plus the lost click-to-select hover affordance:

- A: dead-target re-search now carries a wall-clock backoff (300ms
  doubling to a 5s cap, reset on success) ON TOP of the generation gate,
  plus a 2-searches-per-reconcile-pass budget with a scheduled follow-up
  pass for budget-skipped eligible targets. A page that mutates every
  frame advances domGeneration every frame, so the generation gate alone
  re-ran the whole-document TreeWalker sweep (and anchor re-resolution)
  per frame forever for permanently unresolvable targets.
- B1: early viewport cull (64px margin) for element and range targets:
  wholly offscreen targets skip targetStyleHidden / getComputedStyle /
  clipBoundsFor / client-rect collection entirely and just omit their
  markers, which is what the visible pipeline produced anyway.
- B2: read/write batching in renderAnnotationOverlay: highlight rects are
  queued during the read phase and flushed as one write phase, so the
  pass no longer forces a synchronous layout per record.
- B3: restoreAnnotation defers its render through the existing
  rAF-coalesced reconcile scheduler; restoring N annotations now renders
  once instead of N full passes (searches stay synchronous for the
  mark-applied reply). DOM tests flush the frame via the suite's
  standard macrotask flush.
- B4: zero-work observer gate: page mutations with no records, no
  pending draft, and pinpoint inactive still bump domGeneration but no
  longer schedule a reconcile frame.
- D: hover affordance for click-to-select: the rAF-throttled mousemove
  hit-tests the pointer against the CACHED rendered committed rects and
  toggles a brightness class on that annotation's rect divs inside the
  shadow root. No page-DOM writes, rects stay pointer-transparent, and
  shadow-root writes are unobserved so there is no reconcile loop.
- G: while a text drag is in progress in drag mode, placed markers yield
  pointer input (data-pn-hittest) so the 25px bubble cannot capture a
  selection drag; armed only by a >4px primary-button move from a
  non-overlay mousedown, so marker clicks and click-to-select paths are
  untouched. withMarkersYielded now restores (not clears) the attribute.

New regression tests for A, B1, B3, B4, D; A/B1/B3 mutation-verified
(fix reverted, test observed failing, fix restored).

* fix(annotate): make on-page marker numbers match exportAnnotations numbering

The HtmlViewer sync excluded GLOBAL_COMMENT annotations before numbering
while exportAnnotations numbers '## N.' sections across the FULL list
including globals — so an on-page 'Comment 2' could be '## 3.' in the
feedback the agent reads. The sync now derives each marker's number from
its position in the full createdA-sorted list (globals occupy a number
but ship no entry, leaving the correct gaps on-page). Export format is
unchanged.

New buildSyncNumbering helper + tests asserting a mixed list yields
identical numbers between the sync payload and exportAnnotations output
(mutation-verified against the pre-fix ordering).

* chore: sync stale workspace versions in bun.lock (0.26.1 -> 0.26.7)

* docs: document raw-HTML overlay model, multi-target types, and known limitations

- Data Types: add htmlAdditionalTargets to the Annotation listing plus
  the HtmlElementAnchor (including the optional normalized point used by
  placed markers) and HtmlAnnotationTarget shapes.
- Annotation System: describe the post-#1257 raw-HTML surface (placed
  comment markers + overlay-projected highlights, no inline mark
  mutation; durable anchors persisted, disposable markers projected) and
  the print-parity limitation.
- URL Sharing: note that share links intentionally drop HTML element
  anchors and additional targets (restore is text-search based, per
  sharing.multiTarget.test.ts).

* test: fix Range.getClientRects stub typing in the B1 cull test

* fix(annotate): hover-race teardown and unbounded one-shot dead-search passes

Polish round on the overlay hardening:

- Hover race (1): switching into pinpoint mode (or opening a draft) now
  tears hover down fully via clearHoverHighlight() — cancels the pending
  rAF hit test and clears the tracked position and id — and the rAF
  callback itself refuses to paint outside drag mode / with an open
  draft. Previously the pending callback re-applied the class after the
  mode switch and every flushQueuedHighlights re-painted it from the
  stale hoverHighlightId, leaving a permanent phantom hover.
- One-shot budgets (3): beginDeadSearchPass takes a per-pass budget.
  Reconcile passes keep 2 (they repeat, skipped targets get follow-up
  frames); print and scroll-to are user-initiated one-shots with no
  follow-up and now run unbounded (backoff and generation gates still
  apply), so printing with 3+ dead-but-recoverable targets no longer
  silently prints fewer highlights.

Both changes carry new regression tests, mutation-verified (fix
reverted, test observed failing, fix restored).

* fix(annotate): number markers by array position and cap entries after dropping globals

The createdA sort made the export-match invariant false with external
annotations: exportAnnotations' sort keys tie for every raw-HTML
annotation (blockId '', startOffset 0), so its stable sort numbers the
combined [...local, ...external] list in ARRAY order — and external
annotations arrive appended with server-stamped createdA values that can
interleave with local timestamps. buildSyncNumbering now numbers by
array position of the input (verified to be the same combined list both
consumers receive from packages/editor/App.tsx allAnnotations; the
viewerAnnotations diffContext filter is order-preserving and vacuous on
the raw-HTML surface).

Also reorders the cap: number the full list, drop globals, THEN slice
512 entries — globals no longer waste sync capacity and a non-global the
export numbers past position 512 still syncs while slots remain. Numbers
may now exceed 512 (array positions); the bridge's own bound (100000)
accepts them and its 512-entry cap still agrees with the sender.

Tests updated: interleaved-external agreement with exportAnnotations
(mutation-verified against the createdA sort) and slice-after-filter
capacity.

* docs(opencode): note the accepted cache-hint flattening trade-off in V2 consolidation
2026-08-10 15:27:04 -07:00

138 lines
5.3 KiB
TypeScript

/**
* On-page marker numbers must agree with the numbers the agent reads:
* exportAnnotations (packages/ui/utils/parser.ts) numbers `## N.` sections
* across the FULL annotation list including global comments, so the sync
* payload must derive each marker's number from that same ordering. A sync
* that excludes globals BEFORE numbering makes on-page "Comment 2" read
* `## 3.` in the feedback — the exact confusion this suite guards against.
*/
import { describe, expect, test } from "bun:test";
import type { Annotation } from "../../types";
import { AnnotationType } from "../../types";
import { exportAnnotations } from "../../utils/parser";
import { MAX_SYNC_ANNOTATIONS, buildSyncNumbering } from "./annotationNumbering";
function htmlComment(id: string, createdA: number, originalText: string): Annotation {
return {
id,
blockId: "",
startOffset: 0,
endOffset: 0,
type: AnnotationType.COMMENT,
text: `note about ${originalText}`,
originalText,
createdA,
} as Annotation;
}
function globalComment(id: string, createdA: number, text: string): Annotation {
return {
id,
blockId: "",
startOffset: 0,
endOffset: 0,
type: AnnotationType.GLOBAL_COMMENT,
text,
originalText: "",
createdA,
} as Annotation;
}
/** The `## N.` number of the export section containing `needle`. */
function exportNumberOf(output: string, needle: string): number {
const sections = output.split(/^## /m).slice(1);
const section = sections.find((s) => s.includes(needle));
if (!section) throw new Error(`no export section contains: ${needle}`);
return Number.parseInt(section, 10);
}
describe("buildSyncNumbering", () => {
test("a mixed list yields on-page numbers identical to exportAnnotations output", () => {
const annotations = [
htmlComment("ann-a", 100, "alpha passage"),
globalComment("glob", 200, "overall global note"),
htmlComment("ann-b", 300, "beta passage"),
];
const payload = buildSyncNumbering(annotations);
// Globals occupy a number but ship no entry (no page location): the
// on-page markers show 1 and 3, leaving the gap where the global sits.
expect(payload).toEqual([
{ id: "ann-a", number: 1 },
{ id: "ann-b", number: 3 },
]);
const output = exportAnnotations([], annotations, [], "Plan Feedback", "plan");
expect(exportNumberOf(output, "alpha passage")).toBe(1);
expect(exportNumberOf(output, "overall global note")).toBe(2);
expect(exportNumberOf(output, "beta passage")).toBe(3);
// The payload numbers ARE the export numbers, entry by entry.
const needleById: Record<string, string> = {
"ann-a": "alpha passage",
"ann-b": "beta passage",
};
for (const entry of payload) {
expect(exportNumberOf(output, needleById[entry.id]!)).toBe(entry.number);
}
});
test("interleaved external annotations: numbers follow ARRAY order and agree with the export", () => {
// External annotations arrive with server-stamped createdA values that
// can interleave with local timestamps, but they are APPENDED to the
// combined list — and exportAnnotations' sort keys tie for every
// raw-HTML annotation (blockId "", startOffset 0), so the export numbers
// the ARRAY order. A createdA sort here would renumber the external
// annotation 2 while the export calls it `## 3.`.
const external = {
...htmlComment("ext", 200, "external passage"),
source: "eslint",
} as Annotation;
const annotations = [
htmlComment("loc-a", 100, "alpha passage"),
htmlComment("loc-b", 300, "beta passage"),
external, // appended after loc-b despite the earlier createdA
];
const payload = buildSyncNumbering(annotations);
expect(payload).toEqual([
{ id: "loc-a", number: 1 },
{ id: "loc-b", number: 2 },
{ id: "ext", number: 3 },
]);
const output = exportAnnotations([], annotations, [], "Plan Feedback", "plan");
expect(exportNumberOf(output, "alpha passage")).toBe(1);
expect(exportNumberOf(output, "beta passage")).toBe(2);
expect(exportNumberOf(output, "external passage")).toBe(3);
const needleById: Record<string, string> = {
"loc-a": "alpha passage",
"loc-b": "beta passage",
ext: "external passage",
};
for (const entry of payload) {
expect(exportNumberOf(output, needleById[entry.id]!)).toBe(entry.number);
}
});
test("the entry cap applies AFTER dropping globals, so globals never waste sync capacity", () => {
// One global up front plus MAX + 1 non-globals: the global occupies
// number 1 but ships no entry, and the cap keeps a full 512 non-globals
// — including the one the export numbers at position 513. Slicing before
// the filter would ship only 511 non-globals and strand that entry.
const annotations: Annotation[] = [
globalComment("glob", 0, "leading global"),
...Array.from({ length: MAX_SYNC_ANNOTATIONS + 1 }, (_, i) =>
htmlComment(`bulk-${i}`, i + 1, `passage ${i}`),
),
];
const payload = buildSyncNumbering(annotations);
expect(payload.length).toBe(MAX_SYNC_ANNOTATIONS);
expect(payload[0]).toEqual({ id: "bulk-0", number: 2 });
expect(payload[MAX_SYNC_ANNOTATIONS - 1]).toEqual({
id: `bulk-${MAX_SYNC_ANNOTATIONS - 1}`,
number: MAX_SYNC_ANNOTATIONS + 1,
});
});
});