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/dist/cli.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `nativeproof` CLI — the single-command entry, in the spirit of `playwright test`.
4
+ *
5
+ * It resolves a config (an `nativeproof.config.ts`, else a raw `wdio.conf.ts`), ensures an
6
+ * Appium server is up (starting one if needed), and runs the suite with sane env
7
+ * (PLATFORM / SPEC / NATIVEPROOF_PROJECT / APPIUM_*) — so a consumer types one command
8
+ * instead of remembering env vars and a runner invocation. The device/emulator itself is
9
+ * the environment (the mobile analogue of needing a display) and is left to the host.
10
+ */
11
+ export interface CliArgs {
12
+ command: "test" | "help" | "version";
13
+ config: string | undefined;
14
+ platform: "android" | "ios" | undefined;
15
+ project: string | undefined;
16
+ spec: string | undefined;
17
+ appiumHost: string;
18
+ appiumPort: number;
19
+ appiumPath: string;
20
+ startAppium: boolean;
21
+ }
22
+ export declare function parseArgs(argv: readonly string[]): CliArgs;
23
+ export declare function version(): string;
24
+ export declare function helpText(): string;
25
+ interface ResolvedRunner {
26
+ wdioConfig: string;
27
+ extraEnv: NodeJS.ProcessEnv;
28
+ }
29
+ /** Pick what to hand WebdriverIO: an explicit config, an nativeproof.config.ts, or wdio.conf.ts. */
30
+ export declare function resolveRunner(args: CliArgs, cwd?: string): ResolvedRunner;
31
+ export declare function main(argv: readonly string[]): Promise<number>;
32
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { findConfigFile } from "./config.js";
7
+ const DEFAULTS = {
8
+ command: "test",
9
+ config: undefined,
10
+ platform: undefined,
11
+ project: undefined,
12
+ spec: undefined,
13
+ appiumHost: "127.0.0.1",
14
+ appiumPort: 4723,
15
+ appiumPath: "/wd/hub",
16
+ startAppium: true,
17
+ };
18
+ function valueFor(argv, index, flag) {
19
+ const value = argv[index];
20
+ if (value === undefined)
21
+ throw new Error(`${flag} requires a value`);
22
+ return value;
23
+ }
24
+ export function parseArgs(argv) {
25
+ const args = { ...DEFAULTS };
26
+ for (let i = 0; i < argv.length; i += 1) {
27
+ const arg = argv[i];
28
+ if (arg === "test")
29
+ continue;
30
+ if (arg === "-h" || arg === "--help")
31
+ return { ...args, command: "help" };
32
+ if (arg === "-v" || arg === "--version")
33
+ return { ...args, command: "version" };
34
+ if (arg === "--no-appium") {
35
+ args.startAppium = false;
36
+ }
37
+ else if (arg === "--config") {
38
+ i += 1;
39
+ args.config = valueFor(argv, i, "--config");
40
+ }
41
+ else if (arg === "--project") {
42
+ i += 1;
43
+ args.project = valueFor(argv, i, "--project");
44
+ }
45
+ else if (arg === "--spec") {
46
+ i += 1;
47
+ args.spec = valueFor(argv, i, "--spec");
48
+ }
49
+ else if (arg === "--platform") {
50
+ i += 1;
51
+ const platform = valueFor(argv, i, "--platform");
52
+ if (platform !== "android" && platform !== "ios") {
53
+ throw new Error('--platform must be "android" or "ios"');
54
+ }
55
+ args.platform = platform;
56
+ }
57
+ else if (arg === "--appium-host") {
58
+ i += 1;
59
+ args.appiumHost = valueFor(argv, i, "--appium-host");
60
+ }
61
+ else if (arg === "--appium-port") {
62
+ i += 1;
63
+ args.appiumPort = Number(valueFor(argv, i, "--appium-port"));
64
+ }
65
+ else if (arg === "--appium-path") {
66
+ i += 1;
67
+ args.appiumPath = valueFor(argv, i, "--appium-path");
68
+ }
69
+ else {
70
+ throw new Error(`Unknown argument: ${arg}`);
71
+ }
72
+ }
73
+ return args;
74
+ }
75
+ export function version() {
76
+ try {
77
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
78
+ const pkg = JSON.parse(raw);
79
+ return pkg.version ?? "0.0.0";
80
+ }
81
+ catch {
82
+ return "0.0.0";
83
+ }
84
+ }
85
+ export function helpText() {
86
+ return [
87
+ "nativeproof — Native Mobile E2E test framework inspired by Playwright",
88
+ "",
89
+ "Usage:",
90
+ " nativeproof [test] [options]",
91
+ "",
92
+ "Config is auto-discovered: nativeproof.config.ts (preferred), else wdio.conf.ts.",
93
+ "",
94
+ "Options:",
95
+ " --platform <android|ios> platform to run (sets PLATFORM)",
96
+ " --project <name> run a named project from nativeproof.config.ts",
97
+ " --spec <glob> run only matching specs (sets SPEC)",
98
+ " --config <path> use a raw WebdriverIO config instead of discovery",
99
+ " --appium-host <host> Appium host (default: 127.0.0.1)",
100
+ " --appium-port <port> Appium port (default: 4723)",
101
+ " --appium-path <path> Appium base path (default: /wd/hub)",
102
+ " --no-appium do not auto-start an Appium server",
103
+ " -h, --help show this help",
104
+ " -v, --version print the version",
105
+ ].join("\n");
106
+ }
107
+ function localBin(name) {
108
+ const bin = path.join(process.cwd(), "node_modules", ".bin", name);
109
+ return existsSync(bin) ? bin : name;
110
+ }
111
+ async function appiumReachable(args) {
112
+ try {
113
+ const response = await fetch(`http://${args.appiumHost}:${args.appiumPort}${args.appiumPath}/status`, {
114
+ signal: AbortSignal.timeout(1500),
115
+ });
116
+ return response.ok;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ async function ensureAppium(args) {
123
+ if (await appiumReachable(args))
124
+ return null;
125
+ if (!args.startAppium) {
126
+ throw new Error(`Appium is not reachable at http://${args.appiumHost}:${args.appiumPort}${args.appiumPath} (and --no-appium was set)`);
127
+ }
128
+ console.log("nativeproof: starting Appium …");
129
+ const child = spawn(localBin("appium"), ["--base-path", args.appiumPath, "--relaxed-security"], {
130
+ stdio: "ignore",
131
+ });
132
+ const deadline = Date.now() + 30_000;
133
+ while (Date.now() < deadline) {
134
+ await new Promise((resolve) => setTimeout(resolve, 500));
135
+ if (await appiumReachable(args))
136
+ return child;
137
+ }
138
+ child.kill();
139
+ throw new Error("nativeproof: Appium did not become reachable within 30s");
140
+ }
141
+ function runnerEnv(args) {
142
+ const env = {
143
+ ...process.env,
144
+ APPIUM_HOST: args.appiumHost,
145
+ APPIUM_PORT: String(args.appiumPort),
146
+ APPIUM_PATH: args.appiumPath,
147
+ };
148
+ if (args.platform)
149
+ env.PLATFORM = args.platform;
150
+ if (args.project)
151
+ env.NATIVEPROOF_PROJECT = args.project;
152
+ if (args.spec)
153
+ env.SPEC = args.spec;
154
+ return env;
155
+ }
156
+ /** Pick what to hand WebdriverIO: an explicit config, an nativeproof.config.ts, or wdio.conf.ts. */
157
+ export function resolveRunner(args, cwd = process.cwd()) {
158
+ if (args.config) {
159
+ const resolved = path.resolve(cwd, args.config);
160
+ if (!existsSync(resolved))
161
+ throw new Error(`nativeproof: config not found: ${args.config}`);
162
+ return { wdioConfig: resolved, extraEnv: {} };
163
+ }
164
+ const nativeproofConfig = findConfigFile(cwd);
165
+ if (nativeproofConfig) {
166
+ return {
167
+ wdioConfig: fileURLToPath(new URL("./runner-config.js", import.meta.url)),
168
+ extraEnv: {
169
+ NATIVEPROOF_CONFIG: nativeproofConfig,
170
+ NODE_OPTIONS: `--import tsx ${process.env.NODE_OPTIONS ?? ""}`.trim(),
171
+ },
172
+ };
173
+ }
174
+ const wdioConf = path.resolve(cwd, "wdio.conf.ts");
175
+ if (existsSync(wdioConf))
176
+ return { wdioConfig: wdioConf, extraEnv: {} };
177
+ throw new Error("nativeproof: no nativeproof.config.ts or wdio.conf.ts found (pass --config <path>)");
178
+ }
179
+ async function runTests(args) {
180
+ const { wdioConfig, extraEnv } = resolveRunner(args);
181
+ const appium = await ensureAppium(args);
182
+ try {
183
+ return await new Promise((resolve, reject) => {
184
+ const runner = spawn(localBin("wdio"), ["run", wdioConfig], {
185
+ stdio: "inherit",
186
+ env: { ...runnerEnv(args), ...extraEnv },
187
+ });
188
+ runner.on("error", reject);
189
+ runner.on("exit", (code) => resolve(code ?? 1));
190
+ });
191
+ }
192
+ finally {
193
+ appium?.kill();
194
+ }
195
+ }
196
+ export async function main(argv) {
197
+ const args = parseArgs(argv);
198
+ if (args.command === "help") {
199
+ console.log(helpText());
200
+ return 0;
201
+ }
202
+ if (args.command === "version") {
203
+ console.log(version());
204
+ return 0;
205
+ }
206
+ return runTests(args);
207
+ }
208
+ /** True when this file is the process entry — robust to the symlink npm creates for bins. */
209
+ function isCliEntry() {
210
+ const entry = process.argv[1];
211
+ if (!entry)
212
+ return false;
213
+ try {
214
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
220
+ if (isCliEntry()) {
221
+ main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
222
+ console.error(error instanceof Error ? error.message : String(error));
223
+ process.exit(1);
224
+ });
225
+ }
@@ -0,0 +1,70 @@
1
+ import type { App, ScreenFactories } from "./app.js";
2
+ /**
3
+ * The Playwright-style config: one `nativeproof.config.ts` declares the app, the device
4
+ * projects, and where the tests live. The `nativeproof` CLI auto-discovers it and
5
+ * synthesises the WebdriverIO run from it — so no hand-written `wdio.conf.ts`.
6
+ *
7
+ * ```ts
8
+ * // nativeproof.config.ts
9
+ * const app = defineApp({ ... });
10
+ * export const { test, expect } = createHarness(app); // specs import these
11
+ * export default defineConfig({
12
+ * app,
13
+ * testDir: "tests",
14
+ * projects: [
15
+ * { name: "android", platform: "android", capabilities: { ... } },
16
+ * { name: "ios", platform: "ios", capabilities: { ... } },
17
+ * ],
18
+ * });
19
+ * ```
20
+ */
21
+ /** Appium connection settings (defaults: 127.0.0.1 : 4723 /wd/hub). */
22
+ export interface AppiumOptions {
23
+ host?: string;
24
+ port?: number;
25
+ path?: string;
26
+ }
27
+ /** One device target — the NativeProof analogue of a Playwright project. */
28
+ export interface DeviceProject {
29
+ /** A name to select with `nativeproof --project <name>`. */
30
+ name: string;
31
+ platform: "android" | "ios";
32
+ /** Appium capabilities for this device (e.g. `appium:app`, `appium:deviceName`). */
33
+ capabilities: Record<string, unknown>;
34
+ }
35
+ /** The device/run config the CLI turns into a WebdriverIO run. */
36
+ export interface RunnerConfig {
37
+ /** Directory holding the specs (default "tests"). */
38
+ testDir?: string;
39
+ /** Glob within `testDir` (default "**\/*.spec.ts"). */
40
+ testMatch?: string;
41
+ projects: DeviceProject[];
42
+ appium?: AppiumOptions;
43
+ /** Per-test timeout in ms (default 240000). */
44
+ mochaTimeout?: number;
45
+ }
46
+ export interface NativeProofConfig<S extends ScreenFactories = ScreenFactories> extends RunnerConfig {
47
+ /** The app under test (from `defineApp`). */
48
+ app: App<S>;
49
+ }
50
+ /** 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
+ /** Selection inputs (from the CLI / env) used to resolve the active project + connection. */
53
+ export interface RunnerEnv {
54
+ platform?: string;
55
+ project?: string;
56
+ spec?: string;
57
+ appiumHost?: string;
58
+ appiumPort?: number;
59
+ appiumPath?: string;
60
+ }
61
+ /** Pick the project by explicit name, else by platform, else the first one. */
62
+ export declare function resolveProject(config: RunnerConfig, env?: RunnerEnv): DeviceProject;
63
+ /**
64
+ * Translate an NativeProof config into a WebdriverIO `config` object. Spec paths are made
65
+ * absolute against `cwd` (the project root) because the synthesised config is loaded from
66
+ * inside `node_modules`, so a relative glob would resolve against the wrong directory.
67
+ */
68
+ export declare function buildWdioConfig(config: RunnerConfig, env?: RunnerEnv, cwd?: string): Record<string, unknown>;
69
+ /** Find an `nativeproof.config.*` in `dir`, or null. `exists` is injectable for testing. */
70
+ export declare function findConfigFile(dir: string, exists?: (file: string) => boolean): string | null;
package/dist/config.js ADDED
@@ -0,0 +1,64 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ /** Identity helper for typed config + editor autocomplete (mirrors Playwright's `defineConfig`). */
4
+ export function defineConfig(config) {
5
+ return config;
6
+ }
7
+ /** Pick the project by explicit name, else by platform, else the first one. */
8
+ export function resolveProject(config, env = {}) {
9
+ if (env.project) {
10
+ const named = config.projects.find((project) => project.name === env.project);
11
+ if (!named)
12
+ throw new Error(`nativeproof: no project named "${env.project}"`);
13
+ return named;
14
+ }
15
+ if (env.platform) {
16
+ const byPlatform = config.projects.find((project) => project.platform === env.platform);
17
+ if (byPlatform)
18
+ return byPlatform;
19
+ }
20
+ const first = config.projects[0];
21
+ if (!first)
22
+ throw new Error("nativeproof: config has no `projects`");
23
+ return first;
24
+ }
25
+ /**
26
+ * Translate an NativeProof config into a WebdriverIO `config` object. Spec paths are made
27
+ * absolute against `cwd` (the project root) because the synthesised config is loaded from
28
+ * inside `node_modules`, so a relative glob would resolve against the wrong directory.
29
+ */
30
+ export function buildWdioConfig(config, env = {}, cwd = process.cwd()) {
31
+ const project = resolveProject(config, env);
32
+ const testDir = config.testDir ?? "tests";
33
+ const testMatch = config.testMatch ?? "**/*.spec.ts";
34
+ const specs = [path.resolve(cwd, env.spec ?? `${testDir}/${testMatch}`)];
35
+ return {
36
+ runner: "local",
37
+ hostname: env.appiumHost ?? config.appium?.host ?? "127.0.0.1",
38
+ port: env.appiumPort ?? config.appium?.port ?? 4723,
39
+ path: env.appiumPath ?? config.appium?.path ?? "/wd/hub",
40
+ specs,
41
+ maxInstances: 1,
42
+ capabilities: [project.capabilities],
43
+ framework: "mocha",
44
+ reporters: ["spec"],
45
+ mochaOpts: { ui: "bdd", timeout: config.mochaTimeout ?? 240_000 },
46
+ };
47
+ }
48
+ const CONFIG_NAMES = [
49
+ "nativeproof.config.ts",
50
+ "nativeproof.config.mts",
51
+ "nativeproof.config.cts",
52
+ "nativeproof.config.js",
53
+ "nativeproof.config.mjs",
54
+ "nativeproof.config.cjs",
55
+ ];
56
+ /** Find an `nativeproof.config.*` in `dir`, or null. `exists` is injectable for testing. */
57
+ export function findConfigFile(dir, exists = existsSync) {
58
+ for (const name of CONFIG_NAMES) {
59
+ const candidate = path.join(dir, name);
60
+ if (exists(candidate))
61
+ return candidate;
62
+ }
63
+ return null;
64
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The minimal device contract the locator and expect layers drive.
3
+ *
4
+ * A thin seam over the real engine (WebdriverIO/Appium) — the Playwright `Page`
5
+ * equivalent — that keeps locators and matchers testable without a device: tests
6
+ * supply a fake `Driver`, production uses {@link wdioDriver}.
7
+ */
8
+ export type Platform = "android" | "ios";
9
+ export interface Driver {
10
+ /** The platform under test — selects how a cross-platform selector maps to source. */
11
+ readonly platform: Platform;
12
+ /** Current page-source XML (best-effort; empty string on driver error). */
13
+ source(): Promise<string>;
14
+ /** Idle for the given milliseconds; also used as the poll interval by the waits. */
15
+ pause(ms: number): Promise<void>;
16
+ /** Tap an absolute screen coordinate. */
17
+ tapAt(x: number, y: number): Promise<void>;
18
+ }
19
+ /** A {@link Driver} backed by the live WebdriverIO/Appium session. */
20
+ export declare function wdioDriver(): Driver;
package/dist/driver.js ADDED
@@ -0,0 +1,13 @@
1
+ import { browser } from "@wdio/globals";
2
+ import { tapAt } from "./gestures.js";
3
+ /** A {@link Driver} backed by the live WebdriverIO/Appium session. */
4
+ export function wdioDriver() {
5
+ return {
6
+ get platform() {
7
+ return browser.isAndroid ? "android" : "ios";
8
+ },
9
+ source: () => browser.getPageSource().catch(() => ""),
10
+ pause: (ms) => browser.pause(ms),
11
+ tapAt: (x, y) => tapAt(x, y),
12
+ };
13
+ }
@@ -0,0 +1,5 @@
1
+ export declare function redactEvidenceText(contents: string): string;
2
+ export declare function captureText(filename: string, contents: string): Promise<string>;
3
+ export declare function captureScreenshot(filename: string): Promise<string>;
4
+ /** Capture a screenshot + redacted source pair under one prefix; returns the source. */
5
+ export declare function captureState(prefix: string): Promise<string>;
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { browser } from "@wdio/globals";
4
+ /**
5
+ * Test-time evidence capture: screenshots and redacted page-source snapshots.
6
+ *
7
+ * A passing mobile test must prove app state, not just "the runner did not throw".
8
+ * Every meaningful step writes a `.png` + redacted `.xml` pair into the artifact
9
+ * directory so a green run is auditable. Secrets are stripped before anything
10
+ * touches disk. Part of the reusable framework core.
11
+ */
12
+ const artifactDir = process.env.E2E_ARTIFACT_DIR ?? ".e2e-artifacts";
13
+ export function redactEvidenceText(contents) {
14
+ return String(contents)
15
+ .replace(/(text=")\d{4,8}(")/g, "$1[REDACTED]$2")
16
+ .replace(/(passcode"?\s*[:=]\s*"?)\d{4,8}/gi, "$1[REDACTED]")
17
+ .replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, "$1[REDACTED]");
18
+ }
19
+ async function ensureArtifactDir() {
20
+ await fs.mkdir(artifactDir, { recursive: true });
21
+ }
22
+ export async function captureText(filename, contents) {
23
+ await ensureArtifactDir();
24
+ const target = path.join(artifactDir, filename);
25
+ await fs.writeFile(target, redactEvidenceText(contents), "utf8");
26
+ return target;
27
+ }
28
+ export async function captureScreenshot(filename) {
29
+ await ensureArtifactDir();
30
+ const target = path.join(artifactDir, filename);
31
+ await browser.saveScreenshot(target);
32
+ return target;
33
+ }
34
+ /** Capture a screenshot + redacted source pair under one prefix; returns the source. */
35
+ export async function captureState(prefix) {
36
+ const source = await browser.getPageSource().catch(() => "");
37
+ await captureScreenshot(`${prefix}.png`);
38
+ await captureText(`${prefix}.xml`, source);
39
+ return source;
40
+ }
@@ -0,0 +1,26 @@
1
+ import { Locator, type WaitOptions } from "./locator.js";
2
+ import { type FrameMatch, type MockBackend } from "./mock.js";
3
+ /**
4
+ * Playwright-style assertions with built-in auto-waiting — the "easy visibility"
5
+ * layer. `expect(locator)` asserts UI state; `expect(mock)` asserts backend traffic.
6
+ * Each matcher polls until the condition holds (or the timeout elapses), then asserts;
7
+ * `.not` inverts it. No manual waits in the spec.
8
+ */
9
+ export interface LocatorAssertions {
10
+ readonly not: LocatorAssertions;
11
+ /** The selector matches a node in the source. */
12
+ toBeVisible(options?: WaitOptions): Promise<void>;
13
+ /** The selector is present and `text` is shown on screen. */
14
+ toShow(text: string | RegExp, options?: WaitOptions): Promise<void>;
15
+ /** The matched node's own text equals/contains/matches `text`. */
16
+ toHaveText(text: string | RegExp, options?: WaitOptions): Promise<void>;
17
+ }
18
+ export interface MockAssertions {
19
+ readonly not: MockAssertions;
20
+ /** The app sent a frame matching `match`. */
21
+ toHaveSent(match: FrameMatch, options?: WaitOptions): Promise<void>;
22
+ /** The app received a frame matching `match`. */
23
+ toHaveReceived(match: FrameMatch, options?: WaitOptions): Promise<void>;
24
+ }
25
+ export declare function expect(target: Locator): LocatorAssertions;
26
+ export declare function expect(target: MockBackend): MockAssertions;
package/dist/expect.js ADDED
@@ -0,0 +1,65 @@
1
+ import { describeSelector, Locator, waitUntil } from "./locator.js";
2
+ import { describeMatch, frameExists } from "./mock.js";
3
+ class LocatorExpectation {
4
+ locator;
5
+ negated;
6
+ constructor(locator, negated = false) {
7
+ this.locator = locator;
8
+ this.negated = negated;
9
+ }
10
+ get not() {
11
+ return new LocatorExpectation(this.locator, !this.negated);
12
+ }
13
+ toBeVisible(options = {}) {
14
+ return this.check(() => this.locator.isVisible(), "be visible", options);
15
+ }
16
+ toShow(text, options = {}) {
17
+ return this.check(() => this.locator.shows(text), `show ${JSON.stringify(String(text))}`, options);
18
+ }
19
+ toHaveText(text, options = {}) {
20
+ return this.check(async () => {
21
+ const content = await this.locator.textContent();
22
+ if (content === null)
23
+ return false;
24
+ return typeof text === "string" ? content.includes(text) : text.test(content);
25
+ }, `have text ${JSON.stringify(String(text))}`, options);
26
+ }
27
+ async check(predicate, description, options) {
28
+ const want = !this.negated;
29
+ const opts = { ...options, sleep: (ms) => this.locator.driver.pause(ms) };
30
+ const settled = await waitUntil(predicate, (value) => value === want, opts);
31
+ if (settled !== want) {
32
+ const not = this.negated ? ".not" : "";
33
+ throw new Error(`expect(${describeSelector(this.locator.selector)})${not}.to ${description} — assertion not met`);
34
+ }
35
+ }
36
+ }
37
+ class MockExpectation {
38
+ mock;
39
+ negated;
40
+ constructor(mock, negated = false) {
41
+ this.mock = mock;
42
+ this.negated = negated;
43
+ }
44
+ get not() {
45
+ return new MockExpectation(this.mock, !this.negated);
46
+ }
47
+ toHaveSent(match, options = {}) {
48
+ return this.check("sent", match, options);
49
+ }
50
+ toHaveReceived(match, options = {}) {
51
+ return this.check("received", match, options);
52
+ }
53
+ async check(direction, match, options) {
54
+ const want = !this.negated;
55
+ const settled = await waitUntil(() => frameExists(this.mock, direction, match), (value) => value === want, options);
56
+ if (settled !== want) {
57
+ const not = this.negated ? ".not" : "";
58
+ const matcher = direction === "sent" ? "toHaveSent" : "toHaveReceived";
59
+ throw new Error(`expect(mock)${not}.${matcher}(${describeMatch(match)}) — assertion not met`);
60
+ }
61
+ }
62
+ }
63
+ export function expect(target) {
64
+ return target instanceof Locator ? new LocatorExpectation(target) : new MockExpectation(target);
65
+ }
@@ -0,0 +1,62 @@
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
+ /**
17
+ * Provisioning + teardown for one behaviour scenario's shared fixture context.
18
+ *
19
+ * The context is provisioned ONCE per scenario (Mocha `before`) and shared across
20
+ * its ordered behaviours, then torn down ONCE (`after`) — the analogue of a
21
+ * Playwright scoped fixture or `describe.serial`. This suits stateful mobile
22
+ * sessions where a single login+join underpins many in-session behaviours:
23
+ * re-provisioning per behaviour would be prohibitively slow and would change what
24
+ * is asserted, because the behaviours accumulate session state in a deliberate
25
+ * order. A per-behaviour-isolation mode can layer on later for stateless scenarios
26
+ * without changing this contract.
27
+ *
28
+ * @typeParam Ctx - the fixture context injected into each behaviour.
29
+ */
30
+ export interface ScenarioFixture<Ctx> {
31
+ /** Provision the shared context (e.g. start mock, relaunch app, log in, join). */
32
+ setup(): Promise<Ctx>;
33
+ /**
34
+ * Release everything {@link ScenarioFixture.setup} acquired. Always invoked, even
35
+ * when `setup` threw partway, so it receives `undefined` if no context was
36
+ * produced and must be safe to call in that state.
37
+ */
38
+ teardown(context: Ctx | undefined): Promise<void>;
39
+ }
40
+ /**
41
+ * Registers one behaviour. The provisioned context is injected, so the body is pure
42
+ * behaviour — no `before`/`after`, no threading a backend handle through assertions.
43
+ * Read it Playwright-style: destructure the fixtures the behaviour needs.
44
+ */
45
+ export type BehaviourRegistrar<Ctx> = (title: string, body: (context: Ctx) => void | Promise<void>) => void;
46
+ /**
47
+ * Define a behaviour scenario: a Mocha `describe` whose fixture context is
48
+ * provisioned once, injected into every behaviour, and torn down once.
49
+ *
50
+ * @param title - the scenario description (Mocha `describe` title).
51
+ * @param fixture - how to provision and tear down the shared context.
52
+ * @param define - registers the behaviours; receives a `test` registrar that
53
+ * injects the context.
54
+ *
55
+ * @example
56
+ * describeScenario("chat room", chatRoomScenario(), (test) => {
57
+ * test("renders the latest message", async ({ member }) => {
58
+ * await member.assertLatestMessageVisible();
59
+ * });
60
+ * });
61
+ */
62
+ export declare function describeScenario<Ctx>(title: string, fixture: ScenarioFixture<Ctx>, define: (test: BehaviourRegistrar<Ctx>) => void): void;