From d50fb7678da6baf94f9258865386a4bd22d81072 Mon Sep 17 00:00:00 2001 From: FND Date: Wed, 16 Sep 2026 13:16:35 +0200 Subject: [PATCH 1/4] feat(core): carry elementContext through html-anchor host helpers Persist and project elementContext through the core host helpers; move the validator into core and drop the ui mirror; add outline-less export option. Closes #1521. --- packages/core/html-anchor.test.ts | 218 ++++++++++++++++++ packages/core/html-anchor.ts | 215 ++++++++++++++++- .../html-viewer/useHtmlAnnotation.ts | 156 +------------ packages/ui/utils/parser.test.ts | 46 +++- packages/ui/utils/parser.ts | 12 +- 5 files changed, 490 insertions(+), 157 deletions(-) diff --git a/packages/core/html-anchor.test.ts b/packages/core/html-anchor.test.ts index 256f9954a..0cb15c3a5 100644 --- a/packages/core/html-anchor.test.ts +++ b/packages/core/html-anchor.test.ts @@ -15,7 +15,9 @@ import { describe, expect, test } from "bun:test"; import { buildPersistedHtmlAnchor, DEFAULT_HTML_ANCHOR_MAX_BYTES, + MAX_ELEMENT_CONTEXT_BYTES, MAX_HTML_ADDITIONAL_TARGETS, + parseHtmlElementContext, projectHostThreads, type HostThread, } from "./html-anchor"; @@ -47,6 +49,98 @@ describe("buildPersistedHtmlAnchor", () => { expect(Object.keys(reordered.anchor.htmlAdditionalTargets![0]!)).toEqual(["text", "label", "anchor"]); }); + test("rows without elementContext serialize byte-identically and pin wire fingerprint", () => { + // Failure caught: changing the serialization format or introducing undefined/extra keys + // for rows that do not carry elementContext, which breaks host wire fingerprints. + const withoutContext = { + originalText: "Text quote", + htmlAnchor: { selector: "p.lead", tagName: "p", text: "Text" }, + htmlAdditionalTargets: [ + { text: "Target", label: "Label", anchor: { selector: "#t1", tagName: "div" } }, + ], + }; + const result = buildPersistedHtmlAnchor(withoutContext); + expect(JSON.stringify(result.anchor)).toBe(JSON.stringify(withoutContext)); + expect(Object.keys(result.anchor)).toEqual(["originalText", "htmlAnchor", "htmlAdditionalTargets"]); + expect("elementContext" in result.anchor).toBe(false); + + // When elementContext IS present, it serializes strictly after htmlAdditionalTargets + const withContext = { + ...withoutContext, + elementContext: { tag: "p", id: "lead-para" }, + }; + const resultWithContext = buildPersistedHtmlAnchor(withContext); + expect(Object.keys(resultWithContext.anchor)).toEqual([ + "originalText", + "htmlAnchor", + "htmlAdditionalTargets", + "elementContext", + ]); + }); + + test("shed order: per-target contexts shed first, then primary context, before dropping targets under small maxBytes", () => { + // Failure caught: dropping targets prematurely while expendable contexts remain, + // losing user-selected targets instead of shedding descriptive context. + const targetContext1 = { tag: "button", path: "body > form > button:nth-of-type(1)", outline: "" }; + const targetContext2 = { tag: "button", path: "body > form > button:nth-of-type(2)", outline: "" }; + const primaryContext = { tag: "form", path: "body > form#action-form", outline: "
" }; + + const source = { + originalText: "Save changes", + htmlAnchor: { selector: "form#action-form", tagName: "form" }, + htmlAdditionalTargets: [ + { text: "Btn 1", anchor: { selector: "button.save", tagName: "button" }, context: targetContext1 }, + { text: "Btn 2", anchor: { selector: "button.cancel", tagName: "button" }, context: targetContext2 }, + ], + elementContext: primaryContext, + }; + + // Full anchor with all contexts + const full = buildPersistedHtmlAnchor(source); + const fullBytes = bytes(full.anchor); + expect(full.anchor.elementContext).toBeDefined(); + expect(full.anchor.htmlAdditionalTargets?.[0]?.context).toBeDefined(); + expect(full.anchor.htmlAdditionalTargets?.[1]?.context).toBeDefined(); + expect(full.anchor.htmlAdditionalTargets?.length).toBe(2); + + // Budget just small enough to force shedding the last target's context + const budget1 = fullBytes - 30; + const res1 = buildPersistedHtmlAnchor(source, { maxBytes: budget1 }); + expect(bytes(res1.anchor)).toBeLessThanOrEqual(budget1); + expect(res1.anchor.htmlAdditionalTargets?.length).toBe(2); + expect(res1.anchor.htmlAdditionalTargets?.[0]?.context).toBeDefined(); + // Last target's context was shed first + expect(res1.anchor.htmlAdditionalTargets?.[1]?.context).toBeUndefined(); + expect(res1.anchor.elementContext).toBeDefined(); + expect(res1.droppedTargets).toBe(0); + + // Budget small enough to shed all per-target contexts and primary context, but keep targets + const baseWithoutContexts = buildPersistedHtmlAnchor({ + originalText: source.originalText, + htmlAnchor: source.htmlAnchor, + htmlAdditionalTargets: [ + { text: "Btn 1", anchor: { selector: "button.save", tagName: "button" } }, + { text: "Btn 2", anchor: { selector: "button.cancel", tagName: "button" } }, + ], + }); + const budgetNoContexts = bytes(baseWithoutContexts.anchor) + 10; + const res2 = buildPersistedHtmlAnchor(source, { maxBytes: budgetNoContexts }); + expect(bytes(res2.anchor)).toBeLessThanOrEqual(budgetNoContexts); + // Both target contexts and primary context shed, but targets remain + expect(res2.anchor.htmlAdditionalTargets?.length).toBe(2); + expect(res2.anchor.htmlAdditionalTargets?.[0]?.context).toBeUndefined(); + expect(res2.anchor.htmlAdditionalTargets?.[1]?.context).toBeUndefined(); + expect(res2.anchor.elementContext).toBeUndefined(); + expect(res2.droppedTargets).toBe(0); + + // Even smaller budget: now targets are dropped from the end + const budgetDropTarget = bytes(baseWithoutContexts.anchor) - 20; + const res3 = buildPersistedHtmlAnchor(source, { maxBytes: budgetDropTarget }); + expect(bytes(res3.anchor)).toBeLessThanOrEqual(budgetDropTarget); + expect(res3.anchor.htmlAdditionalTargets?.length).toBe(1); + expect(res3.sizeDroppedTargets).toBe(1); + }); + test("a drag capture without an element anchor writes exactly the legacy shape", () => { const result = buildPersistedHtmlAnchor({ originalText: "plain quote" }); expect(result.anchor).toEqual({ originalText: "plain quote" }); @@ -224,6 +318,56 @@ describe("projectHostThreads", () => { expect(gamma?.htmlAdditionalTargets).toEqual([{ label: "Button", text: "Go", anchor: { selector: "#go", tagName: "button", text: "Go" } }]); }); + test("projection carries validated primary elementContext and target contexts", () => { + // Failure caught: host panels and copy actions dropping elementContext and per-target + // context during projection from stored host rows onto viewer annotations. + const threadWithContext: HostThread = { + id: "ctx1", + originalText: "Quoted", + htmlAnchor: { selector: "#main", tagName: "main" }, + elementContext: { + tag: "MAIN", + id: "main-content", + classes: ["content"], + role: "main", + }, + htmlAdditionalTargets: [ + { + text: "Extra target", + label: "Section", + anchor: { selector: "section#s1", tagName: "section" }, + context: { tag: "SECTION", id: "s1" }, + }, + ], + }; + const projected = projectHostThreads([threadWithContext]); + expect(projected.length).toBe(1); + expect(projected[0]?.elementContext).toEqual({ + tag: "main", + id: "main-content", + classes: ["content"], + role: "main", + }); + expect(projected[0]?.htmlAdditionalTargets?.[0]?.context).toEqual({ + tag: "section", + id: "s1", + }); + + // Malformed context fails closed to undefined without dropping the annotation + const threadWithGarbage: HostThread = { + id: "ctx2", + originalText: "Quoted 2", + elementContext: { tag: "" } as any, + htmlAdditionalTargets: [ + { text: "Extra", context: null as any }, + ], + }; + const projectedGarbage = projectHostThreads([threadWithGarbage]); + expect(projectedGarbage[0]?.elementContext).toBeUndefined(); + expect(projectedGarbage[0]?.htmlAdditionalTargets?.[0]?.context).toBeUndefined(); + expect(projectedGarbage[0]?.originalText).toBe("Quoted 2"); + }); + test("is pure: the same input projects the same output and never mutates it", () => { const frozen = JSON.stringify(rows); const a = projectHostThreads(rows, { openOnly: true }); @@ -232,3 +376,77 @@ describe("projectHostThreads", () => { expect(JSON.stringify(rows)).toBe(frozen); }); }); + +describe("parseHtmlElementContext", () => { + test("accepts what the bridge builds and fails closed on garbage", () => { + // Failure caught: forged or malformed bridge context messages crashing the host + // or injecting unvalidated page-controlled content into storage. + expect(parseHtmlElementContext(null)).toBeUndefined(); + expect(parseHtmlElementContext(undefined)).toBeUndefined(); + expect(parseHtmlElementContext("not-an-object")).toBeUndefined(); + expect(parseHtmlElementContext({})).toBeUndefined(); + expect(parseHtmlElementContext({ tag: "" })).toBeUndefined(); + expect(parseHtmlElementContext({ tag: " " })).toBeUndefined(); + + // Valid context with lowercased tag and parsed fields + const valid = parseHtmlElementContext({ + tag: "BUTTON", + id: "submit-btn", + classes: ["btn", "btn-primary"], + path: "body > form > button", + role: "button", + name: "Submit form", + attrs: [["type", "submit"], ["aria-label", "Submit form"], ["onclick", "steal()"]], + text: "Submit", + outline: "", + children: 1.9, + rect: { x: 10, y: 20, w: 100, h: 40, vw: 1280, vh: 800 }, + landmark: "main", + heading: "h1 \"Checkout\"", + component: "data-component=SubmitBtn", + page: { url: "/checkout?step=2", title: "Checkout" }, + }); + expect(valid).toBeDefined(); + expect(valid?.tag).toBe("button"); + expect(valid?.id).toBe("submit-btn"); + expect(valid?.classes).toEqual(["btn", "btn-primary"]); + expect(valid?.path).toBe("body > form > button"); + expect(valid?.role).toBe("button"); + expect(valid?.name).toBe("Submit form"); + // Non-allowlisted attribute onclick was dropped + expect(valid?.attrs).toEqual([["type", "submit"], ["aria-label", "Submit form"]]); + expect(valid?.text).toBe("Submit"); + // Fenced backticks defused + expect(valid?.outline).not.toContain("```"); + expect(valid?.outline).toContain("'''"); + // Children floored + expect(valid?.children).toBe(1); + expect(valid?.rect).toEqual({ x: 10, y: 20, w: 100, h: 40, vw: 1280, vh: 800 }); + expect(valid?.landmark).toBe("main"); + expect(valid?.heading).toBe("h1 \"Checkout\""); + expect(valid?.component).toBe("data-component=SubmitBtn"); + expect(valid?.page).toEqual({ url: "/checkout?step=2", title: "Checkout" }); + + // Non-finite rect is dropped whole + const badRect = parseHtmlElementContext({ tag: "div", rect: { x: NaN, y: 0, w: 10, h: 10, vw: 100, vh: 100 } }); + expect(badRect?.rect).toBeUndefined(); + + // Unknown keys dropped + const extraKeys = parseHtmlElementContext({ tag: "div", unknownKey: "evil", innerHTML: "