nativeproof 0.1.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +392 -0
  4. package/dist/adb.d.ts +28 -0
  5. package/dist/adb.js +97 -0
  6. package/dist/app.d.ts +49 -0
  7. package/dist/app.js +34 -0
  8. package/dist/cli.d.ts +32 -0
  9. package/dist/cli.js +225 -0
  10. package/dist/config.d.ts +70 -0
  11. package/dist/config.js +64 -0
  12. package/dist/driver.d.ts +20 -0
  13. package/dist/driver.js +13 -0
  14. package/dist/evidence.d.ts +5 -0
  15. package/dist/evidence.js +40 -0
  16. package/dist/expect.d.ts +26 -0
  17. package/dist/expect.js +65 -0
  18. package/dist/fixtures.d.ts +62 -0
  19. package/dist/fixtures.js +53 -0
  20. package/dist/gestures.d.ts +11 -0
  21. package/dist/gestures.js +45 -0
  22. package/dist/harness.d.ts +32 -0
  23. package/dist/harness.js +29 -0
  24. package/dist/index.d.ts +30 -0
  25. package/dist/index.js +30 -0
  26. package/dist/ios.d.ts +39 -0
  27. package/dist/ios.js +92 -0
  28. package/dist/locator.d.ts +78 -0
  29. package/dist/locator.js +116 -0
  30. package/dist/log.d.ts +17 -0
  31. package/dist/log.js +25 -0
  32. package/dist/mock-server.d.ts +17 -0
  33. package/dist/mock-server.js +122 -0
  34. package/dist/mock.d.ts +54 -0
  35. package/dist/mock.js +30 -0
  36. package/dist/page.d.ts +24 -0
  37. package/dist/page.js +17 -0
  38. package/dist/runner-config.d.ts +1 -0
  39. package/dist/runner-config.js +32 -0
  40. package/dist/runner.d.ts +19 -0
  41. package/dist/runner.js +29 -0
  42. package/dist/screen.d.ts +35 -0
  43. package/dist/screen.js +67 -0
  44. package/dist/source.d.ts +32 -0
  45. package/dist/source.js +60 -0
  46. package/dist/test.d.ts +13 -0
  47. package/dist/test.js +15 -0
  48. package/dist/text.d.ts +18 -0
  49. package/dist/text.js +129 -0
  50. package/dist/wait.d.ts +14 -0
  51. package/dist/wait.js +26 -0
  52. package/package.json +72 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to NativeProof are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## 0.1.1
8
+
9
+ Documentation pass: refreshed the README examples, expanded the iOS setup guide, and
10
+ tidied the inline API docs. No API or behaviour changes.
11
+
12
+ ## 0.1.0
13
+
14
+ Initial release — a Native Mobile E2E test framework inspired by Playwright, on
15
+ Appium/WebdriverIO.
16
+
17
+ - **Fixtures** — `defineApp` (the single app seam) and `createHarness(app)` for a typed,
18
+ module-global `test` / `test.describe` with the session context injected per behaviour.
19
+ - **Config** — `nativeproof.config.ts` + `defineConfig` (the `playwright.config.ts` analogue);
20
+ the `nativeproof` CLI auto-discovers it and synthesises the WebdriverIO run.
21
+ - **Locators** — `by.text/testId/label/desc/id` and `page(driver).getByText/getByTestId/
22
+ getByLabel/getById/getByRole`, mapped to the right native attribute per platform.
23
+ - **Assertions** — auto-waiting `expect(locator).toBeVisible/toShow/toHaveText` and
24
+ `expect(mock).toHaveSent/toHaveReceived`, each with `.not`.
25
+ - **Network mocking** — a first-party HTTP + WebSocket mock server with Playwright-style
26
+ `route().fulfill/reject/abort` and traffic assertions; no per-app adapter.
27
+ - **Device commands** — Android `adb` (`adbForceStop`, `resetAppAndBrowserState`, `adbTap`, …)
28
+ and iOS `simctl` (`iosTerminate/Launch/Install/Uninstall/Boot/Shutdown`, `resetAppState`).
29
+ - **CLI** — `nativeproof` runs the suite (auto-starts Appium), for Android and iOS.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Sheridan-Edwards
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,392 @@
1
+ # NativeProof
2
+
3
+ A **Native Mobile E2E test framework inspired by Playwright**. NativeProof brings Playwright's
4
+ developer experience — **`test` / `describe` blocks, locators, auto-waiting `expect`,
5
+ route-style network interception, fixtures, per-test isolation, evidence capture** — to
6
+ **native iOS and Android** apps, layered on **Appium / WebdriverIO** (which can drive the
7
+ native surfaces Playwright itself cannot).
8
+
9
+ ```ts
10
+ import { test, expect } from "./nativeproof.config";
11
+
12
+ test.describe("chat room", "member", () => {
13
+ test("renders the latest message and posts a reply", async ({ member, mock }) => {
14
+ await expect(member.messages).toShow("Welcome to the room");
15
+ await member.sendButton.tap();
16
+ await expect(mock).toHaveSent({ path: "/messages", type: "create", roomId: "general" });
17
+ });
18
+ });
19
+ ```
20
+
21
+ One command runs it on a device or emulator:
22
+
23
+ ```bash
24
+ nativeproof --platform android
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Contents
30
+
31
+ - [Features](#features)
32
+ - [Requirements](#requirements)
33
+ - [Install](#install)
34
+ - [Project setup](#project-setup)
35
+ - [Android setup](#android-setup)
36
+ - [iOS setup](#ios-setup)
37
+ - [Writing tests](#writing-tests)
38
+ - [Test blocks](#test-blocks-describe--test)
39
+ - [Locators & assertions](#locators--assertions)
40
+ - [Network interception & assertions](#network-interception--assertions)
41
+ - [Running](#running)
42
+ - [CI](#ci)
43
+ - [Configuration](#configuration)
44
+ - [API reference](#api-reference)
45
+ - [How it works](#how-it-works)
46
+
47
+ ---
48
+
49
+ ## Features
50
+
51
+ - **Reads like Playwright** — `test.describe` / `test(...)` blocks with a typed fixture
52
+ context injected; no per-test setup/teardown in the spec.
53
+ - **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`).
56
+ - **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.
61
+ - **One seam, by injection** — a single `defineApp(...)` declares the device, mock,
62
+ login flow, screens and secret patterns; the core imports nothing app-specific.
63
+ - **Cross-platform** — the same spec runs on Android (UiAutomator2) and iOS (XCUITest).
64
+ - **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.
66
+
67
+ ## Requirements
68
+
69
+ - **Node.js ≥ 20**
70
+ - **Appium 3** + the platform driver(s): `uiautomator2` (Android) and/or `xcuitest` (iOS, macOS only)
71
+ - **Android:** Android SDK (platform-tools + emulator) and JDK 17
72
+ - **iOS:** macOS with Xcode + Command Line Tools
73
+ - A **debug / E2E build** of the app under test (`.apk` for Android, `.app`/`.ipa` for iOS)
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ npm i -D nativeproof \
79
+ webdriverio @wdio/cli @wdio/local-runner @wdio/mocha-framework \
80
+ appium
81
+
82
+ # install the Appium driver(s) you need
83
+ npx appium driver install uiautomator2 # Android
84
+ npx appium driver install xcuitest # iOS (macOS only)
85
+ ```
86
+
87
+ A typical project:
88
+
89
+ ```
90
+ my-app-e2e/
91
+ ├─ nativeproof.config.ts # the single config — app, device projects, test/expect exports
92
+ ├─ tests/
93
+ │ └─ chat.spec.ts
94
+ └─ app/
95
+ ├─ android/app-debug.apk
96
+ └─ ios/MyApp.app
97
+ ```
98
+
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`.
105
+
106
+ ```ts
107
+ // nativeproof.config.ts
108
+ import { createHarness, defineApp, defineConfig, page, startMockServer, wdioDriver } from "nativeproof";
109
+
110
+ const app = defineApp({
111
+ 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 }) => {
115
+ // drive your app's sign-in; `mock` is the running backend, `role` comes from the describe
116
+ },
117
+ screens: {
118
+ member: ({ driver }) => {
119
+ const p = page(driver);
120
+ return {
121
+ messages: p.getByTestId("message-list"),
122
+ signOut: p.getByLabel("Sign out"),
123
+ sendButton: p.getByRole("button", { name: "Send" }),
124
+ };
125
+ },
126
+ },
127
+ });
128
+
129
+ // specs do: import { test, expect } from "../nativeproof.config";
130
+ export const { test, expect } = createHarness(app);
131
+
132
+ export default defineConfig({
133
+ app,
134
+ testDir: "tests",
135
+ projects: [
136
+ {
137
+ name: "android",
138
+ platform: "android",
139
+ capabilities: {
140
+ platformName: "Android",
141
+ "appium:automationName": "UiAutomator2",
142
+ "appium:app": process.env.ANDROID_APP ?? "app/android/app-debug.apk",
143
+ "appium:autoGrantPermissions": true,
144
+ },
145
+ },
146
+ {
147
+ name: "ios",
148
+ platform: "ios",
149
+ capabilities: {
150
+ platformName: "iOS",
151
+ "appium:automationName": "XCUITest",
152
+ "appium:deviceName": "iPhone 15",
153
+ "appium:platformVersion": "17.5",
154
+ "appium:app": process.env.IOS_APP ?? "app/ios/MyApp.app",
155
+ },
156
+ },
157
+ ],
158
+ });
159
+ ```
160
+
161
+ > File names are conventions, not requirements. Prefer a raw WebdriverIO config? Keep a
162
+ > `wdio.conf.ts` (or pass `nativeproof --config <path>`) — NativeProof uses it when there's no
163
+ > `nativeproof.config.ts`.
164
+
165
+ ## Android setup
166
+
167
+ 1. **SDK & JDK.** Install Android Studio (or the command-line tools) and set:
168
+ ```bash
169
+ export ANDROID_HOME="$HOME/Library/Android/sdk" # Linux: ~/Android/Sdk
170
+ export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
171
+ export JAVA_HOME="$(/usr/libexec/java_home -v 17)" # JDK 17
172
+ ```
173
+ 2. **Driver:** `npx appium driver install uiautomator2`.
174
+ 3. **Emulator.** Create an AVD (Android Studio → Device Manager, or `avdmanager`) and boot it:
175
+ ```bash
176
+ emulator -avd Pixel_7_API_34 -no-window -no-audio &
177
+ adb wait-for-device
178
+ adb devices # should list the emulator
179
+ ```
180
+ 4. **App build.** Point `ANDROID_APP` at your debug/E2E `.apk` (or use `appium:appPackage` +
181
+ `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.
185
+
186
+ ## iOS setup
187
+
188
+ > iOS requires **macOS** (Appium's `xcuitest` driver builds on Xcode and is macOS-only).
189
+
190
+ 1. **Xcode + Command Line Tools.** Install Xcode from the App Store, then:
191
+ ```bash
192
+ xcode-select --install
193
+ xcodebuild -version # confirms the toolchain is wired up
194
+ sudo xcodebuild -license accept
195
+ ```
196
+ 2. **Driver:** `npx appium driver install xcuitest`. On the first run it builds
197
+ **WebDriverAgent** (the on-device agent Appium drives). For the simulator this is automatic;
198
+ for a **real device** you must sign WDA once — set `appium:xcodeOrgId` (your Apple Team ID)
199
+ and `appium:xcodeSigningId` ("Apple Development"), or open
200
+ `node_modules/appium-xcuitest-driver/.../WebDriverAgent.xcodeproj` in Xcode and select a
201
+ signing team. Give the first launch room with `appium:wdaLaunchTimeout`.
202
+ 3. **Simulator.** List and boot one (Xcode → Settings → Components installs runtimes):
203
+ ```bash
204
+ xcrun simctl list devices # names + UDIDs of installed simulators
205
+ xcrun simctl boot "iPhone 15" # or boot by UDID
206
+ open -a Simulator # optional: watch it run
207
+ ```
208
+ Match `appium:deviceName` / `appium:platformVersion` to a simulator that exists, or pin a
209
+ specific one with `appium:udid`.
210
+ 4. **App build.**
211
+ - **Simulator:** point `IOS_APP` at a simulator-built `.app` (an `arm64`/`x86_64` simulator
212
+ binary, not a device build), e.g. `app/ios/MyApp.app`.
213
+ - **Real device:** point `IOS_APP` at a signed `.ipa`, set `appium:udid`, and use a
214
+ provisioning profile that covers both the app and WebDriverAgent.
215
+ - **Already installed:** skip `appium:app` and set `appium:bundleId` instead.
216
+ 5. **Mock host.** The simulator shares the host's network, so the backend base URL is
217
+ `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.
220
+
221
+ ## Writing tests
222
+
223
+ ### Test blocks (`describe` / `test`)
224
+
225
+ `test.describe(title, role?, body)` opens a scenario for a role; each `test(name, fn)` is one
226
+ behaviour with the app's fixture context injected — fully typed, no setup/teardown in the spec:
227
+
228
+ ```ts
229
+ import { test, expect } from "../nativeproof.config";
230
+
231
+ test.describe("chat room", "member", () => {
232
+ test("opens the room", async ({ member }) => {
233
+ await expect(member.messages).toBeVisible();
234
+ });
235
+
236
+ test("signs out", async ({ member }) => {
237
+ await member.signOut.tap();
238
+ });
239
+ });
240
+
241
+ // role is optional — omit it for the default session
242
+ test.describe("home screen", () => {
243
+ test("shows the start button", async ({ home }) => {
244
+ await expect(home.start).toBeVisible();
245
+ });
246
+ });
247
+ ```
248
+
249
+ The destructured fixtures (`member`, `home`, `mock`, …) are exactly the `screens` you
250
+ declared in `defineApp`, plus `mock` and `driver` — typed to your app.
251
+
252
+ ### Locators & assertions
253
+
254
+ Build locators by intent; NativeProof maps each to the right native attribute per platform:
255
+
256
+ ```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)
261
+ ```
262
+
263
+ Assertions auto-wait (poll until the condition holds or the timeout elapses), and `.not` inverts:
264
+
265
+ ```ts
266
+ 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+/);
269
+ await expect(member.spinner).not.toBeVisible({ timeout: 5_000 });
270
+ await member.signOut.tap(); // waits, then taps
271
+ ```
272
+
273
+ ### Network interception & assertions
274
+
275
+ The mock backend works like Playwright's `page.route()`. **Intercept** a path to control its
276
+ reply, and **assert** the traffic the app exchanged:
277
+
278
+ ```ts
279
+ test.describe("send failures", "member", () => {
280
+ 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()
283
+ await member.sendButton.tap();
284
+ await expect(member.errorBanner).toShow("Couldn't send message");
285
+ });
286
+ });
287
+
288
+ test.describe("chat room", "member", () => {
289
+ test("sends a message and receives the next one", async ({ mock }) => {
290
+ // Assertion — what the app sent / received, matched by path + type + payload fields:
291
+ await expect(mock).toHaveSent({ path: "/messages", type: "create" });
292
+ await expect(mock).toHaveReceived({ path: "/messages", type: "new" });
293
+ await expect(mock).not.toHaveSent({ type: "error" });
294
+ });
295
+ });
296
+ ```
297
+
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).
303
+
304
+ `startMockServer()` is a real HTTP + WebSocket server, so no per-app adapter is needed — your
305
+ app just points at its `url` / `wsUrl`.
306
+
307
+ ## Running
308
+
309
+ One command, in the spirit of `playwright test`:
310
+
311
+ ```bash
312
+ nativeproof # auto-discovers nativeproof.config.ts, runs the suite
313
+ nativeproof --platform android # or: --platform ios
314
+ nativeproof --project tablet # a named project from nativeproof.config.ts
315
+ nativeproof --spec tests/chat.spec.ts
316
+ nativeproof --config wdio.conf.ts # escape hatch: a raw WebdriverIO config
317
+ nativeproof --no-appium # use an Appium server you started yourself
318
+ nativeproof --help
319
+ ```
320
+
321
+ `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.
325
+
326
+ ## CI
327
+
328
+ It's a normal WebdriverIO suite, so CI is one command — the only requirement is a device.
329
+
330
+ **Android (GitHub Actions, hardware-accelerated emulator):**
331
+
332
+ ```yaml
333
+ jobs:
334
+ android-e2e:
335
+ runs-on: ubuntu-latest
336
+ steps:
337
+ - uses: actions/checkout@v4
338
+ - uses: actions/setup-node@v4
339
+ with: { node-version: 20 }
340
+ - run: npm ci
341
+ - run: npx appium driver install uiautomator2
342
+ - uses: reactivecircus/android-emulator-runner@v2
343
+ with:
344
+ api-level: 34
345
+ script: npx nativeproof --platform android
346
+ ```
347
+
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.
353
+
354
+ The framework's own unit suite (`npm test`) needs **no device** and runs anywhere.
355
+
356
+ ## Configuration
357
+
358
+ | Where | What |
359
+ |---|---|
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). |
364
+
365
+ ## API reference
366
+
367
+ - `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a fixture.
368
+ - `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()`.
375
+ - Device commands — **Android** (`adb`): `adbForceStop`, `resetAppAndBrowserState`, `adbTap`, `adbDump`, `adbLogcat*`; **iOS** (`simctl`): `iosTerminate`, `iosLaunch`, `iosInstall`, `iosUninstall`, `iosBoot`, `iosShutdown`, `resetAppState`, `iosLogShow`.
376
+ - `wdioDriver()` → the live `Driver`; `useRunner(hooks)` to host on a non-Mocha runner.
377
+
378
+ ## How it works
379
+
380
+ The engine is Appium/WebdriverIO; NativeProof is the DX layer. It's app-agnostic by contract:
381
+ 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
383
+ an in-memory fake device — see `test/demo.test.ts` and run `npm test` (no emulator needed).
384
+
385
+ Package layout: `app.ts` (`defineApp`), `harness.ts` (`createHarness`), `config.ts`
386
+ (`defineConfig`) + `runner-config.ts` (the wdio bridge), `fixtures.ts`, `locator.ts` +
387
+ `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.
389
+
390
+ ## License
391
+
392
+ See [LICENSE](LICENSE).
package/dist/adb.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ export declare function adbDump(): string;
2
+ export declare function adbTap(x: number, y: number): void;
3
+ export declare function adbLogcatClear(): void;
4
+ export declare function adbLogcatDump(): string;
5
+ /**
6
+ * Force-stop an app package (best-effort). Used to halt the real app's WebSocket
7
+ * reconnect loop on an error status before WDIO tears the Appium session down —
8
+ * the loop otherwise keeps the session busy and the final `deleteSession` races
9
+ * with `UND_ERR_CLOSED`.
10
+ */
11
+ export declare function adbForceStop(pkg: string): void;
12
+ /**
13
+ * Reset device state before a fresh controlled-backend login.
14
+ *
15
+ * The app package is fully reset (force-stop + `pm clear`) so the login starts
16
+ * from a signed-out state. Browser packages are ONLY force-stopped — never
17
+ * `pm clear`ed — because clearing Chrome's data also wipes its first-run
18
+ * completion, which re-triggers the Chrome 124 onboarding chain ("Turn on an ad
19
+ * privacy feature" → "Other ad privacy features now available") and blocks the
20
+ * OIDC Custom Tab handoff. There is nothing to gain from clearing the browser:
21
+ * the harness mock owns the `/auth` endpoint and issues a fresh 302 redirect on
22
+ * every request regardless of any browser cookie/session state. Complete Chrome's
23
+ * first-run once on the emulator image and it stays done.
24
+ *
25
+ * Clearing a not-yet-installed package fails on some emulator images, so every
26
+ * step is best-effort.
27
+ */
28
+ export declare function resetAppAndBrowserState(packages: string[]): void;
package/dist/adb.js ADDED
@@ -0,0 +1,97 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /**
3
+ * Thin, best-effort wrappers around `adb`.
4
+ *
5
+ * Android Compose occasionally hides nodes from the Appium accessibility tree
6
+ * (Chrome custom-tab handoff, nested Compose TextViews). A raw `uiautomator dump`
7
+ * and `input tap` give the screens a last-resort path that does not depend on the
8
+ * Appium driver seeing the node. All calls are intentionally fault-tolerant: the
9
+ * backend socket log — not the tap — is the proof that an action fired.
10
+ */
11
+ function adb(args) {
12
+ return execFileSync("adb", args, {
13
+ encoding: "utf8",
14
+ stdio: ["ignore", "pipe", "ignore"],
15
+ });
16
+ }
17
+ export function adbDump() {
18
+ return adb(["exec-out", "uiautomator", "dump", "/dev/tty"]);
19
+ }
20
+ export function adbTap(x, y) {
21
+ // Wake the screen first: on a headless/idle emulator the display sleeps and
22
+ // `input tap` is silently dropped while asleep (this caused the Chrome
23
+ // onboarding taps to "not register"). KEYCODE_WAKEUP is a no-op when already on.
24
+ try {
25
+ adb(["shell", "input", "keyevent", "KEYCODE_WAKEUP"]);
26
+ }
27
+ catch {
28
+ // best-effort
29
+ }
30
+ adb(["shell", "input", "tap", String(x), String(y)]);
31
+ }
32
+ export function adbLogcatClear() {
33
+ try {
34
+ adb(["logcat", "-c"]);
35
+ }
36
+ catch {
37
+ // Runtime logcat is best-effort supporting evidence only.
38
+ }
39
+ }
40
+ export function adbLogcatDump() {
41
+ try {
42
+ return adb(["logcat", "-d", "-v", "time"]);
43
+ }
44
+ catch {
45
+ return "";
46
+ }
47
+ }
48
+ /**
49
+ * Force-stop an app package (best-effort). Used to halt the real app's WebSocket
50
+ * reconnect loop on an error status before WDIO tears the Appium session down —
51
+ * the loop otherwise keeps the session busy and the final `deleteSession` races
52
+ * with `UND_ERR_CLOSED`.
53
+ */
54
+ export function adbForceStop(pkg) {
55
+ try {
56
+ adb(["shell", "am", "force-stop", pkg]);
57
+ }
58
+ catch {
59
+ // best-effort
60
+ }
61
+ }
62
+ /** Browser packages are only force-stopped, never `pm clear`ed (see below). */
63
+ const BROWSER_PACKAGES = new Set(["com.android.chrome", "org.chromium.chrome", "com.chrome.beta"]);
64
+ /**
65
+ * Reset device state before a fresh controlled-backend login.
66
+ *
67
+ * The app package is fully reset (force-stop + `pm clear`) so the login starts
68
+ * from a signed-out state. Browser packages are ONLY force-stopped — never
69
+ * `pm clear`ed — because clearing Chrome's data also wipes its first-run
70
+ * completion, which re-triggers the Chrome 124 onboarding chain ("Turn on an ad
71
+ * privacy feature" → "Other ad privacy features now available") and blocks the
72
+ * OIDC Custom Tab handoff. There is nothing to gain from clearing the browser:
73
+ * the harness mock owns the `/auth` endpoint and issues a fresh 302 redirect on
74
+ * every request regardless of any browser cookie/session state. Complete Chrome's
75
+ * first-run once on the emulator image and it stays done.
76
+ *
77
+ * Clearing a not-yet-installed package fails on some emulator images, so every
78
+ * step is best-effort.
79
+ */
80
+ export function resetAppAndBrowserState(packages) {
81
+ for (const pkg of packages) {
82
+ const actions = BROWSER_PACKAGES.has(pkg)
83
+ ? [["shell", "am", "force-stop", pkg]]
84
+ : [
85
+ ["shell", "am", "force-stop", pkg],
86
+ ["shell", "pm", "clear", pkg],
87
+ ];
88
+ for (const action of actions) {
89
+ try {
90
+ adb(action);
91
+ }
92
+ catch {
93
+ // best-effort
94
+ }
95
+ }
96
+ }
97
+ }
package/dist/app.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { Driver } from "./driver.js";
2
+ import type { ScenarioFixture } from "./fixtures.js";
3
+ import type { MockBackend } from "./mock.js";
4
+ /**
5
+ * `defineApp` — the single seam script.
6
+ *
7
+ * Everything app-specific the framework needs lives in one declarative definition,
8
+ * supplied by injection: how to get the device, how to start the mock backend, the
9
+ * secret/redaction patterns, the login/join flows, and the screen objects. The
10
+ * framework core imports nothing from the app; the app describes itself here once.
11
+ * `app.session(role)` turns that into a {@link ScenarioFixture} the `test` facade runs.
12
+ */
13
+ /** The device handles every session provides before app screens are layered on. */
14
+ export interface DeviceContext {
15
+ driver: Driver;
16
+ mock: MockBackend;
17
+ }
18
+ /** A screen-object factory: given the device context, build that screen's locators/actions. */
19
+ export type ScreenFactory<S> = (context: DeviceContext) => S;
20
+ export type ScreenFactories = Record<string, ScreenFactory<unknown>>;
21
+ /** Context passed to the login/join flows. */
22
+ export type FlowContext = DeviceContext & {
23
+ role: string;
24
+ };
25
+ export interface AppDefinition<S extends ScreenFactories> {
26
+ /** Acquire the device/driver (e.g. wdioDriver()). */
27
+ driver: () => Driver | Promise<Driver>;
28
+ /** Start the app's mock backend. */
29
+ mock: () => MockBackend | Promise<MockBackend>;
30
+ /** App-specific secret patterns to keep out of evidence (injected, never baked into the core). */
31
+ secrets?: readonly RegExp[];
32
+ /** App-specific evidence-redaction patterns. */
33
+ redact?: readonly RegExp[];
34
+ /** Reach a logged-in state for the role. */
35
+ login?: (context: FlowContext) => Promise<void>;
36
+ /** Enter the role's main surface. */
37
+ join?: (context: FlowContext) => Promise<void>;
38
+ /** Screen-object factories, bound to the device context. */
39
+ screens: S;
40
+ }
41
+ /** The fixture context a session injects: the device handles plus each app screen. */
42
+ export type SessionContext<S extends ScreenFactories> = DeviceContext & {
43
+ [K in keyof S]: ReturnType<S[K]>;
44
+ };
45
+ export interface App<S extends ScreenFactories> {
46
+ /** A scenario fixture that provisions a logged-in, joined session for `role`. */
47
+ session(role?: string): ScenarioFixture<SessionContext<S>>;
48
+ }
49
+ export declare function defineApp<S extends ScreenFactories>(definition: AppDefinition<S>): App<S>;
package/dist/app.js ADDED
@@ -0,0 +1,34 @@
1
+ export function defineApp(definition) {
2
+ return {
3
+ session(role = "default") {
4
+ return {
5
+ async setup() {
6
+ const driver = await definition.driver();
7
+ const mock = await definition.mock();
8
+ const device = { driver, mock };
9
+ try {
10
+ if (definition.login)
11
+ await definition.login({ ...device, role });
12
+ if (definition.join)
13
+ await definition.join({ ...device, role });
14
+ const screens = {};
15
+ for (const [name, factory] of Object.entries(definition.screens)) {
16
+ screens[name] = factory(device);
17
+ }
18
+ // Dynamic assembly: the screen factories' precise return types are
19
+ // recovered by SessionContext<S>, so the boundary cast is the honest seam.
20
+ return { ...device, ...screens };
21
+ }
22
+ catch (error) {
23
+ await mock.stop().catch(() => { });
24
+ throw error;
25
+ }
26
+ },
27
+ async teardown(context) {
28
+ if (context)
29
+ await context.mock.stop();
30
+ },
31
+ };
32
+ },
33
+ };
34
+ }