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 +37 -0
- package/README.md +433 -69
- package/dist/driver.d.ts +5 -0
- package/dist/driver.js +1 -0
- package/dist/expect.d.ts +19 -0
- package/dist/expect.js +66 -1
- package/dist/locator.d.ts +15 -1
- package/dist/locator.js +31 -9
- package/dist/source.d.ts +17 -3
- package/dist/source.js +34 -10
- package/package.json +3 -2
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
|
-
- [
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 }),
|
|
113
|
-
secrets: [/\
|
|
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
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
415
|
+
A `Locator` is a lazy, awaitable handle with built-in waiting:
|
|
255
416
|
|
|
256
417
|
```ts
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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");
|
|
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 —
|
|
282
|
-
await mock.route("/messages").reject({ code:
|
|
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
|
-
//
|
|
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
|
|
299
|
-
- `mock.route(path).reject({ code })` — fail it (
|
|
300
|
-
- `mock.route(path).abort()` — drop
|
|
301
|
-
- `expect(mock).toHaveSent(match)` / `toHaveReceived(match)` — `match` is a partial frame
|
|
302
|
-
|
|
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
|
|
305
|
-
|
|
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
|
|
323
|
-
`PLATFORM` / `SPEC` / `NATIVEPROOF_PROJECT` / `APPIUM_*` set. A device or
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
361
|
-
| `
|
|
362
|
-
|
|
|
363
|
-
|
|
|
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
|
|
370
|
-
- `by.text/testId/label
|
|
371
|
-
`new Locator(driver, selector)` — locators
|
|
372
|
-
|
|
373
|
-
-
|
|
374
|
-
- `
|
|
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
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
|
-
|
|
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?:
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
* (`&` → `&`, `<` → `<`, …). `&` is decoded LAST so `&lt;` round-trips
|
|
24
|
+
* to the literal `<` 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 & 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
|
|
29
|
-
*
|
|
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
|
+
* (`&` → `&`, `<` → `<`, …). `&` is decoded LAST so `&lt;` round-trips
|
|
35
|
+
* to the literal `<` rather than `<`.
|
|
36
|
+
*/
|
|
37
|
+
export function decodeXmlEntities(value) {
|
|
38
|
+
return value
|
|
39
|
+
.replace(/</g, "<")
|
|
40
|
+
.replace(/>/g, ">")
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/�?39;|'/g, "'")
|
|
43
|
+
.replace(/&/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 & Conditions"`). `&` is encoded first to avoid double-encoding.
|
|
49
|
+
*/
|
|
50
|
+
export function encodeXmlEntities(value) {
|
|
51
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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
|
|
47
|
-
*
|
|
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
|
|
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 <=
|
|
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] ??
|
|
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.
|
|
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",
|