@shipvo/test 0.1.2 → 0.1.4

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,27 @@ 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
+
23
44
  ## Mock the network
24
45
 
25
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.
package/dist/index.d.ts CHANGED
@@ -2,3 +2,8 @@ export { openApp, Phone, Device, Contacts, Calendar, Files, PhoneError } from ".
2
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
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.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { find, parseScreen } from "./tree.js";
3
+ import { redact } from "./secrets.js";
3
4
  /** An action the simulator refused. `code` is set for typed failures such as `no_url_listener` or `stale_ref`. */
4
5
  export class PhoneError extends Error {
5
6
  code;
6
7
  constructor(message, code) {
7
- super(message);
8
+ super(redact(message));
8
9
  this.code = code;
9
10
  this.name = "PhoneError";
10
11
  }
@@ -71,7 +72,7 @@ export class Phone {
71
72
  return find(this.nodes(), target);
72
73
  }
73
74
  catch (e) {
74
- throw new Error(`${e.message}\n\nScreen now:\n${this.screen}`);
75
+ throw new Error(redact(`${e.message}\n\nScreen now:\n${this.screen}`));
75
76
  }
76
77
  }
77
78
  async tap(target) { await this.act("tap", { ref: this.locate(target).ref }); return this; }
@@ -94,7 +95,7 @@ export class Phone {
94
95
  if (this.sees(text))
95
96
  return this;
96
97
  if (Date.now() > end)
97
- 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}`));
98
99
  await new Promise((r) => setTimeout(r, opts.interval ?? 250));
99
100
  }
100
101
  }
@@ -106,7 +107,7 @@ export class Phone {
106
107
  if (!this.sees(text))
107
108
  return this;
108
109
  if (Date.now() > end)
109
- 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}`));
110
111
  await new Promise((r) => setTimeout(r, opts.interval ?? 250));
111
112
  }
112
113
  }
@@ -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.2",
3
+ "version": "0.1.4",
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.",
@@ -17,6 +17,7 @@
17
17
  }
18
18
  },
19
19
  "files": [
20
+ "skills",
20
21
  "dist",
21
22
  "README.md"
22
23
  ],
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: shipvo-test
3
+ description: Write and run end-to-end tests for a React Native app with @shipvo/test. Use when the project depends on @shipvo/test and you need a typed test, plain-language steps or a network mock.
4
+ ---
5
+
6
+ # @shipvo/test
7
+
8
+ Headless end-to-end tests that run in the Shipvo simulator. No emulator needed.
9
+
10
+ ```bash
11
+ shipvo sim # simulator on http://localhost:4545
12
+ npx vitest run # run typed tests
13
+ shipvo test # run tests/*.steps
14
+ ```
15
+
16
+ ## A typed test
17
+
18
+ ```ts
19
+ import { test, expect } from "@shipvo/test/vitest";
20
+
21
+ test("adds a lure to the cart", async ({ phone }) => {
22
+ await phone.tap("Add 1 to cart");
23
+ expect(phone).toSee("Cart");
24
+ await phone.waitFor(/1 item/);
25
+ });
26
+ ```
27
+
28
+ Other calls: `phone.type("Search", "popper")`, `phone.scroll("down")`, `phone.back()`, `phone.device.set({ location: { latitude: 4.05, longitude: 9.77 } })`.
29
+
30
+ ## Plain-language steps
31
+
32
+ One step per line in `tests/<name>.steps`. `#` starts a comment.
33
+
34
+ ```
35
+ tap "Add 1 to cart"
36
+ type "popper" into "Search" and press enter
37
+ scroll down
38
+ go back
39
+ see "X"
40
+ do not see "X"
41
+ wait for "Cart"
42
+ ```
43
+
44
+ An unknown line fails with code `step_unparsed` and its line number. `shipvo test --repair` shows a diff for broken steps; add `--write` to save it.
45
+
46
+ ## Mock the network
47
+
48
+ ```ts
49
+ await phone.mockNetwork([{ url: /\/api\/products/, status: 503, body: { error: "down" }, delayMs: 300 }]);
50
+ await phone.clearNetworkMocks();
51
+ ```