mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(spec): key web attrs by the names the markup writes
attrs was spread from element.dataset, whose DOMStringMap keys are camelCase, so a spec reading attrs["data-cents"] the way every native host reports it read undefined. In folio-web that left ledgerTxnCount and ledgerBalance permanently zero: someTransactionExists could never be satisfied, balanceMatchesTransaction Delta could never fire, and totalBalanceMatchesAccounts compared 0 to 0 and passed vacuously. Three properties reported nothing because the harness was blind, not because the application was correct. The handle also fills hintText and editable now, so an authored InputText on web names its field the way the same action names it on Android instead of rendering as Type "12.34" into "". Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
1 parent
739e921788
commit
037a803f0e
2 files changed
+148
-5
No files matched your search
@@ -359,6 +359,38 @@ function evaluateXPathAll(xpath: string, root: Node): Element[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// rawAttributes keys an element's attributes by the names the markup writes,
|
||||
// which is what `attrs` means on every other backend. element.dataset would key
|
||||
// `data-cents` as `cents`, so a spec reading attrs["data-cents"] the way the
|
||||
// native hosts report it read undefined on web and every assertion over it
|
||||
// passed vacuously.
|
||||
function rawAttributes(element: Element): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const attribute of Array.from(element.attributes ?? [])) {
|
||||
out[attribute.name] = attribute.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// fieldHint names an editable field the way a user reads it, in the order the
|
||||
// accessible name is computed: its own aria-label, the <label> bound to it, the
|
||||
// placeholder standing in the empty box, then the name the form gives it. It
|
||||
// lands on `hintText`, the rung visibleLabel (internal/verifier/llm.go) reads
|
||||
// first for an editable element, so an authored InputText on web names its field
|
||||
// the way the same action names it on Android.
|
||||
function fieldHint(element: Element): string {
|
||||
if (!isEditableElement(element as HTMLElement)) return "";
|
||||
const ariaLabel = element.getAttribute("aria-label");
|
||||
if (ariaLabel) return ariaLabel;
|
||||
for (const label of Array.from((element as HTMLInputElement).labels ?? [])) {
|
||||
const text = (label.textContent ?? "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
const placeholder = element.getAttribute("placeholder");
|
||||
if (placeholder) return placeholder;
|
||||
return element.getAttribute("name") ?? "";
|
||||
}
|
||||
|
||||
function elementHandle(element: Element): Record<string, unknown> {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.round(rect.left + rect.width / 2);
|
||||
@@ -371,6 +403,13 @@ function elementHandle(element: Element): Record<string, unknown> {
|
||||
const value = (dataset as Record<string, string | undefined>)[key];
|
||||
if (value !== undefined) datasetCopy[key] = value;
|
||||
}
|
||||
const attrs: Record<string, string> = {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
"aria-label": ariaLabel,
|
||||
...rawAttributes(element),
|
||||
};
|
||||
const hint = fieldHint(element);
|
||||
if (hint) attrs.hintText = hint;
|
||||
return {
|
||||
id: element.id,
|
||||
text,
|
||||
@@ -378,6 +417,7 @@ function elementHandle(element: Element): Record<string, unknown> {
|
||||
class: (element as HTMLElement).className ?? "",
|
||||
clickable: true,
|
||||
enabled: !(element as HTMLButtonElement).disabled,
|
||||
editable: isEditableElement(element as HTMLElement),
|
||||
focused: document.activeElement === element,
|
||||
x,
|
||||
y,
|
||||
@@ -387,11 +427,7 @@ function elementHandle(element: Element): Record<string, unknown> {
|
||||
right: Math.round(rect.right),
|
||||
bottom: Math.round(rect.bottom),
|
||||
},
|
||||
attrs: {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
"aria-label": ariaLabel,
|
||||
...datasetCopy,
|
||||
},
|
||||
attrs,
|
||||
dataset: datasetCopy,
|
||||
find(selector: unknown): unknown {
|
||||
const child = queryElement(element, selector);
|
||||
|
||||
@@ -414,3 +414,110 @@ test("selectorFromObject text-only selector becomes an XPath", () => {
|
||||
xpath: `//*[normalize-space(text())="Go"]`,
|
||||
});
|
||||
});
|
||||
|
||||
// domElement is one element as elementHandle reads it. dataset camelCases its
|
||||
// keys the way a real DOMStringMap does, which is what hid `data-cents` and
|
||||
// friends behind `attrs.cents` and made every assertion over them read
|
||||
// undefined.
|
||||
function domElement(spec: {
|
||||
tag: string;
|
||||
attributes?: Record<string, string>;
|
||||
labels?: string[];
|
||||
text?: string;
|
||||
}): unknown {
|
||||
const attributes = spec.attributes ?? {};
|
||||
return {
|
||||
tagName: spec.tag.toUpperCase(),
|
||||
type: spec.tag === "input" ? "text" : "",
|
||||
isContentEditable: false,
|
||||
id: attributes.id ?? "",
|
||||
className: attributes.class ?? "",
|
||||
textContent: spec.text ?? "",
|
||||
dataset: Object.fromEntries(
|
||||
Object.entries(attributes)
|
||||
.filter(([name]) => name.startsWith("data-"))
|
||||
.map(([name, value]) => [
|
||||
name.slice("data-".length).replace(/-(.)/g, (_, letter: string) => letter.toUpperCase()),
|
||||
value,
|
||||
]),
|
||||
),
|
||||
attributes: Object.entries(attributes).map(([name, value]) => ({ name, value })),
|
||||
labels: (spec.labels ?? []).map((textContent) => ({ textContent })),
|
||||
getAttribute: (name: string) => attributes[name] ?? null,
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, right: 40, bottom: 20, width: 40, height: 20 }),
|
||||
};
|
||||
}
|
||||
|
||||
function handleOf(element: unknown): Record<string, unknown> {
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
const original = global.document;
|
||||
global.document = { querySelector: () => element, querySelectorAll: () => [element] };
|
||||
try {
|
||||
const ax = __testing__.buildAx() as { find(selector: unknown): Record<string, unknown> };
|
||||
return ax.find({ id: "any" });
|
||||
} finally {
|
||||
global.document = original;
|
||||
}
|
||||
}
|
||||
|
||||
function attrsOf(element: unknown): Record<string, string> {
|
||||
return handleOf(element).attrs as Record<string, string>;
|
||||
}
|
||||
|
||||
// `attrs` means the same thing on every backend: the attributes the markup
|
||||
// writes, keyed by the names it writes them under. A spec reading
|
||||
// attrs["data-cents"] the way examples/folio-web does read undefined here, so
|
||||
// the properties over those values could never hold OR fail.
|
||||
test("attrs keys data attributes by the name the markup writes", () => {
|
||||
const element = domElement({
|
||||
tag: "div",
|
||||
attributes: { id: "ledger", "data-txn-count": "3", "data-account-id": "a-1" },
|
||||
});
|
||||
assert.equal(attrsOf(element)["data-txn-count"], "3");
|
||||
assert.equal(attrsOf(element)["data-account-id"], "a-1");
|
||||
// `dataset` stays the DOMStringMap view, camelCase keys and all.
|
||||
assert.deepEqual(handleOf(element).dataset, { txnCount: "3", accountId: "a-1" });
|
||||
});
|
||||
|
||||
test("attrs carries every other attribute alongside tag and aria-label", () => {
|
||||
const attrs = attrsOf(
|
||||
domElement({ tag: "input", attributes: { id: "txn-note", placeholder: "What's this for?" } }),
|
||||
);
|
||||
assert.equal(attrs.tag, "input");
|
||||
assert.equal(attrs["aria-label"], "");
|
||||
assert.equal(attrs.id, "txn-note");
|
||||
assert.equal(attrs.placeholder, "What's this for?");
|
||||
});
|
||||
|
||||
// An input has no text of its own, so a handle that names it by text names it
|
||||
// "". hintText is the rung visibleLabel reads first for an editable element,
|
||||
// and it is what lets a model tell the amount field from the note field.
|
||||
test("an editable field's hintText is the label bound to it", () => {
|
||||
const element = domElement({
|
||||
tag: "input",
|
||||
attributes: { id: "txn-amount", placeholder: "0.00" },
|
||||
labels: ["Amount"],
|
||||
});
|
||||
assert.equal(attrsOf(element).hintText, "Amount");
|
||||
assert.equal(handleOf(element).editable, true);
|
||||
assert.equal(handleOf(element).text, "");
|
||||
});
|
||||
|
||||
test("an unlabelled field's hintText falls back to aria-label, placeholder, then name", () => {
|
||||
assert.equal(
|
||||
attrsOf(domElement({ tag: "input", attributes: { "aria-label": "Search", placeholder: "Type here" } }))
|
||||
.hintText,
|
||||
"Search",
|
||||
);
|
||||
assert.equal(
|
||||
attrsOf(domElement({ tag: "input", attributes: { placeholder: "What's this for?" } })).hintText,
|
||||
"What's this for?",
|
||||
);
|
||||
assert.equal(attrsOf(domElement({ tag: "input", attributes: { name: "note" } })).hintText, "note");
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(handleOf(element).editable, false);
|
||||
});
|
||||
Reference in new issue
Block a user