Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions skills/htmlit/client/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ export function loadScript(src) {
document.head.appendChild(s);
});
}
// a CSS selector for the element - sent to the agent, never shown to the user
// A CSS selector that re-resolves to this exact element - sent to the agent and
// used to re-anchor a selection after a morph. It must be rooted (at an ancestor
// id, otherwise <body>): an unrooted path such as "div:nth-of-type(1) > ul > li"
// floats, so document.querySelector can match a deeper, earlier subtree with the
// same tag/nth-of-type skeleton and resolve to the wrong element.
export function cssPath(el) {
if (!el || el.nodeType !== 1) return "";
if (el.id) return "#" + CSS.escape(el.id);
var parts = [];
var node = el;
while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
while (node && node.nodeType === 1 && node !== document.body) {
if (node.id) { parts.unshift("#" + CSS.escape(node.id)); return parts.join(" > "); }
var sel = node.nodeName.toLowerCase();
if (node.id) { parts.unshift("#" + CSS.escape(node.id)); break; }
var parent = node.parentElement;
if (parent) {
var same = Array.prototype.filter.call(parent.children, function (c) { return c.nodeName === node.nodeName; });
Expand All @@ -36,5 +40,6 @@ export function cssPath(el) {
parts.unshift(sel);
node = node.parentElement;
}
parts.unshift("body");
return parts.join(" > ");
}
34 changes: 34 additions & 0 deletions tests/client/fixtures/selector-collision.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Selector collision</title>
</head>
<body>
<div class="layout">
<h1>Cancellation design</h1>

<!--
Decoy subtree. Its <strong> sits inside a first-of-type <div> whose direct
<ul>'s second <li> holds it - the same tag/nth-of-type skeleton the target
below produces. It appears earlier in document order, so an unrooted
selector for the target ("div:nth-of-type(1) > ul > li:nth-of-type(2) >
strong") resolves here instead, breaking annotation of the target.
-->
<div class="grid">
<div class="card">
<ul>
<li>Checkpoint cadence</li>
<li>Checkpoint <strong>polls the queue</strong> on a timer</li>
</ul>
</div>
</div>

<h2>Failure modes</h2>
<ul>
<li>A stale reservation that never clears.</li>
<li>A <strong>leaked testbed lock</strong> whose task is terminal.</li>
</ul>
</div>
</body>
</html>
97 changes: 97 additions & 0 deletions tests/client/selector-anchor.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Browser regression test for annotation-selector anchoring.
*
* When a selection's anchor element had no id, the client built an *unrooted* CSS
* path for it ("div:nth-of-type(1) > ul > li:nth-of-type(2) > strong"). If an
* earlier subtree in the document shared that same tag/nth-of-type skeleton
* (e.g. a <strong> nested in a card grid), document.querySelector re-resolved the
* path to that decoy instead. resolveRange's text guard then failed, pendingRects
* came back empty, and openSelMenu -> clearPending closed the menu and wiped the
* selection - so the line could not be highlighted or commented on at all.
*
* The fixture reproduces exactly that collision. Selecting the target <strong>
* must open the annotation menu (rooting the path at <body> makes it resolve to
* the correct element).
*
* Skips rather than fails when no browser or Python is available.
*/

import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { after, before, test } from "node:test";

import { startReview } from "./harness.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURE = join(HERE, "fixtures", "selector-collision.html");
const state = { browser: null, unavailable: null };

before(async () => {
try {
const { default: puppeteer } = await import("puppeteer");
state.browser = await puppeteer.launch({ args: ["--no-sandbox", "--disable-setuid-sandbox"] });
} catch (err) {
state.unavailable = `no browser available: ${err.message}`;
}
});

after(async () => {
if (state.browser) await state.browser.close();
});

// Select the whole text of the first element matching `needle`, then fire the
// mouseup the client listens for, and report the annotation menu's display.
async function selectAndMenu(page, needle) {
return page.evaluate(async (needle) => {
function findText(n) {
const w = document.createTreeWalker(document.querySelector(".layout") || document.body, NodeFilter.SHOW_TEXT);
let x;
while ((x = w.nextNode())) if ((x.nodeValue || "").toLowerCase().includes(n)) return x;
return null;
}
const node = findText(needle.toLowerCase());
if (!node) return { found: false };
const host = node.parentElement;
host.scrollIntoView({ block: "center" });
await new Promise((r) => setTimeout(r, 100));

const range = document.createRange();
range.setStart(node, 0);
range.setEnd(node, node.nodeValue.length);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);

host.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, composed: true }));
await new Promise((r) => setTimeout(r, 200)); // the client finalizes the selection in a setTimeout(0)

const menu = document.getElementById("htmlit-chrome").shadowRoot.querySelector(".selmenu");
return { found: true, selection: sel.toString(), menu: getComputedStyle(menu).display };
}, needle);
}

test("selecting a nested inline element opens the annotation menu despite a decoy subtree", async (t) => {
if (state.unavailable) return t.skip(state.unavailable);
let review, page;
try {
review = await startReview(FIXTURE);
page = await state.browser.newPage();
await page.setViewport({ width: 1440, height: 1000 });
await page.goto(review.url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(
() => { const c = document.getElementById("htmlit-chrome"); return c && c.shadowRoot && c.shadowRoot.querySelector(".selmenu"); },
{ timeout: 20000 },
);
await new Promise((r) => setTimeout(r, 300));

const target = await selectAndMenu(page, "leaked testbed lock");
assert.ok(target.found, "target line should exist in the fixture");
// Under the bug the unrooted path resolved to the decoy <strong> ("polls the
// queue"), the text guard failed, and the menu was force-closed (display:none).
assert.equal(target.menu, "flex", `annotation menu should open for the target line (got display:${target.menu})`);
} finally {
if (page) await page.close();
if (review) await review.stop();
}
});
Loading