lynkeus 0.2.0

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 (217) hide show
  1. package/lib/__tests__/args.test.d.ts +1 -0
  2. package/lib/__tests__/args.test.js +69 -0
  3. package/lib/__tests__/check.test.d.ts +1 -0
  4. package/lib/__tests__/check.test.js +60 -0
  5. package/lib/__tests__/init.test.d.ts +1 -0
  6. package/lib/__tests__/init.test.js +42 -0
  7. package/lib/__tests__/record.test.d.ts +1 -0
  8. package/lib/__tests__/record.test.js +44 -0
  9. package/lib/__tests__/snapshot.test.d.ts +1 -0
  10. package/lib/__tests__/snapshot.test.js +47 -0
  11. package/lib/__tests__/targets.test.d.ts +1 -0
  12. package/lib/__tests__/targets.test.js +127 -0
  13. package/lib/args.d.ts +30 -0
  14. package/lib/args.js +86 -0
  15. package/lib/cases/__tests__/format.test.d.ts +1 -0
  16. package/lib/cases/__tests__/format.test.js +61 -0
  17. package/lib/cases/__tests__/runner.test.d.ts +1 -0
  18. package/lib/cases/__tests__/runner.test.js +188 -0
  19. package/lib/cases/fixtures.d.ts +13 -0
  20. package/lib/cases/fixtures.js +100 -0
  21. package/lib/cases/format.d.ts +37 -0
  22. package/lib/cases/format.js +169 -0
  23. package/lib/cases/runner.d.ts +61 -0
  24. package/lib/cases/runner.js +590 -0
  25. package/lib/cases/templates.d.ts +20 -0
  26. package/lib/cases/templates.js +156 -0
  27. package/lib/cli.d.ts +6 -0
  28. package/lib/cli.js +173 -0
  29. package/lib/commands/cases.d.ts +1 -0
  30. package/lib/commands/cases.js +179 -0
  31. package/lib/commands/check.d.ts +36 -0
  32. package/lib/commands/check.js +67 -0
  33. package/lib/commands/crawl/return.d.ts +20 -0
  34. package/lib/commands/crawl/return.js +125 -0
  35. package/lib/commands/crawl/state.d.ts +30 -0
  36. package/lib/commands/crawl/state.js +56 -0
  37. package/lib/commands/crawl.d.ts +67 -0
  38. package/lib/commands/crawl.js +213 -0
  39. package/lib/commands/device.d.ts +10 -0
  40. package/lib/commands/device.js +122 -0
  41. package/lib/commands/doctor.d.ts +3 -0
  42. package/lib/commands/doctor.js +240 -0
  43. package/lib/commands/drive.d.ts +1 -0
  44. package/lib/commands/drive.js +410 -0
  45. package/lib/commands/flake.d.ts +14 -0
  46. package/lib/commands/flake.js +42 -0
  47. package/lib/commands/headless.d.ts +1 -0
  48. package/lib/commands/headless.js +127 -0
  49. package/lib/commands/init.d.ts +13 -0
  50. package/lib/commands/init.js +85 -0
  51. package/lib/commands/map.d.ts +2 -0
  52. package/lib/commands/map.js +297 -0
  53. package/lib/commands/os.d.ts +1 -0
  54. package/lib/commands/os.js +193 -0
  55. package/lib/commands/overlay.d.ts +9 -0
  56. package/lib/commands/overlay.js +12 -0
  57. package/lib/commands/record.d.ts +5 -0
  58. package/lib/commands/record.js +55 -0
  59. package/lib/commands/runs.d.ts +1 -0
  60. package/lib/commands/runs.js +452 -0
  61. package/lib/commands/smoke.d.ts +28 -0
  62. package/lib/commands/smoke.js +59 -0
  63. package/lib/commands/snapshot.d.ts +27 -0
  64. package/lib/commands/snapshot.js +69 -0
  65. package/lib/config.d.ts +72 -0
  66. package/lib/config.js +44 -0
  67. package/lib/device/devices.d.ts +11 -0
  68. package/lib/device/devices.js +33 -0
  69. package/lib/device/driver.d.ts +53 -0
  70. package/lib/device/driver.js +169 -0
  71. package/lib/device/platform.d.ts +7 -0
  72. package/lib/device/platform.js +59 -0
  73. package/lib/files.d.ts +7 -0
  74. package/lib/files.js +29 -0
  75. package/lib/flow/artifacts.d.ts +19 -0
  76. package/lib/flow/artifacts.js +49 -0
  77. package/lib/flow/runner.d.ts +13 -0
  78. package/lib/flow/runner.js +169 -0
  79. package/lib/flow/types.d.ts +111 -0
  80. package/lib/flow/types.js +1 -0
  81. package/lib/index.d.ts +18 -0
  82. package/lib/index.js +11 -0
  83. package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
  84. package/lib/knowledge/__tests__/edges.test.js +25 -0
  85. package/lib/knowledge/__tests__/events.test.d.ts +1 -0
  86. package/lib/knowledge/__tests__/events.test.js +39 -0
  87. package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
  88. package/lib/knowledge/__tests__/extract.test.js +69 -0
  89. package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
  90. package/lib/knowledge/__tests__/frontier.test.js +57 -0
  91. package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
  92. package/lib/knowledge/__tests__/funnels.test.js +40 -0
  93. package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
  94. package/lib/knowledge/__tests__/lint.test.js +53 -0
  95. package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
  96. package/lib/knowledge/__tests__/perf.test.js +34 -0
  97. package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
  98. package/lib/knowledge/__tests__/plan.test.js +35 -0
  99. package/lib/knowledge/__tests__/query.test.d.ts +1 -0
  100. package/lib/knowledge/__tests__/query.test.js +55 -0
  101. package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
  102. package/lib/knowledge/__tests__/screens.test.js +30 -0
  103. package/lib/knowledge/__tests__/why.test.d.ts +1 -0
  104. package/lib/knowledge/__tests__/why.test.js +28 -0
  105. package/lib/knowledge/edges.d.ts +30 -0
  106. package/lib/knowledge/edges.js +68 -0
  107. package/lib/knowledge/errors.d.ts +8 -0
  108. package/lib/knowledge/errors.js +62 -0
  109. package/lib/knowledge/events.d.ts +23 -0
  110. package/lib/knowledge/events.js +51 -0
  111. package/lib/knowledge/extract.d.ts +26 -0
  112. package/lib/knowledge/extract.js +365 -0
  113. package/lib/knowledge/frontier.d.ts +26 -0
  114. package/lib/knowledge/frontier.js +60 -0
  115. package/lib/knowledge/funnels.d.ts +46 -0
  116. package/lib/knowledge/funnels.js +103 -0
  117. package/lib/knowledge/lint.d.ts +16 -0
  118. package/lib/knowledge/lint.js +89 -0
  119. package/lib/knowledge/perf.d.ts +32 -0
  120. package/lib/knowledge/perf.js +91 -0
  121. package/lib/knowledge/plan.d.ts +22 -0
  122. package/lib/knowledge/plan.js +81 -0
  123. package/lib/knowledge/problems.d.ts +5 -0
  124. package/lib/knowledge/problems.js +8 -0
  125. package/lib/knowledge/query.d.ts +36 -0
  126. package/lib/knowledge/query.js +127 -0
  127. package/lib/knowledge/route-params.d.ts +6 -0
  128. package/lib/knowledge/route-params.js +42 -0
  129. package/lib/knowledge/screens.d.ts +30 -0
  130. package/lib/knowledge/screens.js +125 -0
  131. package/lib/knowledge/tree.d.ts +15 -0
  132. package/lib/knowledge/tree.js +63 -0
  133. package/lib/knowledge/types.d.ts +62 -0
  134. package/lib/knowledge/types.js +6 -0
  135. package/lib/knowledge/why.d.ts +24 -0
  136. package/lib/knowledge/why.js +153 -0
  137. package/lib/mcp/server.d.ts +4 -0
  138. package/lib/mcp/server.js +111 -0
  139. package/lib/registry.d.ts +78 -0
  140. package/lib/registry.js +113 -0
  141. package/lib/runtime.d.ts +12 -0
  142. package/lib/runtime.js +56 -0
  143. package/lib/targets.d.ts +17 -0
  144. package/lib/targets.js +43 -0
  145. package/package.json +47 -0
  146. package/src/__tests__/args.test.ts +82 -0
  147. package/src/__tests__/check.test.ts +71 -0
  148. package/src/__tests__/init.test.ts +52 -0
  149. package/src/__tests__/record.test.ts +49 -0
  150. package/src/__tests__/snapshot.test.ts +51 -0
  151. package/src/__tests__/targets.test.ts +132 -0
  152. package/src/args.ts +111 -0
  153. package/src/cases/__tests__/format.test.ts +76 -0
  154. package/src/cases/__tests__/runner.test.ts +230 -0
  155. package/src/cases/fixtures.ts +104 -0
  156. package/src/cases/format.ts +183 -0
  157. package/src/cases/runner.ts +575 -0
  158. package/src/cases/templates.ts +159 -0
  159. package/src/cli.ts +176 -0
  160. package/src/commands/cases.ts +170 -0
  161. package/src/commands/check.ts +83 -0
  162. package/src/commands/crawl/return.ts +130 -0
  163. package/src/commands/crawl/state.ts +70 -0
  164. package/src/commands/crawl.ts +262 -0
  165. package/src/commands/device.ts +119 -0
  166. package/src/commands/doctor.ts +237 -0
  167. package/src/commands/drive.ts +413 -0
  168. package/src/commands/flake.ts +48 -0
  169. package/src/commands/headless.ts +131 -0
  170. package/src/commands/init.ts +98 -0
  171. package/src/commands/map.ts +298 -0
  172. package/src/commands/os.ts +195 -0
  173. package/src/commands/overlay.ts +20 -0
  174. package/src/commands/record.ts +46 -0
  175. package/src/commands/runs.ts +475 -0
  176. package/src/commands/smoke.ts +86 -0
  177. package/src/commands/snapshot.ts +87 -0
  178. package/src/config.ts +103 -0
  179. package/src/device/devices.ts +44 -0
  180. package/src/device/driver.ts +197 -0
  181. package/src/device/platform.ts +74 -0
  182. package/src/files.ts +35 -0
  183. package/src/flow/artifacts.ts +61 -0
  184. package/src/flow/runner.ts +182 -0
  185. package/src/flow/types.ts +55 -0
  186. package/src/index.ts +18 -0
  187. package/src/knowledge/__tests__/edges.test.ts +34 -0
  188. package/src/knowledge/__tests__/events.test.ts +45 -0
  189. package/src/knowledge/__tests__/extract.test.ts +88 -0
  190. package/src/knowledge/__tests__/frontier.test.ts +77 -0
  191. package/src/knowledge/__tests__/funnels.test.ts +58 -0
  192. package/src/knowledge/__tests__/lint.test.ts +66 -0
  193. package/src/knowledge/__tests__/perf.test.ts +41 -0
  194. package/src/knowledge/__tests__/plan.test.ts +52 -0
  195. package/src/knowledge/__tests__/query.test.ts +74 -0
  196. package/src/knowledge/__tests__/screens.test.ts +37 -0
  197. package/src/knowledge/__tests__/why.test.ts +40 -0
  198. package/src/knowledge/edges.ts +91 -0
  199. package/src/knowledge/errors.ts +73 -0
  200. package/src/knowledge/events.ts +60 -0
  201. package/src/knowledge/extract.ts +379 -0
  202. package/src/knowledge/frontier.ts +82 -0
  203. package/src/knowledge/funnels.ts +126 -0
  204. package/src/knowledge/lint.ts +99 -0
  205. package/src/knowledge/perf.ts +114 -0
  206. package/src/knowledge/plan.ts +95 -0
  207. package/src/knowledge/problems.ts +12 -0
  208. package/src/knowledge/query.ts +146 -0
  209. package/src/knowledge/route-params.ts +39 -0
  210. package/src/knowledge/screens.ts +139 -0
  211. package/src/knowledge/tree.ts +66 -0
  212. package/src/knowledge/types.ts +60 -0
  213. package/src/knowledge/why.ts +189 -0
  214. package/src/mcp/server.ts +109 -0
  215. package/src/registry.ts +191 -0
  216. package/src/runtime.ts +54 -0
  217. package/src/targets.ts +44 -0
@@ -0,0 +1,452 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ import { pairs, splitLine } from '../args.js';
6
+ import { anyOf, edgesFile, frontierFile, loadGraph, lynkeusDir, runsDir } from '../config.js';
7
+ import { describeScreen } from '../device/driver.js';
8
+ import { readJson, stamp, writeJson } from '../files.js';
9
+ import { describeDiff, diffScreens } from '../flow/artifacts.js';
10
+ import { runFlow } from '../flow/runner.js';
11
+ import * as edges from '../knowledge/edges.js';
12
+ import { describeEvents, eventsFiles, normalizeEvents, readEvents, resolveEventsFile } from '../knowledge/events.js';
13
+ import * as frontier from '../knowledge/frontier.js';
14
+ import { checkFunnel, describeEventsDiff, describeFunnel, diffEvents, funnelCoverage, listFunnels, loadFunnel } from '../knowledge/funnels.js';
15
+ import { plan } from '../knowledge/plan.js';
16
+ import { describeTreeDiff, diffTrees, screenTree } from '../knowledge/tree.js';
17
+ import { define } from '../registry.js';
18
+ import { execute } from '../runtime.js';
19
+ import { parseTarget } from '../targets.js';
20
+ import { crawl } from './crawl.js';
21
+ import { describeFlake, summarizeRuns } from './flake.js';
22
+ import { describeEvent, draftFlow } from './record.js';
23
+ import { smoke } from './smoke.js';
24
+ import { baselinePath, describeSnapshot, snapshot } from './snapshot.js';
25
+ const VAR_FLAG = { var: { type: 'list', help: 'a flow variable (repeatable)', value: 'k=v' } };
26
+ const OVERLAY_FLAGS = {
27
+ 'overlay-when': { type: 'string', help: 'a target the app raises on its own (a lock screen, a rating prompt)', value: 'target' },
28
+ 'overlay-flow': { type: 'string', help: 'the flow that clears it', value: 'flow.json' },
29
+ };
30
+ const overlayOf = (flags) => flags['overlay-when'] && flags['overlay-flow']
31
+ ? { when: parseTarget(flags['overlay-when']), flow: readJson(flags['overlay-flow']) }
32
+ : undefined;
33
+ const varsOf = (flags) => pairs(flags.var);
34
+ const seconds = (since) => Math.round((performance.now() - since) / 1000);
35
+ const indent = (text, by) => text
36
+ .split('\n')
37
+ .map((l) => `${by}${l}`)
38
+ .join('\n');
39
+ export const runsCommands = [
40
+ define({
41
+ name: 'run',
42
+ group: 'runs',
43
+ summary: 'Replay a flow with variables; per-step timing, route and errors',
44
+ positionals: [{ name: 'flow.json', help: 'the flow file', required: true }],
45
+ flags: {
46
+ ...VAR_FLAG,
47
+ screenshots: { type: 'boolean', help: 'one capture per step' },
48
+ diff: { type: 'boolean', help: 'print what changed on screen after each step' },
49
+ events: { type: 'boolean', help: "keep the app's analytics events per step (runs/…/events.jsonl); empties its buffer" },
50
+ },
51
+ needs: 'app',
52
+ run: async (ctx) => {
53
+ const device = await ctx.device();
54
+ const flow = readJson(ctx.args[0]);
55
+ ctx.err(`\n${flow.name}\n`);
56
+ let previous;
57
+ const report = await runFlow(flow, {
58
+ device,
59
+ vars: varsOf(ctx.flags),
60
+ artifactsRoot: runsDir(ctx.root),
61
+ screenshots: !!ctx.flags.screenshots,
62
+ events: !!ctx.flags.events,
63
+ onStep: (r, i) => {
64
+ const [label, value] = Object.entries(r.step)[0];
65
+ ctx.err(` ${r.ok ? '✓' : '✗'} ${String(i + 1).padStart(2)} ${String(r.ms).padStart(6)}ms ${label} ${JSON.stringify(value).slice(0, 70)}${r.route ? ` → ${r.route}` : ''}${r.error ? `\n ${r.error}` : ''}`);
66
+ for (const e of r.events ?? [])
67
+ ctx.err(` event ${e.name}${e.props ? ` ${JSON.stringify(e.props)}` : ''}`);
68
+ if (ctx.flags.diff && r.screen && previous) {
69
+ const lines = describeDiff(diffScreens(previous, r.screen));
70
+ if (lines !== '(no structural change)')
71
+ ctx.err(indent(lines, ' '));
72
+ }
73
+ if (r.screen)
74
+ previous = r.screen;
75
+ },
76
+ });
77
+ const setup = report.steps.filter((r) => 'launch' in r.step).reduce((n, r) => n + r.ms, 0);
78
+ ctx.err(`\n ${report.ok ? 'PASSED' : 'FAILED'} in ${report.totalMs}ms (${report.steps.length}/${flow.steps.length} steps): launch ${setup}ms, driving the app ${report.totalMs - setup}ms${report.dir ? `\n artifacts: ${report.dir}` : ''}\n`);
79
+ if (!report.ok && device.server.connected)
80
+ ctx.err(describeScreen(await device.screen()));
81
+ return { text: '', json: { ok: report.ok, steps: report.steps.length, ms: report.totalMs, dir: report.dir }, code: report.ok ? 0 : 1 };
82
+ },
83
+ }),
84
+ define({
85
+ name: 'events',
86
+ group: 'runs',
87
+ summary: 'What the app told its analytics: live, or step by step in a run',
88
+ details: 'Without `--run`, what the app has recorded so far (its `events` command, left as it is). With `--run <dir>` or `--run last`, the events a `run --events` kept, each with the step and route it happened on.',
89
+ flags: {
90
+ run: { type: 'string', help: 'a run folder, or `last`', value: 'dir' },
91
+ name: { type: 'string', help: 'only events whose name matches', value: 'regex' },
92
+ },
93
+ needs: 'nothing',
94
+ run: async (ctx) => {
95
+ const match = typeof ctx.flags.name === 'string' ? new RegExp(ctx.flags.name) : undefined;
96
+ const wanted = (list) => list.filter((e) => !match || match.test(e.name));
97
+ if (ctx.flags.run) {
98
+ const file = resolveEventsFile(runsDir(ctx.root), ctx.flags.run);
99
+ if (!file || !fs.existsSync(file))
100
+ return { text: 'no events recorded for that run (run a flow with --events)', code: 1 };
101
+ const list = wanted(readEvents(file));
102
+ return { text: describeEvents(list) || '(none)', json: list };
103
+ }
104
+ const list = wanted(normalizeEvents(await (await ctx.device()).command('events')));
105
+ return { text: list.map((e) => `${e.name}${e.props ? ` ${JSON.stringify(e.props)}` : ''}`).join('\n') || '(none)', json: list };
106
+ },
107
+ }),
108
+ define({
109
+ name: 'events diff',
110
+ group: 'runs',
111
+ summary: 'The same flow on two builds: events that appeared, disappeared or changed shape',
112
+ positionals: [
113
+ { name: 'before', help: 'a run folder (main)', required: true },
114
+ { name: 'after', help: 'a run folder (the branch), or `last`', required: true },
115
+ ],
116
+ needs: 'nothing',
117
+ run: async (ctx) => {
118
+ const [a, b] = ctx.args.map((ref) => resolveEventsFile(runsDir(ctx.root), ref));
119
+ if (!a || !b || !fs.existsSync(a) || !fs.existsSync(b))
120
+ return { text: 'both runs need events (run the flow with --events)', code: 1 };
121
+ const diff = diffEvents(readEvents(a), readEvents(b));
122
+ return { text: describeEventsDiff(diff), json: diff, code: diff.lost.length || diff.changed.length ? 1 : 0 };
123
+ },
124
+ }),
125
+ define({
126
+ name: 'funnel check',
127
+ group: 'runs',
128
+ summary: 'Did a run produce a funnel: every step, in order, with the properties it needs',
129
+ details: 'A funnel is `.lynkeus/funnels/<name>.json`: `{ "steps": [{ "event": "signup_started", "props": { "source": "string", "step": "number" } }, { "event": "signup_completed" }] }`. A property names a type the dashboard needs, or a value it filters on. Exit 1 when a step is missing, out of order, or has the wrong shape.',
130
+ positionals: [{ name: 'name', help: 'the funnel', required: true }],
131
+ flags: { run: { type: 'string', help: 'a run folder, or `last`', default: 'last', value: 'dir' } },
132
+ needs: 'nothing',
133
+ run: async (ctx) => {
134
+ const funnel = loadFunnel(lynkeusDir(ctx.root), ctx.args[0]);
135
+ const file = resolveEventsFile(runsDir(ctx.root), ctx.flags.run);
136
+ if (!file || !fs.existsSync(file))
137
+ return { text: 'no events recorded for that run (run the flow with --events)', code: 1 };
138
+ const result = checkFunnel(funnel, readEvents(file));
139
+ return { text: describeFunnel(result), json: result, code: result.ok ? 0 : 1 };
140
+ },
141
+ }),
142
+ define({
143
+ name: 'funnel report',
144
+ group: 'runs',
145
+ summary: 'Across the recorded runs: which funnel steps some run produces, and which none does',
146
+ flags: { last: { type: 'number', help: 'how many recent runs', default: 50, value: 'n' } },
147
+ needs: 'nothing',
148
+ run: async (ctx) => {
149
+ const funnels = listFunnels(lynkeusDir(ctx.root));
150
+ if (funnels.length === 0)
151
+ return { text: 'no funnels under .lynkeus/funnels/', code: 1 };
152
+ const runs = eventsFiles(runsDir(ctx.root))
153
+ .slice(0, ctx.flags.last)
154
+ .map((file) => ({ run: path.relative(runsDir(ctx.root), path.dirname(file)), events: readEvents(file) }));
155
+ const coverage = funnelCoverage(funnels, runs);
156
+ const text = coverage
157
+ .map((f) => [
158
+ f.funnel,
159
+ ...f.steps.map((s) => ` ${s.runs.length ? '✓' : '✗'} ${s.event} ${s.runs.length ? `${s.runs.length} run(s)` : 'no run produces it'}`),
160
+ ].join('\n'))
161
+ .join('\n\n');
162
+ return { text, json: coverage };
163
+ },
164
+ }),
165
+ define({
166
+ name: 'flake',
167
+ group: 'runs',
168
+ summary: 'The same flow n times: how often it passes, and the step and error it dies on',
169
+ positionals: [{ name: 'flow.json', help: 'the flow file', required: true }],
170
+ flags: {
171
+ times: { type: 'number', help: 'how many runs', default: 5, value: 'n' },
172
+ ...VAR_FLAG,
173
+ },
174
+ needs: 'app',
175
+ session: false,
176
+ run: async (ctx) => {
177
+ const device = await ctx.device();
178
+ const flow = readJson(ctx.args[0]);
179
+ const times = ctx.flags.times;
180
+ const reports = [];
181
+ for (let i = 1; i <= times; i++) {
182
+ const report = await runFlow(flow, { device, vars: varsOf(ctx.flags) });
183
+ reports.push(report);
184
+ const failed = report.steps.findIndex((r) => !r.ok);
185
+ ctx.err(` ${i}/${times} ${report.ok ? '✓' : '✗'} ${Math.round(report.totalMs)}ms${failed === -1 ? '' : ` step ${failed + 1}: ${report.steps[failed].error}`}`);
186
+ }
187
+ const summary = summarizeRuns(reports);
188
+ return { text: describeFlake(flow.name, summary), json: summary, code: summary.passed === summary.runs ? 0 : 1 };
189
+ },
190
+ }),
191
+ define({
192
+ name: 'smoke',
193
+ group: 'runs',
194
+ summary: 'Open every parameter-free route (or the prefixes given) and record what renders',
195
+ positionals: [{ name: 'Route.Prefix', help: 'only routes under these prefixes', rest: true }],
196
+ flags: {
197
+ params: { type: 'string', help: 'route params type map, relative to the app', value: 'file' },
198
+ screenshots: { type: 'boolean', help: 'one capture per screen' },
199
+ ...OVERLAY_FLAGS,
200
+ },
201
+ needs: 'app',
202
+ session: false,
203
+ run: async (ctx) => {
204
+ const graph = ctx.graph();
205
+ const device = await ctx.device();
206
+ const outDir = path.join(runsDir(ctx.root), 'smoke', stamp());
207
+ const started = performance.now();
208
+ const results = await smoke({
209
+ device,
210
+ graph,
211
+ appRoot: ctx.root,
212
+ paramsFile: ctx.flags.params,
213
+ outDir,
214
+ only: ctx.args.length ? ctx.args : undefined,
215
+ screenshots: !!ctx.flags.screenshots,
216
+ overlay: overlayOf(ctx.flags),
217
+ appId: ctx.flags.app ?? device.appId ?? undefined,
218
+ errorCopy: anyOf(ctx.config.errorCopy),
219
+ onResult: (r) => ctx.err(` ${r.ok ? '✓' : '✗'} ${String(r.ms).padStart(5)}ms ${r.route.padEnd(36)} ${String(r.elements).padStart(3)} el${r.problems.length ? ` ${r.problems.join('; ')}` : ''}`),
220
+ });
221
+ const bad = results.filter((r) => !r.ok);
222
+ return {
223
+ text: `\n ${results.length - bad.length}/${results.length} screens clean in ${seconds(started)}s\n artifacts: ${outDir}\n`,
224
+ json: results,
225
+ code: bad.length ? 1 : 0,
226
+ };
227
+ },
228
+ }),
229
+ define({
230
+ name: 'crawl',
231
+ group: 'runs',
232
+ summary: 'Press every control it finds, learn where each leads (feeds map paths and plan)',
233
+ details: 'Finishes a screen before it goes anywhere, so the controls that matter are not lost to whatever the first one opened, and remembers in .lynkeus/frontier.jsonl what it did not get to — a second run continues instead of repeating. `--to` walks towards a route instead of exploring everything. Read-only by default: every press happens, but the moment one makes the app write (a POST, PUT, PATCH or DELETE) the crawl reports it and stops exploring past it, and never presses that control again. Controls that must not be pressed at all go in lynkeus.config.json under crawl.never — lynkeus ships no list of its own, because which controls are irreversible depends on the app.',
234
+ positionals: [{ name: 'Route', help: 'where to start (default: the current screen)' }],
235
+ flags: {
236
+ depth: { type: 'number', help: 'how many screens deep', default: 1, value: 'n' },
237
+ max: { type: 'number', help: 'at most this many presses', default: 80, value: 'n' },
238
+ to: { type: 'string', help: 'walk towards this route and stop on arrival', value: 'Route' },
239
+ fresh: { type: 'boolean', help: 'ignore what earlier runs already pressed' },
240
+ ...OVERLAY_FLAGS,
241
+ 'login-flow': { type: 'string', help: 'flows that log in again after a reset, comma-separated', value: 'a.json,b.json' },
242
+ ...VAR_FLAG,
243
+ 'var-cmd': { type: 'list', help: 'a variable produced by a shell command when needed (repeatable)', value: "name='cmd'" },
244
+ 'press-anything': { type: 'boolean', help: 'keep exploring past a press that wrote (a disposable environment only)' },
245
+ verbose: { type: 'boolean', help: 'print every recovery and decision' },
246
+ },
247
+ needs: 'app',
248
+ session: false,
249
+ run: async (ctx) => {
250
+ const device = await ctx.device();
251
+ const outDir = path.join(runsDir(ctx.root), 'crawl', stamp());
252
+ if (ctx.args[0]) {
253
+ await device.navigate(ctx.args[0]);
254
+ await device.idle({ quietMs: 200, timeoutMs: 2000 });
255
+ }
256
+ const started = performance.now();
257
+ const learned = { added: 0, reinforced: 0 };
258
+ const loginFlow = ctx.flags['login-flow'];
259
+ const known = edges.open(edgesFile(ctx.root));
260
+ const memory = ctx.flags.fresh ? undefined : frontier.open(frontierFile(ctx.root));
261
+ const graph = loadGraph(ctx.root);
262
+ const rules = ctx.config.crawl;
263
+ try {
264
+ const { transitions, states } = await crawl({
265
+ device,
266
+ outDir,
267
+ depth: ctx.flags.depth,
268
+ maxActions: ctx.flags.max,
269
+ neverPress: anyOf(rules?.never),
270
+ closers: anyOf(rules?.close),
271
+ backs: anyOf(rules?.back),
272
+ errorCopy: anyOf(ctx.config.errorCopy),
273
+ safety: ctx.flags['press-anything'] ? 'anything' : 'read-only',
274
+ appId: ctx.flags.app ?? device.appId ?? undefined,
275
+ overlay: overlayOf(ctx.flags),
276
+ login: loginFlow
277
+ ? {
278
+ flows: loginFlow.split(',').map((f) => readJson(f.trim())),
279
+ vars: varsOf(ctx.flags),
280
+ varCommands: pairs(ctx.flags['var-cmd']),
281
+ }
282
+ : undefined,
283
+ goal: ctx.flags.to,
284
+ memory,
285
+ directions: graph ? (from, to) => plan(graph, from, to, known.all()) : undefined,
286
+ log: (line) => ctx.flags.verbose && ctx.err(` · ${line}`),
287
+ onTransition: (t) => {
288
+ const b = t.button.testId ? `#${t.button.testId}` : JSON.stringify(t.button.text ?? '');
289
+ ctx.err(` ${t.problems.length ? '✗' : '✓'} ${String(t.ms).padStart(5)}ms ${(t.fromRoute ?? '?').padEnd(28)} ${b.padEnd(44)} → ${t.toRoute ?? '?'}${t.problems.length ? ` ${t.problems.join('; ')}` : ''}`);
290
+ // A press that opened a sheet went somewhere even though the route did not change: the layer is the destination.
291
+ if (t.fromRoute && t.toRoute && (t.fromRoute !== t.toRoute || t.fromLayer !== t.toLayer)) {
292
+ const added = known.record({
293
+ from: t.fromRoute,
294
+ to: t.toRoute,
295
+ via: t.button.testId ?? `text:${t.button.text ?? ''}`,
296
+ fromLayer: t.fromLayer,
297
+ toLayer: t.toLayer,
298
+ });
299
+ learned[added ? 'added' : 'reinforced'] += 1;
300
+ }
301
+ },
302
+ });
303
+ const bad = transitions.filter((t) => t.problems.length);
304
+ const screens = Object.keys(states).length;
305
+ return {
306
+ text: `\n ${transitions.length} transitions, ${screens} screens, ${bad.length} problems in ${seconds(started)}s; learned ${learned.added} new edges (${learned.reinforced} reinforced)\n artifacts: ${outDir}\n`,
307
+ json: { transitions: transitions.length, screens, problems: bad.length, learned, outDir },
308
+ code: bad.length ? 1 : 0,
309
+ };
310
+ }
311
+ finally {
312
+ known.compact();
313
+ memory?.compact();
314
+ }
315
+ },
316
+ }),
317
+ define({
318
+ name: 'record',
319
+ group: 'runs',
320
+ summary: 'Watch what a person does in the app; Ctrl-C writes a flow with what each step caused',
321
+ details: 'Touches are traced on device builds mounted with auto(); a host records routes and requests only. Lines typed on stdin run as commands meanwhile.',
322
+ flags: {
323
+ out: { type: 'string', help: 'where to write the flow', value: 'flow.json' },
324
+ 'so-far': { type: 'boolean', help: 'draft from the trace as it stands and stop' },
325
+ screenshots: { type: 'boolean', help: 'one capture per touch, listed in <out>.strip.md' },
326
+ },
327
+ needs: 'app',
328
+ session: false,
329
+ mcp: false,
330
+ run: async (ctx) => {
331
+ const device = await ctx.device();
332
+ const out = ctx.flags.out ?? path.join(runsDir(ctx.root), 'record', `${stamp()}.json`);
333
+ const events = [];
334
+ const soFar = !!ctx.flags['so-far'];
335
+ let since = soFar ? 0 : (await device.trace()).last;
336
+ const shots = [];
337
+ const shotDir = path.join(path.dirname(out), `${path.basename(out, '.json')}-shots`);
338
+ let touches = 0;
339
+ let busy = Promise.resolve();
340
+ const tick = async () => {
341
+ const { events: fresh, last } = await device.trace(since);
342
+ since = last;
343
+ for (const e of fresh) {
344
+ events.push(e);
345
+ const line = describeEvent(e);
346
+ if (line)
347
+ ctx.err(` ${line}`);
348
+ if (e.kind !== 'touch' || !ctx.flags.screenshots)
349
+ continue;
350
+ const n = ++touches;
351
+ const label = e.testId ?? e.text?.slice(0, 24).replace(/[^\w-]+/g, '_') ?? `${e.x}x${e.y}`;
352
+ busy = busy.then(async () => {
353
+ fs.mkdirSync(shotDir, { recursive: true });
354
+ await device.idle({ quietMs: 300, timeoutMs: 2000 }).catch(() => undefined);
355
+ const file = path.join(shotDir, `${String(n).padStart(2, '0')}-${label}.png`);
356
+ await device.screenshot(file);
357
+ shots.push({ step: n, label, file });
358
+ });
359
+ }
360
+ };
361
+ if (!soFar) {
362
+ ctx.err(`recording; use the app, then Ctrl-C to write ${out}`);
363
+ const timer = setInterval(() => void tick().catch(() => undefined), 300);
364
+ const rl = readline.createInterface({ input: process.stdin });
365
+ rl.on('line', (line) => {
366
+ if (!line.trim())
367
+ return;
368
+ busy = busy.then(async () => {
369
+ const av = line.trim().startsWith('[') ? JSON.parse(line) : splitLine(line);
370
+ const r = await execute(av, { ...ctx, attached: true });
371
+ ctx.err(r.text ?? (r.code ? ` ✗ ${r.error ?? 'failed'}` : ''));
372
+ });
373
+ });
374
+ await new Promise((resolve) => {
375
+ process.once('SIGINT', () => resolve());
376
+ rl.once('close', () => resolve());
377
+ });
378
+ clearInterval(timer);
379
+ }
380
+ await tick().catch(() => undefined);
381
+ await busy;
382
+ const flow = draftFlow(events, path.basename(out, '.json'));
383
+ writeJson(out, flow);
384
+ const lines = [];
385
+ if (shots.length) {
386
+ const strip = `${out.replace(/\.json$/, '')}.strip.md`;
387
+ fs.writeFileSync(strip, [`# ${flow.name}`, '', ...shots.map((s) => `${s.step}. \`${s.label}\`\n\n![${s.label}](${path.relative(path.dirname(strip), s.file)})\n`)].join('\n'));
388
+ lines.push(`${shots.length} screenshots → ${strip}`);
389
+ }
390
+ lines.push(`${flow.steps.length} steps → ${out}`);
391
+ if (!events.some((e) => e.kind === 'touch'))
392
+ lines.push('(no touches were traced: touches are recorded on device builds mounted with auto(); a host records routes and requests only)');
393
+ return { text: lines.join('\n'), json: { out, steps: flow.steps.length, screenshots: shots.length } };
394
+ },
395
+ }),
396
+ define({
397
+ name: 'snapshot',
398
+ group: 'runs',
399
+ summary: 'A baseline for a screen: its tree (diffed like code) and, on a simulator, its pixels',
400
+ details: 'The tree is the screen as nested lines of meaning: kinds, labels, testIDs, values, states, no geometry. A renamed heading or a lost button is a changed line; a font hint is nothing. `--pixels` adds a screenshot compared with a red-on-grey diff. Baselines live under .lynkeus/snapshots/; what differs goes under runs/.',
401
+ positionals: [{ name: 'name', help: 'the baseline name, e.g. onboarding-hero', required: true }],
402
+ flags: {
403
+ update: { type: 'boolean', help: 'the change is intended: overwrite the baseline' },
404
+ pixels: { type: 'boolean', help: 'also compare a screenshot (simulator)' },
405
+ tolerance: { type: 'number', help: 'pixels allowed to differ, as a fraction', default: 0.001, value: 'ratio' },
406
+ 'no-freeze': { type: 'boolean', help: 'do not pin the status bar before the screenshot' },
407
+ },
408
+ needs: 'app',
409
+ run: async (ctx) => {
410
+ const device = await ctx.device();
411
+ const name = ctx.args[0];
412
+ await device.idle({ quietMs: 300, timeoutMs: 3000 }).catch(() => undefined);
413
+ const lines = [];
414
+ let code = 0;
415
+ const tree = screenTree(await device.screen());
416
+ const treeFile = baselinePath(lynkeusDir(ctx.root), name).replace(/\.png$/, '.tree.txt');
417
+ if (ctx.flags.update || !fs.existsSync(treeFile)) {
418
+ fs.mkdirSync(path.dirname(treeFile), { recursive: true });
419
+ fs.writeFileSync(treeFile, tree);
420
+ lines.push(`${name}: tree baseline written → ${treeFile}`);
421
+ }
422
+ else {
423
+ const d = diffTrees(fs.readFileSync(treeFile, 'utf8'), tree);
424
+ if (d.added.length || d.removed.length) {
425
+ const dir = path.join(runsDir(ctx.root), 'snapshots', stamp());
426
+ fs.mkdirSync(dir, { recursive: true });
427
+ fs.writeFileSync(path.join(dir, `${path.basename(treeFile, '.tree.txt')}.actual.tree.txt`), tree);
428
+ lines.push(`${name}: the tree differs from the baseline (${d.removed.length} line(s) gone, ${d.added.length} new):`, describeTreeDiff(d), ` actual: ${dir}`, ' accept with --update if the change is intended');
429
+ code = 1;
430
+ }
431
+ else
432
+ lines.push(`${name}: tree matches the baseline (${d.same} lines)`);
433
+ }
434
+ if (ctx.flags.pixels) {
435
+ if (!ctx.flags['no-freeze'])
436
+ await device.freezeChrome().catch(() => undefined);
437
+ const tmp = path.join(os.tmpdir(), `lynkeus-snapshot-${Date.now()}.png`);
438
+ await device.screenshot(tmp);
439
+ const result = snapshot(lynkeusDir(ctx.root), name, fs.readFileSync(tmp), {
440
+ update: !!ctx.flags.update,
441
+ tolerance: ctx.flags.tolerance,
442
+ runsDir: runsDir(ctx.root),
443
+ });
444
+ fs.rmSync(tmp, { force: true });
445
+ lines.push(describeSnapshot(name, result));
446
+ if (result.status === 'mismatch')
447
+ code = 1;
448
+ }
449
+ return { text: lines.join('\n'), json: { name, code }, code };
450
+ },
451
+ }),
452
+ ];
@@ -0,0 +1,28 @@
1
+ import type { Device } from '../device/driver.js';
2
+ import type { ScreenGraph } from '../knowledge/types.js';
3
+ import { type Overlay } from './overlay.js';
4
+ export type SmokeResult = {
5
+ route: string;
6
+ ok: boolean;
7
+ reached?: string;
8
+ elements: number;
9
+ ms: number;
10
+ problems: string[];
11
+ screen?: string;
12
+ screenshot?: string;
13
+ };
14
+ export type SmokeOptions = {
15
+ device: Device;
16
+ graph: ScreenGraph;
17
+ appRoot: string;
18
+ /** Path of the route params type map, relative to appRoot. */
19
+ paramsFile?: string;
20
+ outDir: string;
21
+ only?: string[];
22
+ screenshots?: boolean;
23
+ overlay?: Overlay;
24
+ appId?: string;
25
+ errorCopy?: RegExp;
26
+ onResult?: (r: SmokeResult) => void;
27
+ };
28
+ export declare const smoke: (options: SmokeOptions) => Promise<SmokeResult[]>;
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { writeJson } from '../files.js';
4
+ import { problemsOf } from '../knowledge/problems.js';
5
+ import { readRouteParams } from '../knowledge/route-params.js';
6
+ import { clearOverlayIfShown } from './overlay.js';
7
+ export const smoke = async (options) => {
8
+ const { device, graph } = options;
9
+ const params = new Map(readRouteParams(options.appRoot, options.paramsFile).map((p) => [p.route, p]));
10
+ const routes = Object.keys(graph.screens)
11
+ .filter((r) => (options.only ? options.only.some((o) => r === o || r.startsWith(o)) : true))
12
+ .filter((r) => params.get(r)?.free ?? false)
13
+ .sort();
14
+ fs.mkdirSync(options.outDir, { recursive: true });
15
+ const results = [];
16
+ const recover = async () => {
17
+ if (!options.appId)
18
+ throw new Error('App disconnected and no --app to relaunch');
19
+ await device.launchApp(options.appId, { fresh: true });
20
+ await clearOverlayIfShown(device, options.overlay);
21
+ };
22
+ await clearOverlayIfShown(device, options.overlay);
23
+ for (const route of routes) {
24
+ const t0 = performance.now();
25
+ const result = { route, ok: true, elements: 0, ms: 0, problems: [] };
26
+ try {
27
+ if (!device.server.connected)
28
+ await recover();
29
+ const before = await device.requests();
30
+ await device.navigate(route);
31
+ await device.idle({ quietMs: 200, timeoutMs: 1500 });
32
+ const screen = await device.screen();
33
+ const { requests } = await device.requests(before.requests.at(-1)?.id);
34
+ result.reached = screen.route;
35
+ result.elements = screen.elements.length;
36
+ if (screen.route !== route && !screen.path.includes(route))
37
+ result.problems.push(`landed on ${screen.route ?? '?'}`);
38
+ if (screen.elements.length && screen.elements.every((e) => e.kind === 'text' || !e.enabled))
39
+ result.problems.push('nothing to interact with');
40
+ result.problems.push(...problemsOf(screen, requests, options.errorCopy));
41
+ const slug = route.replace(/[^\w.]+/g, '_');
42
+ result.screen = path.join(options.outDir, `${slug}.json`);
43
+ writeJson(result.screen, { screen, requests });
44
+ if (options.screenshots) {
45
+ result.screenshot = path.join(options.outDir, `${slug}.png`);
46
+ await device.screenshot(result.screenshot);
47
+ }
48
+ }
49
+ catch (error) {
50
+ result.problems.push(error instanceof Error ? error.message : String(error));
51
+ }
52
+ result.ok = result.problems.length === 0;
53
+ result.ms = Math.round(performance.now() - t0);
54
+ results.push(result);
55
+ options.onResult?.(result);
56
+ }
57
+ writeJson(path.join(options.outDir, 'summary.json'), results);
58
+ return results;
59
+ };
@@ -0,0 +1,27 @@
1
+ export type Comparison = {
2
+ width: number;
3
+ height: number;
4
+ total: number;
5
+ different: number;
6
+ ratio: number;
7
+ sizeMismatch?: {
8
+ a: [number, number];
9
+ b: [number, number];
10
+ };
11
+ /** The baseline faded to grey with the differing pixels in red, when there are any. */
12
+ diff?: Buffer;
13
+ };
14
+ export declare const comparePng: (a: Buffer, b: Buffer, channelThreshold?: number) => Comparison;
15
+ export declare const baselinePath: (lynkeusDir: string, name: string) => string;
16
+ export type SnapshotResult = {
17
+ status: 'baseline' | 'match' | 'mismatch';
18
+ baseline: string;
19
+ comparison?: Comparison;
20
+ diffFile?: string;
21
+ };
22
+ export declare const snapshot: (lynkeusDir: string, name: string, capture: Buffer, options: {
23
+ update?: boolean;
24
+ tolerance?: number;
25
+ runsDir: string;
26
+ }) => SnapshotResult;
27
+ export declare const describeSnapshot: (name: string, r: SnapshotResult) => string;
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PNG } from 'pngjs';
4
+ import { stamp } from '../files.js';
5
+ export const comparePng = (a, b, channelThreshold = 24) => {
6
+ const pa = PNG.sync.read(a);
7
+ const pb = PNG.sync.read(b);
8
+ if (pa.width !== pb.width || pa.height !== pb.height) {
9
+ return {
10
+ width: pa.width,
11
+ height: pa.height,
12
+ total: pa.width * pa.height,
13
+ different: pa.width * pa.height,
14
+ ratio: 1,
15
+ sizeMismatch: { a: [pa.width, pa.height], b: [pb.width, pb.height] },
16
+ };
17
+ }
18
+ const out = new PNG({ width: pa.width, height: pa.height });
19
+ let different = 0;
20
+ for (let i = 0; i < pa.data.length; i += 4) {
21
+ const d = Math.max(Math.abs(pa.data[i] - pb.data[i]), Math.abs(pa.data[i + 1] - pb.data[i + 1]), Math.abs(pa.data[i + 2] - pb.data[i + 2]));
22
+ if (d > channelThreshold) {
23
+ different += 1;
24
+ out.data[i] = 255;
25
+ out.data[i + 1] = 0;
26
+ out.data[i + 2] = 0;
27
+ out.data[i + 3] = 255;
28
+ }
29
+ else {
30
+ out.data[i] = 128 + (pa.data[i] >> 1);
31
+ out.data[i + 1] = 128 + (pa.data[i + 1] >> 1);
32
+ out.data[i + 2] = 128 + (pa.data[i + 2] >> 1);
33
+ out.data[i + 3] = 255;
34
+ }
35
+ }
36
+ const total = pa.width * pa.height;
37
+ return { width: pa.width, height: pa.height, total, different, ratio: different / total, diff: different ? PNG.sync.write(out) : undefined };
38
+ };
39
+ export const baselinePath = (lynkeusDir, name) => path.join(lynkeusDir, 'snapshots', `${name.replace(/[^\w.-]+/g, '-')}.png`);
40
+ export const snapshot = (lynkeusDir, name, capture, options) => {
41
+ const baseline = baselinePath(lynkeusDir, name);
42
+ if (options.update || !fs.existsSync(baseline)) {
43
+ fs.mkdirSync(path.dirname(baseline), { recursive: true });
44
+ fs.writeFileSync(baseline, capture);
45
+ return { status: 'baseline', baseline };
46
+ }
47
+ const comparison = comparePng(fs.readFileSync(baseline), capture);
48
+ const tolerance = options.tolerance ?? 0.001;
49
+ if (comparison.ratio <= tolerance)
50
+ return { status: 'match', baseline, comparison };
51
+ const dir = path.join(options.runsDir, 'snapshots', stamp());
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ const stem = path.basename(baseline, '.png');
54
+ fs.writeFileSync(path.join(dir, `${stem}.actual.png`), capture);
55
+ const diffFile = path.join(dir, `${stem}.diff.png`);
56
+ if (comparison.diff)
57
+ fs.writeFileSync(diffFile, comparison.diff);
58
+ return { status: 'mismatch', baseline, comparison, diffFile };
59
+ };
60
+ export const describeSnapshot = (name, r) => {
61
+ if (r.status === 'baseline')
62
+ return `${name}: baseline written → ${r.baseline}`;
63
+ const c = r.comparison;
64
+ if (r.status === 'match')
65
+ return `${name}: matches the baseline (${c.different} of ${c.total} pixels differ, ${(c.ratio * 100).toFixed(3)}%)`;
66
+ if (c.sizeMismatch)
67
+ return `${name}: size changed ${c.sizeMismatch.a.join('×')} → ${c.sizeMismatch.b.join('×')} (a different device or orientation?); actual next to ${r.diffFile}`;
68
+ return `${name}: differs from the baseline: ${c.different} of ${c.total} pixels (${(c.ratio * 100).toFixed(2)}%)\n diff: ${r.diffFile}\n accept with --update if the change is intended`;
69
+ };