@shipvo/cli 0.1.4 → 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 (93) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +29 -0
  4. package/dist/agents.js +183 -0
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +92 -0
  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 +11 -0
  20. package/dist/ci.js +100 -0
  21. package/dist/cli.js +51 -1
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.js +68 -13
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +88 -8
  26. package/dist/eas.d.ts +29 -0
  27. package/dist/eas.js +258 -0
  28. package/dist/ensure-playwright.d.ts +25 -0
  29. package/dist/ensure-playwright.js +69 -0
  30. package/dist/explore-issues.d.ts +12 -0
  31. package/dist/explore-issues.js +50 -0
  32. package/dist/explore.d.ts +160 -0
  33. package/dist/explore.js +496 -0
  34. package/dist/flows.d.ts +31 -0
  35. package/dist/flows.js +264 -0
  36. package/dist/help.d.ts +2 -2
  37. package/dist/help.js +37 -7
  38. package/dist/hermes.d.ts +10 -0
  39. package/dist/hermes.js +50 -0
  40. package/dist/init.d.ts +1 -0
  41. package/dist/init.js +71 -19
  42. package/dist/json-out.d.ts +53 -0
  43. package/dist/json-out.js +91 -0
  44. package/dist/keys-json.d.ts +1 -0
  45. package/dist/keys-json.js +71 -0
  46. package/dist/login.js +27 -11
  47. package/dist/lumi.d.ts +104 -0
  48. package/dist/lumi.js +364 -0
  49. package/dist/migrate.d.ts +27 -0
  50. package/dist/migrate.js +521 -0
  51. package/dist/policy.d.ts +2 -16
  52. package/dist/policy.js +2 -18
  53. package/dist/preview-recorder.d.ts +1 -0
  54. package/dist/preview-recorder.js +62 -0
  55. package/dist/preview.d.ts +4 -3
  56. package/dist/preview.js +114 -15
  57. package/dist/promote.d.ts +1 -1
  58. package/dist/promote.js +63 -14
  59. package/dist/publish.d.ts +14 -2
  60. package/dist/publish.js +131 -56
  61. package/dist/qr.d.ts +9 -0
  62. package/dist/qr.js +213 -0
  63. package/dist/report-md.d.ts +27 -0
  64. package/dist/report-md.js +58 -0
  65. package/dist/rotation.d.ts +57 -0
  66. package/dist/rotation.js +157 -0
  67. package/dist/run-bridge.d.ts +7 -0
  68. package/dist/run-bridge.js +728 -0
  69. package/dist/run-net.d.ts +39 -0
  70. package/dist/run-net.js +266 -0
  71. package/dist/run-protocol.d.ts +32 -0
  72. package/dist/run-protocol.js +22 -0
  73. package/dist/run-ws.d.ts +8 -0
  74. package/dist/run-ws.js +89 -0
  75. package/dist/run.d.ts +109 -0
  76. package/dist/run.js +385 -0
  77. package/dist/signing.js +13 -0
  78. package/dist/sim.d.ts +4 -0
  79. package/dist/sim.js +25 -7
  80. package/dist/smoke.d.ts +28 -0
  81. package/dist/smoke.js +117 -0
  82. package/dist/spawn-bin.d.ts +11 -0
  83. package/dist/spawn-bin.js +21 -0
  84. package/dist/status.d.ts +71 -1
  85. package/dist/status.js +162 -5
  86. package/dist/store.d.ts +36 -0
  87. package/dist/store.js +266 -3
  88. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  89. package/dist/templates/github-action/shipvo-check.yml +18 -0
  90. package/dist/test.d.ts +46 -0
  91. package/dist/test.js +253 -0
  92. package/package.json +7 -4
  93. package/skills/shipvo-cli/SKILL.md +31 -0
@@ -0,0 +1,496 @@
1
+ // `shipvo explore`: a deterministic walker over the simulator's accessibility tree. No LLM call.
2
+ // The walker (`walk`) only needs a `Driver`, so tests run it against a fake app. `explore` wires it to the simulator.
3
+ import { execSync, spawn } from "node:child_process";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { createRequire } from "node:module";
7
+ import { args as parse, loadConfig } from "./config.js";
8
+ import { portFree, waitListening } from "./sim.js";
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 };
15
+ export const PARITY_LIMITS = [
16
+ "The simulator runs the app's web build in headless Chromium. It is not a phone and not the native app.",
17
+ "Camera, location, biometrics, payments and notifications are simulated. Native crashes and native-only bugs cannot show up here.",
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.",
19
+ "Layout, speed and memory are not measured.",
20
+ "See docs/parity-results.md for what was measured against Android emulators and the iOS Simulator.",
21
+ ];
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
+ }
66
+ const unquote = (raw) => { const q = /^(\s*- )'(.*)'(:.*)?$/.exec(raw); return q ? q[1] + q[2].replace(/''/g, "'") + (q[3] ?? "") : raw; };
67
+ /** Actionable elements in a tree, in order. `nth` counts earlier elements with the same role and name. Disabled ones are left out. */
68
+ export const elements = (tree) => parseEls(tree, ACTIONABLE);
69
+ function parseEls(tree, roles) {
70
+ const out = [];
71
+ const seen = new Map();
72
+ for (const raw of tree.split("\n")) {
73
+ const l = unquote(raw);
74
+ const m = /^\s*- ([\w-]+)(?: ("(?:[^"\\]|\\.)*"))?[^\n]*?\[ref=(\w+)\]/.exec(l);
75
+ if (!m || !roles.has(m[1]) || /\[disabled\]/.test(l))
76
+ continue;
77
+ let name = "";
78
+ try {
79
+ name = m[2] ? JSON.parse(m[2]).trim() : "";
80
+ }
81
+ catch {
82
+ name = m[2].slice(1, -1).trim();
83
+ }
84
+ const key = `${m[1]}|${name}`;
85
+ const nth = seen.get(key) ?? 0;
86
+ seen.set(key, nth + 1);
87
+ out.push({ role: m[1], name, ref: m[3], nth });
88
+ }
89
+ return out;
90
+ }
91
+ export const fieldElements = (tree) => parseEls(tree, FIELDS);
92
+ /** The tree without refs, to tell whether two screens are the same. */
93
+ export const signature = (tree) => tree.replace(/\s*\[ref=\w+\]/g, "");
94
+ const redScreen = (tree) => /Uncaught (\w*Error)|Unhandled Runtime Error|Application error|Something went wrong/i.exec(tree)?.[0];
95
+ // Accepts "close" too, but the sim `back` action only clicks a button named exactly "Back"; the replay fallback covers the rest.
96
+ const hasBack = (els) => els.some((e) => /^(back|go back|close)$/i.test(e.name));
97
+ const label = (e) => `${e.role} "${e.name}"`;
98
+ /** Steps in plain lines, for the report. */
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
+ */
105
+ export async function walk(driver, o = {}) {
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);
109
+ const findings = [];
110
+ const seenFinding = new Set();
111
+ const seen = new Set();
112
+ const nodes = new Map();
113
+ let steps = 0, cut = 0;
114
+ let stopped = null;
115
+ const add = (f) => { const k = `${f.kind}|${f.message}|${signature(f.screen)}`; if (!seenFinding.has(k)) {
116
+ seenFinding.add(k);
117
+ findings.push(f);
118
+ o.onEvent?.({ type: "finding", n: findings.length, finding: f });
119
+ } };
120
+ const need = (r, what) => { if (!r.ok || r.screen == null)
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);
123
+ /** Put the phone back on the screen reached by `path`. Replays are not counted as steps. */
124
+ async function replay(path) {
125
+ let screen = "";
126
+ for (const s of path) {
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");
138
+ screen = need(r, `replaying "${describeStep(s)}"`);
139
+ }
140
+ // Errors raised while replaying are drained here. On a later replay they would repeat an earlier finding.
141
+ // The caller of the first replay of a start path keeps them.
142
+ return { screen, errors: await driver.errors() };
143
+ }
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 });
145
+ async function checkErrors(screen, path) {
146
+ for (const e of await driver.errors())
147
+ addError(e, screen, path);
148
+ const red = redScreen(screen);
149
+ if (red)
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 });
154
+ }
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) {
157
+ const sig = signature(screen);
158
+ if (seen.has(sig))
159
+ return;
160
+ seen.add(sig);
161
+ await checkErrors(screen, path);
162
+ const els = elements(screen);
163
+ o.onEvent?.({ type: "screen", screens: seen.size, sig, controls: els.map((e) => ({ role: e.role, name: e.name })) });
164
+ if (!els.length)
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 });
168
+ for (const e of els)
169
+ if (!e.name)
170
+ add({ kind: "unnamed", message: `Unnamed ${e.role}: it has no accessible name`, screen, steps: path });
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 });
175
+ }
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))
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 });
184
+ }
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);
193
+ }
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;
214
+ }
215
+ }
216
+ return best;
217
+ }
218
+ const starts = o.startPaths ?? [...(o.onlyRoutes ? [] : [[{ action: "open" }]]), ...(o.routes ?? []).map((url) => [{ action: "open" }, { action: "open_url", url }])];
219
+ let error = null;
220
+ try {
221
+ for (const path of starts) {
222
+ const { screen, errors } = await replay(path);
223
+ // The first replay of a start path is the only time load-time errors are seen.
224
+ for (const e of errors)
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;
289
+ }
290
+ }
291
+ catch (e) {
292
+ error = e instanceof Error ? e.message : String(e);
293
+ stopped = `the walk failed: ${error}`;
294
+ }
295
+ if (!stopped && cut)
296
+ stopped = `max-depth (${maxDepth}) reached: ${cut} screen${cut === 1 ? "" : "s"} were not walked`;
297
+ return { app: o.app ?? "", generatedAt: new Date().toISOString(), steps, screens: seen.size, maxSteps, maxDepth, stoppedEarly: stopped, error, findings, parityLimits: PARITY_LIMITS };
298
+ }
299
+ export function renderMarkdown(r) {
300
+ const L = [`# Explore report: ${r.app}`, "", "## Simulator limits", "", ...r.parityLimits.map((p) => `- ${p}`), "", "## Run", "",
301
+ `- Steps taken: ${r.steps} (limit ${r.maxSteps}). Screens seen: ${r.screens}. Depth limit: ${r.maxDepth}.`,
302
+ `- ${r.stoppedEarly ? `Stopped early: ${r.stoppedEarly}.` : "Stopped early: no. The walk ended because every reachable screen within the limits was visited."}`, ""];
303
+ L.push("## Findings", "");
304
+ if (!r.findings.length)
305
+ L.push(`No findings in ${r.steps} steps.`, "");
306
+ r.findings.forEach((f, i) => {
307
+ L.push(`### ${i + 1}. ${f.message}`, "", "Steps to reproduce:", "", ...f.steps.map((s, j) => `${j + 1}. ${describeStep(s)}`), "", "Typed steps:", "", "```json", JSON.stringify(f.steps), "```", "");
308
+ });
309
+ return L.join("\n");
310
+ }
311
+ /** Driver for a running simulator. */
312
+ export function simDriver(base, app, session = `explore-${Date.now().toString(36)}`, platform) {
313
+ const post = async (path, body) => {
314
+ const r = await fetch(base.replace(/\/$/, "") + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) });
315
+ if (!r.ok)
316
+ throw new Error(`simulator ${path} -> ${r.status}`);
317
+ return (await r.json());
318
+ };
319
+ const act = (body) => post("/act", { session, ...body });
320
+ return {
321
+ open: () => post("/session", { session, app, ...(platform ? { device: { platform } } : {}) }),
322
+ openUrl: (url) => act({ action: "open_url", url }),
323
+ tap: (ref) => act({ action: "tap", ref }),
324
+ back: () => act({ action: "back" }),
325
+ type: (ref, text, enter) => act({ action: "type", ref, text, ...(enter ? { enter: true } : {}) }),
326
+ snapshot: () => act({ action: "screen" }),
327
+ errors: async () => (await act({ action: "errors" })).errors ?? [],
328
+ sizes: async () => (await act({ action: "sizes" })).sizes ?? {},
329
+ };
330
+ }
331
+ /** Use the simulator at `--sim <url>`, or build the app and start one on `--port` (default 4546). `stop` kills the one we started. */
332
+ export async function startSim(a, cfg, quiet = false) {
333
+ let base = a.sim;
334
+ let child;
335
+ if (!base) {
336
+ const port = a.port || "4546";
337
+ if (!(await portFree(Number(port))))
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.`);
339
+ if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
340
+ console.error(`Building ${cfg.name} for the simulator…`);
341
+ execSync("npx expo export --platform web --output-dir sim-dist --clear", { stdio: quiet ? ["ignore", 2, 2] : "inherit", env: { ...process.env, PN_SIM: "1" } });
342
+ }
343
+ ensurePlaywright({ cwd: process.cwd(), quiet });
344
+ let server;
345
+ try {
346
+ server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
347
+ }
348
+ catch {
349
+ throw new Error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
350
+ }
351
+ let exited = false;
352
+ child = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId], { stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
353
+ child.on("exit", () => { exited = true; });
354
+ if (!(await waitListening(Number(port), () => exited)))
355
+ throw new Error("the simulator exited before it was ready");
356
+ base = `http://localhost:${port}`;
357
+ }
358
+ return { base, stop: () => { child?.kill(); } };
359
+ }
360
+ /** Returns the exit code. With `--json`, stdout is one envelope and everything else goes to stderr. */
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) {
415
+ const a = parse(argv);
416
+ const cfg = loadConfig();
417
+ const num = (k, d) => { if (a[k] === undefined)
418
+ return d; const n = Number(a[k]); if (!Number.isInteger(n) || n < 1)
419
+ throw new Error(`--${k} must be a whole number of 1 or more`); return n; };
420
+ const maxDepth = num("max-depth", 3), maxSteps = num("max-steps", 100);
421
+ const sim = await startSim(a, cfg, json);
422
+ const base = sim.base;
423
+ try {
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 });
441
+ const out = a.out ?? "explore-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));
446
+ writeFileSync(`${out}.json`, JSON.stringify(report, null, 2) + "\n");
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 };
477
+ }
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;
492
+ }
493
+ finally {
494
+ sim.stop();
495
+ }
496
+ }
@@ -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;