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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/dist/adb.d.ts +28 -0
- package/dist/adb.js +97 -0
- package/dist/app.d.ts +49 -0
- package/dist/app.js +34 -0
- package/dist/cli.d.ts +32 -0
- package/dist/cli.js +225 -0
- package/dist/config.d.ts +70 -0
- package/dist/config.js +64 -0
- package/dist/driver.d.ts +20 -0
- package/dist/driver.js +13 -0
- package/dist/evidence.d.ts +5 -0
- package/dist/evidence.js +40 -0
- package/dist/expect.d.ts +26 -0
- package/dist/expect.js +65 -0
- package/dist/fixtures.d.ts +62 -0
- package/dist/fixtures.js +53 -0
- package/dist/gestures.d.ts +11 -0
- package/dist/gestures.js +45 -0
- package/dist/harness.d.ts +32 -0
- package/dist/harness.js +29 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +30 -0
- package/dist/ios.d.ts +39 -0
- package/dist/ios.js +92 -0
- package/dist/locator.d.ts +78 -0
- package/dist/locator.js +116 -0
- package/dist/log.d.ts +17 -0
- package/dist/log.js +25 -0
- package/dist/mock-server.d.ts +17 -0
- package/dist/mock-server.js +122 -0
- package/dist/mock.d.ts +54 -0
- package/dist/mock.js +30 -0
- package/dist/page.d.ts +24 -0
- package/dist/page.js +17 -0
- package/dist/runner-config.d.ts +1 -0
- package/dist/runner-config.js +32 -0
- package/dist/runner.d.ts +19 -0
- package/dist/runner.js +29 -0
- package/dist/screen.d.ts +35 -0
- package/dist/screen.js +67 -0
- package/dist/source.d.ts +32 -0
- package/dist/source.js +60 -0
- package/dist/test.d.ts +13 -0
- package/dist/test.js +15 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +129 -0
- package/dist/wait.d.ts +14 -0
- package/dist/wait.js +26 -0
- 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
|
+
}
|