diff --git a/src/locator.ts b/src/locator.ts index 9909b3a..c771fe3 100644 --- a/src/locator.ts +++ b/src/locator.ts @@ -37,6 +37,12 @@ export type Selector = readonly checked?: boolean; /** Keep only elements whose own `enabled` attribute matches (no ancestor walk). */ readonly disabled?: boolean; + /** + * Keep only elements the toolkit reports on screen — iOS `visible="true"`, Android + * `displayed="true"`. Native trees often carry offscreen or shadow duplicates of the + * same role (a hidden SwiftUI text field behind the focused one); this picks the live one. + */ + readonly visible?: boolean; }; /** @@ -57,7 +63,7 @@ export const by = { /** A semantic role, matched by element class/type — `checkbox`, `switch`, `button`, `textfield`, `image`. */ role: ( value: string, - options: { name?: string | RegExp; checked?: boolean; disabled?: boolean } = {}, + options: { name?: string | RegExp; checked?: boolean; disabled?: boolean; visible?: boolean } = {}, ): Selector => { const selector: { -readonly [K in keyof Extract]?: unknown } = { by: "role", @@ -66,6 +72,7 @@ export const by = { if (options.name !== undefined) selector.name = options.name; if (options.checked !== undefined) selector.checked = options.checked; if (options.disabled !== undefined) selector.disabled = options.disabled; + if (options.visible !== undefined) selector.visible = options.visible; return selector as Selector; }, } as const; @@ -81,6 +88,7 @@ export function describeSelector(selector: Selector): string { } if (selector.checked !== undefined) options.push(`checked: ${selector.checked}`); if (selector.disabled !== undefined) options.push(`disabled: ${selector.disabled}`); + if (selector.visible !== undefined) options.push(`visible: ${selector.visible}`); return options.length > 0 ? `by.role(${value}, { ${options.join(", ")} })` : `by.role(${value})`; } return `by.${selector.by}(${value})`; @@ -273,13 +281,20 @@ export class Locator { if (this.selector.by !== "role") { return nodesForAttribute(source, this.attribute(), this.selector.value); } - const { checked, disabled } = this.selector; + const { checked, disabled, visible } = this.selector; let nodes = nodesForRole(source, this.selector.value, this.driver.platform, this.selector.name); if (checked !== undefined) nodes = nodes.filter((node) => nodeIsChecked(node) === checked); if (disabled !== undefined) { const disabledPattern = new RegExp(`${attrPattern("enabled")}false"`); nodes = nodes.filter((node) => disabledPattern.test(node) === disabled); } + if (visible !== undefined) { + // iOS reports on-screen state as visible="true", Android as displayed="true". + const visiblePattern = new RegExp( + `${attrPattern(this.driver.platform === "ios" ? "visible" : "displayed")}true"`, + ); + nodes = nodes.filter((node) => visiblePattern.test(node) === visible); + } return nodes; } diff --git a/src/page.ts b/src/page.ts index 865b321..954da83 100644 --- a/src/page.ts +++ b/src/page.ts @@ -22,7 +22,12 @@ export interface Page { */ getByRole( role: string, - options?: { name?: string | RegExp; checked?: boolean; disabled?: boolean } & WaitOptions, + options?: { + name?: string | RegExp; + checked?: boolean; + disabled?: boolean; + visible?: boolean; + } & WaitOptions, ): Locator; } @@ -34,16 +39,22 @@ export function page(driver: Driver): Page { getByLabel: (label, options = {}) => locator(driver, by.label(label), options), getById: (id, options = {}) => locator(driver, by.id(id), options), getByRole: (role, options = {}) => { - const { name, checked, disabled, ...wait } = options; + const { name, checked, disabled, visible, ...wait } = options; if (name === "") { throw new Error( `getByRole(${JSON.stringify(role)}, { name: "" }) — name must be non-empty; omit it to match by role`, ); } - const roleOptions: { name?: string | RegExp; checked?: boolean; disabled?: boolean } = {}; + const roleOptions: { + name?: string | RegExp; + checked?: boolean; + disabled?: boolean; + visible?: boolean; + } = {}; if (name !== undefined) roleOptions.name = name; if (checked !== undefined) roleOptions.checked = checked; if (disabled !== undefined) roleOptions.disabled = disabled; + if (visible !== undefined) roleOptions.visible = visible; return locator(driver, by.role(role, roleOptions), wait); }, }; diff --git a/test/locator.test.ts b/test/locator.test.ts index 2591cfd..6210c15 100644 --- a/test/locator.test.ts +++ b/test/locator.test.ts @@ -921,3 +921,37 @@ test("getByRole state filters pick by checked and disabled", async () => { /by\.role\("checkbox", \{ name: "Wi-Fi", checked: false \}\) did not become visible/, ); }); + +test("getByRole { visible } picks the on-screen instance among shadow duplicates (iOS visible=)", async () => { + // A SwiftUI sheet often exposes a hidden text field behind the focused one; document + // order returns the hidden one first, so fill() types nowhere. { visible: true } picks the live one. + const driver = new FakeDriver( + '' + + '', + ); + driver.platform = "ios"; + assert.equal(await new Locator(driver, by.role("textfield", { visible: true })).inputValue(), "typed"); + assert.equal(await new Locator(driver, by.role("textfield", { visible: false })).inputValue(), ""); + assert.equal(await new Locator(driver, by.role("textfield")).count(), 2); + assert.equal(await new Locator(driver, by.role("textfield", { visible: true })).count(), 1); +}); + +test("getByRole { visible } uses Android displayed= and composes with other filters", async () => { + const driver = new FakeDriver( + '' + + '', + ); + assert.equal(await new Locator(driver, by.role("textfield", { visible: true })).textContent(), "onscreen"); + // A node lacking the displayed attribute is treated as not-visible under { visible: true }. + const noAttr = new FakeDriver(''); + assert.equal(await new Locator(noAttr, by.role("textfield", { visible: true })).count(), 0); + // visible appears in the describeSelector failure message. + await assert.rejects( + () => + new Locator(driver, by.role("textfield", { visible: true, name: "Nope" })).waitFor({ + timeout: 20, + interval: 5, + }), + /by\.role\("textfield", \{ name: "Nope", visible: true \}\)/, + ); +});