@shipvo/cli 0.1.5 → 0.1.6
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 +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +68 -9
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +28 -11
- package/dist/autohalt.d.ts +1 -1
- package/dist/autohalt.js +28 -16
- package/dist/bsdiff.d.ts +4 -0
- package/dist/bsdiff.js +465 -0
- package/dist/ci-comment.d.ts +38 -0
- package/dist/ci-comment.js +169 -0
- package/dist/ci-compare.d.ts +10 -0
- package/dist/ci-compare.js +25 -0
- package/dist/ci-shots.d.ts +9 -0
- package/dist/ci-shots.js +81 -0
- package/dist/ci.d.ts +8 -0
- package/dist/ci.js +64 -6
- package/dist/cli.js +23 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/doctor.js +63 -7
- package/dist/ensure-playwright.d.ts +4 -0
- package/dist/ensure-playwright.js +7 -2
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +83 -7
- package/dist/explore.js +326 -77
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +19 -10
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +41 -6
- package/dist/json-out.d.ts +53 -0
- package/dist/json-out.js +91 -0
- package/dist/keys-json.d.ts +1 -0
- package/dist/keys-json.js +71 -0
- package/dist/login.js +27 -11
- package/dist/lumi.d.ts +104 -0
- package/dist/lumi.js +364 -0
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +521 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +4 -2
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +38 -16
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +126 -62
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +1 -0
- package/dist/report-md.js +1 -1
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.js +356 -2
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +101 -17
- package/dist/signing.js +13 -0
- package/dist/sim.js +7 -3
- package/dist/smoke.d.ts +28 -0
- package/dist/smoke.js +117 -0
- package/dist/spawn-bin.d.ts +11 -0
- package/dist/spawn-bin.js +21 -0
- package/dist/status.d.ts +26 -4
- package/dist/status.js +69 -5
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +81 -18
- package/package.json +4 -3
- package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/explore.js
CHANGED
|
@@ -1,28 +1,78 @@
|
|
|
1
1
|
// `shipvo explore`: a deterministic walker over the simulator's accessibility tree. No LLM call.
|
|
2
2
|
// The walker (`walk`) only needs a `Driver`, so tests run it against a fake app. `explore` wires it to the simulator.
|
|
3
3
|
import { execSync, spawn } from "node:child_process";
|
|
4
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import { args as parse, loadConfig } from "./config.js";
|
|
7
8
|
import { portFree, waitListening } from "./sim.js";
|
|
8
9
|
import { ensurePlaywright } from "./ensure-playwright.js";
|
|
10
|
+
import { writeIssues } from "./explore-issues.js";
|
|
11
|
+
import { emit, envelope } from "./json-out.js";
|
|
12
|
+
import { runFlows, renderFlows, parseFlowNames } from "./flows.js";
|
|
13
|
+
/** Smallest tap target: 44 x 44 pt on iOS (Apple HIG), 48 x 48 dp on Android (Material and Android accessibility guide). */
|
|
14
|
+
export const MIN_TARGET = { ios: 44, android: 48 };
|
|
9
15
|
export const PARITY_LIMITS = [
|
|
10
16
|
"The simulator runs the app's web build in headless Chromium. It is not a phone and not the native app.",
|
|
11
17
|
"Camera, location, biometrics, payments and notifications are simulated. Native crashes and native-only bugs cannot show up here.",
|
|
12
|
-
"The walker
|
|
18
|
+
"The walker taps and types into text fields. It does not scroll or swipe, so content below the fold and rails are only seen if on screen.",
|
|
13
19
|
"Layout, speed and memory are not measured.",
|
|
14
20
|
"See docs/parity-results.md for what was measured against Android emulators and the iOS Simulator.",
|
|
15
21
|
];
|
|
16
22
|
const ACTIONABLE = new Set(["button", "link", "tab", "checkbox", "switch", "radio", "menuitem"]);
|
|
23
|
+
const FIELDS = new Set(["textbox", "searchbox"]);
|
|
24
|
+
/** Labels that end a session or destroy data. The walker taps them last. `cancel` is here too; being last, it is still tapped, so a modal is not skipped. */
|
|
25
|
+
const RISKY = /\b(log ?out|sign ?out|delete|remove|cancel|erase|deactivate|clear all|unsubscribe)\b/i;
|
|
26
|
+
const NETWORK = /network request failed|network error|networkerror|failed to fetch|no internet|you(?:'| a)re offline|(?:can't|cannot|couldn't|unable to) connect|connection (?:failed|lost|error)|ERR_(?:INTERNET|CONNECTION|NAME_NOT)\w*/i;
|
|
27
|
+
/** Only loading indicators count. Text such as a heading "Loading history" does not. */
|
|
28
|
+
const loading = (tree) => /^\s*- (?:progressbar|spinner|activity ?indicator)\b/im.test(tree);
|
|
29
|
+
/** Nothing drawn: no names and no text anywhere in the tree. */
|
|
30
|
+
const blank = (tree) => !/"|: \S/.test(tree);
|
|
31
|
+
/** Small seeded random numbers (mulberry32), so a seed repeats a run exactly. */
|
|
32
|
+
function rng(seed) {
|
|
33
|
+
let a = seed >>> 0;
|
|
34
|
+
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
35
|
+
}
|
|
36
|
+
function shuffle(xs, rand) {
|
|
37
|
+
const a = [...xs];
|
|
38
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
39
|
+
const j = Math.floor(rand() * (i + 1));
|
|
40
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
41
|
+
}
|
|
42
|
+
return a;
|
|
43
|
+
}
|
|
44
|
+
/** A value for a field, from its label: one the field should accept, or one it should reject. */
|
|
45
|
+
export function sampleValue(name, invalid) {
|
|
46
|
+
const n = name.toLowerCase();
|
|
47
|
+
const pick = (ok, bad) => (invalid ? bad : ok);
|
|
48
|
+
if (/e-?mail/.test(n))
|
|
49
|
+
return pick("ada@example.com", "not-an-email");
|
|
50
|
+
if (/pass/.test(n))
|
|
51
|
+
return pick("Correct-Horse-9", "1");
|
|
52
|
+
if (/phone|tel|mobile/.test(n))
|
|
53
|
+
return pick("+14155550123", "12");
|
|
54
|
+
if (/zip|postal|post code/.test(n))
|
|
55
|
+
return pick("94107", "00");
|
|
56
|
+
if (/url|website|link/.test(n))
|
|
57
|
+
return pick("https://example.com", "not a url");
|
|
58
|
+
if (/age|qty|quantity|number|amount|count|price|year/.test(n))
|
|
59
|
+
return pick("42", "abc");
|
|
60
|
+
if (/search|find|query/.test(n))
|
|
61
|
+
return pick("test", "");
|
|
62
|
+
if (/name/.test(n))
|
|
63
|
+
return pick("Ada Lovelace", "");
|
|
64
|
+
return pick("hello", "");
|
|
65
|
+
}
|
|
17
66
|
const unquote = (raw) => { const q = /^(\s*- )'(.*)'(:.*)?$/.exec(raw); return q ? q[1] + q[2].replace(/''/g, "'") + (q[3] ?? "") : raw; };
|
|
18
67
|
/** Actionable elements in a tree, in order. `nth` counts earlier elements with the same role and name. Disabled ones are left out. */
|
|
19
|
-
export
|
|
68
|
+
export const elements = (tree) => parseEls(tree, ACTIONABLE);
|
|
69
|
+
function parseEls(tree, roles) {
|
|
20
70
|
const out = [];
|
|
21
71
|
const seen = new Map();
|
|
22
72
|
for (const raw of tree.split("\n")) {
|
|
23
73
|
const l = unquote(raw);
|
|
24
74
|
const m = /^\s*- ([\w-]+)(?: ("(?:[^"\\]|\\.)*"))?[^\n]*?\[ref=(\w+)\]/.exec(l);
|
|
25
|
-
if (!m || !
|
|
75
|
+
if (!m || !roles.has(m[1]) || /\[disabled\]/.test(l))
|
|
26
76
|
continue;
|
|
27
77
|
let name = "";
|
|
28
78
|
try {
|
|
@@ -38,6 +88,7 @@ export function elements(tree) {
|
|
|
38
88
|
}
|
|
39
89
|
return out;
|
|
40
90
|
}
|
|
91
|
+
export const fieldElements = (tree) => parseEls(tree, FIELDS);
|
|
41
92
|
/** The tree without refs, to tell whether two screens are the same. */
|
|
42
93
|
export const signature = (tree) => tree.replace(/\s*\[ref=\w+\]/g, "");
|
|
43
94
|
const redScreen = (tree) => /Uncaught (\w*Error)|Unhandled Runtime Error|Application error|Something went wrong/i.exec(tree)?.[0];
|
|
@@ -45,112 +96,196 @@ const redScreen = (tree) => /Uncaught (\w*Error)|Unhandled Runtime Error|Applica
|
|
|
45
96
|
const hasBack = (els) => els.some((e) => /^(back|go back|close)$/i.test(e.name));
|
|
46
97
|
const label = (e) => `${e.role} "${e.name}"`;
|
|
47
98
|
/** Steps in plain lines, for the report. */
|
|
48
|
-
export const describeStep = (s) => s.action === "open" ? "open the app" : s.action === "open_url" ? `open_url ${s.url}` : s.action === "tap" ? `tap ${label(s)}${s.nth ? ` (number ${s.nth + 1} with that name)` : ""}` : "back";
|
|
99
|
+
export const describeStep = (s) => s.action === "open" ? "open the app" : s.action === "open_url" ? `open_url ${s.url}` : s.action === "type" ? `type "${s.text}" into ${label(s)}${s.nth ? ` (number ${s.nth + 1} with that name)` : ""}${s.enter ? " and press Enter" : ""}` : s.action === "tap" ? `tap ${label(s)}${s.nth ? ` (number ${s.nth + 1} with that name)` : ""}` : "back";
|
|
100
|
+
/**
|
|
101
|
+
* Walks the app. Each round it picks the screen to work on with an upper confidence bound: screens with nothing tried yet
|
|
102
|
+
* come first, then screens whose taps found new screens, with a bonus for rarely tried ones. On that screen it takes safe actions
|
|
103
|
+
* before risky ones (log out, delete, cancel, remove). Text fields get a valid value, then an invalid one.
|
|
104
|
+
*/
|
|
49
105
|
export async function walk(driver, o = {}) {
|
|
50
|
-
const
|
|
106
|
+
const skin = o.skin ?? "android", min = MIN_TARGET[skin];
|
|
107
|
+
const maxDepth = o.maxDepth ?? 3, maxSteps = o.maxSteps ?? 100, settleMs = o.settleMs ?? 400;
|
|
108
|
+
const rand = rng(o.seed ?? 1);
|
|
51
109
|
const findings = [];
|
|
52
110
|
const seenFinding = new Set();
|
|
53
|
-
const
|
|
54
|
-
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const nodes = new Map();
|
|
113
|
+
let steps = 0, cut = 0;
|
|
55
114
|
let stopped = null;
|
|
56
|
-
let cut = 0;
|
|
57
115
|
const add = (f) => { const k = `${f.kind}|${f.message}|${signature(f.screen)}`; if (!seenFinding.has(k)) {
|
|
58
116
|
seenFinding.add(k);
|
|
59
117
|
findings.push(f);
|
|
118
|
+
o.onEvent?.({ type: "finding", n: findings.length, finding: f });
|
|
60
119
|
} };
|
|
61
120
|
const need = (r, what) => { if (!r.ok || r.screen == null)
|
|
62
121
|
throw new Error(`explore: ${what} failed: ${r.error ?? "no screen"}`); return r.screen; };
|
|
122
|
+
const find = (tree, s, roles = ACTIONABLE) => parseEls(tree, roles).find((e) => e.role === s.role && e.name === s.name && e.nth === s.nth);
|
|
63
123
|
/** Put the phone back on the screen reached by `path`. Replays are not counted as steps. */
|
|
64
124
|
async function replay(path) {
|
|
65
125
|
let screen = "";
|
|
66
126
|
for (const s of path) {
|
|
67
|
-
|
|
68
|
-
|
|
127
|
+
let r;
|
|
128
|
+
if (s.action === "open")
|
|
129
|
+
r = await driver.open();
|
|
130
|
+
else if (s.action === "open_url")
|
|
131
|
+
r = await driver.openUrl(s.url);
|
|
132
|
+
else if (s.action === "back")
|
|
133
|
+
r = await driver.back();
|
|
134
|
+
else if (s.action === "type")
|
|
135
|
+
r = driver.type ? await driver.type(find(screen, s, FIELDS)?.ref ?? "missing", s.text, s.enter) : { ok: false, error: "this simulator cannot type" };
|
|
136
|
+
else
|
|
137
|
+
r = await driver.tap(find(screen, s)?.ref ?? "missing");
|
|
69
138
|
screen = need(r, `replaying "${describeStep(s)}"`);
|
|
70
139
|
}
|
|
71
140
|
// Errors raised while replaying are drained here. On a later replay they would repeat an earlier finding.
|
|
72
141
|
// The caller of the first replay of a start path keeps them.
|
|
73
142
|
return { screen, errors: await driver.errors() };
|
|
74
143
|
}
|
|
75
|
-
const
|
|
144
|
+
const addError = (e, screen, path) => add(NETWORK.test(e) ? { kind: "network_failed", message: `Network failed: ${e}`, screen, steps: path } : { kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
|
|
76
145
|
async function checkErrors(screen, path) {
|
|
77
146
|
for (const e of await driver.errors())
|
|
78
|
-
|
|
147
|
+
addError(e, screen, path);
|
|
79
148
|
const red = redScreen(screen);
|
|
80
149
|
if (red)
|
|
81
150
|
add({ kind: "error", message: `Red screen: the screen shows "${red}"`, screen, steps: path });
|
|
151
|
+
const net = NETWORK.exec(screen)?.[0];
|
|
152
|
+
if (net)
|
|
153
|
+
add({ kind: "network_failed", message: `Network failed: the screen shows "${net}"`, screen, steps: path });
|
|
82
154
|
}
|
|
83
|
-
|
|
155
|
+
/** A screen seen for the first time: run the per-screen checks and list what can be done on it. */
|
|
156
|
+
async function register(screen, path, depth) {
|
|
84
157
|
const sig = signature(screen);
|
|
85
|
-
if (
|
|
158
|
+
if (seen.has(sig))
|
|
86
159
|
return;
|
|
87
|
-
|
|
160
|
+
seen.add(sig);
|
|
88
161
|
await checkErrors(screen, path);
|
|
89
162
|
const els = elements(screen);
|
|
163
|
+
o.onEvent?.({ type: "screen", screens: seen.size, sig, controls: els.map((e) => ({ role: e.role, name: e.name })) });
|
|
90
164
|
if (!els.length)
|
|
91
165
|
add({ kind: "dead_end", message: "Dead end: the screen has no actionable element", screen, steps: path });
|
|
166
|
+
if (blank(screen))
|
|
167
|
+
add({ kind: "blank_screen", message: "Blank screen: nothing is drawn on it", screen, steps: path });
|
|
92
168
|
for (const e of els)
|
|
93
169
|
if (!e.name)
|
|
94
170
|
add({ kind: "unnamed", message: `Unnamed ${e.role}: it has no accessible name`, screen, steps: path });
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (steps >= maxSteps) {
|
|
100
|
-
stopped ??= `max-steps (${maxSteps}) reached`;
|
|
101
|
-
return;
|
|
171
|
+
for (const e of els)
|
|
172
|
+
if (e.name && e.nth === 1) {
|
|
173
|
+
const n = els.filter((x) => x.role === e.role && x.name === e.name).length;
|
|
174
|
+
add({ kind: "duplicate_name", message: `Duplicate name: ${n} ${e.role}s are named "${e.name}", so a screen reader cannot tell them apart`, screen, steps: path });
|
|
102
175
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
176
|
+
if (driver.sizes) {
|
|
177
|
+
// A simulator without the `sizes` action must not break the walk: skip the size check for this screen.
|
|
178
|
+
const sizes = await driver.sizes().catch(() => ({}));
|
|
179
|
+
for (const e of els) {
|
|
180
|
+
const z = sizes[e.ref];
|
|
181
|
+
if (!z || (z[0] >= min && z[1] >= min))
|
|
107
182
|
continue;
|
|
183
|
+
add({ kind: "small_target", message: `Small target: ${e.name ? label(e) : e.role}${e.nth ? ` (number ${e.nth + 1})` : ""} is ${z[0]} x ${z[1]}, under the ${min} x ${min} minimum for ${skin}`, screen, steps: path });
|
|
108
184
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const after = r.screen ?? current;
|
|
118
|
-
await checkErrors(after, here);
|
|
119
|
-
const crashed = !r.ok || r.screen == null || !!redScreen(after);
|
|
120
|
-
const next = signature(after);
|
|
121
|
-
if (!crashed && next !== signature(current) && !seenScreens.has(next)) {
|
|
122
|
-
if (depth >= maxDepth) {
|
|
123
|
-
cut++;
|
|
124
|
-
seenScreens.add(next);
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
await visit(after, here, depth + 1);
|
|
128
|
-
if (steps >= maxSteps && stopped)
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
185
|
+
}
|
|
186
|
+
if (driver.snapshot && loading(screen)) {
|
|
187
|
+
// Look again, twice. A screen that still shows a loading indicator after that is stuck.
|
|
188
|
+
let still = true;
|
|
189
|
+
for (let i = 0; i < 2 && still; i++) {
|
|
190
|
+
await new Promise((r) => setTimeout(r, settleMs));
|
|
191
|
+
const r = await driver.snapshot().catch(() => null);
|
|
192
|
+
still = !!r?.ok && r.screen != null && loading(r.screen);
|
|
131
193
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
194
|
+
if (still)
|
|
195
|
+
add({ kind: "stuck_loading", message: "Stuck loading: the screen still shows a loading indicator after waiting", screen, steps: path });
|
|
196
|
+
}
|
|
197
|
+
const taps = shuffle(els.filter((e) => e.name && !/^(back|go back)$/i.test(e.name)), rand).map((el) => ({ kind: "tap", el, risky: RISKY.test(el.name), done: false }));
|
|
198
|
+
const fs = driver.type ? parseEls(screen, FIELDS) : [];
|
|
199
|
+
const typeActs = (invalid) => fs.map((el) => ({ kind: "type", el, text: sampleValue(el.name, invalid), enter: invalid, risky: false, done: false }));
|
|
200
|
+
nodes.set(sig, { sig, path, depth, acts: o.input === "bad" ? [...typeActs(true), ...taps] : [...typeActs(false), ...taps, ...typeActs(true)], n: 0, reward: 0 });
|
|
201
|
+
}
|
|
202
|
+
const todo = (n) => n.acts.filter((a) => !a.done);
|
|
203
|
+
/** Upper confidence bound over the screens that still have something to try. Untried screens first, risky-only screens last. */
|
|
204
|
+
function pick(here, total) {
|
|
205
|
+
const all = [...nodes.values()].filter((n) => todo(n).length);
|
|
206
|
+
const safe = all.filter((n) => todo(n).some((a) => !a.risky));
|
|
207
|
+
let best = null, bestScore = -Infinity;
|
|
208
|
+
for (const n of safe.length ? safe : all) {
|
|
209
|
+
const ucb = n.n === 0 ? 1e9 : n.reward / n.n + Math.sqrt((2 * Math.log(total + 1)) / n.n);
|
|
210
|
+
const score = ucb + (n.sig === here ? 0.01 : 0) + rand() * 1e-6;
|
|
211
|
+
if (score > bestScore) {
|
|
212
|
+
best = n;
|
|
213
|
+
bestScore = score;
|
|
138
214
|
}
|
|
139
|
-
else
|
|
140
|
-
current = after;
|
|
141
215
|
}
|
|
216
|
+
return best;
|
|
142
217
|
}
|
|
143
|
-
const starts = [[{ action: "open" }], ...(o.routes ?? []).map((url) => [{ action: "open" }, { action: "open_url", url }])];
|
|
218
|
+
const starts = o.startPaths ?? [...(o.onlyRoutes ? [] : [[{ action: "open" }]]), ...(o.routes ?? []).map((url) => [{ action: "open" }, { action: "open_url", url }])];
|
|
144
219
|
let error = null;
|
|
145
220
|
try {
|
|
146
221
|
for (const path of starts) {
|
|
147
|
-
if (stopped)
|
|
148
|
-
break;
|
|
149
222
|
const { screen, errors } = await replay(path);
|
|
150
223
|
// The first replay of a start path is the only time load-time errors are seen.
|
|
151
224
|
for (const e of errors)
|
|
152
|
-
|
|
153
|
-
await
|
|
225
|
+
addError(e, screen, path);
|
|
226
|
+
await register(screen, path, 1);
|
|
227
|
+
}
|
|
228
|
+
let screen = null;
|
|
229
|
+
let at = null;
|
|
230
|
+
for (;;) {
|
|
231
|
+
const node = pick(screen == null ? null : signature(screen), steps);
|
|
232
|
+
if (!node)
|
|
233
|
+
break;
|
|
234
|
+
if (o.shouldStop?.()) {
|
|
235
|
+
stopped = "stopped by you";
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (steps >= maxSteps) {
|
|
239
|
+
stopped = `max-steps (${maxSteps}) reached`;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
// Get to the node's screen: the Back button when it is one step up, a full replay otherwise.
|
|
243
|
+
if (screen == null || signature(screen) !== node.sig) {
|
|
244
|
+
let landed = null;
|
|
245
|
+
if (at && screen != null && at.path.length === node.path.length + 1 && hasBack(elements(screen))) {
|
|
246
|
+
const b = await driver.back();
|
|
247
|
+
if (b.ok && b.screen != null && signature(b.screen) === node.sig)
|
|
248
|
+
landed = b.screen;
|
|
249
|
+
}
|
|
250
|
+
screen = landed ?? (await replay(node.path)).screen;
|
|
251
|
+
}
|
|
252
|
+
const a = todo(node).find((x) => !x.risky) ?? todo(node)[0];
|
|
253
|
+
a.done = true;
|
|
254
|
+
const target = find(screen, a.el, a.kind === "type" ? FIELDS : ACTIONABLE);
|
|
255
|
+
if (!target) {
|
|
256
|
+
at = node;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const step = a.kind === "type" ? { action: "type", role: a.el.role, name: a.el.name, nth: a.el.nth, text: a.text, ...(a.enter ? { enter: true } : {}) } : { action: "tap", role: a.el.role, name: a.el.name, nth: a.el.nth };
|
|
260
|
+
const here = [...node.path, step];
|
|
261
|
+
steps++;
|
|
262
|
+
node.n++;
|
|
263
|
+
o.onEvent?.({ type: "step", n: steps, step, text: describeStep(step) });
|
|
264
|
+
const r = a.kind === "type" ? await driver.type(target.ref, a.text, a.enter) : await driver.tap(target.ref);
|
|
265
|
+
if (r.delivery === "not_delivered")
|
|
266
|
+
add({ kind: "not_delivered", message: `Not delivered (${r.reason ?? "unknown reason"}): ${r.error ?? "the app did not receive the tap"}`, screen, steps: here });
|
|
267
|
+
else if (!r.ok)
|
|
268
|
+
add({ kind: "action_failed", message: `Action failed${r.code ? ` [${r.code}]` : ""}: ${r.error ?? "unknown"}`, screen, steps: here });
|
|
269
|
+
const after = r.screen ?? screen;
|
|
270
|
+
await checkErrors(after, here);
|
|
271
|
+
const crashed = !r.ok || r.screen == null || !!redScreen(after);
|
|
272
|
+
const next = signature(after);
|
|
273
|
+
if (crashed) {
|
|
274
|
+
screen = null;
|
|
275
|
+
at = null;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (next !== node.sig && !seen.has(next)) {
|
|
279
|
+
node.reward++;
|
|
280
|
+
if (node.depth >= maxDepth) {
|
|
281
|
+
cut++;
|
|
282
|
+
seen.add(next);
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
await register(after, here, node.depth + 1);
|
|
286
|
+
}
|
|
287
|
+
screen = after;
|
|
288
|
+
at = nodes.get(next) ?? null;
|
|
154
289
|
}
|
|
155
290
|
}
|
|
156
291
|
catch (e) {
|
|
@@ -159,7 +294,7 @@ export async function walk(driver, o = {}) {
|
|
|
159
294
|
}
|
|
160
295
|
if (!stopped && cut)
|
|
161
296
|
stopped = `max-depth (${maxDepth}) reached: ${cut} screen${cut === 1 ? "" : "s"} were not walked`;
|
|
162
|
-
return { app: o.app ?? "", generatedAt: new Date().toISOString(), steps, screens:
|
|
297
|
+
return { app: o.app ?? "", generatedAt: new Date().toISOString(), steps, screens: seen.size, maxSteps, maxDepth, stoppedEarly: stopped, error, findings, parityLimits: PARITY_LIMITS };
|
|
163
298
|
}
|
|
164
299
|
export function renderMarkdown(r) {
|
|
165
300
|
const L = [`# Explore report: ${r.app}`, "", "## Simulator limits", "", ...r.parityLimits.map((p) => `- ${p}`), "", "## Run", "",
|
|
@@ -174,7 +309,7 @@ export function renderMarkdown(r) {
|
|
|
174
309
|
return L.join("\n");
|
|
175
310
|
}
|
|
176
311
|
/** Driver for a running simulator. */
|
|
177
|
-
export function simDriver(base, app, session = `explore-${Date.now().toString(36)}
|
|
312
|
+
export function simDriver(base, app, session = `explore-${Date.now().toString(36)}`, platform) {
|
|
178
313
|
const post = async (path, body) => {
|
|
179
314
|
const r = await fetch(base.replace(/\/$/, "") + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) });
|
|
180
315
|
if (!r.ok)
|
|
@@ -183,15 +318,18 @@ export function simDriver(base, app, session = `explore-${Date.now().toString(36
|
|
|
183
318
|
};
|
|
184
319
|
const act = (body) => post("/act", { session, ...body });
|
|
185
320
|
return {
|
|
186
|
-
open: () => post("/session", { session, app }),
|
|
321
|
+
open: () => post("/session", { session, app, ...(platform ? { device: { platform } } : {}) }),
|
|
187
322
|
openUrl: (url) => act({ action: "open_url", url }),
|
|
188
323
|
tap: (ref) => act({ action: "tap", ref }),
|
|
189
324
|
back: () => act({ action: "back" }),
|
|
325
|
+
type: (ref, text, enter) => act({ action: "type", ref, text, ...(enter ? { enter: true } : {}) }),
|
|
326
|
+
snapshot: () => act({ action: "screen" }),
|
|
190
327
|
errors: async () => (await act({ action: "errors" })).errors ?? [],
|
|
328
|
+
sizes: async () => (await act({ action: "sizes" })).sizes ?? {},
|
|
191
329
|
};
|
|
192
330
|
}
|
|
193
331
|
/** Use the simulator at `--sim <url>`, or build the app and start one on `--port` (default 4546). `stop` kills the one we started. */
|
|
194
|
-
export async function startSim(a, cfg) {
|
|
332
|
+
export async function startSim(a, cfg, quiet = false) {
|
|
195
333
|
let base = a.sim;
|
|
196
334
|
let child;
|
|
197
335
|
if (!base) {
|
|
@@ -200,9 +338,9 @@ export async function startSim(a, cfg) {
|
|
|
200
338
|
throw new Error(`Port ${port} is in use. Stop the other simulator, pass --port <n>, or pass --sim <url> to use one that is running.`);
|
|
201
339
|
if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
|
|
202
340
|
console.error(`Building ${cfg.name} for the simulator…`);
|
|
203
|
-
execSync("
|
|
341
|
+
execSync("npx expo export --platform web --output-dir sim-dist --clear", { stdio: quiet ? ["ignore", 2, 2] : "inherit", env: { ...process.env, PN_SIM: "1" } });
|
|
204
342
|
}
|
|
205
|
-
ensurePlaywright({ cwd: process.cwd() });
|
|
343
|
+
ensurePlaywright({ cwd: process.cwd(), quiet });
|
|
206
344
|
let server;
|
|
207
345
|
try {
|
|
208
346
|
server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
|
|
@@ -219,27 +357,138 @@ export async function startSim(a, cfg) {
|
|
|
219
357
|
}
|
|
220
358
|
return { base, stop: () => { child?.kill(); } };
|
|
221
359
|
}
|
|
222
|
-
/** Returns the exit code. */
|
|
360
|
+
/** Returns the exit code. With `--json`, stdout is one envelope and everything else goes to stderr. */
|
|
223
361
|
export async function explore(argv) {
|
|
362
|
+
if (!argv.includes("--json"))
|
|
363
|
+
return exploreRun(argv, false);
|
|
364
|
+
try {
|
|
365
|
+
return await exploreRun(argv, true);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
emit(envelope("error", e instanceof Error ? e.message : String(e), { findings: [] }, [{ command: "shipvo doctor --json", why: "Check the setup." }], 2));
|
|
369
|
+
return 2;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** The app's URL scheme: `expo.scheme` in app.json, else the scheme of a configured route such as "tide://cart". */
|
|
373
|
+
export function appScheme(routes, dir = process.cwd()) {
|
|
374
|
+
try {
|
|
375
|
+
const j = JSON.parse(readFileSync(join(dir, "app.json"), "utf8"));
|
|
376
|
+
const sc = j.expo?.scheme ?? j.scheme;
|
|
377
|
+
const first = Array.isArray(sc) ? sc[0] : sc;
|
|
378
|
+
if (first)
|
|
379
|
+
return first;
|
|
380
|
+
}
|
|
381
|
+
catch { /* no app.json: try the configured routes */ }
|
|
382
|
+
return routes?.map((r) => /^([a-z][a-z0-9+.-]*):\/\//i.exec(r)?.[1]).find(Boolean);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Deep links to walk for `--changed-from <sha>`: the changed, routable screens (up to four), as `<scheme>://<screen>`.
|
|
386
|
+
* The walker opens them with the simulator's `open_url` action, which needs a URL the app's `onUrl` listener understands.
|
|
387
|
+
* If the diff cannot be read (for example a shallow checkout), or the app has no known scheme, say so and fall back to the full walk.
|
|
388
|
+
*/
|
|
389
|
+
export async function routesFromChange(sha, log = console.error, scheme) {
|
|
390
|
+
const { changedFiles, changedScreens } = await import("./ci-comment.js");
|
|
391
|
+
const { routableScreens } = await import("./ci-shots.js");
|
|
392
|
+
let files;
|
|
393
|
+
try {
|
|
394
|
+
files = changedFiles(sha);
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
log(`could not read the diff: ${e instanceof Error ? e.message : e}. Walking the whole app.`);
|
|
398
|
+
return { onlyRoutes: false };
|
|
399
|
+
}
|
|
400
|
+
const screens = routableScreens(changedScreens(files));
|
|
401
|
+
if (!screens.length)
|
|
402
|
+
return { routes: [], onlyRoutes: true };
|
|
403
|
+
if (!scheme) {
|
|
404
|
+
log("could not find the app's URL scheme (expo.scheme in app.json), so changed screens cannot be opened. Walking the whole app.");
|
|
405
|
+
return { onlyRoutes: false };
|
|
406
|
+
}
|
|
407
|
+
return { routes: screens.map(([, r]) => `${scheme}://${r === "/" ? "" : r.slice(1)}`), onlyRoutes: true };
|
|
408
|
+
}
|
|
409
|
+
/** Exit code and `--json` code. A failed flow counts as a finding for `--fail-on-findings`; it has its own code when the walk found nothing. */
|
|
410
|
+
export function outcome(report, flowsFailed, failOn) {
|
|
411
|
+
const code = report.error ? "walk_failed" : report.findings.length ? "findings" : flowsFailed ? "flows_failed" : "ok";
|
|
412
|
+
return { exit: report.error ? 1 : (report.findings.length || flowsFailed) && failOn ? 1 : 0, code };
|
|
413
|
+
}
|
|
414
|
+
async function exploreRun(argv, json) {
|
|
224
415
|
const a = parse(argv);
|
|
225
416
|
const cfg = loadConfig();
|
|
226
417
|
const num = (k, d) => { if (a[k] === undefined)
|
|
227
418
|
return d; const n = Number(a[k]); if (!Number.isInteger(n) || n < 1)
|
|
228
419
|
throw new Error(`--${k} must be a whole number of 1 or more`); return n; };
|
|
229
420
|
const maxDepth = num("max-depth", 3), maxSteps = num("max-steps", 100);
|
|
230
|
-
const sim = await startSim(a, cfg);
|
|
421
|
+
const sim = await startSim(a, cfg, json);
|
|
231
422
|
const base = sim.base;
|
|
232
423
|
try {
|
|
233
|
-
|
|
424
|
+
if (a.skin !== undefined && a.skin !== "ios" && a.skin !== "android")
|
|
425
|
+
throw new Error("--skin must be android or ios");
|
|
426
|
+
let routes = cfg.routes, onlyRoutes = false;
|
|
427
|
+
if (a["changed-from"] !== undefined) {
|
|
428
|
+
if (a["changed-from"] === "true")
|
|
429
|
+
throw new Error("--changed-from needs a commit sha");
|
|
430
|
+
// With --changed-from, the changed screens replace the configured routes. Only a failed diff falls back to them.
|
|
431
|
+
({ routes = cfg.routes, onlyRoutes } = await routesFromChange(a["changed-from"], console.error, appScheme(cfg.routes)));
|
|
432
|
+
}
|
|
433
|
+
let seed;
|
|
434
|
+
if (a.seed !== undefined) {
|
|
435
|
+
seed = Number(a.seed);
|
|
436
|
+
if (!Number.isInteger(seed) || seed < 0)
|
|
437
|
+
throw new Error("--seed must be a whole number of 0 or more");
|
|
438
|
+
}
|
|
439
|
+
const flowNames = a.flows !== undefined ? parseFlowNames(a.flows) : undefined;
|
|
440
|
+
const report = await walk(simDriver(base, cfg.appId), { routes, onlyRoutes, maxDepth, maxSteps, seed, app: cfg.appId, skin: a.skin });
|
|
234
441
|
const out = a.out ?? "explore-report";
|
|
235
|
-
|
|
442
|
+
const nothingChanged = onlyRoutes && !routes?.length;
|
|
443
|
+
if (nothingChanged)
|
|
444
|
+
console.error("No screens changed, nothing explored.");
|
|
445
|
+
writeFileSync(`${out}.md`, (nothingChanged ? "No screens changed, nothing explored.\n\n" : "") + renderMarkdown(report));
|
|
236
446
|
writeFileSync(`${out}.json`, JSON.stringify(report, null, 2) + "\n");
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
447
|
+
let flowsFailed = false;
|
|
448
|
+
let flowInfo = {};
|
|
449
|
+
let flowFiles = [];
|
|
450
|
+
if (flowNames) {
|
|
451
|
+
const fr = await runFlows(simDriver(base, cfg.appId), { flows: flowNames, app: cfg.appId });
|
|
452
|
+
writeFileSync(`${out}-flows.md`, renderFlows(fr));
|
|
453
|
+
writeFileSync(`${out}-flows.json`, JSON.stringify(fr, null, 2) + "\n");
|
|
454
|
+
flowsFailed = fr.flows.some((f) => f.status === "failed");
|
|
455
|
+
flowFiles = [`${out}-flows.md`, `${out}-flows.json`];
|
|
456
|
+
flowInfo = { flows: fr.flows.map((f) => ({ flow: f.flow, status: f.status, invalid_status: f.invalid.status, message: f.message })) };
|
|
457
|
+
const msg = `Flows: ${fr.flows.map((f) => `${f.flow} ${f.status}`).join(", ")}. Wrote ${out}-flows.md and ${out}-flows.json`;
|
|
458
|
+
if (json)
|
|
459
|
+
console.error(msg);
|
|
460
|
+
else
|
|
461
|
+
console.log(msg);
|
|
462
|
+
}
|
|
463
|
+
let issueInfo = {};
|
|
464
|
+
const line = `${report.findings.length ? `${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}` : `No findings in ${report.steps} steps`}${report.stoppedEarly ? `. Stopped early: ${report.stoppedEarly}` : ""}. Wrote ${out}.md and ${out}.json`;
|
|
465
|
+
if (a["issues-dir"] !== undefined) {
|
|
466
|
+
if (a["issues-dir"] === "true")
|
|
467
|
+
throw new Error("--issues-dir needs a directory");
|
|
468
|
+
const { files, commands } = writeIssues(report.findings, a.skin ?? "android", a["issues-dir"]);
|
|
469
|
+
if (!json) {
|
|
470
|
+
if (!files.length)
|
|
471
|
+
console.log("No findings, so no issue files written.");
|
|
472
|
+
else
|
|
473
|
+
console.log(`Wrote ${files.length} issue file${files.length === 1 ? "" : "s"} to ${a["issues-dir"]}. To file them:\n${commands.join("\n")}`);
|
|
474
|
+
}
|
|
475
|
+
else
|
|
476
|
+
issueInfo = { issue_files: files, file_commands: commands };
|
|
241
477
|
}
|
|
242
|
-
|
|
478
|
+
const failOn = a["fail-on-findings"] === "true";
|
|
479
|
+
const { exit, code } = outcome(report, flowsFailed, failOn);
|
|
480
|
+
if (json) {
|
|
481
|
+
const data = { ...issueInfo, ...flowInfo, report_files: [`${out}.md`, `${out}.json`, ...flowFiles], steps: report.steps, screens: report.screens, stopped_early: report.stoppedEarly, error: report.error, findings: report.findings.map((f) => ({ kind: f.kind, message: f.message, steps: f.steps.map(describeStep) })) };
|
|
482
|
+
const next = report.error ? [{ command: "shipvo doctor --json", why: "Check the simulator setup." }]
|
|
483
|
+
: report.findings.length || flowsFailed ? [{ command: `open ${out}.md`, why: "Read the steps to reproduce each finding." }, { command: "shipvo explore --json", why: "Run again after fixing to confirm." }]
|
|
484
|
+
: [{ command: "shipvo test --json", why: "Run your step files." }];
|
|
485
|
+
emit(envelope(code, report.error ?? line, data, next, exit));
|
|
486
|
+
}
|
|
487
|
+
else
|
|
488
|
+
console.log(line);
|
|
489
|
+
if (report.error && !json)
|
|
490
|
+
console.error(report.error);
|
|
491
|
+
return exit;
|
|
243
492
|
}
|
|
244
493
|
finally {
|
|
245
494
|
sim.stop();
|
package/dist/flows.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Driver, type Step } from "./explore.js";
|
|
2
|
+
export type FlowName = "signup" | "login" | "search" | "add_to_cart" | "checkout";
|
|
3
|
+
export declare const FLOW_NAMES: FlowName[];
|
|
4
|
+
export type FlowStatus = "passed" | "failed" | "not_found";
|
|
5
|
+
export type FlowRun = {
|
|
6
|
+
status: FlowStatus | "not_run";
|
|
7
|
+
message: string;
|
|
8
|
+
step?: string;
|
|
9
|
+
steps: Step[];
|
|
10
|
+
};
|
|
11
|
+
export type FlowResult = {
|
|
12
|
+
flow: FlowName;
|
|
13
|
+
status: FlowStatus;
|
|
14
|
+
message: string;
|
|
15
|
+
step?: string;
|
|
16
|
+
steps: Step[];
|
|
17
|
+
invalid: FlowRun;
|
|
18
|
+
};
|
|
19
|
+
export type FlowReport = {
|
|
20
|
+
app: string;
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
flows: FlowResult[];
|
|
23
|
+
};
|
|
24
|
+
/** Turn the `--flows` value into flow names. A bare flag (the arg parser gives "true") means all of them. */
|
|
25
|
+
export declare function parseFlowNames(v: string): FlowName[];
|
|
26
|
+
/** Run each flow with valid input, then once with invalid input. Each run starts on a fresh phone. */
|
|
27
|
+
export declare function runFlows(d: Driver, o?: {
|
|
28
|
+
flows?: FlowName[];
|
|
29
|
+
app?: string;
|
|
30
|
+
}): Promise<FlowReport>;
|
|
31
|
+
export declare function renderFlows(r: FlowReport): string;
|