nativeproof 0.6.0 → 0.7.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 CHANGED
@@ -4,6 +4,19 @@ 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.7.0
8
+
9
+ Role selectors — `getByRole` matches by element role, not just name.
10
+
11
+ **Added**
12
+
13
+ - **`getByRole(role)` / `by.role(role)` match by element class/type** when no `name` is given —
14
+ `checkbox`, `switch`, `button`, `textfield`, `image`. Maps to the Android widget `class` / iOS
15
+ XCUITest `type` as a substring, so `SwitchCompat`, `MaterialButton`, and Compose's
16
+ `android.widget.CheckBox` all resolve. Combine with `.nth()` / `expect(locator).toBeChecked()`.
17
+ `getByRole(role, { name })` is unchanged (matches the accessibility label); an unknown role throws
18
+ with the supported list. New `nodesForRole` source helper.
19
+
7
20
  ## 0.6.0
8
21
 
9
22
  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)?/)`), tested against the element's decoded value.
65
- - **Auto-waiting `expect`** — `expect(locator).toBeVisible()/toShow()/toHaveText()` and
66
- `.not`, each polling until the condition holds (default 10s); plus synchronous `expect(value)`
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", () => { /* uses { member, mock } */ });
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 (role is advisory on native)
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,6 +444,11 @@ 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
426
452
  ```
427
453
 
428
454
  `tap()` resolves the element's bounds from the page source and taps the centre — a coordinate
@@ -447,12 +473,16 @@ Assertions **auto-wait** (poll until the condition holds or the timeout elapses,
447
473
  await expect(member.messages).toBeVisible();
448
474
  await expect(member.messages).toShow("Welcome to the room"); // present + text anywhere on screen
449
475
  await expect(member.roomTitle).toHaveText(/Room: \w+/); // the node's OWN text (substring or regex)
476
+ await expect(member.terms).toBeChecked(); // checkbox / switch is on
477
+ await expect(member.results).toHaveCount(3); // exactly 3 elements match
450
478
  await expect(member.spinner).not.toBeVisible({ timeout: 5_000 });
451
479
  ```
452
480
 
453
481
  - `toBeVisible(opts?)` — the selector matches a node in the source.
454
482
  - `toShow(text, opts?)` — the selector is present **and** `text` appears in the source.
455
483
  - `toHaveText(text, opts?)` — the matched node's **own** text contains / matches `text`.
484
+ - `toBeChecked(opts?)` — the matched checkbox / switch is checked (`checked="true"`).
485
+ - `toHaveCount(n, opts?)` — exactly `n` elements match the selector.
456
486
  - `opts` is `{ timeout?, interval? }` (ms).
457
487
 
458
488
  **Value matchers** — `expect(value)` also takes a plain value, for the non-UI assertions a spec
@@ -573,6 +603,11 @@ const app = defineApp({
573
603
  The framework depends only on the `MockBackend` interface, never a concrete server — so
574
604
  `expect(mock).toHaveSent(...)` reads your backend's traffic with no other changes.
575
605
 
606
+ > **Just need the assertions?** If your mock can't implement `route` / `stop` (e.g. it only writes a
607
+ > request/socket log), expose a frames-only **`FrameLog`** — `{ frames(): Promise<MockFrame[]> }`,
608
+ > which `MockBackend` extends — and pass *that* to `expect(...)`: `expect(traffic).toHaveSent({ path, type })`
609
+ > / `.toHaveReceived(...)` auto-wait over it, no `route`/`stop` needed.
610
+
576
611
  ### Gestures & scrolling
577
612
 
578
613
  For motion the locator layer doesn't cover (scrolling a list, swiping a carousel), use the
@@ -724,15 +759,17 @@ The framework's own unit suite (`npm test`) needs **no device** and runs anywher
724
759
 
725
760
  ## API reference
726
761
 
727
- - `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture.
728
- - `createHarness(app)` `{ test, expect }` typed, app-bound test surface.
762
+ - `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture. `definition` also
763
+ takes optional `teardown(ctx)` (before mock stop / session delete) and `onFailure(ctx, { title, error })`.
764
+ - `createHarness(app)` → `{ test, expect }` — typed, app-bound test surface; `test.beforeEach` / `test.afterEach`
765
+ register per-behaviour hooks with the context injected.
729
766
  - `defineConfig({ app, projects, testDir?, testMatch?, appium?, mochaTimeout? })` — the config the CLI runs.
730
- - `by.text/desc/id/testId/label`, `page(driver).getByText/getByTestId/getByLabel/getById/getByRole`,
767
+ - `by.text/desc/id/testId/label` (string **or** `RegExp`), `page(driver).getByText/getByTestId/getByLabel/getById/getByRole`,
731
768
  `page(driver).locator(selector)`, `new Locator(driver, selector)` — locators
732
- (`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill` `tap({ clickableAncestor })`
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.
769
+ (`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill`, `isChecked`, `check`, `uncheck`,
770
+ `count`, `nth`, `first`, `last` — `tap({ clickableAncestor })` for non-clickable labels).
771
+ - `expect(locator)` → `toBeVisible` / `toShow` / `toHaveText` / `toBeChecked` / `toHaveCount` (+ `.not`), each `(value?, { timeout?, interval? })`.
772
+ - `expect(mock | frameLog)` → `toHaveSent` / `toHaveReceived` (+ `.not`), matched by partial frame; accepts any `FrameLog` (`{ frames() }`).
736
773
  - `expect(value)` → `toBe` / `toEqual` / `toContain` / `toBeTruthy` / `toBeFalsy` / `toBeDefined` / `toBeNull` (+ `.not`) — synchronous matchers for plain values.
737
774
  - `startMockServer({ port?, host? })` → a `MockServer` (`url`, `wsUrl`, `route()`, `frames()`, `send()`, `stop()`).
738
775
  - `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 {
@@ -73,6 +78,8 @@ export declare class Locator {
73
78
  /** When set, the locator resolves to the nth match (negative counts from the end). */
74
79
  index?: number | undefined);
75
80
  private attribute;
81
+ /** Node tags this selector matches in `source`, in document order (role- or attribute-based). */
82
+ private nodesIn;
76
83
  /** All node tags this selector matches in the current source, in document order. */
77
84
  private matchedNodes;
78
85
  /** The single node this locator resolves to (the nth match, or the first when unindexed). */
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",
@@ -72,9 +77,15 @@ export class Locator {
72
77
  attribute() {
73
78
  return attributeFor(this.selector, this.driver.platform);
74
79
  }
80
+ /** Node tags this selector matches in `source`, in document order (role- or attribute-based). */
81
+ nodesIn(source) {
82
+ return this.selector.by === "role"
83
+ ? nodesForRole(source, this.selector.value, this.driver.platform)
84
+ : nodesForAttribute(source, this.attribute(), this.selector.value);
85
+ }
75
86
  /** All node tags this selector matches in the current source, in document order. */
76
87
  async matchedNodes() {
77
- return nodesForAttribute(await this.driver.source(), this.attribute(), this.selector.value);
88
+ return this.nodesIn(await this.driver.source());
78
89
  }
79
90
  /** The single node this locator resolves to (the nth match, or the first when unindexed). */
80
91
  pick(nodes) {
@@ -127,7 +138,7 @@ export class Locator {
127
138
  /** True if the selector is present AND `text` appears in the source. */
128
139
  async shows(text) {
129
140
  const source = await this.driver.source();
130
- if (this.pick(nodesForAttribute(source, this.attribute(), this.selector.value)) === null)
141
+ if (this.pick(this.nodesIn(source)) === null)
131
142
  return false;
132
143
  const pattern = typeof text === "string" ? new RegExp(escapeRegExp(encodeXmlEntities(text))) : text;
133
144
  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 accessible name. Native accessibility trees don't expose web-style roles
18
- * reliably, so `role` is advisory and the dependable signal is the name (the element's
19
- * accessibility label). Pass `{ name }` — a string for exact match, or a RegExp.
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: string | RegExp;
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 (!name) {
12
- throw new Error(`getByRole(${JSON.stringify(role)}) needs { name } on native it matches the accessible label`);
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 locator(driver, by.label(name), wait);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativeproof",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Native Mobile E2E test framework inspired by Playwright (fixtures, locators, expect, route-style mocking) on Appium/WebdriverIO.",