fix(web-runtime): answer clickable for an element reached through ax

The handle hardcoded true, so every text node and container a spec reached
through state.ax claimed to be a tap target while the enumeration and the
hierarchy dump both resolved it through the tappable selector.

The parity test now compares the handle against the enumeration element by
element in a real browser, which is where the three answers have to agree.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
pj committed 2026-08-15 13:45:30 +05:30
1 parent 9b9c7cf6e2
commit 7066b23a22
4 files changed
+128 -22

No files matched your search

+4 -1
View File
@@ -488,7 +488,10 @@ function elementHandle(element: Element, selector: unknown): Record<string, unkn
text,
desc: ariaLabel,
class: (element as HTMLElement).className ?? "",
clickable: true,
// The selector collectTargets and the hierarchy dump (driver.go) both
// resolve clickable through. Hardcoded true here, every text node and
// container a spec reached through state.ax claimed to be a tap target.
clickable: element.matches(TAPPABLE_SELECTOR),
enabled: isEnabled(element),
editable: isEditableElement(element as HTMLElement),
focused: document.activeElement === element,
+16 -1
View File
@@ -11,11 +11,23 @@
import { __testing__ } from "../src/web-runtime.ts";
const { collectTargets, targetElements } = __testing__;
const { collectTargets, targetElements, buildAx } = __testing__;
// The handle facts are read back off the shipped ax element, the way a spec
// reaches them and the way internal/verifier/llm.go handleLabel reads them, so
// the comparison runs over what the model is actually shown rather than over a
// recomputation of it.
type AxHandle = { attrs?: Record<string, string>; clickable?: boolean };
type Ax = { find(selector: unknown): AxHandle | undefined };
function handleOf(ax: Ax, id: string): AxHandle | undefined {
return id ? ax.find({ id }) : undefined;
}
function domFacts(): unknown[] {
const elements = targetElements();
const facts = collectTargets();
const ax = buildAx() as Ax;
if (elements.length !== facts.length) {
throw new Error(
`collectTargets reported ${facts.length} targets over ${elements.length} elements`,
@@ -23,6 +35,7 @@ function domFacts(): unknown[] {
}
return elements.map((element, index) => {
const target = facts[index]!;
const handle = handleOf(ax, element.id);
return {
id: element.id,
tag: element.tagName.toLowerCase(),
@@ -30,6 +43,8 @@ function domFacts(): unknown[] {
enabled: target.enabled,
editable: target.editable,
scrollable: target.scrollable,
hintText: handle?.attrs?.hintText ?? "",
handleClickable: handle?.clickable ?? false,
width: target.width ?? 0,
height: target.height ?? 0,
};
+42
View File
@@ -555,10 +555,28 @@ function domElement(spec: {
attributes: Object.entries(attributes).map(([name, value]) => ({ name, value })),
labels: (spec.labels ?? []).map((textContent) => ({ textContent })),
getAttribute: (name: string) => attributes[name] ?? null,
matches: (selector: string) => matchesAnyPart(selector, spec.tag, attributes),
getBoundingClientRect: () => ({ left: 0, top: 0, right: 40, bottom: 20, width: 40, height: 20 }),
};
}
// matchesAnyPart answers a comma-joined list of tag and attribute selectors over
// the fake's own tag and attributes, so the production selector string is what
// gets evaluated here and a role added to it is covered without teaching this
// harness about it.
function matchesAnyPart(
selector: string,
tag: string,
attributes: Record<string, string>,
): boolean {
return selector.split(",").some((part) => {
const attribute = /^\[([^\]=]+)(?:="([^"]*)")?\]$/.exec(part.trim());
if (!attribute) return part.trim() === tag;
const value = attributes[attribute[1]!];
return value !== undefined && (attribute[2] === undefined || value === attribute[2]);
});
}
function handleOf(element: unknown): Record<string, unknown> {
const global = globalThis as Record<string, unknown>;
const original = global.document;
@@ -627,6 +645,28 @@ test("an unlabelled field's hintText falls back to aria-label, placeholder, then
assert.equal(attrsOf(domElement({ tag: "input", attributes: { name: "note" } })).hintText, "note");
});
// clickable was hardcoded true here while the enumeration and the hierarchy dump
// both resolved it through TAPPABLE_SELECTOR, so every text node and container a
// spec reached through state.ax claimed to be a tap target on one host only.
test("an element reached through ax reports the tappable selector's clickability", () => {
const clickabilityOf = (element: unknown) => handleOf(element).clickable;
assert.equal(clickabilityOf(domElement({ tag: "button", attributes: { id: "submit" } })), true);
assert.equal(clickabilityOf(domElement({ tag: "input", attributes: { id: "amount" } })), true);
assert.equal(
clickabilityOf(domElement({ tag: "div", attributes: { id: "row", role: "option" } })),
true,
);
assert.equal(
clickabilityOf(domElement({ tag: "div", attributes: { id: "click", onclick: "void 0" } })),
true,
);
assert.equal(clickabilityOf(domElement({ tag: "div", attributes: { id: "balance" } })), false);
assert.equal(
clickabilityOf(domElement({ tag: "span", attributes: { id: "note", role: "presentation" } })),
false,
);
});
test("a non-editable element carries no hintText", () => {
const element = domElement({ tag: "button", attributes: { id: "txn-submit" }, text: "Add credit" });
assert.equal(attrsOf(element).hintText, undefined);
@@ -664,6 +704,7 @@ test("ax.findAll resolves a selector path segment by segment", () => {
textContent: id,
dataset: {},
getAttribute: () => null,
matches: (selector: string) => matchesAnyPart(selector, "div", {}),
getBoundingClientRect: () => rect,
querySelectorAll: (selector: string) => answers[selector] ?? [],
});
@@ -704,6 +745,7 @@ test("ax.find and ax.findAll label the element with its selector", () => {
textContent: "Submit",
dataset: {},
getAttribute: () => null,
matches: (selector: string) => matchesAnyPart(selector, "div", {}),
getBoundingClientRect: () => rect,
};
const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`;