@shipvo/test 0.1.1 → 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 +19 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/phone.d.ts +105 -3
- package/dist/phone.js +96 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,25 @@ npx shipvo sim # the simulator, http://localhost:4545
|
|
|
20
20
|
npx vitest run
|
|
21
21
|
```
|
|
22
22
|
|
|
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
|
+
|
|
23
42
|
## Failure-path examples
|
|
24
43
|
|
|
25
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).
|
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
|
|
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.
|
|
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.",
|