diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 0559339..0d2d2bb 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -454,6 +454,21 @@ function selectorTag(selector: unknown): string { return ""; } +// selectorTagFor names the element only when no other element in the document +// answers to the selector. The runner prefers tree.Find(action.On) over the +// coordinates the element reported (resolveCoordinates in internal/runner) and +// Find takes the first match, so naming an element by a selector its siblings +// share sends every one of their actions to the first sibling. It is the rule +// selectorsFor already applies to the builtin target enumeration, and it is +// checked document-wide even for a child lookup because the runner re-resolves +// against the whole dump rather than the parent's subtree. +function selectorTagFor(element: Element, selector: unknown): string { + for (const match of queryAllElements(document, selector)) { + if (match !== element) return ""; + } + return selectorTag(selector); +} + // isEnabled answers the `enabled` fact. `.disabled` is a property only real form // controls have, so it reads undefined on the role-based controls the tappable // set now covers, and every one of them looked enabled however plainly it was @@ -505,7 +520,7 @@ function elementHandle(element: Element, selector: unknown): Record { .findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]) .map((card) => card.text); }); + __testing__.runtime.extract((state) => { + const ax = (state as { ax: { findAll(s: unknown): Record[] } }).ax; + return ax + .findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]) + .map((card) => card.__sanderlingSelector); + }); const values = __testing__.evaluateExtractors(); // Scoped to the head match: the cards come from the HomeScreen node, not // from a document-wide sweep for AccountCard. assert.deepEqual(readingOf(values, 0), ["first", "second"]); + // Both cards answer to the same path, so neither may carry it: the runner + // re-resolves a named target and would send both taps to the first card. + assert.deepEqual(readingOf(values, 1), ["", ""]); } finally { g.document = originalDocument; g.window = originalWindow; } }); -test("ax.find and ax.findAll label the element with its selector", () => { - const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 }; - const submit = { - id: "TxnSubmit", +// A selector is a name only while ONE element answers to it. The runner prefers +// the name over the coordinates the element reported (resolveCoordinates in +// internal/runner) and takes the first match, so labelling siblings that share a +// testTag sends every one of their taps to the first sibling: on folio's Home +// screen no account but the first could ever be opened. +test("ax.find and ax.findAll label the element with the selector only when it names that element alone", () => { + const rect = (top: number) => ({ left: 0, top, right: 10, bottom: top + 10, width: 10, height: 10 }); + const node = (id: string, top: number) => ({ + id, tagName: "DIV", className: "", - textContent: "Submit", + textContent: id, dataset: {}, getAttribute: () => null, matches: (selector: string) => matchesAnyPart(selector, "div", {}), - getBoundingClientRect: () => rect, - }; + getBoundingClientRect: () => rect(top), + }); + const submit = node("TxnSubmit", 0); + const cards = [node("Alpha", 20), node("Beta", 40), node("Gamma", 60)]; const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`; + const cardMatches = `:is([data-testid="AccountCard"], [id="AccountCard"])`; const g = globalThis as Record; const originalDocument = g.document; const originalWindow = g.window; - g.document = { querySelectorAll: (selector: string) => (selector === matches ? [submit] : []) }; + g.document = { + querySelectorAll: (selector: string) => { + if (selector === matches) return [submit]; + if (selector === cardMatches) return cards; + return []; + }, + }; g.window = {}; try { __testing__.extractors.length = 0; @@ -764,6 +787,10 @@ test("ax.find and ax.findAll label the element with its selector", () => { const ax = (state as { ax: { findAll(s: unknown): Record[] } }).ax; return ax.findAll({ testTag: "TxnSubmit" }); }); + __testing__.runtime.extract((state) => { + const ax = (state as { ax: { findAll(s: unknown): Record[] } }).ax; + return ax.findAll({ testTag: "AccountCard" }); + }); const values = __testing__.evaluateExtractors(); const found = readingOf(values, 0) as Record; assert.equal(found.__sanderlingSelector, "testTag:TxnSubmit"); @@ -771,6 +798,77 @@ test("ax.find and ax.findAll label the element with its selector", () => { // reference would hand the array INDEX to the runtime as the selector. const all = readingOf(values, 1) as Record[]; assert.equal(all[0]!.__sanderlingSelector, "testTag:TxnSubmit"); + const siblings = readingOf(values, 2) as Record[]; + assert.deepEqual( + siblings.map((card) => card.__sanderlingSelector), + ["", "", ""], + ); + assert.deepEqual( + siblings.map((card) => card.y), + [25, 45, 65], + ); + } finally { + g.document = originalDocument; + g.window = originalWindow; + } +}); + +// The same rule for a child lookup, which is the shape a spec reaches a row +// through: screen.findAll({...}). The runner resolves the child selector against +// the whole dump, not the parent's subtree, so scoping does not make a shared +// name safe. +test("element.find and element.findAll label a child only when the selector names it alone", () => { + const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 }; + const node = (id: string, answers: Record = {}) => ({ + id, + tagName: "DIV", + className: "", + textContent: id, + dataset: {}, + getAttribute: () => null, + matches: (selector: string) => matchesAnyPart(selector, "div", {}), + getBoundingClientRect: () => rect, + querySelectorAll: (selector: string) => answers[selector] ?? [], + }); + const cardCss = `:is([data-testid="AccountCard"], [id="AccountCard"])`; + const totalCss = `:is([data-testid="Total"], [id="Total"])`; + const screenCss = `:is([data-testid="HomeScreen"], [id="HomeScreen"])`; + const cards = [node("first"), node("second")]; + const total = node("Total"); + const home = node("HomeScreen", { [cardCss]: cards, [totalCss]: [total] }); + + const g = globalThis as Record; + const originalDocument = g.document; + const originalWindow = g.window; + g.document = { + querySelectorAll: (selector: string) => { + if (selector === screenCss) return [home]; + if (selector === cardCss) return cards; + if (selector === totalCss) return [total]; + return []; + }, + }; + g.window = {}; + try { + __testing__.extractors.length = 0; + __testing__.runtime.extract((state) => { + const ax = (state as { + ax: { find(s: unknown): { findAll(s: unknown): Record[] } }; + }).ax; + return ax + .find({ testTag: "HomeScreen" }) + .findAll({ testTag: "AccountCard" }) + .map((card) => card.__sanderlingSelector); + }); + __testing__.runtime.extract((state) => { + const ax = (state as { + ax: { find(s: unknown): { find(s: unknown): Record } }; + }).ax; + return ax.find({ testTag: "HomeScreen" }).find({ testTag: "Total" }).__sanderlingSelector; + }); + const values = __testing__.evaluateExtractors(); + assert.deepEqual(readingOf(values, 0), ["", ""]); + assert.equal(readingOf(values, 1), "testTag:Total"); } finally { g.document = originalDocument; g.window = originalWindow;