@shipvo/test 0.1.2 → 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 +21 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/phone.js +5 -4
- package/dist/repair.d.ts +18 -0
- package/dist/repair.js +65 -0
- package/dist/secrets.d.ts +6 -0
- package/dist/secrets.js +29 -0
- package/dist/steps.d.ts +38 -0
- package/dist/steps.js +67 -0
- package/dist/vitest.js +3 -2
- package/package.json +1 -1
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
|
}
|
package/dist/repair.d.ts
ADDED
|
@@ -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;
|
package/dist/secrets.js
ADDED
|
@@ -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
|
+
}
|
package/dist/steps.d.ts
ADDED
|
@@ -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.
|
|
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.",
|