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
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Playwright-style behaviour fixtures for Appium/WebdriverIO + Mocha.
3
+ *
4
+ * Gives specs the developer experience of Playwright — a typed fixture context
5
+ * injected into each behaviour, with provisioning and teardown owned by the
6
+ * framework instead of copy-pasted into every `before`/`after` — without depending
7
+ * on Playwright itself (which cannot drive native iOS/Android). It is a thin layer
8
+ * over whatever BDD runner hosts it (Mocha by default; see {@link runner}).
9
+ *
10
+ * App-agnostic by contract: nothing here may import app-specific code. A caller
11
+ * supplies a {@link ScenarioFixture} describing how to provision and tear down its
12
+ * own context (start a mock, relaunch the app, log in, join …); this module only
13
+ * wires that lifecycle into the runner and injects the result. It is part of the
14
+ * surface intended for extraction into a standalone package.
15
+ */
16
+ import { runner } from "./runner.js";
17
+ /**
18
+ * Define a behaviour scenario: a Mocha `describe` whose fixture context is
19
+ * provisioned once, injected into every behaviour, and torn down once.
20
+ *
21
+ * @param title - the scenario description (Mocha `describe` title).
22
+ * @param fixture - how to provision and tear down the shared context.
23
+ * @param define - registers the behaviours; receives a `test` registrar that
24
+ * injects the context.
25
+ *
26
+ * @example
27
+ * describeScenario("chat room", chatRoomScenario(), (test) => {
28
+ * test("renders the latest message", async ({ member }) => {
29
+ * await member.assertLatestMessageVisible();
30
+ * });
31
+ * });
32
+ */
33
+ export function describeScenario(title, fixture, define) {
34
+ const { describe, before, after, it } = runner();
35
+ describe(title, () => {
36
+ let context;
37
+ before(async () => {
38
+ context = await fixture.setup();
39
+ });
40
+ after(async () => {
41
+ await fixture.teardown(context);
42
+ });
43
+ const test = (behaviourTitle, body) => {
44
+ it(behaviourTitle, async () => {
45
+ if (context === undefined) {
46
+ throw new Error(`Scenario "${title}" ran a behaviour before its fixture context was provisioned`);
47
+ }
48
+ await body(context);
49
+ });
50
+ };
51
+ define(test);
52
+ });
53
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Low-level pointer gestures.
3
+ *
4
+ * Compose / SwiftUI surfaces frequently expose accessibility nodes that Appium
5
+ * reports as non-clickable or `visible=false`, so coordinate taps (computed from
6
+ * the page source bounds) are a first-class fallback throughout the app's screen objects.
7
+ * These helpers are app-agnostic and form part of the reusable framework core.
8
+ */
9
+ export declare function tapAt(x: number, y: number): Promise<void>;
10
+ export declare function swipe(fromX: number, fromY: number, toX: number, toY: number, duration?: number): Promise<void>;
11
+ export declare function pause(ms: number): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { browser } from "@wdio/globals";
2
+ /**
3
+ * Low-level pointer gestures.
4
+ *
5
+ * Compose / SwiftUI surfaces frequently expose accessibility nodes that Appium
6
+ * reports as non-clickable or `visible=false`, so coordinate taps (computed from
7
+ * the page source bounds) are a first-class fallback throughout the app's screen objects.
8
+ * These helpers are app-agnostic and form part of the reusable framework core.
9
+ */
10
+ export async function tapAt(x, y) {
11
+ await browser.performActions([
12
+ {
13
+ type: "pointer",
14
+ id: "finger1",
15
+ parameters: { pointerType: "touch" },
16
+ actions: [
17
+ { type: "pointerMove", duration: 0, x, y },
18
+ { type: "pointerDown", button: 0 },
19
+ { type: "pause", duration: 100 },
20
+ { type: "pointerUp", button: 0 },
21
+ ],
22
+ },
23
+ ]);
24
+ await browser.releaseActions();
25
+ }
26
+ export async function swipe(fromX, fromY, toX, toY, duration = 600) {
27
+ await browser.performActions([
28
+ {
29
+ type: "pointer",
30
+ id: "finger1",
31
+ parameters: { pointerType: "touch" },
32
+ actions: [
33
+ { type: "pointerMove", duration: 0, x: fromX, y: fromY },
34
+ { type: "pointerDown", button: 0 },
35
+ { type: "pause", duration: 100 },
36
+ { type: "pointerMove", duration, x: toX, y: toY },
37
+ { type: "pointerUp", button: 0 },
38
+ ],
39
+ },
40
+ ]);
41
+ await browser.releaseActions();
42
+ }
43
+ export async function pause(ms) {
44
+ await browser.pause(ms);
45
+ }
@@ -0,0 +1,32 @@
1
+ import type { App, ScreenFactories, SessionContext } from "./app.js";
2
+ import { expect } from "./expect.js";
3
+ /**
4
+ * `createHarness(app)` — the Playwright `@playwright/test` pattern.
5
+ *
6
+ * Returns a `test` / `expect` pair bound to one app, so specs import them from a single
7
+ * project file and write module-level `test(...)` / `test.describe(...)` with the app's
8
+ * fixture context flowing in fully typed — no `(test) =>` registrar to thread, no
9
+ * per-spec wiring:
10
+ *
11
+ * ```ts
12
+ * // harness.ts
13
+ * export const { test, expect } = createHarness(app);
14
+ * // chat.spec.ts
15
+ * import { test, expect } from "./harness";
16
+ * test.describe("chat room", "member", () => {
17
+ * test("renders the latest message", async ({ member, mock }) => { ... }); // typed
18
+ * });
19
+ * ```
20
+ */
21
+ export interface HarnessTest<S extends ScreenFactories> {
22
+ (name: string, body: (context: SessionContext<S>) => void | Promise<void>): void;
23
+ /** Open a scenario block for the default role. */
24
+ describe(title: string, body: () => void): void;
25
+ /** Open a scenario block for a specific role (e.g. "member" / "guest"). */
26
+ describe(title: string, role: string, body: () => void): void;
27
+ }
28
+ export interface Harness<S extends ScreenFactories> {
29
+ test: HarnessTest<S>;
30
+ expect: typeof expect;
31
+ }
32
+ export declare function createHarness<S extends ScreenFactories>(app: App<S>): Harness<S>;
@@ -0,0 +1,29 @@
1
+ import { expect } from "./expect.js";
2
+ import { describeScenario } from "./fixtures.js";
3
+ export function createHarness(app) {
4
+ let active = null;
5
+ const test = ((name, body) => {
6
+ if (!active) {
7
+ throw new Error(`test(${JSON.stringify(name)}) must be called inside test.describe(...)`);
8
+ }
9
+ active(name, body);
10
+ });
11
+ test.describe = ((title, roleOrBody, maybeBody) => {
12
+ const role = typeof roleOrBody === "string" ? roleOrBody : undefined;
13
+ const body = typeof roleOrBody === "function" ? roleOrBody : maybeBody;
14
+ if (!body) {
15
+ throw new Error(`test.describe(${JSON.stringify(title)}) requires a body function`);
16
+ }
17
+ describeScenario(title, app.session(role), (register) => {
18
+ const previous = active;
19
+ active = register;
20
+ try {
21
+ body();
22
+ }
23
+ finally {
24
+ active = previous;
25
+ }
26
+ });
27
+ });
28
+ return { test, expect };
29
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * NativeProof — a Native Mobile E2E test framework inspired by Playwright.
3
+ *
4
+ * Playwright's developer experience (fixtures, locators, expect, route-style mocking,
5
+ * evidence) layered on Appium/WebdriverIO, which can drive the native iOS/Android
6
+ * surfaces Playwright cannot. App-agnostic: consumers wire all app-specifics (selectors,
7
+ * secret patterns, login/join flows, mock backend) in by injection; nothing in this
8
+ * package imports app-specific code.
9
+ */
10
+ export * from "./adb.js";
11
+ export * from "./app.js";
12
+ export * from "./config.js";
13
+ export * from "./driver.js";
14
+ export * from "./evidence.js";
15
+ export * from "./expect.js";
16
+ export * from "./fixtures.js";
17
+ export * from "./gestures.js";
18
+ export * from "./harness.js";
19
+ export * from "./ios.js";
20
+ export * from "./locator.js";
21
+ export * from "./log.js";
22
+ export * from "./mock.js";
23
+ export * from "./mock-server.js";
24
+ export * from "./page.js";
25
+ export * from "./runner.js";
26
+ export * from "./screen.js";
27
+ export * from "./source.js";
28
+ export * from "./test.js";
29
+ export * from "./text.js";
30
+ export * from "./wait.js";
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * NativeProof — a Native Mobile E2E test framework inspired by Playwright.
3
+ *
4
+ * Playwright's developer experience (fixtures, locators, expect, route-style mocking,
5
+ * evidence) layered on Appium/WebdriverIO, which can drive the native iOS/Android
6
+ * surfaces Playwright cannot. App-agnostic: consumers wire all app-specifics (selectors,
7
+ * secret patterns, login/join flows, mock backend) in by injection; nothing in this
8
+ * package imports app-specific code.
9
+ */
10
+ export * from "./adb.js";
11
+ export * from "./app.js";
12
+ export * from "./config.js";
13
+ export * from "./driver.js";
14
+ export * from "./evidence.js";
15
+ export * from "./expect.js";
16
+ export * from "./fixtures.js";
17
+ export * from "./gestures.js";
18
+ export * from "./harness.js";
19
+ export * from "./ios.js";
20
+ export * from "./locator.js";
21
+ export * from "./log.js";
22
+ export * from "./mock.js";
23
+ export * from "./mock-server.js";
24
+ export * from "./page.js";
25
+ export * from "./runner.js";
26
+ export * from "./screen.js";
27
+ export * from "./source.js";
28
+ export * from "./test.js";
29
+ export * from "./text.js";
30
+ export * from "./wait.js";
package/dist/ios.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Thin, best-effort wrappers around `xcrun simctl` — the iOS-simulator counterpart
3
+ * to {@link file://./adb.ts}. They control app + simulator lifecycle (terminate, launch,
4
+ * install, uninstall, boot, shutdown, reset) so a consuming app can reset state between
5
+ * scenarios on iOS the way `adb` does on Android.
6
+ *
7
+ * The device defaults to `"booted"` (the currently-booted simulator), the simctl analogue
8
+ * of adb's single-device default. Note there is no iOS equivalent of `adbDump`: iOS exposes
9
+ * the element tree only through the Appium (XCUITest) page source, so screens fall back to
10
+ * that, not to an out-of-band dump.
11
+ */
12
+ /** A command runner; injectable so the command mapping is unit-testable without a simulator. */
13
+ export type SimctlRunner = (args: string[]) => string;
14
+ /** Build the argv for an `xcrun simctl <action> <udid> [...]` invocation (pure). */
15
+ export declare function simctlArgv(action: string, udid: string, ...rest: string[]): string[];
16
+ /** Stop a running app (best-effort) — the iOS analogue of `adbForceStop`. */
17
+ export declare function iosTerminate(bundleId: string, udid?: string, run?: SimctlRunner): void;
18
+ /** Launch an installed app (best-effort). */
19
+ export declare function iosLaunch(bundleId: string, udid?: string, run?: SimctlRunner): void;
20
+ /** Install a `.app`/`.ipa` build (best-effort). */
21
+ export declare function iosInstall(appPath: string, udid?: string, run?: SimctlRunner): void;
22
+ /** Uninstall an app, clearing its data + sandbox (best-effort). */
23
+ export declare function iosUninstall(bundleId: string, udid?: string, run?: SimctlRunner): void;
24
+ /** Boot a simulator by udid (best-effort; no-op if already booted). */
25
+ export declare function iosBoot(udid: string, run?: SimctlRunner): void;
26
+ /** Shut a simulator down (best-effort). */
27
+ export declare function iosShutdown(udid?: string, run?: SimctlRunner): void;
28
+ /**
29
+ * Reset an app to a clean state before a fresh login — the iOS analogue of
30
+ * `resetAppAndBrowserState`. Terminates then uninstalls the app (which clears its data and
31
+ * sandbox); reinstalls from `appPath` when given so the next launch starts signed-out.
32
+ */
33
+ export declare function resetAppState(bundleId: string, options?: {
34
+ udid?: string;
35
+ appPath?: string;
36
+ run?: SimctlRunner;
37
+ }): void;
38
+ /** Recent simulator logs (best-effort supporting evidence) — the analogue of `adbLogcatDump`. */
39
+ export declare function iosLogShow(udid?: string, since?: string, run?: SimctlRunner): string;
package/dist/ios.js ADDED
@@ -0,0 +1,92 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const realRunner = (args) => execFileSync("xcrun", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
3
+ /** Build the argv for an `xcrun simctl <action> <udid> [...]` invocation (pure). */
4
+ export function simctlArgv(action, udid, ...rest) {
5
+ return ["simctl", action, udid, ...rest];
6
+ }
7
+ /** Stop a running app (best-effort) — the iOS analogue of `adbForceStop`. */
8
+ export function iosTerminate(bundleId, udid = "booted", run = realRunner) {
9
+ try {
10
+ run(simctlArgv("terminate", udid, bundleId));
11
+ }
12
+ catch {
13
+ /* best-effort */
14
+ }
15
+ }
16
+ /** Launch an installed app (best-effort). */
17
+ export function iosLaunch(bundleId, udid = "booted", run = realRunner) {
18
+ try {
19
+ run(simctlArgv("launch", udid, bundleId));
20
+ }
21
+ catch {
22
+ /* best-effort */
23
+ }
24
+ }
25
+ /** Install a `.app`/`.ipa` build (best-effort). */
26
+ export function iosInstall(appPath, udid = "booted", run = realRunner) {
27
+ try {
28
+ run(simctlArgv("install", udid, appPath));
29
+ }
30
+ catch {
31
+ /* best-effort */
32
+ }
33
+ }
34
+ /** Uninstall an app, clearing its data + sandbox (best-effort). */
35
+ export function iosUninstall(bundleId, udid = "booted", run = realRunner) {
36
+ try {
37
+ run(simctlArgv("uninstall", udid, bundleId));
38
+ }
39
+ catch {
40
+ /* best-effort */
41
+ }
42
+ }
43
+ /** Boot a simulator by udid (best-effort; no-op if already booted). */
44
+ export function iosBoot(udid, run = realRunner) {
45
+ try {
46
+ run(simctlArgv("boot", udid));
47
+ }
48
+ catch {
49
+ /* best-effort */
50
+ }
51
+ }
52
+ /** Shut a simulator down (best-effort). */
53
+ export function iosShutdown(udid = "booted", run = realRunner) {
54
+ try {
55
+ run(simctlArgv("shutdown", udid));
56
+ }
57
+ catch {
58
+ /* best-effort */
59
+ }
60
+ }
61
+ /**
62
+ * Reset an app to a clean state before a fresh login — the iOS analogue of
63
+ * `resetAppAndBrowserState`. Terminates then uninstalls the app (which clears its data and
64
+ * sandbox); reinstalls from `appPath` when given so the next launch starts signed-out.
65
+ */
66
+ export function resetAppState(bundleId, options = {}) {
67
+ const udid = options.udid ?? "booted";
68
+ const run = options.run ?? realRunner;
69
+ const steps = [
70
+ simctlArgv("terminate", udid, bundleId),
71
+ simctlArgv("uninstall", udid, bundleId),
72
+ ];
73
+ if (options.appPath)
74
+ steps.push(simctlArgv("install", udid, options.appPath));
75
+ for (const step of steps) {
76
+ try {
77
+ run(step);
78
+ }
79
+ catch {
80
+ /* best-effort */
81
+ }
82
+ }
83
+ }
84
+ /** Recent simulator logs (best-effort supporting evidence) — the analogue of `adbLogcatDump`. */
85
+ export function iosLogShow(udid = "booted", since = "1m", run = realRunner) {
86
+ try {
87
+ return run(simctlArgv("spawn", udid, "log", "show", "--style", "compact", "--last", since));
88
+ }
89
+ catch {
90
+ return "";
91
+ }
92
+ }
@@ -0,0 +1,78 @@
1
+ import type { Driver } from "./driver.js";
2
+ import { type Bounds } from "./source.js";
3
+ /**
4
+ * Cross-platform locators — the reusable heart of the framework.
5
+ *
6
+ * A {@link Locator} is a lazy, awaitable handle to an element addressed by a
7
+ * platform-agnostic selector (`by.text` / `by.desc` / `by.id`), with built-in
8
+ * waiting and a source-bounds coordinate-tap fallback. It is the Playwright
9
+ * `Locator` equivalent and the thing that lets `expect(locator).toShow(...)` exist.
10
+ */
11
+ /** A cross-platform element selector. */
12
+ export type Selector = {
13
+ readonly by: "text";
14
+ readonly value: string;
15
+ } | {
16
+ readonly by: "desc";
17
+ readonly value: string;
18
+ } | {
19
+ readonly by: "id";
20
+ readonly value: string;
21
+ } | {
22
+ readonly by: "testId";
23
+ readonly value: string;
24
+ } | {
25
+ readonly by: "label";
26
+ readonly value: string;
27
+ };
28
+ /**
29
+ * Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
30
+ * per platform (see {@link attributeFor}), so you never have to know whether it's a
31
+ * `content-desc` or a `name`: `by.text("Submit")`, `by.testId("login-button")`,
32
+ * `by.label("Sign out")`, `by.id("message-list")`.
33
+ */
34
+ export declare const by: {
35
+ readonly text: (value: string) => Selector;
36
+ readonly desc: (value: string) => Selector;
37
+ readonly id: (value: string) => Selector;
38
+ /** The app's test id (Android `resource-id` / Compose testTag, iOS accessibilityIdentifier). */
39
+ readonly testId: (value: string) => Selector;
40
+ /** The accessibility label (Android `content-desc`, iOS `label`). */
41
+ readonly label: (value: string) => Selector;
42
+ };
43
+ export declare function describeSelector(selector: Selector): string;
44
+ export interface WaitOptions {
45
+ timeout?: number;
46
+ interval?: number;
47
+ /** Sleep awaited between polls; defaults to a real timer. The locator injects the driver's pause. */
48
+ sleep?: (ms: number) => Promise<void>;
49
+ }
50
+ /**
51
+ * Poll `produce` until `done(value)` holds or the timeout elapses, returning the
52
+ * last value either way (callers decide what an unmet condition means). The interval
53
+ * is awaited via `options.sleep` — a real timer by default, the driver's `pause` when
54
+ * a locator drives it — so a fake clock can control test timing. No device required.
55
+ */
56
+ export declare function waitUntil<T>(produce: () => Promise<T>, done: (value: T) => boolean, options?: WaitOptions): Promise<T>;
57
+ export declare class Locator {
58
+ readonly driver: Driver;
59
+ readonly selector: Selector;
60
+ private readonly options;
61
+ constructor(driver: Driver, selector: Selector, options?: WaitOptions);
62
+ private attribute;
63
+ private presencePattern;
64
+ /** True if the selector matches a node in the current source. */
65
+ isVisible(): Promise<boolean>;
66
+ /** Bounds of the matched node in the current source, or null if absent. */
67
+ bounds(): Promise<Bounds | null>;
68
+ /** The matched node's own visible text, or null if the node is absent. */
69
+ textContent(): Promise<string | null>;
70
+ /** True if the selector is present AND `text` appears in the source. */
71
+ shows(text: string | RegExp): Promise<boolean>;
72
+ /** Wait until the selector is visible; throws on timeout. */
73
+ waitFor(options?: WaitOptions): Promise<void>;
74
+ /** Wait for the element, then tap its centre (a source-bounds coordinate tap). */
75
+ tap(options?: WaitOptions): Promise<void>;
76
+ }
77
+ /** Convenience factory: `locator(driver, by.text("Submit"))`. */
78
+ export declare function locator(driver: Driver, selector: Selector, options?: WaitOptions): Locator;
@@ -0,0 +1,116 @@
1
+ import { boundsForAttribute } from "./source.js";
2
+ /**
3
+ * Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
4
+ * per platform (see {@link attributeFor}), so you never have to know whether it's a
5
+ * `content-desc` or a `name`: `by.text("Submit")`, `by.testId("login-button")`,
6
+ * `by.label("Sign out")`, `by.id("message-list")`.
7
+ */
8
+ export const by = {
9
+ text: (value) => ({ by: "text", value }),
10
+ desc: (value) => ({ by: "desc", value }),
11
+ id: (value) => ({ by: "id", value }),
12
+ /** The app's test id (Android `resource-id` / Compose testTag, iOS accessibilityIdentifier). */
13
+ testId: (value) => ({ by: "testId", value }),
14
+ /** The accessibility label (Android `content-desc`, iOS `label`). */
15
+ label: (value) => ({ by: "label", value }),
16
+ };
17
+ export function describeSelector(selector) {
18
+ return `by.${selector.by}(${JSON.stringify(selector.value)})`;
19
+ }
20
+ function escapeRegExp(value) {
21
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ }
23
+ /** The page-source attribute a selector resolves to on each platform. */
24
+ function attributeFor(selector, platform) {
25
+ const android = {
26
+ text: "text",
27
+ desc: "content-desc",
28
+ id: "resource-id",
29
+ testId: "resource-id",
30
+ label: "content-desc",
31
+ };
32
+ const ios = { text: "label", desc: "name", id: "name", testId: "name", label: "label" };
33
+ return (platform === "ios" ? ios : android)[selector.by];
34
+ }
35
+ const DEFAULTS = { timeout: 10_000, interval: 250 };
36
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+ /**
38
+ * Poll `produce` until `done(value)` holds or the timeout elapses, returning the
39
+ * last value either way (callers decide what an unmet condition means). The interval
40
+ * is awaited via `options.sleep` — a real timer by default, the driver's `pause` when
41
+ * a locator drives it — so a fake clock can control test timing. No device required.
42
+ */
43
+ export async function waitUntil(produce, done, options = {}) {
44
+ const timeout = options.timeout ?? DEFAULTS.timeout;
45
+ const interval = options.interval ?? DEFAULTS.interval;
46
+ const sleep = options.sleep ?? realSleep;
47
+ const deadline = Date.now() + timeout;
48
+ let value = await produce();
49
+ while (!done(value) && Date.now() < deadline) {
50
+ await sleep(interval);
51
+ value = await produce();
52
+ }
53
+ return value;
54
+ }
55
+ export class Locator {
56
+ driver;
57
+ selector;
58
+ options;
59
+ constructor(driver, selector, options = {}) {
60
+ this.driver = driver;
61
+ this.selector = selector;
62
+ this.options = options;
63
+ }
64
+ attribute() {
65
+ return attributeFor(this.selector, this.driver.platform);
66
+ }
67
+ presencePattern() {
68
+ return new RegExp(`${this.attribute()}="${escapeRegExp(this.selector.value)}"`);
69
+ }
70
+ /** True if the selector matches a node in the current source. */
71
+ async isVisible() {
72
+ return this.presencePattern().test(await this.driver.source());
73
+ }
74
+ /** Bounds of the matched node in the current source, or null if absent. */
75
+ async bounds() {
76
+ return boundsForAttribute(await this.driver.source(), this.attribute(), this.selector.value);
77
+ }
78
+ /** The matched node's own visible text, or null if the node is absent. */
79
+ async textContent() {
80
+ const source = await this.driver.source();
81
+ const node = new RegExp(`<[^>]*${this.attribute()}="${escapeRegExp(this.selector.value)}"[^>]*>`).exec(source)?.[0];
82
+ if (!node)
83
+ return null;
84
+ const textAttr = this.driver.platform === "ios" ? "value|label" : "text";
85
+ return new RegExp(`(?:${textAttr})="([^"]*)"`).exec(node)?.[1] ?? null;
86
+ }
87
+ /** True if the selector is present AND `text` appears in the source. */
88
+ async shows(text) {
89
+ const source = await this.driver.source();
90
+ if (!this.presencePattern().test(source))
91
+ return false;
92
+ const pattern = typeof text === "string" ? new RegExp(escapeRegExp(text)) : text;
93
+ return pattern.test(source);
94
+ }
95
+ /** Wait until the selector is visible; throws on timeout. */
96
+ async waitFor(options = {}) {
97
+ const opts = { ...this.options, ...options, sleep: (ms) => this.driver.pause(ms) };
98
+ const visible = await waitUntil(() => this.isVisible(), (v) => v, opts);
99
+ if (!visible) {
100
+ throw new Error(`${describeSelector(this.selector)} did not become visible within ${opts.timeout ?? DEFAULTS.timeout}ms`);
101
+ }
102
+ }
103
+ /** Wait for the element, then tap its centre (a source-bounds coordinate tap). */
104
+ async tap(options = {}) {
105
+ const opts = { ...this.options, ...options, sleep: (ms) => this.driver.pause(ms) };
106
+ const bounds = await waitUntil(() => this.bounds(), (b) => b !== null, opts);
107
+ if (!bounds) {
108
+ throw new Error(`${describeSelector(this.selector)} was not found to tap within ${opts.timeout ?? DEFAULTS.timeout}ms`);
109
+ }
110
+ await this.driver.tapAt(bounds.centerX, bounds.centerY);
111
+ }
112
+ }
113
+ /** Convenience factory: `locator(driver, by.text("Submit"))`. */
114
+ export function locator(driver, selector, options = {}) {
115
+ return new Locator(driver, selector, options);
116
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Network-evidence log helpers.
3
+ *
4
+ * The mock backend writes request and socket activity as JSONL. These pure
5
+ * helpers let assertions reason about app-originated network behaviour — the
6
+ * Playwright-`page.route()` equivalent that native Appium otherwise lacks.
7
+ * App-agnostic; app-specific frame semantics live in the consumer's assertions.
8
+ */
9
+ export declare function readLog(logPath: string): Promise<string>;
10
+ export declare function countMatches(text: string, pattern: RegExp): number;
11
+ /**
12
+ * Tokens that must never appear in captured evidence. App-agnostic by default
13
+ * (non-fake Bearer tokens); a consumer composes in its own app-specific secrets
14
+ * (e.g. a passcode literal) — destined to become injected config.
15
+ */
16
+ export declare const LEAKED_SECRET_PATTERN: RegExp;
17
+ export declare function containsLeakedSecret(text: string): boolean;
package/dist/log.js ADDED
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs/promises";
2
+ /**
3
+ * Network-evidence log helpers.
4
+ *
5
+ * The mock backend writes request and socket activity as JSONL. These pure
6
+ * helpers let assertions reason about app-originated network behaviour — the
7
+ * Playwright-`page.route()` equivalent that native Appium otherwise lacks.
8
+ * App-agnostic; app-specific frame semantics live in the consumer's assertions.
9
+ */
10
+ export async function readLog(logPath) {
11
+ return fs.readFile(logPath, "utf8").catch(() => "");
12
+ }
13
+ export function countMatches(text, pattern) {
14
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
15
+ return (text.match(new RegExp(pattern.source, flags)) ?? []).length;
16
+ }
17
+ /**
18
+ * Tokens that must never appear in captured evidence. App-agnostic by default
19
+ * (non-fake Bearer tokens); a consumer composes in its own app-specific secrets
20
+ * (e.g. a passcode literal) — destined to become injected config.
21
+ */
22
+ export const LEAKED_SECRET_PATTERN = /Bearer\s+(?!fake-e2e)[A-Za-z0-9._-]+/i;
23
+ export function containsLeakedSecret(text) {
24
+ return LEAKED_SECRET_PATTERN.test(text);
25
+ }
@@ -0,0 +1,17 @@
1
+ import type { MockBackend } from "./mock.js";
2
+ export interface MockServerOptions {
3
+ /** Port to listen on; 0 (the default) picks a free ephemeral port. */
4
+ port?: number;
5
+ /** Interface to bind; default 127.0.0.1. Use 0.0.0.0 to reach it from an emulator. */
6
+ host?: string;
7
+ }
8
+ export interface MockServer extends MockBackend {
9
+ /** HTTP base URL the app points at, e.g. `http://127.0.0.1:18113`. */
10
+ readonly url: string;
11
+ /** WebSocket base URL, e.g. `ws://127.0.0.1:18113`. */
12
+ readonly wsUrl: string;
13
+ readonly port: number;
14
+ /** Push a server-initiated frame to every socket open on `path` (recorded as received). */
15
+ send(path: string, frame: Record<string, unknown>): void;
16
+ }
17
+ export declare function startMockServer(options?: MockServerOptions): Promise<MockServer>;