@shipvo/test 0.1.0 → 0.1.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/README.md CHANGED
@@ -20,4 +20,62 @@ npx shipvo sim # the simulator, http://localhost:4545
20
20
  npx vitest run
21
21
  ```
22
22
 
23
- Works with any runner through `openApp` from `@shipvo/test`. Full guide, CI workflow and API: [Test your app in CI](/docs/testing/).
23
+ ## Mock the network
24
+
25
+ Stub what the app's `fetch` gets back, so you can test slow, empty and failing responses without a backend. Requests that match no rule go to the network as usual.
26
+
27
+ ```ts
28
+ import { test, expect } from "@shipvo/test/vitest";
29
+
30
+ test("shows an error when the catalogue is down", async ({ phone }) => {
31
+ await phone.mockNetwork([
32
+ { url: /\/api\/products/, status: 503, body: { error: "down" }, delayMs: 300 },
33
+ ]);
34
+ await phone.tap("Reload");
35
+ await phone.waitFor(/try again/i);
36
+ await phone.clearNetworkMocks(); // back to the real network
37
+ });
38
+ ```
39
+
40
+ A rule has `url` (a string that must equal the full request URL, or a RegExp), an optional `method` (any method if left out), `status`, `body` (a string, or an object sent as JSON), optional `headers` and optional `delayMs`. The first matching rule wins, and each `mockNetwork` call replaces the earlier rules. Only `fetch` and XHR calls are stubbed. A bad rule throws a `PhoneError` with code `mock_invalid`.
41
+
42
+ ## Failure-path examples
43
+
44
+ Runnable versions are in [`examples/flows.ts`](examples/flows.ts). They use the Tide & Tackle labels. Change them to match your app. The checkout flows need a cart, so open the phone with one: `openApp({ seedCart: [{ product_id: "rod-surf-36", quantity: 1 }] })`. The full purchase is in [`checkout.test.ts`](../../apps/tide-and-tackle/tests/checkout.test.ts).
45
+
46
+ **Permission denied**
47
+
48
+ ```ts
49
+ await phone.tap(/^Open cart/);
50
+ await phone.tap("Choose delivery and pay");
51
+ await phone.tap("Use my location");
52
+ await phone.tap("Deny");
53
+ await phone.waitFor("Location permission was not granted.");
54
+ ```
55
+
56
+ **Offline**
57
+
58
+ ```ts
59
+ await phone.device.set({ online: false });
60
+ await phone.waitFor("You're offline");
61
+ ```
62
+
63
+ **Payment decline**
64
+
65
+ ```ts
66
+ await phone.device.set({ payment: { nextResult: "decline", pin: "1234" }, biometrics: { enrolled: true, nextResult: "success" } });
67
+ await phone.tap(/^Open cart/);
68
+ await phone.tap("Choose delivery and pay");
69
+ await phone.tap(/^Any day Shop pickup/);
70
+ await phone.tap("Mobile money");
71
+ await phone.type("Mobile money number", "677000111");
72
+ await phone.tap("Place order");
73
+ await phone.tap("Use fingerprint");
74
+ await phone.type("Mobile money PIN", "1234");
75
+ await phone.tap("Confirm payment");
76
+ await phone.waitFor("Payment declined");
77
+ ```
78
+
79
+ The tests for these flows run against a small fake simulator, not the real app. The fake follows the real screen order and button names.
80
+
81
+ Works with any runner through `openApp` from `@shipvo/test`. Full guide, CI workflow and API: [Test your app in CI](https://shipvo.dev/docs/testing/).
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { openApp, Phone, Device } from "./phone.js";
2
- export type { DeviceSettings, DeviceProfile, OpenOptions, Notification } from "./phone.js";
1
+ export { openApp, Phone, Device, Contacts, Calendar, Files, PhoneError } from "./phone.js";
2
+ export type { DeviceSettings, DeviceProfile, OpenOptions, Notification, Contact, CalendarEvent, SeedFile, MockRule, MockResponse } from "./phone.js";
3
3
  export { parseScreen, find } from "./tree.js";
4
4
  export type { Node, Target } from "./tree.js";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { openApp, Phone, Device } from "./phone.js";
1
+ export { openApp, Phone, Device, Contacts, Calendar, Files, PhoneError } from "./phone.js";
2
2
  export { parseScreen, find } from "./tree.js";
package/dist/phone.d.ts CHANGED
@@ -1,4 +1,27 @@
1
1
  import { type Node, type Target } from "./tree.js";
2
+ /** One address-book entry. */
3
+ export type Contact = {
4
+ id: string;
5
+ name: string;
6
+ phoneNumbers?: string[];
7
+ emails?: string[];
8
+ };
9
+ /** One calendar event. `start` and `end` are ISO 8601 strings. */
10
+ export type CalendarEvent = {
11
+ id: string;
12
+ title: string;
13
+ start: string;
14
+ end: string;
15
+ allDay?: boolean;
16
+ location?: string;
17
+ notes?: string;
18
+ };
19
+ /** A file the simulated picker offers. `content` is its text. */
20
+ export type SeedFile = {
21
+ name: string;
22
+ mimeType: string;
23
+ content: string;
24
+ };
2
25
  type Grant = "granted" | "denied" | "undetermined";
3
26
  /** What the simulated phone has. Takes effect for the next action. */
4
27
  export type DeviceSettings = {
@@ -19,7 +42,15 @@ export type DeviceSettings = {
19
42
  camera?: Grant;
20
43
  location?: Grant;
21
44
  notifications?: Grant;
45
+ contacts?: Grant;
46
+ calendar?: Grant;
22
47
  };
48
+ /** The address book the app can read once contacts permission is granted. Replaces the current one. */
49
+ contacts?: Contact[];
50
+ /** The calendar events the app can read once calendar permission is granted. Replaces the current ones. */
51
+ calendar?: CalendarEvent[];
52
+ /** The files the file picker offers. Replaces the current ones. */
53
+ files?: SeedFile[];
23
54
  biometrics?: {
24
55
  enrolled?: boolean;
25
56
  nextResult?: "success" | "fail" | "cancel";
@@ -52,8 +83,26 @@ export type Notification = {
52
83
  body: string;
53
84
  data?: Record<string, unknown>;
54
85
  };
86
+ /** An action the simulator refused. `code` is set for typed failures such as `no_url_listener` or `stale_ref`. */
87
+ export declare class PhoneError extends Error {
88
+ readonly code?: string | undefined;
89
+ constructor(message: string, code?: string | undefined);
90
+ }
91
+ /** What a mocked request answers with. An object `body` is sent as JSON. */
92
+ export type MockResponse = {
93
+ status: number;
94
+ body?: string | Record<string, unknown> | unknown[] | null;
95
+ headers?: Record<string, string>;
96
+ delayMs?: number;
97
+ };
98
+ /** A string `url` must equal the full request URL. A RegExp is tested against it. Without `method`, any method matches. The first matching rule wins. */
99
+ export type MockRule = {
100
+ url: string | RegExp;
101
+ method?: string;
102
+ } & MockResponse;
55
103
  type Reply = {
56
104
  ok: boolean;
105
+ code?: string;
57
106
  screen?: string | null;
58
107
  error?: string;
59
108
  path?: string;
@@ -61,6 +110,8 @@ type Reply = {
61
110
  log?: unknown[];
62
111
  capabilities?: unknown;
63
112
  release?: string | number;
113
+ delivered?: boolean;
114
+ id?: string;
64
115
  };
65
116
  /** A simulated phone running your app. Every action returns once the screen has settled, and updates `screen`. */
66
117
  export declare class Phone {
@@ -71,6 +122,13 @@ export declare class Phone {
71
122
  /** The current screen as an accessibility tree. */
72
123
  screen: string;
73
124
  readonly device: Device;
125
+ /** The simulated address book. */
126
+ readonly contacts: Contacts;
127
+ readonly calendar: Calendar;
128
+ /** The notification shade. Call it to list notifications; `receive` and `tap` deliver and open one. */
129
+ readonly notifications: Notifications;
130
+ /** The simulated file picker. */
131
+ readonly files: Files;
74
132
  constructor(session: string, sim: string, app: string, release: string | number);
75
133
  /** @internal */
76
134
  call(path: string, body: Record<string, unknown>): Promise<Reply>;
@@ -99,16 +157,21 @@ export declare class Phone {
99
157
  timeout?: number;
100
158
  interval?: number;
101
159
  }): Promise<this>;
102
- /** Deliver a push notification: it shows as a banner the app can be opened from. */
160
+ /** Deliver a push notification: it shows as a banner the app can be opened from. It reaches the app only if notification permission is granted (`phone.device.set({ permissions: { notifications: "granted" } })`); otherwise nothing is delivered and this still resolves. Use `phone.notifications.receive()` to learn whether it was delivered. */
103
161
  notify(n: {
104
162
  title: string;
105
163
  body: string;
106
164
  data?: Record<string, unknown>;
107
165
  }): Promise<this>;
108
- /** The notification shade (banners vanish after about 5 s, the shade keeps them). */
109
- notifications(): Promise<Notification[]>;
110
166
  /** Tap a notification from the shade (the newest if no id). */
111
167
  openNotification(id?: string): Promise<this>;
168
+ /** Open a deep link in the app, as a push tap or share link would. Throws a `PhoneError` with code `no_url_listener` if the app has no `onUrl` listener. */
169
+ openUrl(url: string): Promise<this>;
170
+ /** Stub `fetch` and XHR calls the app makes. Replaces earlier rules. Unmatched requests go to the network as usual.
171
+ * Throws a `PhoneError` with code `mock_invalid` for a bad rule. */
172
+ mockNetwork(rules: MockRule[]): Promise<this>;
173
+ /** Remove every network mock. */
174
+ clearNetworkMocks(): Promise<this>;
112
175
  /** What the app did with the device: permission prompts and answers, location reads, scans, fingerprint checks, payments. */
113
176
  deviceLog(): Promise<unknown[]>;
114
177
  /** A PNG of the screen. Works when the test runs on the same machine as the simulator, as in CI. */
@@ -116,6 +179,45 @@ export declare class Phone {
116
179
  /** Nothing to release on the simulator side today; kept so tests read the same if that changes. */
117
180
  close(): Promise<void>;
118
181
  }
182
+ /** Turns rules into the wire format. The sim's parseMockRules (packages/sim/mock-rules.mjs) reads it: the two must stay in sync. */
183
+ export declare function encodeMockRules(rules: MockRule[]): unknown[];
184
+ /** The simulated address book. Apps read it with `expo-contacts` or `device.contacts.getAll()`; writing back is not simulated. */
185
+ export declare class Contacts {
186
+ private readonly phone;
187
+ constructor(phone: Phone);
188
+ /** Seed the address book (replaces what was there). The app still asks for permission; answer with `phone.device.set({ permissions: { contacts } })` or tap the dialog. */
189
+ set(contacts: Contact[]): Promise<Phone>;
190
+ }
191
+ /** `phone.notifications()` lists the shade (banners vanish after about 5 s, the shade keeps them). */
192
+ export interface Notifications {
193
+ (): Promise<Notification[]>;
194
+ /** Push a notification to the app. It reaches the app only if notification permission is granted; resolves `false` (and the app sees nothing) when it is not. */
195
+ receive(n: {
196
+ title: string;
197
+ body: string;
198
+ data?: Record<string, unknown>;
199
+ }): Promise<boolean>;
200
+ /** Tap a notification in the shade (the newest if no id): the app's tap handler runs with its `data`. */
201
+ tap(id?: string): Promise<Phone>;
202
+ }
203
+ /** The simulated file picker. The app opens it with `expo-document-picker` or `device.files.pick()`; you answer it here. */
204
+ export declare class Files {
205
+ private readonly phone;
206
+ constructor(phone: Phone);
207
+ /** Seed the files the picker offers (replaces what was there). */
208
+ set(files: SeedFile[]): Promise<Phone>;
209
+ /** Pick a file in the open picker dialog by name. */
210
+ pick(name: string): Promise<Phone>;
211
+ /** Cancel the open picker dialog. */
212
+ cancel(): Promise<Phone>;
213
+ }
214
+ /** The simulated calendar. Apps read it with `expo-calendar` or `device.calendar.getEvents()`; writing back is not simulated. */
215
+ export declare class Calendar {
216
+ private readonly phone;
217
+ constructor(phone: Phone);
218
+ /** Seed the calendar (replaces what was there). The app still asks for permission; answer with `phone.device.set({ permissions: { calendar } })` or tap the dialog. */
219
+ set(events: CalendarEvent[]): Promise<Phone>;
220
+ }
119
221
  /** The simulated phone's hardware and settings. */
120
222
  export declare class Device {
121
223
  private readonly phone;
package/dist/phone.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { find, parseScreen } from "./tree.js";
3
+ /** An action the simulator refused. `code` is set for typed failures such as `no_url_listener` or `stale_ref`. */
4
+ export class PhoneError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "PhoneError";
10
+ }
11
+ }
3
12
  /** A simulated phone running your app. Every action returns once the screen has settled, and updates `screen`. */
4
13
  export class Phone {
5
14
  session;
@@ -9,12 +18,23 @@ export class Phone {
9
18
  /** The current screen as an accessibility tree. */
10
19
  screen = "";
11
20
  device;
21
+ /** The simulated address book. */
22
+ contacts;
23
+ calendar;
24
+ /** The notification shade. Call it to list notifications; `receive` and `tap` deliver and open one. */
25
+ notifications;
26
+ /** The simulated file picker. */
27
+ files;
12
28
  constructor(session, sim, app, release) {
13
29
  this.session = session;
14
30
  this.sim = sim;
15
31
  this.app = app;
16
32
  this.release = release;
17
33
  this.device = new Device(this);
34
+ this.contacts = new Contacts(this);
35
+ this.calendar = new Calendar(this);
36
+ this.files = new Files(this);
37
+ this.notifications = makeNotifications(this);
18
38
  }
19
39
  /** @internal */
20
40
  async call(path, body) {
@@ -36,7 +56,7 @@ export class Phone {
36
56
  this.screen = again.screen;
37
57
  }
38
58
  if (!r.ok)
39
- throw new Error(`${action} failed: ${r.error}\n\nScreen now:\n${this.screen}`);
59
+ throw new PhoneError(`${action} failed: ${r.error}\n\nScreen now:\n${this.screen}`, r.code);
40
60
  return r;
41
61
  }
42
62
  /** Every element on the current screen. */
@@ -90,12 +110,17 @@ export class Phone {
90
110
  await new Promise((r) => setTimeout(r, opts.interval ?? 250));
91
111
  }
92
112
  }
93
- /** Deliver a push notification: it shows as a banner the app can be opened from. */
113
+ /** Deliver a push notification: it shows as a banner the app can be opened from. It reaches the app only if notification permission is granted (`phone.device.set({ permissions: { notifications: "granted" } })`); otherwise nothing is delivered and this still resolves. Use `phone.notifications.receive()` to learn whether it was delivered. */
94
114
  async notify(n) { await this.act("push_notification", n); return this; }
95
- /** The notification shade (banners vanish after about 5 s, the shade keeps them). */
96
- async notifications() { return (await this.act("notifications")).notifications ?? []; }
97
115
  /** Tap a notification from the shade (the newest if no id). */
98
116
  async openNotification(id) { await this.act("open_notification", { id }); return this; }
117
+ /** Open a deep link in the app, as a push tap or share link would. Throws a `PhoneError` with code `no_url_listener` if the app has no `onUrl` listener. */
118
+ async openUrl(url) { await this.act("open_url", { url }); return this; }
119
+ /** Stub `fetch` and XHR calls the app makes. Replaces earlier rules. Unmatched requests go to the network as usual.
120
+ * Throws a `PhoneError` with code `mock_invalid` for a bad rule. */
121
+ async mockNetwork(rules) { await this.act("set_network_mocks", { rules: encodeMockRules(rules) }); return this; }
122
+ /** Remove every network mock. */
123
+ async clearNetworkMocks() { await this.act("set_network_mocks", { rules: [] }); return this; }
99
124
  /** What the app did with the device: permission prompts and answers, location reads, scans, fingerprint checks, payments. */
100
125
  async deviceLog() { return (await this.act("device_log")).log ?? []; }
101
126
  /** A PNG of the screen. Works when the test runs on the same machine as the simulator, as in CI. */
@@ -108,6 +133,73 @@ export class Phone {
108
133
  /** Nothing to release on the simulator side today; kept so tests read the same if that changes. */
109
134
  async close() { }
110
135
  }
136
+ /** Turns rules into the wire format. The sim's parseMockRules (packages/sim/mock-rules.mjs) reads it: the two must stay in sync. */
137
+ export function encodeMockRules(rules) {
138
+ const bad = (i, why) => new PhoneError(`mockNetwork rule ${i}: ${why}`, "mock_invalid");
139
+ if (!Array.isArray(rules))
140
+ throw new PhoneError("mockNetwork needs an array of rules", "mock_invalid");
141
+ return rules.map((r, i) => {
142
+ if (!r || typeof r !== "object")
143
+ throw bad(i, "must be an object");
144
+ const { url, method, status, body, headers, delayMs } = r;
145
+ if (!(typeof url === "string" && url) && !(url instanceof RegExp))
146
+ throw bad(i, "url must be a non-empty string or a RegExp");
147
+ if (method !== undefined && (typeof method !== "string" || !method))
148
+ throw bad(i, "method must be a non-empty string");
149
+ if (!Number.isInteger(status) || status < 100 || status > 599)
150
+ throw bad(i, "status must be an integer from 100 to 599");
151
+ if (delayMs !== undefined && (typeof delayMs !== "number" || !(delayMs >= 0) || delayMs > 60000))
152
+ throw bad(i, "delayMs must be a number from 0 to 60000");
153
+ const h = { ...(headers ?? {}) };
154
+ let text = "";
155
+ if (typeof body === "string")
156
+ text = body;
157
+ else if (body !== undefined && body !== null) {
158
+ text = JSON.stringify(body);
159
+ if (!Object.keys(h).some((k) => k.toLowerCase() === "content-type"))
160
+ h["content-type"] = "application/json";
161
+ }
162
+ return { ...(typeof url === "string" ? { url } : { urlRegex: { source: url.source, flags: url.flags } }), method, response: { status, body: text, headers: h, delayMs } };
163
+ });
164
+ }
165
+ /** The simulated address book. Apps read it with `expo-contacts` or `device.contacts.getAll()`; writing back is not simulated. */
166
+ export class Contacts {
167
+ phone;
168
+ constructor(phone) {
169
+ this.phone = phone;
170
+ }
171
+ /** Seed the address book (replaces what was there). The app still asks for permission; answer with `phone.device.set({ permissions: { contacts } })` or tap the dialog. */
172
+ async set(contacts) { await this.phone.act("set_device", { device: { contacts } }); return this.phone; }
173
+ }
174
+ function makeNotifications(phone) {
175
+ const list = async () => ((await phone.act("notifications")).notifications ?? []);
176
+ return Object.assign(list, {
177
+ async receive(n) { return (await phone.act("push_notification", n)).delivered === true; },
178
+ async tap(id) { await phone.act("open_notification", { id }); return phone; },
179
+ });
180
+ }
181
+ /** The simulated file picker. The app opens it with `expo-document-picker` or `device.files.pick()`; you answer it here. */
182
+ export class Files {
183
+ phone;
184
+ constructor(phone) {
185
+ this.phone = phone;
186
+ }
187
+ /** Seed the files the picker offers (replaces what was there). */
188
+ async set(files) { await this.phone.act("set_device", { device: { files } }); return this.phone; }
189
+ /** Pick a file in the open picker dialog by name. */
190
+ async pick(name) { return this.phone.tap({ role: "button", name }); }
191
+ /** Cancel the open picker dialog. */
192
+ async cancel() { return this.phone.tap({ role: "button", name: "Cancel" }); }
193
+ }
194
+ /** The simulated calendar. Apps read it with `expo-calendar` or `device.calendar.getEvents()`; writing back is not simulated. */
195
+ export class Calendar {
196
+ phone;
197
+ constructor(phone) {
198
+ this.phone = phone;
199
+ }
200
+ /** Seed the calendar (replaces what was there). The app still asks for permission; answer with `phone.device.set({ permissions: { calendar } })` or tap the dialog. */
201
+ async set(events) { await this.phone.act("set_device", { device: { calendar: events } }); return this.phone; }
202
+ }
111
203
  /** The simulated phone's hardware and settings. */
112
204
  export class Device {
113
205
  phone;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/test",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Typed end-to-end tests for React Native apps that run in the Shipvo simulator: open the app, tap, type, set the phone's location, camera and permissions, and assert on what is on screen. Works in Vitest or any test runner, headless, in CI.",
@@ -51,6 +51,6 @@
51
51
  },
52
52
  "scripts": {
53
53
  "build": "tsc -p tsconfig.json",
54
- "test": "vitest run"
54
+ "test": "tsc -p tsconfig.examples.json && vitest run"
55
55
  }
56
56
  }