@shipvo/test 0.1.1 → 0.1.3

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,6 +20,46 @@ npx shipvo sim # the simulator, http://localhost:4545
20
20
  npx vitest run
21
21
  ```
22
22
 
23
+ ## Plain-language steps
24
+
25
+ Write a test as plain lines, one step per line. Blank lines and lines starting with `#` are ignored. Quoted text is the element's accessibility name or text, as in `phone.tap("...")`.
26
+
27
+ ```ts
28
+ import { openApp, parseSteps, runSteps } from "@shipvo/test";
29
+
30
+ const phone = await openApp();
31
+ await runSteps(phone, parseSteps(`
32
+ # find a lure and put it in the cart
33
+ type "popper" into "Search" and press enter
34
+ tap "Add 1 to cart"
35
+ wait for "Cart"
36
+ do not see "Error"
37
+ scroll down
38
+ go back
39
+ `));
40
+ ```
41
+
42
+ Lines: `tap "X"`, `type "X" into "Y"` (optional `and press enter`), `scroll down`, `go back`, `see "X"`, `do not see "X"`, `wait for "X"`. An unknown line throws a `PhoneError` with code `step_unparsed` and its line number. `runSteps` stops at the first failing step and names its line.
43
+
44
+ ## Mock the network
45
+
46
+ 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.
47
+
48
+ ```ts
49
+ import { test, expect } from "@shipvo/test/vitest";
50
+
51
+ test("shows an error when the catalogue is down", async ({ phone }) => {
52
+ await phone.mockNetwork([
53
+ { url: /\/api\/products/, status: 503, body: { error: "down" }, delayMs: 300 },
54
+ ]);
55
+ await phone.tap("Reload");
56
+ await phone.waitFor(/try again/i);
57
+ await phone.clearNetworkMocks(); // back to the real network
58
+ });
59
+ ```
60
+
61
+ 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`.
62
+
23
63
  ## Failure-path examples
24
64
 
25
65
  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,9 @@
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";
5
+ export { parseSteps, runSteps } from "./steps.js";
6
+ export type { Step } from "./steps.js";
7
+ export { proposeRepair } from "./repair.js";
8
+ export type { Repair } from "./repair.js";
9
+ export { secret, redact } from "./secrets.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
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";
3
+ export { parseSteps, runSteps } from "./steps.js";
4
+ export { proposeRepair } from "./repair.js";
5
+ export { secret, redact } from "./secrets.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,15 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { find, parseScreen } from "./tree.js";
3
+ import { redact } from "./secrets.js";
4
+ /** An action the simulator refused. `code` is set for typed failures such as `no_url_listener` or `stale_ref`. */
5
+ export class PhoneError extends Error {
6
+ code;
7
+ constructor(message, code) {
8
+ super(redact(message));
9
+ this.code = code;
10
+ this.name = "PhoneError";
11
+ }
12
+ }
3
13
  /** A simulated phone running your app. Every action returns once the screen has settled, and updates `screen`. */
4
14
  export class Phone {
5
15
  session;
@@ -9,12 +19,23 @@ export class Phone {
9
19
  /** The current screen as an accessibility tree. */
10
20
  screen = "";
11
21
  device;
22
+ /** The simulated address book. */
23
+ contacts;
24
+ calendar;
25
+ /** The notification shade. Call it to list notifications; `receive` and `tap` deliver and open one. */
26
+ notifications;
27
+ /** The simulated file picker. */
28
+ files;
12
29
  constructor(session, sim, app, release) {
13
30
  this.session = session;
14
31
  this.sim = sim;
15
32
  this.app = app;
16
33
  this.release = release;
17
34
  this.device = new Device(this);
35
+ this.contacts = new Contacts(this);
36
+ this.calendar = new Calendar(this);
37
+ this.files = new Files(this);
38
+ this.notifications = makeNotifications(this);
18
39
  }
19
40
  /** @internal */
20
41
  async call(path, body) {
@@ -36,7 +57,7 @@ export class Phone {
36
57
  this.screen = again.screen;
37
58
  }
38
59
  if (!r.ok)
39
- throw new Error(`${action} failed: ${r.error}\n\nScreen now:\n${this.screen}`);
60
+ throw new PhoneError(`${action} failed: ${r.error}\n\nScreen now:\n${this.screen}`, r.code);
40
61
  return r;
41
62
  }
42
63
  /** Every element on the current screen. */
@@ -51,7 +72,7 @@ export class Phone {
51
72
  return find(this.nodes(), target);
52
73
  }
53
74
  catch (e) {
54
- throw new Error(`${e.message}\n\nScreen now:\n${this.screen}`);
75
+ throw new Error(redact(`${e.message}\n\nScreen now:\n${this.screen}`));
55
76
  }
56
77
  }
57
78
  async tap(target) { await this.act("tap", { ref: this.locate(target).ref }); return this; }
@@ -74,7 +95,7 @@ export class Phone {
74
95
  if (this.sees(text))
75
96
  return this;
76
97
  if (Date.now() > end)
77
- throw new Error(`timed out waiting for ${text instanceof RegExp ? text : `"${text}"`}\n\nScreen now:\n${this.screen}`);
98
+ throw new Error(redact(`timed out waiting for ${text instanceof RegExp ? text : `"${text}"`}\n\nScreen now:\n${this.screen}`));
78
99
  await new Promise((r) => setTimeout(r, opts.interval ?? 250));
79
100
  }
80
101
  }
@@ -86,16 +107,21 @@ export class Phone {
86
107
  if (!this.sees(text))
87
108
  return this;
88
109
  if (Date.now() > end)
89
- throw new Error(`"${text}" is still on screen after ${opts.timeout ?? 5000} ms\n\nScreen now:\n${this.screen}`);
110
+ throw new Error(redact(`"${text}" is still on screen after ${opts.timeout ?? 5000} ms\n\nScreen now:\n${this.screen}`));
90
111
  await new Promise((r) => setTimeout(r, opts.interval ?? 250));
91
112
  }
92
113
  }
93
- /** Deliver a push notification: it shows as a banner the app can be opened from. */
114
+ /** 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
115
  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
116
  /** Tap a notification from the shade (the newest if no id). */
98
117
  async openNotification(id) { await this.act("open_notification", { id }); return this; }
118
+ /** 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. */
119
+ async openUrl(url) { await this.act("open_url", { url }); return this; }
120
+ /** Stub `fetch` and XHR calls the app makes. Replaces earlier rules. Unmatched requests go to the network as usual.
121
+ * Throws a `PhoneError` with code `mock_invalid` for a bad rule. */
122
+ async mockNetwork(rules) { await this.act("set_network_mocks", { rules: encodeMockRules(rules) }); return this; }
123
+ /** Remove every network mock. */
124
+ async clearNetworkMocks() { await this.act("set_network_mocks", { rules: [] }); return this; }
99
125
  /** What the app did with the device: permission prompts and answers, location reads, scans, fingerprint checks, payments. */
100
126
  async deviceLog() { return (await this.act("device_log")).log ?? []; }
101
127
  /** A PNG of the screen. Works when the test runs on the same machine as the simulator, as in CI. */
@@ -108,6 +134,73 @@ export class Phone {
108
134
  /** Nothing to release on the simulator side today; kept so tests read the same if that changes. */
109
135
  async close() { }
110
136
  }
137
+ /** Turns rules into the wire format. The sim's parseMockRules (packages/sim/mock-rules.mjs) reads it: the two must stay in sync. */
138
+ export function encodeMockRules(rules) {
139
+ const bad = (i, why) => new PhoneError(`mockNetwork rule ${i}: ${why}`, "mock_invalid");
140
+ if (!Array.isArray(rules))
141
+ throw new PhoneError("mockNetwork needs an array of rules", "mock_invalid");
142
+ return rules.map((r, i) => {
143
+ if (!r || typeof r !== "object")
144
+ throw bad(i, "must be an object");
145
+ const { url, method, status, body, headers, delayMs } = r;
146
+ if (!(typeof url === "string" && url) && !(url instanceof RegExp))
147
+ throw bad(i, "url must be a non-empty string or a RegExp");
148
+ if (method !== undefined && (typeof method !== "string" || !method))
149
+ throw bad(i, "method must be a non-empty string");
150
+ if (!Number.isInteger(status) || status < 100 || status > 599)
151
+ throw bad(i, "status must be an integer from 100 to 599");
152
+ if (delayMs !== undefined && (typeof delayMs !== "number" || !(delayMs >= 0) || delayMs > 60000))
153
+ throw bad(i, "delayMs must be a number from 0 to 60000");
154
+ const h = { ...(headers ?? {}) };
155
+ let text = "";
156
+ if (typeof body === "string")
157
+ text = body;
158
+ else if (body !== undefined && body !== null) {
159
+ text = JSON.stringify(body);
160
+ if (!Object.keys(h).some((k) => k.toLowerCase() === "content-type"))
161
+ h["content-type"] = "application/json";
162
+ }
163
+ return { ...(typeof url === "string" ? { url } : { urlRegex: { source: url.source, flags: url.flags } }), method, response: { status, body: text, headers: h, delayMs } };
164
+ });
165
+ }
166
+ /** The simulated address book. Apps read it with `expo-contacts` or `device.contacts.getAll()`; writing back is not simulated. */
167
+ export class Contacts {
168
+ phone;
169
+ constructor(phone) {
170
+ this.phone = phone;
171
+ }
172
+ /** 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. */
173
+ async set(contacts) { await this.phone.act("set_device", { device: { contacts } }); return this.phone; }
174
+ }
175
+ function makeNotifications(phone) {
176
+ const list = async () => ((await phone.act("notifications")).notifications ?? []);
177
+ return Object.assign(list, {
178
+ async receive(n) { return (await phone.act("push_notification", n)).delivered === true; },
179
+ async tap(id) { await phone.act("open_notification", { id }); return phone; },
180
+ });
181
+ }
182
+ /** The simulated file picker. The app opens it with `expo-document-picker` or `device.files.pick()`; you answer it here. */
183
+ export class Files {
184
+ phone;
185
+ constructor(phone) {
186
+ this.phone = phone;
187
+ }
188
+ /** Seed the files the picker offers (replaces what was there). */
189
+ async set(files) { await this.phone.act("set_device", { device: { files } }); return this.phone; }
190
+ /** Pick a file in the open picker dialog by name. */
191
+ async pick(name) { return this.phone.tap({ role: "button", name }); }
192
+ /** Cancel the open picker dialog. */
193
+ async cancel() { return this.phone.tap({ role: "button", name: "Cancel" }); }
194
+ }
195
+ /** The simulated calendar. Apps read it with `expo-calendar` or `device.calendar.getEvents()`; writing back is not simulated. */
196
+ export class Calendar {
197
+ phone;
198
+ constructor(phone) {
199
+ this.phone = phone;
200
+ }
201
+ /** Seed the calendar (replaces what was there). The app still asks for permission; answer with `phone.device.set({ permissions: { calendar } })` or tap the dialog. */
202
+ async set(events) { await this.phone.act("set_device", { device: { calendar: events } }); return this.phone; }
203
+ }
111
204
  /** The simulated phone's hardware and settings. */
112
205
  export class Device {
113
206
  phone;
@@ -0,0 +1,18 @@
1
+ import type { Node } from "./tree.js";
2
+ import type { Step } from "./steps.js";
3
+ export type Repair = {
4
+ kind: "repaired";
5
+ step: Step;
6
+ reason: string;
7
+ } | {
8
+ kind: "unresolved";
9
+ candidates: Node[];
10
+ };
11
+ /**
12
+ * Propose a fix for a failed `tap` or `type` step, given the current accessibility tree (a screen string or parsed nodes).
13
+ * Returns `repaired` when exactly one element with the right role has the closest name. Returns `unresolved`, with the
14
+ * nearby candidates, when there is none, several are equally close, the best match has a different role, or its label cannot be written in a step line (it has a double quote or a newline).
15
+ * A repaired step is the same step with a new target: its text, enter flag and line are kept. Other step kinds are always `unresolved`.
16
+ * This function never removes or skips a step.
17
+ */
18
+ export declare function proposeRepair(step: Step, tree: string | Node[]): Repair;
package/dist/repair.js ADDED
@@ -0,0 +1,65 @@
1
+ import { parseScreen } from "./tree.js";
2
+ const TEXT_ROLES = ["textbox", "searchbox", "spinbutton", "combobox"];
3
+ const TAP_ROLES = ["button", "link", "tab", "checkbox", "switch", "radio", "menuitem", "option"];
4
+ const label = (n) => n.name || n.text;
5
+ const norm = (s) => s.trim().toLowerCase();
6
+ function distance(a, b) {
7
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
8
+ for (let i = 1; i <= a.length; i++) {
9
+ const cur = [i];
10
+ for (let j = 1; j <= b.length; j++)
11
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
12
+ prev = cur;
13
+ }
14
+ return prev[b.length];
15
+ }
16
+ /** 1 for equal names, towards 0 for unrelated ones. A name that contains the other counts as close. */
17
+ function similarity(a, b) {
18
+ a = norm(a);
19
+ b = norm(b);
20
+ if (!a || !b)
21
+ return 0;
22
+ if (a === b)
23
+ return 1;
24
+ const contained = a.includes(b) || b.includes(a) ? 0.5 + 0.5 * (Math.min(a.length, b.length) / Math.max(a.length, b.length)) : 0;
25
+ return Math.max(contained, 1 - distance(a, b) / Math.max(a.length, b.length));
26
+ }
27
+ const CLOSE = 0.5;
28
+ /**
29
+ * Propose a fix for a failed `tap` or `type` step, given the current accessibility tree (a screen string or parsed nodes).
30
+ * Returns `repaired` when exactly one element with the right role has the closest name. Returns `unresolved`, with the
31
+ * nearby candidates, when there is none, several are equally close, the best match has a different role, or its label cannot be written in a step line (it has a double quote or a newline).
32
+ * A repaired step is the same step with a new target: its text, enter flag and line are kept. Other step kinds are always `unresolved`.
33
+ * This function never removes or skips a step.
34
+ */
35
+ export function proposeRepair(step, tree) {
36
+ const nodes = typeof tree === "string" ? parseScreen(tree) : tree;
37
+ if (step.kind !== "tap" && step.kind !== "type")
38
+ return { kind: "unresolved", candidates: [] };
39
+ const roles = step.role ? [step.role] : step.kind === "type" ? TEXT_ROLES : TAP_ROLES;
40
+ const scored = nodes
41
+ .filter((n) => label(n) !== "")
42
+ .map((n) => ({ n, s: similarity(step.target, label(n)) }))
43
+ .filter((x) => x.s >= CLOSE)
44
+ .sort((a, b) => b.s - a.s);
45
+ const sameRole = scored.filter((x) => roles.includes(x.n.role));
46
+ if (sameRole.length === 0)
47
+ return { kind: "unresolved", candidates: scored.map((x) => x.n) };
48
+ const top = sameRole.filter((x) => x.s === sameRole[0].s);
49
+ if (top.length > 1)
50
+ return { kind: "unresolved", candidates: top.map((x) => x.n) };
51
+ const best = top[0].n;
52
+ // A closer match with another role means the role changed: do not guess.
53
+ if (scored[0].s > top[0].s)
54
+ return { kind: "unresolved", candidates: scored.map((x) => x.n) };
55
+ // The step grammar has no escapes, so a label with a quote or newline cannot be written back as a step line.
56
+ if (/["\r\n]/.test(label(best)))
57
+ return { kind: "unresolved", candidates: [best] };
58
+ if (label(best) === step.target)
59
+ return { kind: "unresolved", candidates: [best] };
60
+ return {
61
+ kind: "repaired",
62
+ step: { ...step, target: label(best) },
63
+ reason: `${step.kind} target "${step.target}" is now "${label(best)}" (${best.role}, ref ${best.ref})`,
64
+ };
65
+ }
@@ -0,0 +1,6 @@
1
+ /** Read `SHIPVO_TEST_<NAME>` from the environment. Throws, naming the variable, if it is missing or empty. The value is masked as `***` in errors from this package. */
2
+ export declare function secret(name: string): string;
3
+ /** Replace every secret value that was read so far with `***`. */
4
+ export declare function redact(text: string): string;
5
+ /** Replace each `{secret:NAME}` with its value. Throws if a variable is missing. */
6
+ export declare function resolveSecrets(text: string): string;
@@ -0,0 +1,29 @@
1
+ /** Secret values read so far. Shared by the whole process and never cleared. */
2
+ const known = new Set();
3
+ /** Values shorter than this are not masked: masking `1` would garble every error. */
4
+ const MIN_MASK = 4;
5
+ /** Read `SHIPVO_TEST_<NAME>` from the environment. Throws, naming the variable, if it is missing or empty. The value is masked as `***` in errors from this package. */
6
+ export function secret(name) {
7
+ const key = name.toUpperCase();
8
+ const variable = `SHIPVO_TEST_${key}`;
9
+ const value = process.env[variable];
10
+ if (!value) {
11
+ const err = new Error(`secret "${name}" is not set. Set the environment variable ${variable} (in CI, map a repository secret to it with \`env:\`).`);
12
+ err.code = "secret_missing";
13
+ throw err;
14
+ }
15
+ if (value.length >= MIN_MASK)
16
+ known.add(value);
17
+ return value;
18
+ }
19
+ /** Replace every secret value that was read so far with `***`. */
20
+ export function redact(text) {
21
+ let out = text;
22
+ for (const v of [...known].sort((a, b) => b.length - a.length))
23
+ out = out.split(v).join("***");
24
+ return out;
25
+ }
26
+ /** Replace each `{secret:NAME}` with its value. Throws if a variable is missing. */
27
+ export function resolveSecrets(text) {
28
+ return text.replace(/\{secret:([A-Za-z0-9_]+)\}/g, (_m, n) => secret(n));
29
+ }
@@ -0,0 +1,38 @@
1
+ import type { Phone } from "./phone.js";
2
+ /** One parsed line. `target` is the quoted text: an accessibility name or visible text, as in the typed API. `role` is optional and narrows which elements a repair may pick. `line` is the 1-based line number in the source text. */
3
+ export type Step = {
4
+ kind: "tap";
5
+ target: string;
6
+ role?: string;
7
+ line: number;
8
+ } | {
9
+ kind: "type";
10
+ target: string;
11
+ text: string;
12
+ enter: boolean;
13
+ role?: string;
14
+ line: number;
15
+ } | {
16
+ kind: "scroll";
17
+ direction: "down";
18
+ line: number;
19
+ } | {
20
+ kind: "back";
21
+ line: number;
22
+ } | {
23
+ kind: "see";
24
+ text: string;
25
+ line: number;
26
+ } | {
27
+ kind: "not_see";
28
+ text: string;
29
+ line: number;
30
+ } | {
31
+ kind: "wait_for";
32
+ text: string;
33
+ line: number;
34
+ };
35
+ /** Turn plain-language lines into typed steps. Blank lines and lines starting with `#` are ignored. An unknown line throws a `PhoneError` with code `step_unparsed`. */
36
+ export declare function parseSteps(text: string): Step[];
37
+ /** Run steps in order. Stops at the first failing step and throws an error that names its line. */
38
+ export declare function runSteps(phone: Phone, steps: Step[]): Promise<void>;
package/dist/steps.js ADDED
@@ -0,0 +1,67 @@
1
+ import { PhoneError } from "./phone.js";
2
+ import { redact, resolveSecrets } from "./secrets.js";
3
+ const Q = '"([^"]*)"';
4
+ const rules = [
5
+ [new RegExp(`^tap ${Q}$`, "i"), (m, line) => ({ kind: "tap", target: m[1], line })],
6
+ [new RegExp(`^type ${Q} into ${Q}( and press enter)?$`, "i"), (m, line) => ({ kind: "type", text: m[1], target: m[2], enter: !!m[3], line })],
7
+ [/^scroll down$/i, (_m, line) => ({ kind: "scroll", direction: "down", line })],
8
+ [/^go back$/i, (_m, line) => ({ kind: "back", line })],
9
+ [new RegExp(`^do not see ${Q}$`, "i"), (m, line) => ({ kind: "not_see", text: m[1], line })],
10
+ [new RegExp(`^see ${Q}$`, "i"), (m, line) => ({ kind: "see", text: m[1], line })],
11
+ [new RegExp(`^wait for ${Q}$`, "i"), (m, line) => ({ kind: "wait_for", text: m[1], line })],
12
+ ];
13
+ /** Turn plain-language lines into typed steps. Blank lines and lines starting with `#` are ignored. An unknown line throws a `PhoneError` with code `step_unparsed`. */
14
+ export function parseSteps(text) {
15
+ const steps = [];
16
+ text.split(/\r?\n/).forEach((raw, i) => {
17
+ const s = raw.trim();
18
+ if (!s || s.startsWith("#"))
19
+ return;
20
+ for (const [re, make] of rules) {
21
+ const m = s.match(re);
22
+ if (m) {
23
+ steps.push(make(m, i + 1));
24
+ return;
25
+ }
26
+ }
27
+ throw new PhoneError(`line ${i + 1}: cannot understand "${s}"`, "step_unparsed");
28
+ });
29
+ return steps;
30
+ }
31
+ /** Run steps in order. Stops at the first failing step and throws an error that names its line. */
32
+ export async function runSteps(phone, steps) {
33
+ for (const s of steps) {
34
+ try {
35
+ switch (s.kind) {
36
+ case "tap":
37
+ await phone.tap(s.target);
38
+ break;
39
+ case "type":
40
+ await phone.type(s.target, resolveSecrets(s.text), s.enter ? { enter: true } : {});
41
+ break;
42
+ case "scroll":
43
+ await phone.scroll(s.direction);
44
+ break;
45
+ case "back":
46
+ await phone.back();
47
+ break;
48
+ case "wait_for":
49
+ await phone.waitFor(s.text);
50
+ break;
51
+ // see and not_see put the screen in the message: the catch below redacts it. Keep that.
52
+ case "see":
53
+ if (!phone.sees(s.text))
54
+ throw new Error(`expected to see "${s.text}"\n\nScreen now:\n${phone.screen}`);
55
+ break;
56
+ case "not_see":
57
+ if (phone.sees(s.text))
58
+ throw new Error(`expected not to see "${s.text}"\n\nScreen now:\n${phone.screen}`);
59
+ break;
60
+ }
61
+ }
62
+ catch (e) {
63
+ const err = e;
64
+ throw new PhoneError(`step on line ${s.line} failed: ${redact(err.message)}`, err.code ?? "step_failed");
65
+ }
66
+ }
67
+ }
package/dist/vitest.js CHANGED
@@ -3,15 +3,16 @@
3
3
  // test("checkout", async ({ phone }) => { await phone.tap("Add to cart"); expect(phone).toSee("Cart (1)"); });
4
4
  import { expect, test as base } from "vitest";
5
5
  import { openApp } from "./phone.js";
6
+ import { redact } from "./secrets.js";
6
7
  const show = (t) => (t instanceof RegExp ? String(t) : JSON.stringify(t));
7
8
  expect.extend({
8
9
  toSee(phone, text) {
9
10
  const pass = phone.sees(text);
10
- return { pass, message: () => (pass ? `expected the screen not to show ${show(text)}\n\n${phone.screen}` : `expected the screen to show ${show(text)}\n\nScreen now:\n${phone.screen}`) };
11
+ return { pass, message: () => redact(pass ? `expected the screen not to show ${show(text)}\n\n${phone.screen}` : `expected the screen to show ${show(text)}\n\nScreen now:\n${phone.screen}`) };
11
12
  },
12
13
  toHaveButton(phone, name) {
13
14
  const pass = phone.nodes().some((n) => n.role === "button" && (name instanceof RegExp ? name.test(n.name) : n.name === name));
14
- return { pass, message: () => (pass ? `expected no button ${show(name)}` : `expected a button ${show(name)}\n\nScreen now:\n${phone.screen}`) };
15
+ return { pass, message: () => redact(pass ? `expected no button ${show(name)}` : `expected a button ${show(name)}\n\nScreen now:\n${phone.screen}`) };
15
16
  },
16
17
  });
17
18
  /** A fresh simulated phone per test, closed afterwards. Needs the simulator running (npx shipvo sim). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/test",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.",