nativeproof 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.10.2
8
+
9
+ Generic mock typing and built-in evidence-on-failure.
10
+
11
+ **Added**
12
+
13
+ - `defineApp` (and `createHarness` / `defineConfig`) are now generic over the mock type. An app
14
+ whose mock extends the base contract gets that concrete type through screens, login/join,
15
+ teardown, `onFailure` and the session context — no casts. Existing single-type-arg uses are
16
+ unchanged (the mock type defaults to `MockBackend`).
17
+ - The synthesised runner config captures evidence on a failed behaviour out of the box — a
18
+ screenshot + redacted page source named after the spec — so apps need no hand-written
19
+ `afterTest`. Best-effort: a capture error never masks the real failure.
20
+
21
+ ## 0.10.1
22
+
23
+ Cross-platform locator resolution fixes.
24
+
25
+ **Fixed**
26
+
27
+ - `by.text` / `getByText` with a `RegExp` now matches a label exposed via `content-desc`
28
+ (e.g. Compose). The RegExp path previously tested only the first attribute in the
29
+ `text`/`content-desc` alternation, so a label was missed when an empty `text=""` preceded it.
30
+ - Locators now resolve iOS element geometry (XCUITest `x`/`y`/`width`/`height`), so `tap()`,
31
+ `bounds()` and `near()` work on iOS — not just Android `bounds="[x1,y1][x2,y2]"`.
32
+
7
33
  ## 0.10.0
8
34
 
9
35
  Element enabled-state assertions.
package/README.md CHANGED
@@ -418,6 +418,13 @@ Every `getBy*` (and `by.*`) takes a **string** (exact match) or a **`RegExp`** (
418
418
  element's decoded value), so a human pattern matches even entity-escaped source — `getByText(/Save( draft)?/)`,
419
419
  `getByLabel(/^Remove /)`, `getByRole("checkbox", { name: /terms/i })`. The Playwright muscle memory carries over.
420
420
 
421
+ > **A string selector is exact — case-, space- and punctuation-sensitive.** `getByText("Sign In")`
422
+ > does **not** match a `Sign in` label; it just times out as "not found", with no hint that you were
423
+ > one capital letter away. When you don't yet know a label verbatim — porting an existing suite, or
424
+ > writing the spec before you've seen the screen — reach for a `RegExp` (`getByText(/sign in/i)`) or
425
+ > read the real label off the page source first (see [Troubleshooting](#troubleshooting)). A wrong
426
+ > guess fails the same way a genuinely-missing element does, so confirm the string against the device.
427
+
421
428
  How each maps to the page source:
422
429
 
423
430
  | Locator | Android attribute | iOS attribute |
@@ -763,9 +770,21 @@ The framework's own unit suite (`npm test`) needs **no device** and runs anywher
763
770
  | `no nativeproof.config.ts or wdio.conf.ts found` | Run from the project root, or pass `--config <path>`. |
764
771
  | "No specs found" | Specs must match `testDir`/`testMatch` (default `tests/**/*.spec.ts`), or pass `--spec`. |
765
772
  | 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"`. |
766
- | `expect(...)` times out | The selector never matched — confirm the attribute mapping (see the [Locators](#locators) table) and the value; raise `{ timeout }` for slow screens. |
773
+ | `expect(...)` times out | The selector never matched — confirm the attribute mapping (see the [Locators](#locators) table) and the **exact** value (see below); raise `{ timeout }` for slow screens. |
767
774
  | iOS first run hangs | WebDriverAgent is building/signing. Set `appium:wdaLaunchTimeout` and, on a real device, the signing capabilities. |
768
775
 
776
+ **A selector won't match? Read the source — don't re-guess.** A string that's off by a capital letter,
777
+ a trailing space, or `(1)` vs ` (1)` fails as a silent timeout, identical to a genuinely-absent element.
778
+ The fix is always the same: look at what the device actually exposes.
779
+
780
+ - On any failure, the `onFailure` hook / `captureState(prefix)` writes the page source to your artifacts
781
+ dir. Grep it for the real label: `grep -oE 'text="[^"]+"' <file>` and `content-desc="…"` on Android,
782
+ `label="…"` / `value="…"` on iOS — then match it exactly, or with a `RegExp` if it varies.
783
+ - Or read it live mid-spec: `console.log(await wdioDriver().source())`.
784
+
785
+ This one-step loop — *fail → read the real attribute → match it* — is behind most "element not found"
786
+ mysteries, and it beats guessing label strings every time.
787
+
769
788
  ## API reference
770
789
 
771
790
  - `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture. `definition` also
package/dist/app.d.ts CHANGED
@@ -10,31 +10,36 @@ import type { MockBackend } from "./mock.js";
10
10
  * framework core imports nothing from the app; the app describes itself here once.
11
11
  * `app.session(role)` turns that into a {@link ScenarioFixture} the `test` facade runs.
12
12
  */
13
- /** The device handles every session provides before app screens are layered on. */
14
- export interface DeviceContext {
13
+ /**
14
+ * The device handles every session provides before app screens are layered on.
15
+ * Generic over the mock type `M` (default {@link MockBackend}) so an app with a richer
16
+ * mock — extra socket/presence controls beyond the base contract — gets `mock` typed as
17
+ * that richer type throughout, with no casts. Existing one-arg uses keep the base mock.
18
+ */
19
+ export interface DeviceContext<M extends MockBackend = MockBackend> {
15
20
  driver: Driver;
16
- mock: MockBackend;
21
+ mock: M;
17
22
  }
18
23
  /** 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>>;
24
+ export type ScreenFactory<S, M extends MockBackend = MockBackend> = (context: DeviceContext<M>) => S;
25
+ export type ScreenFactories<M extends MockBackend = MockBackend> = Record<string, ScreenFactory<unknown, M>>;
21
26
  /** Context passed to the login/join flows. */
22
- export type FlowContext = DeviceContext & {
27
+ export type FlowContext<M extends MockBackend = MockBackend> = DeviceContext<M> & {
23
28
  role: string;
24
29
  };
25
- export interface AppDefinition<S extends ScreenFactories> {
30
+ export interface AppDefinition<S extends ScreenFactories<M>, M extends MockBackend = MockBackend> {
26
31
  /** Acquire the device/driver (e.g. wdioDriver()). */
27
32
  driver: () => Driver | Promise<Driver>;
28
- /** Start the app's mock backend. */
29
- mock: () => MockBackend | Promise<MockBackend>;
33
+ /** Start the app's mock backend (its concrete type `M` flows through the whole session). */
34
+ mock: () => M | Promise<M>;
30
35
  /** App-specific secret patterns to keep out of evidence (injected, never baked into the core). */
31
36
  secrets?: readonly RegExp[];
32
37
  /** App-specific evidence-redaction patterns. */
33
38
  redact?: readonly RegExp[];
34
39
  /** Reach a logged-in state for the role. */
35
- login?: (context: FlowContext) => Promise<void>;
40
+ login?: (context: FlowContext<M>) => Promise<void>;
36
41
  /** Enter the role's main surface. */
37
- join?: (context: FlowContext) => Promise<void>;
42
+ join?: (context: FlowContext<M>) => Promise<void>;
38
43
  /** Screen-object factories, bound to the device context. */
39
44
  screens: S;
40
45
  /**
@@ -43,20 +48,20 @@ export interface AppDefinition<S extends ScreenFactories> {
43
48
  * so its background sockets are gone before `deleteSession`. The mock is still stopped
44
49
  * even if this throws.
45
50
  */
46
- teardown?: (context: SessionContext<S>) => Promise<void> | void;
51
+ teardown?: (context: SessionContext<S, M>) => Promise<void> | void;
47
52
  /**
48
53
  * Invoked when a behaviour throws, before the failure propagates — wire on-failure
49
54
  * evidence here (e.g. `captureState(...)`) so capture lives in one place, not in every
50
55
  * behaviour. Its own errors are swallowed so they never mask the real failure.
51
56
  */
52
- onFailure?: (context: SessionContext<S>, info: FailureInfo) => Promise<void> | void;
57
+ onFailure?: (context: SessionContext<S, M>, info: FailureInfo) => Promise<void> | void;
53
58
  }
54
59
  /** The fixture context a session injects: the device handles plus each app screen. */
55
- export type SessionContext<S extends ScreenFactories> = DeviceContext & {
60
+ export type SessionContext<S extends ScreenFactories<M>, M extends MockBackend = MockBackend> = DeviceContext<M> & {
56
61
  [K in keyof S]: ReturnType<S[K]>;
57
62
  };
58
- export interface App<S extends ScreenFactories> {
63
+ export interface App<S extends ScreenFactories<M>, M extends MockBackend = MockBackend> {
59
64
  /** A scenario fixture that provisions a logged-in, joined session for `role`. */
60
- session(role?: string): ScenarioFixture<SessionContext<S>>;
65
+ session(role?: string): ScenarioFixture<SessionContext<S, M>>;
61
66
  }
62
- export declare function defineApp<S extends ScreenFactories>(definition: AppDefinition<S>): App<S>;
67
+ export declare function defineApp<S extends ScreenFactories<M>, M extends MockBackend = MockBackend>(definition: AppDefinition<S, M>): App<S, M>;
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { App, ScreenFactories } from "./app.js";
2
+ import type { MockBackend } from "./mock.js";
2
3
  /**
3
4
  * The Playwright-style config: one `nativeproof.config.ts` declares the app, the device
4
5
  * projects, and where the tests live. The `nativeproof` CLI auto-discovers it and
@@ -43,12 +44,12 @@ export interface RunnerConfig {
43
44
  /** Per-test timeout in ms (default 240000). */
44
45
  mochaTimeout?: number;
45
46
  }
46
- export interface NativeProofConfig<S extends ScreenFactories = ScreenFactories> extends RunnerConfig {
47
+ export interface NativeProofConfig<S extends ScreenFactories<M> = ScreenFactories, M extends MockBackend = MockBackend> extends RunnerConfig {
47
48
  /** The app under test (from `defineApp`). */
48
- app: App<S>;
49
+ app: App<S, M>;
49
50
  }
50
51
  /** Identity helper for typed config + editor autocomplete (mirrors Playwright's `defineConfig`). */
51
- export declare function defineConfig<S extends ScreenFactories>(config: NativeProofConfig<S>): NativeProofConfig<S>;
52
+ export declare function defineConfig<S extends ScreenFactories<M> = ScreenFactories, M extends MockBackend = MockBackend>(config: NativeProofConfig<S, M>): NativeProofConfig<S, M>;
52
53
  /** Selection inputs (from the CLI / env) used to resolve the active project + connection. */
53
54
  export interface RunnerEnv {
54
55
  platform?: string;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { captureState, failureEvidenceName } from "./evidence.js";
3
4
  /** Identity helper for typed config + editor autocomplete (mirrors Playwright's `defineConfig`). */
4
5
  export function defineConfig(config) {
5
6
  return config;
@@ -43,6 +44,15 @@ export function buildWdioConfig(config, env = {}, cwd = process.cwd()) {
43
44
  framework: "mocha",
44
45
  reporters: ["spec"],
45
46
  mochaOpts: { ui: "bdd", timeout: config.mochaTimeout ?? 240_000 },
47
+ // Evidence on failure, out of the box: on a failed behaviour, snapshot a screenshot +
48
+ // redacted page source into the artifact dir, named after the spec. Best-effort — a
49
+ // capture error never masks the real failure — and consumers get it without writing
50
+ // their own afterTest hook.
51
+ afterTest: async (test, _context, result) => {
52
+ if (result.passed)
53
+ return;
54
+ await captureState(failureEvidenceName(test)).catch(() => { });
55
+ },
46
56
  };
47
57
  }
48
58
  const CONFIG_NAMES = [
@@ -3,3 +3,13 @@ export declare function captureText(filename: string, contents: string): Promise
3
3
  export declare function captureScreenshot(filename: string): Promise<string>;
4
4
  /** Capture a screenshot + redacted source pair under one prefix; returns the source. */
5
5
  export declare function captureState(prefix: string): Promise<string>;
6
+ /**
7
+ * A filesystem-safe evidence prefix for a failed behaviour — `failure-<describe>-<test>`
8
+ * with runs of non-word characters collapsed to `_` and capped at 120 chars. Used by the
9
+ * runner's built-in on-failure capture so a failing spec leaves a screenshot + source pair
10
+ * named after it, with no per-spec wiring.
11
+ */
12
+ export declare function failureEvidenceName(test: {
13
+ parent: string;
14
+ title: string;
15
+ }): string;
package/dist/evidence.js CHANGED
@@ -42,3 +42,12 @@ export async function captureState(prefix) {
42
42
  await captureText(`${prefix}.xml`, source);
43
43
  return source;
44
44
  }
45
+ /**
46
+ * A filesystem-safe evidence prefix for a failed behaviour — `failure-<describe>-<test>`
47
+ * with runs of non-word characters collapsed to `_` and capped at 120 chars. Used by the
48
+ * runner's built-in on-failure capture so a failing spec leaves a screenshot + source pair
49
+ * named after it, with no per-spec wiring.
50
+ */
51
+ export function failureEvidenceName(test) {
52
+ return `failure-${test.parent}-${test.title}`.replace(/[^\w.-]+/g, "_").slice(0, 120);
53
+ }
package/dist/harness.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { App, ScreenFactories, SessionContext } from "./app.js";
2
2
  import { expect } from "./expect.js";
3
+ import type { MockBackend } from "./mock.js";
3
4
  /**
4
5
  * `createHarness(app)` — the Playwright `@playwright/test` pattern.
5
6
  *
@@ -18,19 +19,19 @@ import { expect } from "./expect.js";
18
19
  * });
19
20
  * ```
20
21
  */
21
- export interface HarnessTest<S extends ScreenFactories> {
22
- (name: string, body: (context: SessionContext<S>) => void | Promise<void>): void;
22
+ export interface HarnessTest<S extends ScreenFactories<M>, M extends MockBackend = MockBackend> {
23
+ (name: string, body: (context: SessionContext<S, M>) => void | Promise<void>): void;
23
24
  /** Open a scenario block for the default role. */
24
25
  describe(title: string, body: () => void): void;
25
26
  /** Open a scenario block for a specific role (e.g. "member" / "guest"). */
26
27
  describe(title: string, role: string, body: () => void): void;
27
28
  /** Run before each behaviour in the open scenario, with the session context injected. */
28
- beforeEach(body: (context: SessionContext<S>) => void | Promise<void>): void;
29
+ beforeEach(body: (context: SessionContext<S, M>) => void | Promise<void>): void;
29
30
  /** Run after each behaviour in the open scenario, with the session context injected. */
30
- afterEach(body: (context: SessionContext<S>) => void | Promise<void>): void;
31
+ afterEach(body: (context: SessionContext<S, M>) => void | Promise<void>): void;
31
32
  }
32
- export interface Harness<S extends ScreenFactories> {
33
- test: HarnessTest<S>;
33
+ export interface Harness<S extends ScreenFactories<M>, M extends MockBackend = MockBackend> {
34
+ test: HarnessTest<S, M>;
34
35
  expect: typeof expect;
35
36
  }
36
- export declare function createHarness<S extends ScreenFactories>(app: App<S>): Harness<S>;
37
+ export declare function createHarness<S extends ScreenFactories<M>, M extends MockBackend = MockBackend>(app: App<S, M>): Harness<S, M>;
package/dist/locator.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decodeXmlEntities, encodeXmlEntities, escapeRegExp, nodesForAttribute, nodesForRole, parseBounds, smallestClickableAncestorBounds, } from "./source.js";
1
+ import { decodeXmlEntities, encodeXmlEntities, escapeRegExp, nodesForAttribute, nodesForRole, parseNodeBounds, 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
@@ -100,7 +100,7 @@ export class Locator {
100
100
  return [];
101
101
  const { maxDistance } = this.proximity;
102
102
  return nodes
103
- .map((node) => ({ node, bounds: parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) }))
103
+ .map((node) => ({ node, bounds: parseNodeBounds(node) }))
104
104
  .filter((entry) => entry.bounds !== null)
105
105
  .map((entry) => ({
106
106
  node: entry.node,
@@ -149,7 +149,7 @@ export class Locator {
149
149
  /** Bounds of the matched node in the current source, or null if absent. */
150
150
  async bounds() {
151
151
  const node = this.pick(await this.matchedNodes());
152
- return node ? parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) : null;
152
+ return node ? parseNodeBounds(node) : null;
153
153
  }
154
154
  /** The matched node's own visible text, or null if the node is absent. */
155
155
  async textContent() {
package/dist/source.d.ts CHANGED
@@ -18,6 +18,14 @@ export type Bounds = {
18
18
  centerY: number;
19
19
  };
20
20
  export declare function parseBounds(bounds: string | undefined | null): Bounds | null;
21
+ /**
22
+ * Bounds of a single element node, cross-platform. Android UiAutomator exposes geometry as
23
+ * `bounds="[x1,y1][x2,y2]"`; iOS XCUITest exposes separate `x`/`y`/`width`/`height` attributes
24
+ * (no `bounds`). Tries the Android form first, then falls back to the iOS attributes — so locators
25
+ * resolve a tap point on both platforms. Without this, iOS elements have null bounds and `tap()`
26
+ * never finds a target.
27
+ */
28
+ export declare function parseNodeBounds(node: string): Bounds | null;
21
29
  export declare function escapeRegExp(value: string): string;
22
30
  /**
23
31
  * Decode the XML entities UiAutomator/XCUITest escape attribute values with
package/dist/source.js CHANGED
@@ -26,6 +26,38 @@ export function parseBounds(bounds) {
26
26
  centerY: Math.round((y1 + y2) / 2),
27
27
  };
28
28
  }
29
+ /**
30
+ * Bounds of a single element node, cross-platform. Android UiAutomator exposes geometry as
31
+ * `bounds="[x1,y1][x2,y2]"`; iOS XCUITest exposes separate `x`/`y`/`width`/`height` attributes
32
+ * (no `bounds`). Tries the Android form first, then falls back to the iOS attributes — so locators
33
+ * resolve a tap point on both platforms. Without this, iOS elements have null bounds and `tap()`
34
+ * never finds a target.
35
+ */
36
+ export function parseNodeBounds(node) {
37
+ const android = parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]);
38
+ if (android)
39
+ return android;
40
+ const attr = (name) => {
41
+ const m = new RegExp(`\\b${name}="(-?\\d+(?:\\.\\d+)?)"`).exec(node);
42
+ return m ? Number(m[1]) : null;
43
+ };
44
+ const x = attr("x");
45
+ const y = attr("y");
46
+ const w = attr("width");
47
+ const h = attr("height");
48
+ if (x === null || y === null || w === null || h === null)
49
+ return null;
50
+ return {
51
+ x1: Math.round(x),
52
+ y1: Math.round(y),
53
+ x2: Math.round(x + w),
54
+ y2: Math.round(y + h),
55
+ width: Math.round(w),
56
+ height: Math.round(h),
57
+ centerX: Math.round(x + w / 2),
58
+ centerY: Math.round(y + h / 2),
59
+ };
60
+ }
29
61
  export function escapeRegExp(value) {
30
62
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
63
  }
@@ -68,12 +100,19 @@ export function nodesForAttribute(source, attribute, value) {
68
100
  // A `g`-flagged RegExp is stateful across `.test()` calls; use a non-global copy so the
69
101
  // per-candidate test is order-independent.
70
102
  const test = value.global ? new RegExp(value.source, value.flags.replace("g", "")) : value;
71
- const candidate = new RegExp(`${attribute}="([^"]*)"`);
103
+ // `attribute` may be an alternation (e.g. `(?:text|content-desc)`), and a node can carry
104
+ // more than one of them — Android often exposes both `text=""` and `content-desc="…"`. Test
105
+ // the pattern against EVERY matching attribute's value, not just the first: otherwise a label
106
+ // that lives in `content-desc` is missed whenever an empty `text=""` precedes it in the tag.
107
+ const candidates = new RegExp(`${attribute}="([^"]*)"`, "g");
72
108
  const nodes = [];
73
109
  for (const tag of source.matchAll(/<[^>]*>/g)) {
74
- const attr = candidate.exec(tag[0]);
75
- if (attr && test.test(decodeXmlEntities(attr[1] ?? "")))
76
- nodes.push(tag[0]);
110
+ for (const attr of tag[0].matchAll(candidates)) {
111
+ if (test.test(decodeXmlEntities(attr[1] ?? ""))) {
112
+ nodes.push(tag[0]);
113
+ break;
114
+ }
115
+ }
77
116
  }
78
117
  return nodes;
79
118
  }
@@ -115,7 +154,7 @@ export function attributeMatches(source, attribute, value) {
115
154
  */
116
155
  export function boundsForAttribute(source, attribute, value) {
117
156
  const node = nodeForAttribute(source, attribute, value);
118
- return node ? parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) : null;
157
+ return node ? parseNodeBounds(node) : null;
119
158
  }
120
159
  /** Bounds of an element addressed by Android `content-desc`. */
121
160
  export function boundsForContentDesc(source, contentDesc) {
@@ -132,7 +171,7 @@ export function boundsForText(source, text) {
132
171
  */
133
172
  export function smallestClickableAncestorBounds(source, nodeBounds) {
134
173
  const clickable = [...source.matchAll(/<[^>]*clickable="true"[^>]*>/g)]
135
- .map((m) => parseBounds(/bounds="([^"]+)"/.exec(m[0])?.[1]))
174
+ .map((m) => parseNodeBounds(m[0]))
136
175
  .filter((b) => b !== null)
137
176
  .filter((b) => b.x1 <= nodeBounds.x1 && b.x2 >= nodeBounds.x2 && b.y1 <= nodeBounds.y1 && b.y2 >= nodeBounds.y2)
138
177
  .sort((a, b) => a.width * a.height - b.width * b.height);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativeproof",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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.",