Skip to content
Merged
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
19 changes: 17 additions & 2 deletions src/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -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<Selector, { by: "role" }>]?: unknown } = {
by: "role",
Expand All @@ -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;
Expand All @@ -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})`;
Expand Down Expand Up @@ -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;
}

Expand Down
17 changes: 14 additions & 3 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
},
};
Expand Down
34 changes: 34 additions & 0 deletions test/locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<XCUIElementTypeTextField type="XCUIElementTypeTextField" value="" visible="false" x="0" y="0" width="100" height="40" />' +
'<XCUIElementTypeTextField type="XCUIElementTypeTextField" value="typed" visible="true" x="0" y="60" width="100" height="40" />',
);
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(
'<node class="android.widget.EditText" text="offscreen" displayed="false" bounds="[0,0][10,10]" />' +
'<node class="android.widget.EditText" text="onscreen" displayed="true" bounds="[0,20][10,30]" />',
);
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('<node class="android.widget.EditText" text="x" bounds="[0,0][10,10]" />');
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 \}\)/,
);
});