@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.
Files changed (77) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +15 -0
  4. package/dist/agents.js +68 -9
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +28 -11
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +8 -0
  20. package/dist/ci.js +64 -6
  21. package/dist/cli.js +23 -1
  22. package/dist/config.d.ts +13 -0
  23. package/dist/config.js +43 -3
  24. package/dist/doctor.js +63 -7
  25. package/dist/ensure-playwright.d.ts +4 -0
  26. package/dist/ensure-playwright.js +7 -2
  27. package/dist/explore-issues.d.ts +12 -0
  28. package/dist/explore-issues.js +50 -0
  29. package/dist/explore.d.ts +83 -7
  30. package/dist/explore.js +326 -77
  31. package/dist/flows.d.ts +31 -0
  32. package/dist/flows.js +264 -0
  33. package/dist/help.d.ts +2 -2
  34. package/dist/help.js +19 -10
  35. package/dist/hermes.d.ts +10 -0
  36. package/dist/hermes.js +50 -0
  37. package/dist/init.d.ts +1 -0
  38. package/dist/init.js +41 -6
  39. package/dist/json-out.d.ts +53 -0
  40. package/dist/json-out.js +91 -0
  41. package/dist/keys-json.d.ts +1 -0
  42. package/dist/keys-json.js +71 -0
  43. package/dist/login.js +27 -11
  44. package/dist/lumi.d.ts +104 -0
  45. package/dist/lumi.js +364 -0
  46. package/dist/migrate.d.ts +27 -0
  47. package/dist/migrate.js +521 -0
  48. package/dist/preview.d.ts +1 -0
  49. package/dist/preview.js +4 -2
  50. package/dist/promote.d.ts +1 -1
  51. package/dist/promote.js +38 -16
  52. package/dist/publish.d.ts +14 -2
  53. package/dist/publish.js +126 -62
  54. package/dist/qr.d.ts +9 -0
  55. package/dist/qr.js +213 -0
  56. package/dist/report-md.d.ts +1 -0
  57. package/dist/report-md.js +1 -1
  58. package/dist/rotation.d.ts +57 -0
  59. package/dist/rotation.js +157 -0
  60. package/dist/run-bridge.js +356 -2
  61. package/dist/run-net.d.ts +39 -0
  62. package/dist/run-net.js +266 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +101 -17
  65. package/dist/signing.js +13 -0
  66. package/dist/sim.js +7 -3
  67. package/dist/smoke.d.ts +28 -0
  68. package/dist/smoke.js +117 -0
  69. package/dist/spawn-bin.d.ts +11 -0
  70. package/dist/spawn-bin.js +21 -0
  71. package/dist/status.d.ts +26 -4
  72. package/dist/status.js +69 -5
  73. package/dist/templates/github-action/shipvo-check.yml +18 -0
  74. package/dist/test.d.ts +1 -1
  75. package/dist/test.js +81 -18
  76. package/package.json +4 -3
  77. 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 only taps. It does not type, scroll or swipe, so content below the fold and rails are only seen if on screen.",
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 function elements(tree) {
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 || !ACTIONABLE.has(m[1]) || /\[disabled\]/.test(l))
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 maxDepth = o.maxDepth ?? 3, maxSteps = o.maxSteps ?? 100;
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 seenScreens = new Set();
54
- let steps = 0;
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
- const r = s.action === "open" ? await driver.open() : s.action === "open_url" ? await driver.openUrl(s.url) : s.action === "back" ? await driver.back()
68
- : await driver.tap(find(screen, s)?.ref ?? "missing");
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 find = (tree, s) => elements(tree).find((e) => e.role === s.role && e.name === s.name && e.nth === s.nth);
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
- add({ kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
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
- async function visit(screen, path, depth) {
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 (seenScreens.has(sig))
158
+ if (seen.has(sig))
86
159
  return;
87
- seenScreens.add(sig);
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
- let current = screen;
96
- for (const e of els) {
97
- if (!e.name || /^(back|go back)$/i.test(e.name))
98
- continue;
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
- const target = find(current, e);
104
- if (!target) {
105
- current = (await replay(path)).screen;
106
- if (!find(current, e))
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
- const step = { action: "tap", role: e.role, name: e.name, nth: e.nth };
110
- const here = [...path, step];
111
- steps++;
112
- const r = await driver.tap(find(current, e).ref);
113
- if (r.delivery === "not_delivered")
114
- add({ kind: "not_delivered", message: `Not delivered (${r.reason ?? "unknown reason"}): ${r.error ?? "the app did not receive the tap"}`, screen: current, steps: here });
115
- else if (!r.ok)
116
- add({ kind: "action_failed", message: `Action failed${r.code ? ` [${r.code}]` : ""}: ${r.error ?? "unknown"}`, screen: current, steps: here });
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
- // Back to this screen: Back button first, a full replay if that does not land here.
133
- if (next !== signature(current) || crashed) {
134
- let b;
135
- if (!crashed && hasBack(elements(after)))
136
- b = await driver.back();
137
- current = b?.ok && b.screen != null && signature(b.screen) === sig ? b.screen : (await replay(path)).screen;
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
- add({ kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
153
- await visit(screen, path, 1);
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: seenScreens.size, maxSteps, maxDepth, stoppedEarly: stopped, error, findings, parityLimits: PARITY_LIMITS };
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("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
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
- const report = await walk(simDriver(base, cfg.appId), { routes: cfg.routes, maxDepth, maxSteps, app: cfg.appId });
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
- writeFileSync(`${out}.md`, renderMarkdown(report));
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
- console.log(`${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`);
238
- if (report.error) {
239
- console.error(report.error);
240
- return 1;
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
- return report.findings.length && a["fail-on-findings"] === "true" ? 1 : 0;
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();
@@ -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;