nativeproof 0.1.1 → 0.2.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,43 @@ 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.2.0
8
+
9
+ Locator interaction, generic assertions, and Compose/SwiftUI robustness — plus the docs for each.
10
+
11
+ **Added**
12
+
13
+ - **`Locator.fill(text, opts?)`** — native text entry, the analogue of Playwright's `locator.fill()`:
14
+ it focuses the field (a `tap()`) and types through the device keyboard. New optional `Driver.typeText?`
15
+ hook (`wdioDriver()` implements it via `browser.keys`); `fill()` throws a clear error on drivers
16
+ without text input. It does **not** clear existing content first. (#5)
17
+ - **Generic `expect(value)` matchers** — `toBe` / `toEqual` / `toContain` / `toBeTruthy` / `toBeFalsy` /
18
+ `toBeDefined` / `toBeNull` (+ `.not`). These are **synchronous** (unlike the auto-waiting locator/mock
19
+ matchers), so non-UI checks (counts, ids, parsed payloads) need no second assertion library. (#4)
20
+ - **`tap({ clickableAncestor: true })`** — taps the smallest `clickable="true"` ancestor that contains
21
+ the matched node, for Compose/SwiftUI labels that sit on a non-clickable child of the real touch target. (#3)
22
+
23
+ **Changed**
24
+
25
+ - **`getByText` / `by.text` is now forgiving** — it matches `text` *or* `content-desc` on Android and
26
+ `label` *or* `value` on iOS, so a visible label is found wherever the toolkit exposes it. This broadens
27
+ matching for existing `by.text` selectors; use `getByLabel` / `by.desc` when you want the accessibility
28
+ description specifically. (#3)
29
+
30
+ **Fixed**
31
+
32
+ - **XML entity handling in locators** — selectors built from human-readable strings
33
+ (`by.text("Terms & Conditions")`) now match the entity-escaped page source, and `textContent()` decodes
34
+ entities back to plain text. (#2)
35
+ - **`./package.json` is now exported** — resolvable through the package `exports` map for tooling and
36
+ bundlers that read it. (#1)
37
+
38
+ **Docs**
39
+
40
+ - "Bring your own backend" — documents the `MockBackend` adapter pattern for apps with an existing mock
41
+ server, and clarifies which symbols import from `nativeproof` vs your `nativeproof.config`. (#6)
42
+ - Every feature above is documented in the README (Locators, Assertions, Features, API reference).
43
+
7
44
  ## 0.1.1
8
45
 
9
46
  Documentation pass: refreshed the README examples, expanded the iOS setup guide, and
package/README.md CHANGED
@@ -31,16 +31,23 @@ nativeproof --platform android
31
31
  - [Features](#features)
32
32
  - [Requirements](#requirements)
33
33
  - [Install](#install)
34
+ - [Quick start](#quick-start)
34
35
  - [Project setup](#project-setup)
35
36
  - [Android setup](#android-setup)
36
37
  - [iOS setup](#ios-setup)
37
38
  - [Writing tests](#writing-tests)
38
39
  - [Test blocks](#test-blocks-describe--test)
39
- - [Locators & assertions](#locators--assertions)
40
+ - [Fixtures, roles & the app seam](#fixtures-roles--the-app-seam)
41
+ - [Locators](#locators)
42
+ - [Assertions](#assertions)
40
43
  - [Network interception & assertions](#network-interception--assertions)
44
+ - [Bring your own backend](#bring-your-own-backend)
45
+ - [Gestures & scrolling](#gestures--scrolling)
46
+ - [Evidence & secrets](#evidence--secrets)
41
47
  - [Running](#running)
42
48
  - [CI](#ci)
43
49
  - [Configuration](#configuration)
50
+ - [Troubleshooting](#troubleshooting)
44
51
  - [API reference](#api-reference)
45
52
  - [How it works](#how-it-works)
46
53
 
@@ -51,18 +58,21 @@ nativeproof --platform android
51
58
  - **Reads like Playwright** — `test.describe` / `test(...)` blocks with a typed fixture
52
59
  context injected; no per-test setup/teardown in the spec.
53
60
  - **Locators** — `by.text/testId/label/desc/id` and `page(driver).getByText/getByTestId/
54
- getByLabel/getByRole`, mapped to the right native attribute per platform (so you never
55
- guess `content-desc` vs `accessibilityIdentifier`).
61
+ getByLabel/getById/getByRole`, each mapped to the right native attribute per platform (so you
62
+ never guess `content-desc` vs `accessibilityIdentifier`), with built-in auto-waiting and
63
+ `tap()` / `fill()` for interaction.
56
64
  - **Auto-waiting `expect`** — `expect(locator).toBeVisible()/toShow()/toHaveText()` and
57
- `.not`, each polling until the condition holds.
58
- - **Network interception** a first-party mock server with `route().fulfill/reject/abort`
59
- (like `page.route()`) and `expect(mock).toHaveSent()/toHaveReceived()` traffic assertions.
60
- No per-app adapter.
65
+ `.not`, each polling until the condition holds (default 10s); plus synchronous `expect(value)`
66
+ matchers (`toBe`/`toEqual`/`toContain`/…) so non-UI checks need no second assertion library.
67
+ - **Network interception** — a first-party HTTP + WebSocket mock server with
68
+ `route().fulfill/reject/abort` (like `page.route()`) and `expect(mock).toHaveSent()/
69
+ toHaveReceived()` traffic assertions. No per-app adapter.
61
70
  - **One seam, by injection** — a single `defineApp(...)` declares the device, mock,
62
- login flow, screens and secret patterns; the core imports nothing app-specific.
71
+ login/join flows, screens and secret patterns; the core imports nothing app-specific.
63
72
  - **Cross-platform** — the same spec runs on Android (UiAutomator2) and iOS (XCUITest).
64
73
  - **One command** — `nativeproof` resolves your config, ensures Appium is up, and runs the suite.
65
- - **TypeScript-first**, strict, with evidence (redacted screenshots + source) on every step.
74
+ - **TypeScript-first**, strict, with evidence (redacted screenshots + page source) for an
75
+ auditable green run.
66
76
 
67
77
  ## Requirements
68
78
 
@@ -82,26 +92,96 @@ npm i -D nativeproof \
82
92
  # install the Appium driver(s) you need
83
93
  npx appium driver install uiautomator2 # Android
84
94
  npx appium driver install xcuitest # iOS (macOS only)
95
+
96
+ # optional: verify the toolchain is wired up
97
+ npx appium driver doctor uiautomator2
85
98
  ```
86
99
 
100
+ ## Quick start
101
+
102
+ Four steps from zero to a green run on Android.
103
+
104
+ **1. Configure** — one `nativeproof.config.ts` at the project root:
105
+
106
+ ```ts
107
+ import { createHarness, defineApp, defineConfig, page, startMockServer, wdioDriver } from "nativeproof";
108
+
109
+ const app = defineApp({
110
+ driver: () => wdioDriver(),
111
+ mock: () => startMockServer({ port: 18113, host: "0.0.0.0" }), // 0.0.0.0 so a device can reach it
112
+ screens: {
113
+ home: ({ driver }) => ({
114
+ title: page(driver).getByText("Welcome"),
115
+ start: page(driver).getByRole("button", { name: "Get started" }),
116
+ }),
117
+ },
118
+ });
119
+
120
+ export const { test, expect } = createHarness(app); // specs import these
121
+
122
+ export default defineConfig({
123
+ app,
124
+ testDir: "tests",
125
+ projects: [
126
+ {
127
+ name: "android",
128
+ platform: "android",
129
+ capabilities: {
130
+ platformName: "Android",
131
+ "appium:automationName": "UiAutomator2",
132
+ "appium:app": process.env.ANDROID_APP ?? "app/android/app-debug.apk",
133
+ "appium:autoGrantPermissions": true,
134
+ },
135
+ },
136
+ ],
137
+ });
138
+ ```
139
+
140
+ **2. Write a spec** — `tests/home.spec.ts`:
141
+
142
+ ```ts
143
+ import { test, expect } from "../nativeproof.config";
144
+
145
+ test.describe("home screen", () => {
146
+ test("greets the user and starts", async ({ home }) => {
147
+ await expect(home.title).toBeVisible();
148
+ await home.start.tap();
149
+ });
150
+ });
151
+ ```
152
+
153
+ **3. Boot a device** — an emulator (Android) or simulator (iOS) must be running (see
154
+ [Android setup](#android-setup) / [iOS setup](#ios-setup)).
155
+
156
+ **4. Run:**
157
+
158
+ ```bash
159
+ npx nativeproof --platform android
160
+ ```
161
+
162
+ `nativeproof` discovers the config, starts Appium if it isn't already up, and runs the suite.
163
+
164
+ ## Project setup
165
+
166
+ Everything lives in one **`nativeproof.config.ts`** — the `playwright.config.ts` analogue. It
167
+ declares the app (the injected seam), exports the typed `test` / `expect` your specs import,
168
+ and lists the device **projects**. The CLI auto-discovers it and synthesises the WebdriverIO
169
+ run, so there's no hand-written `wdio.conf.ts`.
170
+
87
171
  A typical project:
88
172
 
89
173
  ```
90
174
  my-app-e2e/
91
175
  ├─ nativeproof.config.ts # the single config — app, device projects, test/expect exports
92
176
  ├─ tests/
177
+ │ ├─ home.spec.ts
93
178
  │ └─ chat.spec.ts
94
179
  └─ app/
95
180
  ├─ android/app-debug.apk
96
181
  └─ ios/MyApp.app
97
182
  ```
98
183
 
99
- ## Project setup
100
-
101
- Everything lives in one **`nativeproof.config.ts`** — the `playwright.config.ts` analogue. It
102
- declares the app (the injected seam), exports the typed `test` / `expect` your specs import,
103
- and lists the device **projects**. The `nativeproof` CLI auto-discovers it and synthesises the
104
- WebdriverIO run, so there's no hand-written `wdio.conf.ts`.
184
+ The full, cross-platform config:
105
185
 
106
186
  ```ts
107
187
  // nativeproof.config.ts
@@ -109,9 +189,9 @@ import { createHarness, defineApp, defineConfig, page, startMockServer, wdioDriv
109
189
 
110
190
  const app = defineApp({
111
191
  driver: () => wdioDriver(), // the live WebdriverIO/Appium session
112
- mock: () => startMockServer({ port: 18113 }), // first-party mock; route()/frames built in
113
- secrets: [/\b2468\b/], // kept out of captured evidence
114
- login: async ({ role, mock }) => {
192
+ mock: () => startMockServer({ port: 18113, host: "0.0.0.0" }), // first-party mock; route()/frames built in
193
+ secrets: [/\b\d{6}\b/], // app-specific patterns kept out of captured evidence
194
+ login: async ({ role, driver, mock }) => {
115
195
  // drive your app's sign-in; `mock` is the running backend, `role` comes from the describe
116
196
  },
117
197
  screens: {
@@ -119,8 +199,12 @@ const app = defineApp({
119
199
  const p = page(driver);
120
200
  return {
121
201
  messages: p.getByTestId("message-list"),
122
- signOut: p.getByLabel("Sign out"),
202
+ roomTitle: p.getByTestId("room-title"),
203
+ composer: p.getByTestId("composer"),
123
204
  sendButton: p.getByRole("button", { name: "Send" }),
205
+ errorBanner: p.getByTestId("error-banner"),
206
+ spinner: p.getByTestId("loading"),
207
+ signOut: p.getByLabel("Sign out"),
124
208
  };
125
209
  },
126
210
  },
@@ -179,9 +263,10 @@ export default defineConfig({
179
263
  ```
180
264
  4. **App build.** Point `ANDROID_APP` at your debug/E2E `.apk` (or use `appium:appPackage` +
181
265
  `appium:appActivity` for an already-installed build).
182
- 5. **Mock host.** From the emulator, the host machine is reachable at **`10.0.2.2`**. Build
183
- your E2E app so its backend base URL is `http://10.0.2.2:18113` (the mock server's port),
184
- so `mock.route(...)` and `expect(mock)` see the app's traffic.
266
+ 5. **Mock host.** From an emulator, the host machine is reachable at **`10.0.2.2`** — build your
267
+ E2E app so its backend base URL is `http://10.0.2.2:18113` (the mock server's port). Bind the
268
+ mock with `host: "0.0.0.0"` so the device can reach it (a real device uses your Mac's LAN IP,
269
+ e.g. `http://192.168.1.20:18113`). Then `mock.route(...)` and `expect(mock)` see the traffic.
185
270
 
186
271
  ## iOS setup
187
272
 
@@ -215,8 +300,7 @@ export default defineConfig({
215
300
  - **Already installed:** skip `appium:app` and set `appium:bundleId` instead.
216
301
  5. **Mock host.** The simulator shares the host's network, so the backend base URL is
217
302
  `http://127.0.0.1:18113` (the mock server's port). A **real device** must reach your Mac by
218
- its LAN IP instead, e.g. `http://192.168.1.20:18113` set the mock's `host` so both bind
219
- the same interface.
303
+ its LAN IP instead — bind the mock with `host: "0.0.0.0"` so both ends use the same interface.
220
304
 
221
305
  ## Writing tests
222
306
 
@@ -246,63 +330,285 @@ test.describe("home screen", () => {
246
330
  });
247
331
  ```
248
332
 
249
- The destructured fixtures (`member`, `home`, `mock`, …) are exactly the `screens` you
333
+ The destructured fixtures (`member`, `home`, `mock`, `driver`, …) are exactly the `screens` you
250
334
  declared in `defineApp`, plus `mock` and `driver` — typed to your app.
251
335
 
252
- ### Locators & assertions
336
+ > **Where imports come from:** specs import **`test` / `expect`** from your
337
+ > `nativeproof.config.ts` (the typed pair `createHarness` returns). Everything else —
338
+ > `page`, `by`, the gesture helpers (`swipe` / `tapAt`), `captureState`, and the types —
339
+ > imports from the **`nativeproof`** package directly.
340
+
341
+ ### Fixtures, roles & the app seam
342
+
343
+ A scenario's context is provisioned **once** before its behaviours and torn down **once** after
344
+ (the analogue of a Playwright scoped fixture / `describe.serial`) — so a single sign-in underpins
345
+ many ordered checks instead of re-logging-in per test. The order is: `driver` → `mock` →
346
+ `login(role)` → `join(role)` → build `screens`.
347
+
348
+ The **role** string from `test.describe(title, role, …)` flows into `login`/`join`, so one app
349
+ definition drives many roles:
350
+
351
+ ```ts
352
+ const app = defineApp({
353
+ driver: () => wdioDriver(),
354
+ mock: () => startMockServer({ port: 18113, host: "0.0.0.0" }),
355
+ secrets: [/\b\d{6}\b/], // e.g. a 6-digit OTP — redacted from evidence
356
+
357
+ // runs once per describe, before screens are built:
358
+ login: async ({ driver, role }) => {
359
+ const p = page(driver);
360
+ await p.getByTestId("email").fill(`${role}@example.com`); // taps to focus, then types
361
+ await p.getByRole("button", { name: "Sign in" }).tap();
362
+ },
363
+
364
+ // enters the role's main surface after login:
365
+ join: async ({ driver, role }) => {
366
+ if (role === "member") await page(driver).getByText("My rooms").tap();
367
+ },
368
+
369
+ screens: {
370
+ member: ({ driver }) => ({ messages: page(driver).getByTestId("message-list") }),
371
+ guest: ({ driver }) => ({ banner: page(driver).getByTestId("signup-banner") }),
372
+ },
373
+ });
374
+ ```
375
+
376
+ ```ts
377
+ test.describe("a signed-in member", "member", () => { /* uses { member, mock } */ });
378
+ test.describe("a guest", "guest", () => { /* uses { guest, mock } */ });
379
+ ```
380
+
381
+ > NativeProof locators **read, tap and fill** (text entry). For input `fill()` doesn't cover
382
+ > (clearing a field first, custom keyboards, key chords), drive WebdriverIO's `$(...).setValue(...)`
383
+ > directly inside `login` (the `@wdio/globals` `browser`/`$` are available in the live session).
384
+
385
+ ### Locators
386
+
387
+ Build locators by intent; NativeProof maps each to the right native attribute per platform — so
388
+ you address elements the way a person describes them, not by `content-desc` vs `name`:
389
+
390
+ ```ts
391
+ const p = page(driver);
392
+ p.getByText("Sign in"); // visible text
393
+ p.getByTestId("login-button"); // your test id
394
+ p.getByLabel("Sign out"); // accessibility label
395
+ p.getById("message-list"); // resource id
396
+ p.getByRole("button", { name: "Send" }); // accessible name (role is advisory on native)
397
+ p.locator(by.desc("Open menu")); // escape hatch: a raw selector
398
+ ```
399
+
400
+ How each maps to the page source:
401
+
402
+ | Locator | Android attribute | iOS attribute |
403
+ |---|---|---|
404
+ | `getByText` / `by.text` | `text` or `content-desc` | `label` or `value` |
405
+ | `getByLabel` / `by.label` / `getByRole({name})` | `content-desc` | `label` |
406
+ | `getByTestId` / `by.testId` | `resource-id` | `name` |
407
+ | `getById` / `by.id` | `resource-id` | `name` |
408
+ | `by.desc` | `content-desc` | `name` |
409
+
410
+ > **`getByText` is forgiving.** A visible label surfaces as `text` *or* `content-desc` on Android
411
+ > (Jetpack Compose) and as `label` *or* `value` on iOS (SwiftUI), so `getByText` / `by.text` finds
412
+ > the label wherever the toolkit put it — not just the node's own `text`. Reach for `getByLabel` /
413
+ > `by.desc` when you specifically want the accessibility description.
253
414
 
254
- Build locators by intent; NativeProof maps each to the right native attribute per platform:
415
+ A `Locator` is a lazy, awaitable handle with built-in waiting:
255
416
 
256
417
  ```ts
257
- page(driver).getByText("Sign in"); // Android text / iOS label
258
- page(driver).getByTestId("login-button"); // Android resource-id / iOS accessibilityIdentifier
259
- page(driver).getByLabel("Sign out"); // Android content-desc / iOS label
260
- page(driver).getByRole("button", { name: "Send" });// accessible-name match (role is advisory on native)
418
+ await member.messages.isVisible(); // boolean, no waiting
419
+ await member.roomTitle.textContent(); // the node's own text, or null
420
+ await member.spinner.waitFor(); // wait until visible (throws on timeout)
421
+ await member.sendButton.tap(); // wait for it, then tap its centre
422
+ await member.sendButton.tap({ timeout: 2_000, interval: 100 }); // tune the wait
423
+ await member.row.tap({ clickableAncestor: true }); // tap the clickable parent of a non-clickable label
424
+ await member.composer.fill("Hello team"); // focus the field (tap), then type
261
425
  ```
262
426
 
263
- Assertions auto-wait (poll until the condition holds or the timeout elapses), and `.not` inverts:
427
+ `tap()` resolves the element's bounds from the page source and taps the centre — a coordinate
428
+ tap that works even on Compose / SwiftUI nodes Appium reports as non-clickable.
429
+
430
+ On Compose / SwiftUI the visible label often sits on a **non-clickable** child of the real touch
431
+ target (a list row, a card). `tap({ clickableAncestor: true })` taps the smallest
432
+ `clickable="true"` ancestor that fully contains the matched node instead of the node's own centre,
433
+ falling back to the node itself when nothing clickable wraps it.
434
+
435
+ `fill(text, opts?)` focuses the field with a `tap()` and types `text` through the device keyboard
436
+ — the native analogue of Playwright's `locator.fill()`. It types into the focused field and does
437
+ **not** clear existing content first; it needs a driver with text input (the bundled `wdioDriver()`
438
+ has it) and throws a clear error otherwise. `opts` is the same `{ timeout?, interval? }` as `tap()`.
439
+
440
+ ### Assertions
441
+
442
+ Assertions **auto-wait** (poll until the condition holds or the timeout elapses, default
443
+ **10s** / 250ms interval), accept a string or `RegExp`, and `.not` inverts:
264
444
 
265
445
  ```ts
266
446
  await expect(member.messages).toBeVisible();
267
- await expect(member.messages).toShow("Welcome to the room"); // visible + text present
268
- await expect(member.roomTitle).toHaveText(/Room: \w+/);
447
+ await expect(member.messages).toShow("Welcome to the room"); // present + text anywhere on screen
448
+ await expect(member.roomTitle).toHaveText(/Room: \w+/); // the node's OWN text (substring or regex)
269
449
  await expect(member.spinner).not.toBeVisible({ timeout: 5_000 });
270
- await member.signOut.tap(); // waits, then taps
271
450
  ```
272
451
 
452
+ - `toBeVisible(opts?)` — the selector matches a node in the source.
453
+ - `toShow(text, opts?)` — the selector is present **and** `text` appears in the source.
454
+ - `toHaveText(text, opts?)` — the matched node's **own** text contains / matches `text`.
455
+ - `opts` is `{ timeout?, interval? }` (ms).
456
+
457
+ **Value matchers** — `expect(value)` also takes a plain value, for the non-UI assertions a spec
458
+ still needs (counts, ids, parsed payloads). The locator and mock matchers auto-wait and return
459
+ promises; value matchers assert a value you already have, so they are **synchronous** (no `await`)
460
+ and `.not` inverts:
461
+
462
+ ```ts
463
+ const frames = await mock.frames();
464
+
465
+ expect(2 + 2).toBe(4); // strict identity (Object.is)
466
+ expect(frames).toContain(someFrame); // membership (arrays) or substring (strings)
467
+ expect({ id: 7, name: "Ada" }).toEqual(user); // deep structural equality
468
+ expect(member.unreadBadge).toBeTruthy();
469
+ expect(reply.error).toBeNull();
470
+ expect(reply.id).toBeDefined();
471
+ expect(reply.id).not.toBe(previousId);
472
+ ```
473
+
474
+ - `toBe(expected)` — strict identity (`Object.is`).
475
+ - `toEqual(expected)` — deep structural equality.
476
+ - `toContain(expected)` — substring (for strings) or membership (for arrays).
477
+ - `toBeTruthy()` / `toBeFalsy()` / `toBeDefined()` / `toBeNull()` — value guards.
478
+
479
+ This keeps every assertion in a spec under one `expect`, so there's no second assertion library to
480
+ import for the checks the UI/traffic matchers don't cover.
481
+
273
482
  ### Network interception & assertions
274
483
 
275
484
  The mock backend works like Playwright's `page.route()`. **Intercept** a path to control its
276
- reply, and **assert** the traffic the app exchanged:
485
+ reply, and **assert** the traffic the app exchanged — over both REST and WebSocket:
277
486
 
278
487
  ```ts
279
488
  test.describe("send failures", "member", () => {
280
489
  test("surfaces a rejected send", async ({ member, mock }) => {
281
- // Interception — control how a path replies (routes apply to the next request/connect):
282
- await mock.route("/messages").reject({ code: 4 }); // or .fulfill({ ... }) / .abort()
490
+ // Interception — routes apply to the next request/connect on that path:
491
+ await mock.route("/messages").reject({ code: 503 }); // HTTP status, or WS close code (3000–4999)
283
492
  await member.sendButton.tap();
284
493
  await expect(member.errorBanner).toShow("Couldn't send message");
285
494
  });
286
495
  });
287
496
 
497
+ test.describe("loading a room", "member", () => {
498
+ test("renders history fetched on open", async ({ member, mock }) => {
499
+ // fulfill answers the request/connect with a canned frame/body:
500
+ await mock.route("/messages").fulfill({ type: "history", messages: ["Hello", "Hi there"] });
501
+ await expect(member.messages).toShow("Hi there");
502
+ });
503
+ });
504
+
288
505
  test.describe("chat room", "member", () => {
289
506
  test("sends a message and receives the next one", async ({ mock }) => {
290
- // Assertionwhat the app sent / received, matched by path + type + payload fields:
291
- await expect(mock).toHaveSent({ path: "/messages", type: "create" });
507
+ // Assertions — matched by path + type + any payload field:
508
+ await expect(mock).toHaveSent({ path: "/messages", type: "create" }); // a WS message
509
+ await expect(mock).toHaveSent({ path: "/profile", type: "request", method: "GET" }); // a REST call
292
510
  await expect(mock).toHaveReceived({ path: "/messages", type: "new" });
293
511
  await expect(mock).not.toHaveSent({ type: "error" });
294
512
  });
295
513
  });
296
514
  ```
297
515
 
298
- - `mock.route(path).fulfill(frame)` — answer the request/connect with a canned frame/body.
299
- - `mock.route(path).reject({ code })` — fail it (WS close / HTTP status).
300
- - `mock.route(path).abort()` — drop it.
301
- - `expect(mock).toHaveSent(match)` / `toHaveReceived(match)` — `match` is a partial frame
302
- (`path`/`type` plus any payload fields).
516
+ - `mock.route(path).fulfill(frame)` — answer with a canned frame/body (WS connect reply or HTTP JSON).
517
+ - `mock.route(path).reject({ code })` — fail it (HTTP status, or WebSocket close code 3000–4999).
518
+ - `mock.route(path).abort()` — drop the connect/request entirely.
519
+ - `expect(mock).toHaveSent(match)` / `toHaveReceived(match)` — `match` is a partial frame:
520
+ `path` / `type` plus any payload fields.
521
+
522
+ **Frame types** — real protocol messages keep their own `type` (a WS JSON message's `type`); the
523
+ server also synthesises types for primitives so you can assert on them:
524
+
525
+ | What the app did | Recorded as |
526
+ |---|---|
527
+ | Opened a WebSocket | `{ type: "open", direction: "sent" }` |
528
+ | Sent a WS JSON message | `{ type: <message.type>, direction: "sent", payload }` |
529
+ | Made an HTTP request | `{ type: "request", direction: "sent", payload: { method } }` |
530
+ | Got a fulfilled reply | `{ type: <frame.type ?? "response" | "message">, direction: "received" }` |
531
+
532
+ `startMockServer()` is a real HTTP + WebSocket server (`url` / `wsUrl`), so there's no per-app
533
+ adapter — your app just points at it. It can also push a server-initiated frame to open sockets
534
+ with `server.send(path, frame)` (useful for simulating an incoming message in a config-level helper).
535
+
536
+ ### Bring your own backend
303
537
 
304
- `startMockServer()` is a real HTTP + WebSocket server, so no per-app adapter is needed — your
305
- app just points at its `url` / `wsUrl`.
538
+ `startMockServer` is the batteries-included option, but `defineApp({ mock })` accepts **any**
539
+ `MockBackend`. An app with its own protocol (or an existing mock server) injects a small adapter
540
+ that exposes the three-method contract — then `route()` and the traffic assertions work unchanged:
541
+
542
+ ```ts
543
+ import { defineApp, type MockBackend, type MockFrame, type MockRoute, wdioDriver } from "nativeproof";
544
+
545
+ function adapt(server: MyExistingMock): MockBackend {
546
+ return {
547
+ frames: async (): Promise<MockFrame[]> =>
548
+ server.log.map((f) => ({
549
+ path: f.path,
550
+ type: f.kind,
551
+ direction: f.outbound ? "sent" : "received",
552
+ payload: f.body,
553
+ })),
554
+ route: (path: string): MockRoute => ({
555
+ fulfill: (frame) => server.stub(path, frame),
556
+ reject: ({ code }) => server.fail(path, code),
557
+ abort: () => server.drop(path),
558
+ }),
559
+ stop: () => server.close(),
560
+ };
561
+ }
562
+
563
+ const app = defineApp({
564
+ driver: () => wdioDriver(),
565
+ mock: () => adapt(startMyMock()),
566
+ screens: {
567
+ /* … */
568
+ },
569
+ });
570
+ ```
571
+
572
+ The framework depends only on the `MockBackend` interface, never a concrete server — so
573
+ `expect(mock).toHaveSent(...)` reads your backend's traffic with no other changes.
574
+
575
+ ### Gestures & scrolling
576
+
577
+ For motion the locator layer doesn't cover (scrolling a list, swiping a carousel), use the
578
+ low-level pointer helpers — coordinates are in screen pixels:
579
+
580
+ ```ts
581
+ import { swipe, tapAt, pause } from "nativeproof";
582
+
583
+ await swipe(540, 1500, 540, 500); // drag up to scroll a feed down (fromX, fromY, toX, toY, duration=600)
584
+ await tapAt(540, 120); // a raw coordinate tap
585
+ await pause(250); // idle (e.g. let an animation settle)
586
+ ```
587
+
588
+ A common pattern: swipe until the target appears, then assert.
589
+
590
+ ```ts
591
+ while (!(await member.messages.shows("Older message"))) {
592
+ await swipe(540, 1500, 540, 600);
593
+ }
594
+ await expect(member.messages).toShow("Older message");
595
+ ```
596
+
597
+ ### Evidence & secrets
598
+
599
+ A passing mobile test should prove app state, not just "the runner didn't throw". Capture a
600
+ screenshot + a **redacted** page-source snapshot at any meaningful step:
601
+
602
+ ```ts
603
+ import { captureState } from "nativeproof";
604
+
605
+ await member.sendButton.tap();
606
+ await captureState("after-send"); // writes .e2e-artifacts/after-send.png + after-send.xml (redacted)
607
+ ```
608
+
609
+ - Artifacts land in `.e2e-artifacts/` (override with the `E2E_ARTIFACT_DIR` env var).
610
+ - Source is redacted before it touches disk: built-in patterns strip 4–8 digit values, `passcode`
611
+ fields, and `Bearer` tokens; add your app's own patterns via `secrets` / `redact` in `defineApp`.
306
612
 
307
613
  ## Running
308
614
 
@@ -315,13 +621,14 @@ nativeproof --project tablet # a named project from nativeproof.config.t
315
621
  nativeproof --spec tests/chat.spec.ts
316
622
  nativeproof --config wdio.conf.ts # escape hatch: a raw WebdriverIO config
317
623
  nativeproof --no-appium # use an Appium server you started yourself
624
+ nativeproof --appium-host 10.0.0.5 --appium-port 4723 # point at a remote/farm Appium
318
625
  nativeproof --help
319
626
  ```
320
627
 
321
628
  `nativeproof` discovers `nativeproof.config.ts` (or falls back to a `wdio.conf.ts`), ensures an
322
- Appium server is reachable (starting one unless `--no-appium`), and runs the suite with
323
- `PLATFORM` / `SPEC` / `NATIVEPROOF_PROJECT` / `APPIUM_*` set. A device or emulator must already
324
- be running — the mobile analogue of needing a display.
629
+ Appium server is reachable (starting one with `--relaxed-security` unless `--no-appium`), and runs
630
+ the suite with `PLATFORM` / `SPEC` / `NATIVEPROOF_PROJECT` / `APPIUM_*` set for you. A device or
631
+ emulator must already be running — the mobile analogue of needing a display.
325
632
 
326
633
  ## CI
327
634
 
@@ -345,33 +652,90 @@ jobs:
345
652
  script: npx nativeproof --platform android
346
653
  ```
347
654
 
348
- **iOS** runs the same way on a `macos-latest` runner (Xcode + a booted simulator), with
349
- `npx nativeproof --platform ios`. To offload either platform, point a project's Appium
350
- `host`/`port` (in `nativeproof.config.ts`, or `nativeproof --appium-host/--appium-port`) at a
351
- **device farm** (BrowserStack, Sauce Labs, Firebase Test Lab) — NativeProof is just Appium, so
352
- no test changes are needed.
655
+ **iOS (GitHub Actions, macOS runner + simulator):**
656
+
657
+ ```yaml
658
+ jobs:
659
+ ios-e2e:
660
+ runs-on: macos-latest
661
+ steps:
662
+ - uses: actions/checkout@v4
663
+ - uses: actions/setup-node@v4
664
+ with: { node-version: 20 }
665
+ - run: npm ci
666
+ - run: npx appium driver install xcuitest
667
+ - run: xcrun simctl boot "iPhone 15" || true
668
+ - run: npx nativeproof --platform ios
669
+ ```
670
+
671
+ To offload either platform, point a project's Appium `host`/`port` (in `nativeproof.config.ts`,
672
+ or `nativeproof --appium-host/--appium-port`) at a **device farm** (BrowserStack, Sauce Labs,
673
+ Firebase Test Lab) — NativeProof is just Appium, so no test changes are needed.
353
674
 
354
675
  The framework's own unit suite (`npm test`) needs **no device** and runs anywhere.
355
676
 
356
677
  ## Configuration
357
678
 
358
- | Where | What |
679
+ **`defineConfig({ ... })`**
680
+
681
+ | Field | Type | Default | What |
682
+ |---|---|---|---|
683
+ | `app` | `App` | — | the app under test (from `defineApp`) |
684
+ | `projects` | `DeviceProject[]` | — | device targets; each `{ name, platform, capabilities }` |
685
+ | `testDir` | `string` | `"tests"` | directory holding the specs |
686
+ | `testMatch` | `string` | `"**/*.spec.ts"` | glob within `testDir` |
687
+ | `appium` | `{ host?, port?, path? }` | `127.0.0.1` : `4723` `/wd/hub` | Appium connection |
688
+ | `mochaTimeout` | `number` | `240000` | per-test timeout (ms) |
689
+
690
+ **`defineApp({ ... })`** — the seam
691
+
692
+ | Field | Type | What |
693
+ |---|---|---|
694
+ | `driver` | `() => Driver` | acquire the device (e.g. `wdioDriver()`) |
695
+ | `mock` | `() => MockBackend` | start the mock backend (e.g. `startMockServer(...)`) |
696
+ | `screens` | `Record<string, factory>` | screen-object factories, bound to the device context |
697
+ | `login?` | `({ driver, mock, role }) => Promise` | reach a logged-in state for the role |
698
+ | `join?` | `({ driver, mock, role }) => Promise` | enter the role's main surface |
699
+ | `secrets?` | `RegExp[]` | patterns kept out of captured evidence |
700
+ | `redact?` | `RegExp[]` | extra evidence-redaction patterns |
701
+
702
+ **CLI flags & env**
703
+
704
+ | Flag | Env it sets | Default |
705
+ |---|---|---|
706
+ | `--platform <android\|ios>` | `PLATFORM` | — |
707
+ | `--project <name>` | `NATIVEPROOF_PROJECT` | first project |
708
+ | `--spec <glob>` | `SPEC` | all specs in `testDir` |
709
+ | `--config <path>` | — | auto-discovered |
710
+ | `--appium-host/-port/-path` | `APPIUM_HOST/PORT/PATH` | `127.0.0.1` / `4723` / `/wd/hub` |
711
+ | `--no-appium` | — | auto-start Appium |
712
+
713
+ ## Troubleshooting
714
+
715
+ | Symptom | Likely cause / fix |
359
716
  |---|---|
360
- | `nativeproof.config.ts` | `defineConfig({...})`: the `app`, device `projects`, `testDir`, `appium`, and the `test`/`expect` exports. |
361
- | `defineApp({...})` | The seam: `driver`, `mock`, `login`, `join`, `screens`, `secrets`, `redact`. |
362
- | `nativeproof` flags | `--platform`, `--project`, `--spec`, `--config`, `--appium-host/port/path`, `--no-appium`. |
363
- | Env | `PLATFORM`, `NATIVEPROOF_PROJECT`, `SPEC`, `APPIUM_HOST/PORT/PATH` (set by the CLI; read by the synthesised config). |
717
+ | `Appium is not reachable …` | No device, or `--no-appium` set without a server. Boot the emulator/simulator; drop `--no-appium` to let NativeProof start Appium. |
718
+ | `no nativeproof.config.ts or wdio.conf.ts found` | Run from the project root, or pass `--config <path>`. |
719
+ | "No specs found" | Specs must match `testDir`/`testMatch` (default `tests/**/*.spec.ts`), or pass `--spec`. |
720
+ | App can't reach the mock | Emulator use `10.0.2.2`; real device your machine's LAN IP. Bind the mock with `host: "0.0.0.0"`. |
721
+ | `expect(...)` times out | The selector never matched — confirm the attribute mapping (see the [Locators](#locators) table) and the value; raise `{ timeout }` for slow screens. |
722
+ | iOS first run hangs | WebDriverAgent is building/signing. Set `appium:wdaLaunchTimeout` and, on a real device, the signing capabilities. |
364
723
 
365
724
  ## API reference
366
725
 
367
- - `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a fixture.
726
+ - `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture.
368
727
  - `createHarness(app)` → `{ test, expect }` — typed, app-bound test surface.
369
- - `defineConfig({ app, projects, testDir?, appium? })` — the `nativeproof.config.ts` the CLI runs.
370
- - `by.text/testId/label/desc/id`, `page(driver).getByText/getByTestId/getByLabel/getById/getByRole`,
371
- `new Locator(driver, selector)` — locators (`isVisible`, `textContent`, `tap`, `waitFor`, …).
372
- - `expect(locator)` `toBeVisible` / `toShow` / `toHaveText` (+ `.not`).
373
- - `expect(mock)` → `toHaveSent` / `toHaveReceived` (+ `.not`).
374
- - `startMockServer({ port?, host? })` → a `MockBackend` with `route()`, `frames()`, `send()`.
728
+ - `defineConfig({ app, projects, testDir?, testMatch?, appium?, mochaTimeout? })` — the config the CLI runs.
729
+ - `by.text/desc/id/testId/label`, `page(driver).getByText/getByTestId/getByLabel/getById/getByRole`,
730
+ `page(driver).locator(selector)`, `new Locator(driver, selector)` — locators
731
+ (`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill` `tap({ clickableAncestor })`
732
+ for non-clickable labels).
733
+ - `expect(locator)` → `toBeVisible` / `toShow` / `toHaveText` (+ `.not`), each `(value?, { timeout?, interval? })`.
734
+ - `expect(mock)` → `toHaveSent` / `toHaveReceived` (+ `.not`), matched by partial frame.
735
+ - `expect(value)` → `toBe` / `toEqual` / `toContain` / `toBeTruthy` / `toBeFalsy` / `toBeDefined` / `toBeNull` (+ `.not`) — synchronous matchers for plain values.
736
+ - `startMockServer({ port?, host? })` → a `MockServer` (`url`, `wsUrl`, `route()`, `frames()`, `send()`, `stop()`).
737
+ - `swipe`, `tapAt`, `pause` — low-level pointer gestures.
738
+ - `captureState(prefix)` / `captureScreenshot` / `captureText` / `redactEvidenceText` — evidence.
375
739
  - Device commands — **Android** (`adb`): `adbForceStop`, `resetAppAndBrowserState`, `adbTap`, `adbDump`, `adbLogcat*`; **iOS** (`simctl`): `iosTerminate`, `iosLaunch`, `iosInstall`, `iosUninstall`, `iosBoot`, `iosShutdown`, `resetAppState`, `iosLogShow`.
376
740
  - `wdioDriver()` → the live `Driver`; `useRunner(hooks)` to host on a non-Mocha runner.
377
741
 
@@ -379,13 +743,13 @@ The framework's own unit suite (`npm test`) needs **no device** and runs anywher
379
743
 
380
744
  The engine is Appium/WebdriverIO; NativeProof is the DX layer. It's app-agnostic by contract:
381
745
  a consuming app injects all of its specifics through `defineApp`, and nothing in the package
382
- imports app code (dependency is one-way, app → framework). The whole DX self-verifies against
746
+ imports app code (the dependency is one-way, app → framework). The whole DX self-verifies against
383
747
  an in-memory fake device — see `test/demo.test.ts` and run `npm test` (no emulator needed).
384
748
 
385
749
  Package layout: `app.ts` (`defineApp`), `harness.ts` (`createHarness`), `config.ts`
386
750
  (`defineConfig`) + `runner-config.ts` (the wdio bridge), `fixtures.ts`, `locator.ts` +
387
751
  `page.ts`, `expect.ts`, `mock.ts` + `mock-server.ts`, `driver.ts`, `runner.ts`, `cli.ts`
388
- (the `nativeproof` bin), plus source/wait/gesture/adb/log/evidence primitives.
752
+ (the `nativeproof` bin), plus source/wait/gesture/adb/ios/log/evidence primitives.
389
753
 
390
754
  ## License
391
755
 
package/dist/driver.d.ts CHANGED
@@ -15,6 +15,11 @@ export interface Driver {
15
15
  pause(ms: number): Promise<void>;
16
16
  /** Tap an absolute screen coordinate. */
17
17
  tapAt(x: number, y: number): Promise<void>;
18
+ /**
19
+ * Type into the currently focused element (keyboard input). Optional: drivers that
20
+ * cannot type leave it undefined, and {@link Locator.fill} throws a clear error.
21
+ */
22
+ typeText?(text: string): Promise<void>;
18
23
  }
19
24
  /** A {@link Driver} backed by the live WebdriverIO/Appium session. */
20
25
  export declare function wdioDriver(): Driver;
package/dist/driver.js CHANGED
@@ -9,5 +9,6 @@ export function wdioDriver() {
9
9
  source: () => browser.getPageSource().catch(() => ""),
10
10
  pause: (ms) => browser.pause(ms),
11
11
  tapAt: (x, y) => tapAt(x, y),
12
+ typeText: (text) => browser.keys(text),
12
13
  };
13
14
  }
package/dist/expect.d.ts CHANGED
@@ -22,5 +22,24 @@ export interface MockAssertions {
22
22
  /** The app received a frame matching `match`. */
23
23
  toHaveReceived(match: FrameMatch, options?: WaitOptions): Promise<void>;
24
24
  }
25
+ /**
26
+ * Synchronous matchers for a plain value — for the non-UI assertions a spec still needs
27
+ * (counts, ids, parsed payloads). UI/traffic matchers auto-wait and return promises;
28
+ * these assert a value that is already known, so they run synchronously and `.not` inverts.
29
+ */
30
+ export interface ValueAssertions<T> {
31
+ readonly not: ValueAssertions<T>;
32
+ /** Strict identity (`Object.is`). */
33
+ toBe(expected: T): void;
34
+ /** Deep structural equality. */
35
+ toEqual(expected: T): void;
36
+ /** Substring (for strings) or membership (for arrays). */
37
+ toContain(expected: unknown): void;
38
+ toBeTruthy(): void;
39
+ toBeFalsy(): void;
40
+ toBeDefined(): void;
41
+ toBeNull(): void;
42
+ }
25
43
  export declare function expect(target: Locator): LocatorAssertions;
26
44
  export declare function expect(target: MockBackend): MockAssertions;
45
+ export declare function expect<T>(target: T): ValueAssertions<T>;
package/dist/expect.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { describeSelector, Locator, waitUntil } from "./locator.js";
2
3
  import { describeMatch, frameExists } from "./mock.js";
3
4
  class LocatorExpectation {
@@ -60,6 +61,70 @@ class MockExpectation {
60
61
  }
61
62
  }
62
63
  }
64
+ function describeValue(value) {
65
+ try {
66
+ return JSON.stringify(value) ?? String(value);
67
+ }
68
+ catch {
69
+ return String(value);
70
+ }
71
+ }
72
+ class ValueExpectation {
73
+ actual;
74
+ negated;
75
+ constructor(actual, negated = false) {
76
+ this.actual = actual;
77
+ this.negated = negated;
78
+ }
79
+ get not() {
80
+ return new ValueExpectation(this.actual, !this.negated);
81
+ }
82
+ toBe(expected) {
83
+ this.assert(Object.is(this.actual, expected), "toBe", describeValue(expected));
84
+ }
85
+ toEqual(expected) {
86
+ this.assert(isDeepStrictEqual(this.actual, expected), "toEqual", describeValue(expected));
87
+ }
88
+ toContain(expected) {
89
+ const ok = typeof this.actual === "string"
90
+ ? this.actual.includes(String(expected))
91
+ : Array.isArray(this.actual)
92
+ ? this.actual.includes(expected)
93
+ : false;
94
+ this.assert(ok, "toContain", describeValue(expected));
95
+ }
96
+ toBeTruthy() {
97
+ this.assert(Boolean(this.actual), "toBeTruthy");
98
+ }
99
+ toBeFalsy() {
100
+ this.assert(!this.actual, "toBeFalsy");
101
+ }
102
+ toBeDefined() {
103
+ this.assert(this.actual !== undefined, "toBeDefined");
104
+ }
105
+ toBeNull() {
106
+ this.assert(this.actual === null, "toBeNull");
107
+ }
108
+ assert(pass, matcher, expectedDesc = "") {
109
+ const want = !this.negated;
110
+ if (pass === want)
111
+ return;
112
+ const not = this.negated ? ".not" : "";
113
+ throw new Error(`expect(${describeValue(this.actual)})${not}.${matcher}(${expectedDesc}) — assertion not met`);
114
+ }
115
+ }
116
+ /** Structural guard: a {@link MockBackend} is anything with `frames`/`route`/`stop`. */
117
+ function isMockBackend(target) {
118
+ return (typeof target === "object" &&
119
+ target !== null &&
120
+ typeof target.frames === "function" &&
121
+ typeof target.route === "function" &&
122
+ typeof target.stop === "function");
123
+ }
63
124
  export function expect(target) {
64
- return target instanceof Locator ? new LocatorExpectation(target) : new MockExpectation(target);
125
+ if (target instanceof Locator)
126
+ return new LocatorExpectation(target);
127
+ if (isMockBackend(target))
128
+ return new MockExpectation(target);
129
+ return new ValueExpectation(target);
65
130
  }
package/dist/locator.d.ts CHANGED
@@ -47,6 +47,14 @@ export interface WaitOptions {
47
47
  /** Sleep awaited between polls; defaults to a real timer. The locator injects the driver's pause. */
48
48
  sleep?: (ms: number) => Promise<void>;
49
49
  }
50
+ export interface TapOptions extends WaitOptions {
51
+ /**
52
+ * Tap the smallest `clickable="true"` ancestor that contains the matched node, rather
53
+ * than the node itself. Compose/SwiftUI often expose a label on a non-clickable child;
54
+ * this taps the real touch target around it.
55
+ */
56
+ clickableAncestor?: boolean;
57
+ }
50
58
  /**
51
59
  * Poll `produce` until `done(value)` holds or the timeout elapses, returning the
52
60
  * last value either way (callers decide what an unmet condition means). The interval
@@ -72,7 +80,13 @@ export declare class Locator {
72
80
  /** Wait until the selector is visible; throws on timeout. */
73
81
  waitFor(options?: WaitOptions): Promise<void>;
74
82
  /** Wait for the element, then tap its centre (a source-bounds coordinate tap). */
75
- tap(options?: WaitOptions): Promise<void>;
83
+ tap(options?: TapOptions): Promise<void>;
84
+ /**
85
+ * Focus the field (tap it) and type `text`. Requires a driver with text input
86
+ * ({@link Driver.typeText}); throws a clear error otherwise. Types into the focused
87
+ * field — it does not clear existing content first.
88
+ */
89
+ fill(text: string, options?: WaitOptions): Promise<void>;
76
90
  }
77
91
  /** Convenience factory: `locator(driver, by.text("Submit"))`. */
78
92
  export declare function locator(driver: Driver, selector: Selector, options?: WaitOptions): Locator;
package/dist/locator.js CHANGED
@@ -1,4 +1,4 @@
1
- import { boundsForAttribute } from "./source.js";
1
+ import { boundsForAttribute, decodeXmlEntities, encodeXmlEntities, 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
@@ -20,16 +20,21 @@ export function describeSelector(selector) {
20
20
  function escapeRegExp(value) {
21
21
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
22
  }
23
- /** The page-source attribute a selector resolves to on each platform. */
23
+ /**
24
+ * The page-source attribute a selector resolves to on each platform. `text` is an
25
+ * alternation, because a visible label surfaces as `text` OR `content-desc` on Android
26
+ * (Compose) and as `label` OR `value` on iOS — so `getByText` finds a label wherever
27
+ * the toolkit put it, not just the node's own `text` attribute.
28
+ */
24
29
  function attributeFor(selector, platform) {
25
30
  const android = {
26
- text: "text",
31
+ text: "(?:text|content-desc)",
27
32
  desc: "content-desc",
28
33
  id: "resource-id",
29
34
  testId: "resource-id",
30
35
  label: "content-desc",
31
36
  };
32
- const ios = { text: "label", desc: "name", id: "name", testId: "name", label: "label" };
37
+ const ios = { text: "(?:label|value)", desc: "name", id: "name", testId: "name", label: "label" };
33
38
  return (platform === "ios" ? ios : android)[selector.by];
34
39
  }
35
40
  const DEFAULTS = { timeout: 10_000, interval: 250 };
@@ -65,7 +70,7 @@ export class Locator {
65
70
  return attributeFor(this.selector, this.driver.platform);
66
71
  }
67
72
  presencePattern() {
68
- return new RegExp(`${this.attribute()}="${escapeRegExp(this.selector.value)}"`);
73
+ return new RegExp(`${this.attribute()}="${escapeRegExp(encodeXmlEntities(this.selector.value))}"`);
69
74
  }
70
75
  /** True if the selector matches a node in the current source. */
71
76
  async isVisible() {
@@ -78,18 +83,20 @@ export class Locator {
78
83
  /** The matched node's own visible text, or null if the node is absent. */
79
84
  async textContent() {
80
85
  const source = await this.driver.source();
81
- const node = new RegExp(`<[^>]*${this.attribute()}="${escapeRegExp(this.selector.value)}"[^>]*>`).exec(source)?.[0];
86
+ const selectorPattern = escapeRegExp(encodeXmlEntities(this.selector.value));
87
+ const node = new RegExp(`<[^>]*${this.attribute()}="${selectorPattern}"[^>]*>`).exec(source)?.[0];
82
88
  if (!node)
83
89
  return null;
84
90
  const textAttr = this.driver.platform === "ios" ? "value|label" : "text";
85
- return new RegExp(`(?:${textAttr})="([^"]*)"`).exec(node)?.[1] ?? null;
91
+ const raw = new RegExp(`(?:${textAttr})="([^"]*)"`).exec(node)?.[1];
92
+ return raw == null ? null : decodeXmlEntities(raw);
86
93
  }
87
94
  /** True if the selector is present AND `text` appears in the source. */
88
95
  async shows(text) {
89
96
  const source = await this.driver.source();
90
97
  if (!this.presencePattern().test(source))
91
98
  return false;
92
- const pattern = typeof text === "string" ? new RegExp(escapeRegExp(text)) : text;
99
+ const pattern = typeof text === "string" ? new RegExp(escapeRegExp(encodeXmlEntities(text))) : text;
93
100
  return pattern.test(source);
94
101
  }
95
102
  /** Wait until the selector is visible; throws on timeout. */
@@ -107,7 +114,22 @@ export class Locator {
107
114
  if (!bounds) {
108
115
  throw new Error(`${describeSelector(this.selector)} was not found to tap within ${opts.timeout ?? DEFAULTS.timeout}ms`);
109
116
  }
110
- await this.driver.tapAt(bounds.centerX, bounds.centerY);
117
+ const target = options.clickableAncestor
118
+ ? smallestClickableAncestorBounds(await this.driver.source(), bounds)
119
+ : bounds;
120
+ await this.driver.tapAt(target.centerX, target.centerY);
121
+ }
122
+ /**
123
+ * Focus the field (tap it) and type `text`. Requires a driver with text input
124
+ * ({@link Driver.typeText}); throws a clear error otherwise. Types into the focused
125
+ * field — it does not clear existing content first.
126
+ */
127
+ async fill(text, options = {}) {
128
+ if (!this.driver.typeText) {
129
+ throw new Error(`${describeSelector(this.selector)}.fill(...) needs a driver that supports text input (Driver.typeText)`);
130
+ }
131
+ await this.tap(options);
132
+ await this.driver.typeText(text);
111
133
  }
112
134
  }
113
135
  /** Convenience factory: `locator(driver, by.text("Submit"))`. */
package/dist/source.d.ts CHANGED
@@ -18,6 +18,18 @@ export type Bounds = {
18
18
  centerY: number;
19
19
  };
20
20
  export declare function parseBounds(bounds: string | undefined | null): Bounds | null;
21
+ /**
22
+ * Decode the XML entities UiAutomator/XCUITest escape attribute values with
23
+ * (`&amp;` → `&`, `&lt;` → `<`, …). `&amp;` is decoded LAST so `&amp;lt;` round-trips
24
+ * to the literal `&lt;` rather than `<`.
25
+ */
26
+ export declare function decodeXmlEntities(value: string): string;
27
+ /**
28
+ * Encode a human-readable value to the entity-escaped form the page source uses, so a
29
+ * selector built from a plain string (`"Terms & Conditions"`) matches the escaped source
30
+ * (`text="Terms &amp; Conditions"`). `&` is encoded first to avoid double-encoding.
31
+ */
32
+ export declare function encodeXmlEntities(value: string): string;
21
33
  /** Bounds of the first element whose `bounds` follows the given attribute match. */
22
34
  export declare function boundsForAttribute(source: string, attribute: string, value: string): Bounds | null;
23
35
  /** Bounds of an element addressed by Android `content-desc`. */
@@ -25,8 +37,10 @@ export declare function boundsForContentDesc(source: string, contentDesc: string
25
37
  /** Bounds of an element addressed by visible `text`. */
26
38
  export declare function boundsForText(source: string, text: string): Bounds | null;
27
39
  /**
28
- * The smallest clickable ancestor that fully contains the given text node — used
29
- * to turn a non-clickable Compose `TextView` into a reliable tap target (e.g. the
30
- * "Members (3)" list rows).
40
+ * The smallest element flagged `clickable="true"` that fully contains the given
41
+ * bounds, or the bounds themselves if none does turns a non-clickable Compose node
42
+ * into a reliable tap target (e.g. the "Members (3)" list rows).
31
43
  */
44
+ export declare function smallestClickableAncestorBounds(source: string, nodeBounds: Bounds): Bounds;
45
+ /** The smallest clickable ancestor that fully contains the node with the given visible text. */
32
46
  export declare function clickableAncestorBoundsForText(source: string, text: string): Bounds | null;
package/dist/source.js CHANGED
@@ -29,9 +29,31 @@ export function parseBounds(bounds) {
29
29
  function escapeRegExp(value) {
30
30
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
31
  }
32
+ /**
33
+ * Decode the XML entities UiAutomator/XCUITest escape attribute values with
34
+ * (`&amp;` → `&`, `&lt;` → `<`, …). `&amp;` is decoded LAST so `&amp;lt;` round-trips
35
+ * to the literal `&lt;` rather than `<`.
36
+ */
37
+ export function decodeXmlEntities(value) {
38
+ return value
39
+ .replace(/&lt;/g, "<")
40
+ .replace(/&gt;/g, ">")
41
+ .replace(/&quot;/g, '"')
42
+ .replace(/&#0?39;|&apos;/g, "'")
43
+ .replace(/&amp;/g, "&");
44
+ }
45
+ /**
46
+ * Encode a human-readable value to the entity-escaped form the page source uses, so a
47
+ * selector built from a plain string (`"Terms & Conditions"`) matches the escaped source
48
+ * (`text="Terms &amp; Conditions"`). `&` is encoded first to avoid double-encoding.
49
+ */
50
+ export function encodeXmlEntities(value) {
51
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
52
+ }
32
53
  /** Bounds of the first element whose `bounds` follows the given attribute match. */
33
54
  export function boundsForAttribute(source, attribute, value) {
34
- const match = new RegExp(`${attribute}="${escapeRegExp(value)}"[^>]*bounds="([^"]+)"`).exec(source);
55
+ const escaped = escapeRegExp(encodeXmlEntities(value));
56
+ const match = new RegExp(`${attribute}="${escaped}"[^>]*bounds="([^"]+)"`).exec(source);
35
57
  return match ? parseBounds(match[1]) : null;
36
58
  }
37
59
  /** Bounds of an element addressed by Android `content-desc`. */
@@ -43,18 +65,20 @@ export function boundsForText(source, text) {
43
65
  return boundsForAttribute(source, "text", text);
44
66
  }
45
67
  /**
46
- * The smallest clickable ancestor that fully contains the given text node — used
47
- * to turn a non-clickable Compose `TextView` into a reliable tap target (e.g. the
48
- * "Members (3)" list rows).
68
+ * The smallest element flagged `clickable="true"` that fully contains the given
69
+ * bounds, or the bounds themselves if none does turns a non-clickable Compose node
70
+ * into a reliable tap target (e.g. the "Members (3)" list rows).
49
71
  */
50
- export function clickableAncestorBoundsForText(source, text) {
51
- const textBounds = boundsForText(source, text);
52
- if (!textBounds)
53
- return null;
72
+ export function smallestClickableAncestorBounds(source, nodeBounds) {
54
73
  const clickable = [...source.matchAll(/clickable="true"[^>]*bounds="([^"]+)"/g)]
55
74
  .map((m) => parseBounds(m[1]))
56
75
  .filter((b) => b !== null)
57
- .filter((b) => b.x1 <= textBounds.x1 && b.x2 >= textBounds.x2 && b.y1 <= textBounds.y1 && b.y2 >= textBounds.y2)
76
+ .filter((b) => b.x1 <= nodeBounds.x1 && b.x2 >= nodeBounds.x2 && b.y1 <= nodeBounds.y1 && b.y2 >= nodeBounds.y2)
58
77
  .sort((a, b) => a.width * a.height - b.width * b.height);
59
- return clickable[0] ?? textBounds;
78
+ return clickable[0] ?? nodeBounds;
79
+ }
80
+ /** The smallest clickable ancestor that fully contains the node with the given visible text. */
81
+ export function clickableAncestorBoundsForText(source, text) {
82
+ const textBounds = boundsForText(source, text);
83
+ return textBounds ? smallestClickableAncestorBounds(source, textBounds) : null;
60
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativeproof",
3
- "version": "0.1.1",
3
+ "version": "0.2.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.",
@@ -36,7 +36,8 @@
36
36
  ".": {
37
37
  "types": "./dist/index.d.ts",
38
38
  "default": "./dist/index.js"
39
- }
39
+ },
40
+ "./package.json": "./package.json"
40
41
  },
41
42
  "files": [
42
43
  "dist",