nativeproof 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +58 -14
- package/dist/locator.d.ts +27 -2
- package/dist/locator.js +49 -6
- package/dist/page.d.ts +5 -5
- package/dist/page.js +6 -4
- package/dist/source.d.ts +7 -0
- package/dist/source.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ All notable changes to NativeProof are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.8.0
|
|
8
|
+
|
|
9
|
+
Relative locators — `Locator.near` scopes to the match nearest an anchor.
|
|
10
|
+
|
|
11
|
+
**Added**
|
|
12
|
+
|
|
13
|
+
- **`Locator.near(anchor, { maxDistance? })`** — orders this locator's matches by bounds-centre
|
|
14
|
+
distance to the `anchor` locator's match, nearest first, so a control is addressed by the element
|
|
15
|
+
beside it: `getByRole("checkbox").near(getByText("Wi-Fi"))` is the checkbox in the Wi-Fi row.
|
|
16
|
+
`maxDistance` (px) drops farther matches, so an absent control resolves to nothing. Composes with
|
|
17
|
+
`.nth()` / `.check()` / `expect(locator).toBeChecked()`. Together with role selectors this retires
|
|
18
|
+
source-bounds geometry for "the control next to this label".
|
|
19
|
+
|
|
20
|
+
## 0.7.0
|
|
21
|
+
|
|
22
|
+
Role selectors — `getByRole` matches by element role, not just name.
|
|
23
|
+
|
|
24
|
+
**Added**
|
|
25
|
+
|
|
26
|
+
- **`getByRole(role)` / `by.role(role)` match by element class/type** when no `name` is given —
|
|
27
|
+
`checkbox`, `switch`, `button`, `textfield`, `image`. Maps to the Android widget `class` / iOS
|
|
28
|
+
XCUITest `type` as a substring, so `SwitchCompat`, `MaterialButton`, and Compose's
|
|
29
|
+
`android.widget.CheckBox` all resolve. Combine with `.nth()` / `expect(locator).toBeChecked()`.
|
|
30
|
+
`getByRole(role, { name })` is unchanged (matches the accessibility label); an unknown role throws
|
|
31
|
+
with the supported list. New `nodesForRole` source helper.
|
|
32
|
+
|
|
7
33
|
## 0.6.0
|
|
8
34
|
|
|
9
35
|
Playwright-parity additions to locators, assertions, fixtures, and evidence.
|
package/README.md
CHANGED
|
@@ -60,11 +60,11 @@ nativeproof --platform android
|
|
|
60
60
|
- **Locators** — `by.text/testId/label/desc/id` and `page(driver).getByText/getByTestId/
|
|
61
61
|
getByLabel/getById/getByRole`, each mapped to the right native attribute per platform (so you
|
|
62
62
|
never guess `content-desc` vs `accessibilityIdentifier`), with built-in auto-waiting and
|
|
63
|
-
`tap()` / `fill()` for interaction. Each takes a string (exact) or a **`RegExp`**
|
|
64
|
-
(`getByText(/Save( draft)?/)`)
|
|
65
|
-
- **Auto-waiting `expect`** — `expect(locator).toBeVisible()/toShow()/toHaveText()
|
|
66
|
-
`.not`, each polling until the condition holds (default 10s); plus synchronous
|
|
67
|
-
matchers (`toBe`/`toEqual`/`toContain`/…) so non-UI checks need no second assertion library.
|
|
63
|
+
`tap()` / `fill()` / `check()` for interaction. Each takes a string (exact) or a **`RegExp`**
|
|
64
|
+
(`getByText(/Save( draft)?/)`); `.nth()` / `.first()` / `.last()` / `.count()` handle multiple matches.
|
|
65
|
+
- **Auto-waiting `expect`** — `expect(locator).toBeVisible()/toShow()/toHaveText()/toBeChecked()/
|
|
66
|
+
toHaveCount()` and `.not`, each polling until the condition holds (default 10s); plus synchronous
|
|
67
|
+
`expect(value)` matchers (`toBe`/`toEqual`/`toContain`/…) so non-UI checks need no second assertion library.
|
|
68
68
|
- **Network interception** — a first-party HTTP + WebSocket mock server with
|
|
69
69
|
`route().fulfill/reject/abort` (like `page.route()`) and `expect(mock).toHaveSent()/
|
|
70
70
|
toHaveReceived()` traffic assertions. No per-app adapter.
|
|
@@ -371,14 +371,28 @@ const app = defineApp({
|
|
|
371
371
|
member: ({ driver }) => ({ messages: page(driver).getByTestId("message-list") }),
|
|
372
372
|
guest: ({ driver }) => ({ banner: page(driver).getByTestId("signup-banner") }),
|
|
373
373
|
},
|
|
374
|
+
|
|
375
|
+
// optional lifecycle hooks:
|
|
376
|
+
teardown: async ({ driver }) => { /* release app resources before the session is deleted */ },
|
|
377
|
+
onFailure: async (ctx, { title, error }) => { await captureState(`fail-${title}`); }, // evidence on any failed behaviour
|
|
374
378
|
});
|
|
375
379
|
```
|
|
376
380
|
|
|
377
381
|
```ts
|
|
378
|
-
test.describe("a signed-in member", "member", () => {
|
|
382
|
+
test.describe("a signed-in member", "member", () => {
|
|
383
|
+
test.beforeEach(async ({ member }) => { /* reset to a known state before each behaviour */ });
|
|
384
|
+
test.afterEach(async ({ member }) => { /* per-behaviour cleanup, if any */ });
|
|
385
|
+
test("renders the latest message", async ({ member, mock }) => { /* … */ });
|
|
386
|
+
});
|
|
379
387
|
test.describe("a guest", "guest", () => { /* uses { guest, mock } */ });
|
|
380
388
|
```
|
|
381
389
|
|
|
390
|
+
The session fixture is provisioned **once** per `describe` (the slow login + join) and shared across
|
|
391
|
+
its behaviours; `beforeEach` / `afterEach` run around each behaviour with the context injected — a
|
|
392
|
+
repeatable reset without per-spec boilerplate. `teardown` runs before the mock stops and the session
|
|
393
|
+
is deleted (e.g. force-stop the app); `onFailure` runs when a behaviour throws, before the failure
|
|
394
|
+
propagates, so on-failure evidence lives in one place instead of every behaviour.
|
|
395
|
+
|
|
382
396
|
> NativeProof locators **read, tap and fill** (text entry). For input `fill()` doesn't cover
|
|
383
397
|
> (clearing a field first, custom keyboards, key chords), drive WebdriverIO's `$(...).setValue(...)`
|
|
384
398
|
> directly inside `login` (the `@wdio/globals` `browser`/`$` are available in the live session).
|
|
@@ -391,19 +405,26 @@ you address elements the way a person describes them, not by `content-desc` vs `
|
|
|
391
405
|
```ts
|
|
392
406
|
const p = page(driver);
|
|
393
407
|
p.getByText("Sign in"); // visible text
|
|
408
|
+
p.getByText(/Sign ?in/i); // ...or a RegExp, matched against the element's decoded value
|
|
394
409
|
p.getByTestId("login-button"); // your test id
|
|
395
410
|
p.getByLabel("Sign out"); // accessibility label
|
|
396
411
|
p.getById("message-list"); // resource id
|
|
397
|
-
p.getByRole("button", { name: "Send" }); // accessible name
|
|
412
|
+
p.getByRole("button", { name: "Send" }); // accessible name
|
|
413
|
+
p.getByRole("checkbox"); // by role/class — checkbox/switch/button/textfield/image
|
|
398
414
|
p.locator(by.desc("Open menu")); // escape hatch: a raw selector
|
|
399
415
|
```
|
|
400
416
|
|
|
417
|
+
Every `getBy*` (and `by.*`) takes a **string** (exact match) or a **`RegExp`** (tested against the
|
|
418
|
+
element's decoded value), so a human pattern matches even entity-escaped source — `getByText(/Save( draft)?/)`,
|
|
419
|
+
`getByLabel(/^Remove /)`, `getByRole("checkbox", { name: /terms/i })`. The Playwright muscle memory carries over.
|
|
420
|
+
|
|
401
421
|
How each maps to the page source:
|
|
402
422
|
|
|
403
423
|
| Locator | Android attribute | iOS attribute |
|
|
404
424
|
|---|---|---|
|
|
405
425
|
| `getByText` / `by.text` | `text` or `content-desc` | `label` or `value` |
|
|
406
426
|
| `getByLabel` / `by.label` / `getByRole({name})` | `content-desc` | `label` |
|
|
427
|
+
| `getByRole(role)` / `by.role` (no name) | widget `class` | XCUITest `type` |
|
|
407
428
|
| `getByTestId` / `by.testId` | `resource-id` | `name` |
|
|
408
429
|
| `getById` / `by.id` | `resource-id` | `name` |
|
|
409
430
|
| `by.desc` | `content-desc` | `name` |
|
|
@@ -423,8 +444,20 @@ await member.sendButton.tap(); // wait for it, then tap its centre
|
|
|
423
444
|
await member.sendButton.tap({ timeout: 2_000, interval: 100 }); // tune the wait
|
|
424
445
|
await member.row.tap({ clickableAncestor: true }); // tap the clickable parent of a non-clickable label
|
|
425
446
|
await member.composer.fill("Hello team"); // focus the field (tap), then type
|
|
447
|
+
|
|
448
|
+
await p.getByText("Item").count(); // how many elements match
|
|
449
|
+
await p.getByText("Item").nth(1).tap(); // the 2nd match (.first() / .last(); negative counts from the end)
|
|
450
|
+
await member.terms.check(); // checkbox/switch → tap to checked (no-op if already there); also uncheck()
|
|
451
|
+
await member.terms.isChecked(); // boolean
|
|
452
|
+
await p.getByRole("checkbox").near(p.getByText("Wi-Fi")).check(); // the checkbox in the Wi-Fi row
|
|
426
453
|
```
|
|
427
454
|
|
|
455
|
+
**Relative locators.** `getByRole(role)` matches by element class/type (`checkbox`, `switch`, `button`,
|
|
456
|
+
`textfield`, `image`), and `Locator.near(anchor, { maxDistance? })` scopes to the match nearest an
|
|
457
|
+
anchor's element — so a control beside a label is addressed by the label, not by coordinates:
|
|
458
|
+
`page(driver).getByRole("checkbox").near(page(driver).getByText("Wi-Fi"))`. Compose with `.check()` /
|
|
459
|
+
`expect(locator).toBeChecked()`.
|
|
460
|
+
|
|
428
461
|
`tap()` resolves the element's bounds from the page source and taps the centre — a coordinate
|
|
429
462
|
tap that works even on Compose / SwiftUI nodes Appium reports as non-clickable.
|
|
430
463
|
|
|
@@ -447,12 +480,16 @@ Assertions **auto-wait** (poll until the condition holds or the timeout elapses,
|
|
|
447
480
|
await expect(member.messages).toBeVisible();
|
|
448
481
|
await expect(member.messages).toShow("Welcome to the room"); // present + text anywhere on screen
|
|
449
482
|
await expect(member.roomTitle).toHaveText(/Room: \w+/); // the node's OWN text (substring or regex)
|
|
483
|
+
await expect(member.terms).toBeChecked(); // checkbox / switch is on
|
|
484
|
+
await expect(member.results).toHaveCount(3); // exactly 3 elements match
|
|
450
485
|
await expect(member.spinner).not.toBeVisible({ timeout: 5_000 });
|
|
451
486
|
```
|
|
452
487
|
|
|
453
488
|
- `toBeVisible(opts?)` — the selector matches a node in the source.
|
|
454
489
|
- `toShow(text, opts?)` — the selector is present **and** `text` appears in the source.
|
|
455
490
|
- `toHaveText(text, opts?)` — the matched node's **own** text contains / matches `text`.
|
|
491
|
+
- `toBeChecked(opts?)` — the matched checkbox / switch is checked (`checked="true"`).
|
|
492
|
+
- `toHaveCount(n, opts?)` — exactly `n` elements match the selector.
|
|
456
493
|
- `opts` is `{ timeout?, interval? }` (ms).
|
|
457
494
|
|
|
458
495
|
**Value matchers** — `expect(value)` also takes a plain value, for the non-UI assertions a spec
|
|
@@ -573,6 +610,11 @@ const app = defineApp({
|
|
|
573
610
|
The framework depends only on the `MockBackend` interface, never a concrete server — so
|
|
574
611
|
`expect(mock).toHaveSent(...)` reads your backend's traffic with no other changes.
|
|
575
612
|
|
|
613
|
+
> **Just need the assertions?** If your mock can't implement `route` / `stop` (e.g. it only writes a
|
|
614
|
+
> request/socket log), expose a frames-only **`FrameLog`** — `{ frames(): Promise<MockFrame[]> }`,
|
|
615
|
+
> which `MockBackend` extends — and pass *that* to `expect(...)`: `expect(traffic).toHaveSent({ path, type })`
|
|
616
|
+
> / `.toHaveReceived(...)` auto-wait over it, no `route`/`stop` needed.
|
|
617
|
+
|
|
576
618
|
### Gestures & scrolling
|
|
577
619
|
|
|
578
620
|
For motion the locator layer doesn't cover (scrolling a list, swiping a carousel), use the
|
|
@@ -724,15 +766,17 @@ The framework's own unit suite (`npm test`) needs **no device** and runs anywher
|
|
|
724
766
|
|
|
725
767
|
## API reference
|
|
726
768
|
|
|
727
|
-
- `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture.
|
|
728
|
-
|
|
769
|
+
- `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture. `definition` also
|
|
770
|
+
takes optional `teardown(ctx)` (before mock stop / session delete) and `onFailure(ctx, { title, error })`.
|
|
771
|
+
- `createHarness(app)` → `{ test, expect }` — typed, app-bound test surface; `test.beforeEach` / `test.afterEach`
|
|
772
|
+
register per-behaviour hooks with the context injected.
|
|
729
773
|
- `defineConfig({ app, projects, testDir?, testMatch?, appium?, mochaTimeout? })` — the config the CLI runs.
|
|
730
|
-
- `by.text/desc/id/testId/label
|
|
774
|
+
- `by.text/desc/id/testId/label` (string **or** `RegExp`), `page(driver).getByText/getByTestId/getByLabel/getById/getByRole`,
|
|
731
775
|
`page(driver).locator(selector)`, `new Locator(driver, selector)` — locators
|
|
732
|
-
(`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill`
|
|
733
|
-
for non-clickable labels).
|
|
734
|
-
- `expect(locator)` → `toBeVisible` / `toShow` / `toHaveText` (+ `.not`), each `(value?, { timeout?, interval? })`.
|
|
735
|
-
- `expect(mock)` → `toHaveSent` / `toHaveReceived` (+ `.not`), matched by partial frame.
|
|
776
|
+
(`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill`, `isChecked`, `check`, `uncheck`,
|
|
777
|
+
`count`, `nth`, `first`, `last` — `tap({ clickableAncestor })` for non-clickable labels).
|
|
778
|
+
- `expect(locator)` → `toBeVisible` / `toShow` / `toHaveText` / `toBeChecked` / `toHaveCount` (+ `.not`), each `(value?, { timeout?, interval? })`.
|
|
779
|
+
- `expect(mock | frameLog)` → `toHaveSent` / `toHaveReceived` (+ `.not`), matched by partial frame; accepts any `FrameLog` (`{ frames() }`).
|
|
736
780
|
- `expect(value)` → `toBe` / `toEqual` / `toContain` / `toBeTruthy` / `toBeFalsy` / `toBeDefined` / `toBeNull` (+ `.not`) — synchronous matchers for plain values.
|
|
737
781
|
- `startMockServer({ port?, host? })` → a `MockServer` (`url`, `wsUrl`, `route()`, `frames()`, `send()`, `stop()`).
|
|
738
782
|
- `swipe`, `tapAt`, `pause` — low-level pointer gestures.
|
package/dist/locator.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ export type Selector = {
|
|
|
24
24
|
} | {
|
|
25
25
|
readonly by: "label";
|
|
26
26
|
readonly value: string | RegExp;
|
|
27
|
+
} | {
|
|
28
|
+
readonly by: "role";
|
|
29
|
+
readonly value: string;
|
|
27
30
|
};
|
|
28
31
|
/**
|
|
29
32
|
* Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
|
|
@@ -40,6 +43,8 @@ export declare const by: {
|
|
|
40
43
|
readonly testId: (value: string | RegExp) => Selector;
|
|
41
44
|
/** The accessibility label (Android `content-desc`, iOS `label`). */
|
|
42
45
|
readonly label: (value: string | RegExp) => Selector;
|
|
46
|
+
/** A semantic role, matched by element class/type — `checkbox`, `switch`, `button`, `textfield`, `image`. */
|
|
47
|
+
readonly role: (value: string) => Selector;
|
|
43
48
|
};
|
|
44
49
|
export declare function describeSelector(selector: Selector): string;
|
|
45
50
|
export interface WaitOptions {
|
|
@@ -69,16 +74,36 @@ export declare class Locator {
|
|
|
69
74
|
private readonly options;
|
|
70
75
|
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
71
76
|
private readonly index?;
|
|
77
|
+
/** When set, matches are ordered by proximity to the anchor (and filtered by maxDistance). */
|
|
78
|
+
private readonly proximity?;
|
|
72
79
|
constructor(driver: Driver, selector: Selector, options?: WaitOptions,
|
|
73
80
|
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
74
|
-
index?: number | undefined
|
|
81
|
+
index?: number | undefined,
|
|
82
|
+
/** When set, matches are ordered by proximity to the anchor (and filtered by maxDistance). */
|
|
83
|
+
proximity?: {
|
|
84
|
+
anchor: Locator;
|
|
85
|
+
maxDistance?: number;
|
|
86
|
+
} | undefined);
|
|
75
87
|
private attribute;
|
|
76
|
-
/**
|
|
88
|
+
/** Node tags this selector matches in `source`, in document order (role- or attribute-based). */
|
|
89
|
+
private nodesIn;
|
|
90
|
+
/**
|
|
91
|
+
* All node tags this selector matches in the current source — document order, or, when a
|
|
92
|
+
* `near` anchor is set, ordered nearest-first by bounds-centre distance (filtered by maxDistance).
|
|
93
|
+
*/
|
|
77
94
|
private matchedNodes;
|
|
78
95
|
/** The single node this locator resolves to (the nth match, or the first when unindexed). */
|
|
79
96
|
private pick;
|
|
80
97
|
/** A locator scoped to the nth match (0-based; negative counts from the end). */
|
|
81
98
|
nth(index: number): Locator;
|
|
99
|
+
/**
|
|
100
|
+
* Scope to the match nearest `anchor` (by bounds-centre distance) — the relative locator for
|
|
101
|
+
* native: `getByRole("checkbox").near(getByText("Wi-Fi"))` is the checkbox in the Wi-Fi row.
|
|
102
|
+
* `maxDistance` (px) drops matches farther than that, so an absent control resolves to nothing.
|
|
103
|
+
*/
|
|
104
|
+
near(anchor: Locator, options?: {
|
|
105
|
+
maxDistance?: number;
|
|
106
|
+
}): Locator;
|
|
82
107
|
/** A locator scoped to the first match. */
|
|
83
108
|
first(): Locator;
|
|
84
109
|
/** A locator scoped to the last match. */
|
package/dist/locator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeXmlEntities, encodeXmlEntities, escapeRegExp, nodesForAttribute, parseBounds, smallestClickableAncestorBounds, } from "./source.js";
|
|
1
|
+
import { decodeXmlEntities, encodeXmlEntities, escapeRegExp, nodesForAttribute, nodesForRole, parseBounds, smallestClickableAncestorBounds, } from "./source.js";
|
|
2
2
|
/**
|
|
3
3
|
* Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
|
|
4
4
|
* per platform (see {@link attributeFor}), so you never have to know whether it's a
|
|
@@ -14,6 +14,8 @@ export const by = {
|
|
|
14
14
|
testId: (value) => ({ by: "testId", value }),
|
|
15
15
|
/** The accessibility label (Android `content-desc`, iOS `label`). */
|
|
16
16
|
label: (value) => ({ by: "label", value }),
|
|
17
|
+
/** A semantic role, matched by element class/type — `checkbox`, `switch`, `button`, `textfield`, `image`. */
|
|
18
|
+
role: (value) => ({ by: "role", value }),
|
|
17
19
|
};
|
|
18
20
|
export function describeSelector(selector) {
|
|
19
21
|
const value = selector.value instanceof RegExp ? String(selector.value) : JSON.stringify(selector.value);
|
|
@@ -26,6 +28,9 @@ export function describeSelector(selector) {
|
|
|
26
28
|
* the toolkit put it, not just the node's own `text` attribute.
|
|
27
29
|
*/
|
|
28
30
|
function attributeFor(selector, platform) {
|
|
31
|
+
if (selector.by === "role") {
|
|
32
|
+
throw new Error("role selectors match by element class/type, not a single attribute");
|
|
33
|
+
}
|
|
29
34
|
const android = {
|
|
30
35
|
text: "(?:text|content-desc)",
|
|
31
36
|
desc: "content-desc",
|
|
@@ -61,20 +66,49 @@ export class Locator {
|
|
|
61
66
|
selector;
|
|
62
67
|
options;
|
|
63
68
|
index;
|
|
69
|
+
proximity;
|
|
64
70
|
constructor(driver, selector, options = {},
|
|
65
71
|
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
66
|
-
index
|
|
72
|
+
index,
|
|
73
|
+
/** When set, matches are ordered by proximity to the anchor (and filtered by maxDistance). */
|
|
74
|
+
proximity) {
|
|
67
75
|
this.driver = driver;
|
|
68
76
|
this.selector = selector;
|
|
69
77
|
this.options = options;
|
|
70
78
|
this.index = index;
|
|
79
|
+
this.proximity = proximity;
|
|
71
80
|
}
|
|
72
81
|
attribute() {
|
|
73
82
|
return attributeFor(this.selector, this.driver.platform);
|
|
74
83
|
}
|
|
75
|
-
/**
|
|
84
|
+
/** Node tags this selector matches in `source`, in document order (role- or attribute-based). */
|
|
85
|
+
nodesIn(source) {
|
|
86
|
+
return this.selector.by === "role"
|
|
87
|
+
? nodesForRole(source, this.selector.value, this.driver.platform)
|
|
88
|
+
: nodesForAttribute(source, this.attribute(), this.selector.value);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* All node tags this selector matches in the current source — document order, or, when a
|
|
92
|
+
* `near` anchor is set, ordered nearest-first by bounds-centre distance (filtered by maxDistance).
|
|
93
|
+
*/
|
|
76
94
|
async matchedNodes() {
|
|
77
|
-
|
|
95
|
+
const nodes = this.nodesIn(await this.driver.source());
|
|
96
|
+
if (!this.proximity)
|
|
97
|
+
return nodes;
|
|
98
|
+
const anchor = await this.proximity.anchor.bounds();
|
|
99
|
+
if (!anchor)
|
|
100
|
+
return [];
|
|
101
|
+
const { maxDistance } = this.proximity;
|
|
102
|
+
return nodes
|
|
103
|
+
.map((node) => ({ node, bounds: parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) }))
|
|
104
|
+
.filter((entry) => entry.bounds !== null)
|
|
105
|
+
.map((entry) => ({
|
|
106
|
+
node: entry.node,
|
|
107
|
+
distance: Math.hypot(entry.bounds.centerX - anchor.centerX, entry.bounds.centerY - anchor.centerY),
|
|
108
|
+
}))
|
|
109
|
+
.filter((entry) => maxDistance === undefined || entry.distance <= maxDistance)
|
|
110
|
+
.sort((a, b) => a.distance - b.distance)
|
|
111
|
+
.map((entry) => entry.node);
|
|
78
112
|
}
|
|
79
113
|
/** The single node this locator resolves to (the nth match, or the first when unindexed). */
|
|
80
114
|
pick(nodes) {
|
|
@@ -85,7 +119,16 @@ export class Locator {
|
|
|
85
119
|
}
|
|
86
120
|
/** A locator scoped to the nth match (0-based; negative counts from the end). */
|
|
87
121
|
nth(index) {
|
|
88
|
-
return new Locator(this.driver, this.selector, this.options, index);
|
|
122
|
+
return new Locator(this.driver, this.selector, this.options, index, this.proximity);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Scope to the match nearest `anchor` (by bounds-centre distance) — the relative locator for
|
|
126
|
+
* native: `getByRole("checkbox").near(getByText("Wi-Fi"))` is the checkbox in the Wi-Fi row.
|
|
127
|
+
* `maxDistance` (px) drops matches farther than that, so an absent control resolves to nothing.
|
|
128
|
+
*/
|
|
129
|
+
near(anchor, options = {}) {
|
|
130
|
+
const proximity = options.maxDistance === undefined ? { anchor } : { anchor, maxDistance: options.maxDistance };
|
|
131
|
+
return new Locator(this.driver, this.selector, this.options, this.index, proximity);
|
|
89
132
|
}
|
|
90
133
|
/** A locator scoped to the first match. */
|
|
91
134
|
first() {
|
|
@@ -127,7 +170,7 @@ export class Locator {
|
|
|
127
170
|
/** True if the selector is present AND `text` appears in the source. */
|
|
128
171
|
async shows(text) {
|
|
129
172
|
const source = await this.driver.source();
|
|
130
|
-
if (this.pick(
|
|
173
|
+
if (this.pick(this.nodesIn(source)) === null)
|
|
131
174
|
return false;
|
|
132
175
|
const pattern = typeof text === "string" ? new RegExp(escapeRegExp(encodeXmlEntities(text))) : text;
|
|
133
176
|
return pattern.test(source);
|
package/dist/page.d.ts
CHANGED
|
@@ -14,12 +14,12 @@ export interface Page {
|
|
|
14
14
|
getByLabel(label: string | RegExp, options?: WaitOptions): Locator;
|
|
15
15
|
getById(id: string | RegExp, options?: WaitOptions): Locator;
|
|
16
16
|
/**
|
|
17
|
-
* Match by
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Match by role. With `{ name }`, matches the element's accessibility label (a string or RegExp) —
|
|
18
|
+
* the dependable signal on native. Without a name, matches by element class/type
|
|
19
|
+
* (`checkbox`, `switch`, `button`, `textfield`, `image`) — e.g. `getByRole("checkbox")`.
|
|
20
20
|
*/
|
|
21
|
-
getByRole(role: string, options
|
|
22
|
-
name
|
|
21
|
+
getByRole(role: string, options?: {
|
|
22
|
+
name?: string | RegExp;
|
|
23
23
|
} & WaitOptions): Locator;
|
|
24
24
|
}
|
|
25
25
|
export declare function page(driver: Driver): Page;
|
package/dist/page.js
CHANGED
|
@@ -6,12 +6,14 @@ export function page(driver) {
|
|
|
6
6
|
getByTestId: (testId, options = {}) => locator(driver, by.testId(testId), options),
|
|
7
7
|
getByLabel: (label, options = {}) => locator(driver, by.label(label), options),
|
|
8
8
|
getById: (id, options = {}) => locator(driver, by.id(id), options),
|
|
9
|
-
getByRole: (role, options) => {
|
|
9
|
+
getByRole: (role, options = {}) => {
|
|
10
10
|
const { name, ...wait } = options;
|
|
11
|
-
if (
|
|
12
|
-
throw new Error(`getByRole(${JSON.stringify(role)}
|
|
11
|
+
if (name === "") {
|
|
12
|
+
throw new Error(`getByRole(${JSON.stringify(role)}, { name: "" }) — name must be non-empty; omit it to match by role`);
|
|
13
13
|
}
|
|
14
|
-
return
|
|
14
|
+
return name !== undefined
|
|
15
|
+
? locator(driver, by.label(name), wait) // name is the dependable signal on native
|
|
16
|
+
: locator(driver, by.role(role), wait); // no name → match by element class/type
|
|
15
17
|
},
|
|
16
18
|
};
|
|
17
19
|
}
|
package/dist/source.d.ts
CHANGED
|
@@ -40,6 +40,13 @@ export declare function encodeXmlEntities(value: string): string;
|
|
|
40
40
|
export declare function nodeForAttribute(source: string, attribute: string, value: string | RegExp): string | null;
|
|
41
41
|
/** Every element tag exposing `attribute` with a value matching `value`, in document order. */
|
|
42
42
|
export declare function nodesForAttribute(source: string, attribute: string, value: string | RegExp): string[];
|
|
43
|
+
/** Roles `by.role` / `getByRole(role)` can match without a name. */
|
|
44
|
+
export declare const KNOWN_ROLES: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Every element whose class (Android) / type (iOS) backs `role`, in document order.
|
|
47
|
+
* Throws on an unknown role, listing the supported set.
|
|
48
|
+
*/
|
|
49
|
+
export declare function nodesForRole(source: string, role: string, platform: "android" | "ios"): string[];
|
|
43
50
|
/** True if any element exposes `attribute` with a value matching `value` (string exact or RegExp). */
|
|
44
51
|
export declare function attributeMatches(source: string, attribute: string, value: string | RegExp): boolean;
|
|
45
52
|
/**
|
package/dist/source.js
CHANGED
|
@@ -77,6 +77,33 @@ export function nodesForAttribute(source, attribute, value) {
|
|
|
77
77
|
}
|
|
78
78
|
return nodes;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Element classes/types that back a semantic role, per platform — Android exposes a widget
|
|
82
|
+
* `class`, iOS an XCUITest `type`. Matched as a substring, so framework variants
|
|
83
|
+
* (`SwitchCompat`, `MaterialButton`, Compose's `android.widget.CheckBox`) all resolve.
|
|
84
|
+
*/
|
|
85
|
+
const ROLE_PATTERNS = {
|
|
86
|
+
checkbox: { android: "CheckBox", ios: "XCUIElementTypeSwitch" },
|
|
87
|
+
switch: { android: "Switch", ios: "XCUIElementTypeSwitch" },
|
|
88
|
+
button: { android: "Button", ios: "XCUIElementTypeButton" },
|
|
89
|
+
textfield: { android: "EditText", ios: "XCUIElementTypeTextField" },
|
|
90
|
+
image: { android: "ImageView", ios: "XCUIElementTypeImage" },
|
|
91
|
+
};
|
|
92
|
+
/** Roles `by.role` / `getByRole(role)` can match without a name. */
|
|
93
|
+
export const KNOWN_ROLES = Object.keys(ROLE_PATTERNS);
|
|
94
|
+
/**
|
|
95
|
+
* Every element whose class (Android) / type (iOS) backs `role`, in document order.
|
|
96
|
+
* Throws on an unknown role, listing the supported set.
|
|
97
|
+
*/
|
|
98
|
+
export function nodesForRole(source, role, platform) {
|
|
99
|
+
const patterns = ROLE_PATTERNS[role.toLowerCase()];
|
|
100
|
+
if (!patterns) {
|
|
101
|
+
throw new Error(`Unknown role "${role}". Known roles: ${KNOWN_ROLES.join(", ")}. Use getByLabel / getByText for arbitrary elements.`);
|
|
102
|
+
}
|
|
103
|
+
const attribute = platform === "ios" ? "type" : "class";
|
|
104
|
+
const pattern = escapeRegExp(platform === "ios" ? patterns.ios : patterns.android);
|
|
105
|
+
return [...source.matchAll(new RegExp(`<[^>]*${attribute}="[^"]*${pattern}[^"]*"[^>]*>`, "g"))].map((m) => m[0]);
|
|
106
|
+
}
|
|
80
107
|
/** True if any element exposes `attribute` with a value matching `value` (string exact or RegExp). */
|
|
81
108
|
export function attributeMatches(source, attribute, value) {
|
|
82
109
|
return nodeForAttribute(source, attribute, value) !== null;
|
package/package.json
CHANGED