Repository navigation
feat(core)!: strict mode for native locators — an action or single-element query on a locator matching several elements fails at once #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,11 @@ | ||
| export { Locator, LocatorError, type LocatorOptions, type ScrollIntoViewOptions, type StepFn } from './locator.js'; | ||
| export { Locator, LocatorError, StrictModeViolationError, type LocatorOptions, type ScrollIntoViewOptions, type StepFn } from './locator.js'; | ||
| export { WebViewLocator } from './webview-locator.js'; | ||
| export { Screen, type GetByWebViewOptions, type DialogPredicate, type WaitForDialogOptions } from './screen.js'; | ||
| export { Dialog, type DialogType, type DialogHandler } from './dialog.js'; | ||
| export { Device, type DeviceOptions } from './device.js'; | ||
| export { MobileWebViewPage, MobileWebViewPage as Page } from './page.js'; | ||
| export { MobileWebViewLocator, MobileWebViewLocator as WebLocator } from './web-locator.js'; | ||
| export { expect, ExpectError, setSoftFailureHandler, setDefaultStepFn, type ExpectOptions, type ExpectTextOptions, type SoftFailureHandler } from './expect.js'; | ||
| export { queryAll, ROLE_TYPE_MAP, bareTypeName, type LocatorStrategy, type Role } from './query-engine.js'; | ||
| export { queryAll, describeStrategy, ROLE_TYPE_MAP, bareTypeName, type LocatorStrategy, type Role } from './query-engine.js'; | ||
| export { sleep } from './sleep.js'; | ||
| export type { HardwareButton } from '@mobilewright/protocol'; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -385,3 +385,74 @@ function isContainedWithin(inner: Bounds, outer: Bounds): boolean { | |||||
| inner.y + inner.height <= outer.y + outer.height | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| function describeValue(value: string | RegExp): string { | ||||||
| if (value instanceof RegExp) { | ||||||
| return String(value); | ||||||
| } | ||||||
| const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, '\\\''); | ||||||
| return `'${escaped}'`; | ||||||
| } | ||||||
|
|
||||||
| function describeExact(exact: boolean | undefined): string { | ||||||
| return exact ? ', { exact: true }' : ''; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Include If a caller uses Proposed fix--- "a/packages/mobilewright-core/src/query-engine.ts"
+++ "b/packages/mobilewright-core/src/query-engine.ts"
@@ -395,7 +395,7 @@
}
function describeExact(exact: boolean | undefined): string {
- return exact ? ', { exact: true }' : '';
+ return exact === undefined ? '' : `, { exact: ${exact} }`;
}
/** Render a strategy the way a test would write it, e.g. getByRole('button', { name: 'Save' }). */📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| } | ||||||
|
|
||||||
| /** Render a strategy the way a test would write it, e.g. getByRole('button', { name: 'Save' }). */ | ||||||
| export function describeStrategy(strategy: LocatorStrategy): string { | ||||||
| switch (strategy.kind) { | ||||||
| case 'root': return 'screen'; | ||||||
| case 'label': return `getByLabel(${describeValue(strategy.value)}${describeExact(strategy.exact)})`; | ||||||
| case 'testId': return `getByTestId(${describeValue(strategy.value)})`; | ||||||
| case 'text': return `getByText(${describeValue(strategy.value)}${describeExact(strategy.exact)})`; | ||||||
| case 'type': return `getByType(${describeValue(strategy.value)})`; | ||||||
| case 'placeholder': return `getByPlaceholder(${describeValue(strategy.value)}${describeExact(strategy.exact)})`; | ||||||
| case 'webview': return strategy.testId === undefined ? 'getByWebView()' : `getByWebView({ testId: ${describeValue(strategy.testId)} })`; | ||||||
| case 'role': { | ||||||
| const name = strategy.name === undefined ? '' : `, { name: ${describeValue(strategy.name)} }`; | ||||||
| return `getByRole(${describeValue(strategy.value)}${name})`; | ||||||
| } | ||||||
| case 'chain': | ||||||
| return strategy.parent.kind === 'root' | ||||||
| ? describeStrategy(strategy.child) | ||||||
| : `${describeStrategy(strategy.parent)}.${describeStrategy(strategy.child)}`; | ||||||
| case 'nth': | ||||||
| if (strategy.index === 0) return `${describeStrategy(strategy.parent)}.first()`; | ||||||
| if (strategy.index === -1) return `${describeStrategy(strategy.parent)}.last()`; | ||||||
| return `${describeStrategy(strategy.parent)}.nth(${strategy.index})`; | ||||||
| case 'filter': { | ||||||
| const parts: string[] = []; | ||||||
| if (strategy.hasText !== undefined) parts.push(`hasText: ${describeValue(strategy.hasText)}`); | ||||||
| if (strategy.hasNotText !== undefined) parts.push(`hasNotText: ${describeValue(strategy.hasNotText)}`); | ||||||
| if (strategy.has !== undefined) parts.push(`has: ${describeStrategy(strategy.has)}`); | ||||||
| if (strategy.hasNot !== undefined) parts.push(`hasNot: ${describeStrategy(strategy.hasNot)}`); | ||||||
| return `${describeStrategy(strategy.parent)}.filter({ ${parts.join(', ')} })`; | ||||||
| } | ||||||
| case 'and': return `${describeStrategy(strategy.left)}.and(${describeStrategy(strategy.right)})`; | ||||||
| case 'or': return `${describeStrategy(strategy.left)}.or(${describeStrategy(strategy.right)})`; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** Short description of a node for error messages: type, visible text and test id. */ | ||||||
| export function describeNode(node: ViewNode): string { | ||||||
| const text = node.label ?? node.text ?? node.value ?? ''; | ||||||
| const id = node.identifier ?? node.resourceId ?? node.key; | ||||||
| return `${node.type} ${JSON.stringify(text)}${id ? ` [${id}]` : ''}`; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Collapse matches that sit on one ancestor chain into their outermost node: | ||||||
| * an iOS Button and the StaticText inside it carry the same label and are one | ||||||
| * element reported at two levels. `matches` is in document order, so an | ||||||
| * ancestor always precedes its descendants. | ||||||
| */ | ||||||
| export function collapseAncestorChains(matches: ViewNode[]): ViewNode[] { | ||||||
| const kept: ViewNode[] = []; | ||||||
| for (const node of matches) { | ||||||
| const insideKept = kept.some((outer) => flattenNodes(outer.children).includes(node)); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Deduplicate the same node before checking ancestry. If nested Proposed fix--- "a/packages/mobilewright-core/src/query-engine.ts"
+++ "b/packages/mobilewright-core/src/query-engine.ts"
@@ -449,7 +449,7 @@
export function collapseAncestorChains(matches: ViewNode[]): ViewNode[] {
const kept: ViewNode[] = [];
for (const node of matches) {
- const insideKept = kept.some((outer) => flattenNodes(outer.children).includes(node));
+ const insideKept = kept.some((outer) => outer === node || flattenNodes(outer.children).includes(node));
if (!insideKept) {
kept.push(node);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| if (!insideKept) { | ||||||
| kept.push(node); | ||||||
| } | ||||||
| } | ||||||
| return kept; | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: mobile-next/mobilewright
Length of output: 7308
🏁 Script executed:
Repository: mobile-next/mobilewright
Length of output: 34738
🏁 Script executed:
Repository: mobile-next/mobilewright
Length of output: 4643
Apply ancestor-chain normalization to collection and positional selection.
The documentation states that an ancestor and its matching text child count as one element.
count()andall()use rawqueryAllresults, andtoHaveCount()delegates tocount(). Positional methods also index raw parent results. These methods can therefore report or select both nodes instead of the collapsed outermost node.Suggested fix
This preserves non-strict collection behavior while making collection counts and positional selection use the documented ancestor-chain rule.
🤖 Prompt for AI Agents