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,156 @@
1
+ /**
2
+ * What `lynkeus case init` writes: one case to copy from, the reference of the
3
+ * format next to it, and a fixtures server with two empty fixtures, so a
4
+ * project starts from something that runs instead of from a blank folder.
5
+ */
6
+ export const EXAMPLE_CASE = `---
7
+ id: example
8
+ title: "A user who was given points sees them on the first screen"
9
+ description: >
10
+ What this proves and why it matters, in a sentence or two. A case reads top
11
+ to bottom: what has to exist, what the person does, what must be true after.
12
+ ---
13
+ setup:
14
+ # Anything that is not an app step is a fixture: fixtures.mjs answers it.
15
+ - create_user: { as: u, name: "Ana" }
16
+
17
+ steps:
18
+ - app.see: { text: "Hello" }
19
+ # \`user: u\` points at what \`create_user\` answered (cases.refs in lynkeus.config.json).
20
+ - give_points: { user: u, points: 25 }
21
+ - app.see: { text: "25 points", timeoutMs: 10000 }
22
+ - assert: { user: u, path: points, equals: 25 }
23
+
24
+ expected: |
25
+ The points on screen and the points the backend holds are both 25.
26
+ `;
27
+ export const FORMAT = `# Cases
28
+
29
+ A case is a markdown file: frontmatter that names it, then \`setup\`, \`steps\`
30
+ and \`expected\`. \`lynkeus case run cases/example.md\` runs one; without files
31
+ it runs every case in this folder. A step that fails stops the case, and the
32
+ report carries the screen, the requests and the reason.
33
+
34
+ ## Steps that drive the app
35
+
36
+ | Step | Value | |
37
+ |---|---|---|
38
+ | \`app.press\` | target, or \`{ target, within?, nth?, optional?, hold? }\` | Waits for the control to be there and enabled. \`optional\` presses only if it is on screen. |
39
+ | \`app.type\` | \`{ text, target?, submit?, clear? }\` | \`target\` is tapped first; \`submit\` ends with the return key. |
40
+ | \`app.swipe\` | \`{ direction, target?, distance? }\` or \`{ from, to }\` | |
41
+ | \`app.wait\` | route, target, ms, \`{ target, scroll? }\` or \`{ request, status? }\` | \`scroll\` swipes until the target is in the window. A \`request\` is \`METHOD /path\`. |
42
+ | \`app.see\` | target, or \`{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }\` | An assertion on what the app shows or did. Everything named must hold. |
43
+ | \`app.nav\` | route, or \`{ route, params }\` | A teleport, for getting to where the case starts. |
44
+ | \`app.back\` | | |
45
+ | \`app.requests\` | \`mark\` | From here on, \`request\` only counts what the app asks for. |
46
+ | \`app.call\` | \`{ command, params?, expect?, as? }\` | A command the app registered with \`qa.register\`. |
47
+ | \`app.run\` | \`{ flow, vars? }\` | A lynkeus flow, from the flows folder. |
48
+ | \`app.events\` / \`app.event\` | \`clear\` / \`{ name, props?, absent? }\` | What the app told its analytics. |
49
+ | \`app.<command>\` | its arguments by name | Any other lynkeus command: \`app.mock\`, \`app.network\`, \`app.appstate\`, \`app.deeplink\`… |
50
+
51
+ A target is \`#testId\`, \`"text"\`, an index or \`x,y\`.
52
+
53
+ A case that runs over several inputs declares \`matrix: { locale: [en, fr] }\`
54
+ in its frontmatter and runs once per combination; \`{{matrix.locale}}\` is that
55
+ run's value.
56
+
57
+ One case runs on a host and on a device. What only one of them needs — closing a
58
+ native screen the host never shows — is kept to it:
59
+
60
+ \`\`\`yaml
61
+ - on: device # or headless, ios, android
62
+ do:
63
+ - app.press: "364,84"
64
+ \`\`\`
65
+
66
+ ## Steps that prepare and read the backend
67
+
68
+ Any other step name is a **fixture**: lynkeus sends it to the command in
69
+ \`cases.fixtures\` (lynkeus.config.json) as one JSON line and reads one back.
70
+
71
+ \`\`\`
72
+ → {"command": "create_user", "params": {"name": "Ana"}, "env": {"NAME": "Ana"}}
73
+ ← {"ok": true, "user_id": "u_1", "name": "Ana"}
74
+ \`\`\`
75
+
76
+ \`as: u\` keeps the answer; \`{{u.user_id}}\` reads it later. \`assert\` calls
77
+ the \`inspect\` fixture and checks one path of its answer with \`equals\`,
78
+ \`includes\`, \`gte\`, \`lte\`, \`near\`, \`matches\`, \`present\` or \`absent\`.
79
+ \`exec: { run: "…", as? }\` runs a shell command and keeps its JSON output.
80
+
81
+ ## Steps of your own
82
+
83
+ \`cases.macros\` names a step and the steps it stands for. Inside,
84
+ \`{{param.x}}\` is what the case passed, and a parameter that names an alias
85
+ is that alias.
86
+
87
+ \`\`\`json
88
+ "macros": {
89
+ "app.login": [
90
+ { "app.call": { "command": "reset" } },
91
+ { "app.deeplink": "myapp://dev-login?user={{param.as.user_id}}" },
92
+ { "when": "wait", "do": { "app.wait": "{{param.wait}}" } }
93
+ ]
94
+ }
95
+ \`\`\`
96
+
97
+ \`when\` runs a step only if the case passed that parameter (\`"when": "wait"\`)
98
+ or passed that value (\`"when": "method=phone"\`); \`unless\` is the opposite.
99
+
100
+ \`cases.interruptions\` names what the app may put in the way of any step — a
101
+ lock screen, a consent sheet — and the steps that get past it:
102
+ \`{ "see": "#unlock-keypad", "do": ["app.unlock"], "unless": "keypad" }\`. It
103
+ runs before a press, a type, a swipe or a see finds it there; a step whose own
104
+ words match \`unless\` is about it, and is left alone.
105
+
106
+ Cases and fixtures often live in a repository of their own: \`LYNKEUS_CONFIG\`
107
+ names a second configuration file, read over the app's, and the paths under
108
+ its \`cases\` are relative to it.
109
+ `;
110
+ export const FIXTURES = `#!/usr/bin/env node
111
+ // The fixtures of this project: how a case prepares the backend and reads it.
112
+ // lynkeus starts this once per run and talks JSON lines to it: one line in per
113
+ // step, one line out. Replace the bodies with calls to your backend — its
114
+ // admin API, a script, a database seed.
115
+ import readline from 'node:readline';
116
+
117
+ const users = new Map();
118
+
119
+ const fixtures = {
120
+ async create_user({ name = 'Test user' }) {
121
+ const user = { user_id: \`u_\${users.size + 1}\`, name, points: 0 };
122
+ users.set(user.user_id, user);
123
+ return user;
124
+ },
125
+ async give_points({ user_id, points }) {
126
+ const user = users.get(user_id);
127
+ if (!user) throw new Error(\`no user \${user_id}\`);
128
+ user.points += Number(points);
129
+ return { user_id, points: user.points };
130
+ },
131
+ // What \`assert\` reads.
132
+ async inspect({ user_id }) {
133
+ return users.get(user_id) ?? {};
134
+ },
135
+ };
136
+
137
+ for await (const line of readline.createInterface({ input: process.stdin })) {
138
+ if (!line.trim()) continue;
139
+ const { command, params } = JSON.parse(line);
140
+ try {
141
+ const fixture = fixtures[command];
142
+ if (!fixture) throw new Error(\`no fixture \${command}\`);
143
+ console.log(JSON.stringify({ ok: true, ...(await fixture(params ?? {})) }));
144
+ } catch (error) {
145
+ console.log(JSON.stringify({ ok: false, error: error.message }));
146
+ }
147
+ }
148
+ `;
149
+ export const CONFIG = {
150
+ dir: 'cases',
151
+ flows: 'flows',
152
+ fixtures: { command: 'node cases/fixtures.mjs' },
153
+ inspect: 'inspect',
154
+ refs: { user: 'user_id' },
155
+ macros: {},
156
+ };
package/lib/cli.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { type Base } from './registry.js';
3
+ export type Session = {
4
+ ctx: Base;
5
+ stop: () => Promise<void>;
6
+ };
package/lib/cli.js ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { AgentServer } from 'lynkeus-client';
5
+ import { GLOBAL_FLAGS } from './args.js';
6
+ import { caseCommands } from './commands/cases.js';
7
+ import { driveCommands } from './commands/drive.js';
8
+ import { headlessCommands } from './commands/headless.js';
9
+ import { mapCommands } from './commands/map.js';
10
+ import { osCommands } from './commands/os.js';
11
+ import { runsCommands } from './commands/runs.js';
12
+ import { graphFile, loadConfig, loadGraph, portFromEnv } from './config.js';
13
+ import { HEADLESS_HOST, pickAgentDevice } from './device/devices.js';
14
+ import { Device } from './device/driver.js';
15
+ import { commands, find, helpFor, inSession, referenceMarkdown, register, resolve, usage } from './registry.js';
16
+ import { execute } from './runtime.js';
17
+ register([...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...caseCommands, ...headlessCommands]);
18
+ /** The global flags, read leniently from the whole argv before any command parses its own. */
19
+ const globals = (argv) => {
20
+ const out = {};
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const t = argv[i];
23
+ if (t === '--')
24
+ break;
25
+ if (!t.startsWith('--'))
26
+ continue;
27
+ const name = t.slice(2).split('=')[0];
28
+ const spec = GLOBAL_FLAGS[name];
29
+ if (!spec)
30
+ continue;
31
+ if (spec.type === 'boolean')
32
+ out[name] = true;
33
+ else
34
+ out[name] = t.includes('=') ? t.slice(t.indexOf('=') + 1) : argv[i + 1];
35
+ }
36
+ return out;
37
+ };
38
+ const contextFor = (argv, attached = false) => {
39
+ const g = globals(argv);
40
+ const root = path.resolve(g.root ?? process.cwd());
41
+ const config = loadConfig(root);
42
+ const err = (line) => console.error(line);
43
+ let graph;
44
+ let device;
45
+ const connect = async () => {
46
+ const appId = g.app ?? process.env.LYNKEUS_APP_ID ?? config.app;
47
+ const started = performance.now();
48
+ const attachedIn = (d) => err(`attached in ${Math.round(performance.now() - started)}ms (${d.info.name})`);
49
+ const server = new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv(config) });
50
+ const picking = pickAgentDevice(g.device ?? process.env.LYNKEUS_DEVICE ?? config.device);
51
+ if (g.launch && appId) {
52
+ const d = new Device(await picking, { appId, server });
53
+ await d.launchApp(appId, { fresh: true });
54
+ attachedIn(d);
55
+ return d;
56
+ }
57
+ // Asking the OS which simulators are up takes longer than the app takes to dial in, so listen first.
58
+ picking.catch(() => undefined);
59
+ await server.listen();
60
+ const hello = await server
61
+ .waitForApp(Number(g['attach-timeout'] ?? process.env.LYNKEUS_ATTACH_TIMEOUT_MS ?? config.attachTimeout ?? 15000))
62
+ .catch(async (error) => {
63
+ await server.close();
64
+ await picking;
65
+ throw error;
66
+ });
67
+ const headless = hello.app?.runtime === 'headless';
68
+ const info = {
69
+ ...HEADLESS_HOST,
70
+ name: headless ? HEADLESS_HOST.name : String(hello.app?.name ?? hello.platform),
71
+ platform: hello.platform === 'android' ? 'android' : 'ios',
72
+ };
73
+ const d = new Device(info, { appId: appId ?? null, server, resolving: headless ? undefined : picking });
74
+ attachedIn(d);
75
+ return d;
76
+ };
77
+ const ctx = {
78
+ root,
79
+ config,
80
+ attached,
81
+ err,
82
+ graph: () => {
83
+ graph ??= loadGraph(root);
84
+ if (!graph)
85
+ throw new Error(`No graph at ${graphFile(root)}: run \`lynkeus map build\` in the app's folder first.`);
86
+ return graph;
87
+ },
88
+ device: () => {
89
+ device ??= connect();
90
+ return device;
91
+ },
92
+ };
93
+ return {
94
+ ctx,
95
+ stop: async () => {
96
+ await device?.then((d) => d.stop()).catch(() => undefined);
97
+ },
98
+ };
99
+ };
100
+ /** Each stdin line is a JSON array of arguments (`["press", "#x"]`); each answer is one JSON line. */
101
+ const session = async (argv) => {
102
+ const { ctx, stop } = contextFor(argv, true);
103
+ const device = await ctx.device();
104
+ console.log(JSON.stringify({ ok: true, out: `attached (${device.info.name})` }));
105
+ for await (const line of readline.createInterface({ input: process.stdin })) {
106
+ if (!line.trim())
107
+ continue;
108
+ let av;
109
+ try {
110
+ av = JSON.parse(line);
111
+ }
112
+ catch {
113
+ console.log(JSON.stringify({ ok: false, error: 'each line must be a JSON array of arguments' }));
114
+ continue;
115
+ }
116
+ if (av[0] === 'exit')
117
+ break;
118
+ const found = resolve(av)?.command;
119
+ if (found && !inSession(found)) {
120
+ console.log(JSON.stringify({ ok: false, error: `${found.name} is not available in a session` }));
121
+ continue;
122
+ }
123
+ const r = await execute(av, ctx);
124
+ console.log(JSON.stringify(r.error
125
+ ? { ok: false, error: r.error, ...(r.why ? { why: r.why } : {}) }
126
+ : { ok: r.code === 0, out: r.text ?? '', ...(r.json !== undefined ? { json: r.json } : {}) }));
127
+ }
128
+ await stop();
129
+ };
130
+ const help = (argv) => {
131
+ if (argv.includes('--markdown'))
132
+ return referenceMarkdown();
133
+ const topic = argv
134
+ .slice(1)
135
+ .filter((a) => !a.startsWith('--'))
136
+ .join(' ');
137
+ if (!topic)
138
+ return usage();
139
+ const c = find(topic);
140
+ return c ? helpFor(c) : `no command "${topic}"\n\n${usage()}`;
141
+ };
142
+ const main = async () => {
143
+ const argv = process.argv.slice(2);
144
+ const [first] = argv;
145
+ if (!first || first === 'help' || first === '--help')
146
+ return console.log(help(argv));
147
+ if (first === 'commands')
148
+ return console.log(commands()
149
+ .map((c) => c.name)
150
+ .join('\n'));
151
+ if (first === 'mcp') {
152
+ const { serve } = await import('./mcp/server.js');
153
+ return serve(contextFor(argv, true), { full: argv.includes('--full') });
154
+ }
155
+ if (first === 'session') {
156
+ await session(argv);
157
+ return process.exit(0);
158
+ }
159
+ const { ctx, stop } = contextFor(argv);
160
+ const outcome = await execute(argv, ctx);
161
+ if (outcome.error) {
162
+ console.error(`✗ ${outcome.error}`);
163
+ if (outcome.why)
164
+ console.error(`why:\n${outcome.why}`);
165
+ }
166
+ else if (globals(argv).json && outcome.json !== undefined)
167
+ console.log(JSON.stringify(outcome.json, null, 2));
168
+ else if (outcome.text)
169
+ console.log(outcome.text);
170
+ await stop();
171
+ process.exit(outcome.code);
172
+ };
173
+ void main();
@@ -0,0 +1 @@
1
+ export declare const caseCommands: import("../registry.js").Command[];
@@ -0,0 +1,179 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Fixtures } from '../cases/fixtures.js';
5
+ import { readCases } from '../cases/format.js';
6
+ import { runCase } from '../cases/runner.js';
7
+ import { CONFIG, EXAMPLE_CASE, FIXTURES, FORMAT } from '../cases/templates.js';
8
+ import { configFile } from '../config.js';
9
+ import { readJson, writeJson } from '../files.js';
10
+ import { eventsFile } from '../knowledge/events.js';
11
+ import { define } from '../registry.js';
12
+ const startHost = async (root, err) => {
13
+ // Its own process group: the host starts a test runner of its own, and stopping one without the other leaves an app dialing in.
14
+ const host = spawn(process.execPath, [process.argv[1], 'headless', 'start'], {
15
+ cwd: root,
16
+ env: process.env,
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ detached: true,
19
+ });
20
+ let output = '';
21
+ const tail = (n) => output.trim().split('\n').slice(-n).join(' · ');
22
+ await new Promise((resolve, reject) => {
23
+ const settle = (error) => {
24
+ clearTimeout(timer);
25
+ if (error)
26
+ reject(error);
27
+ else
28
+ resolve();
29
+ };
30
+ const timer = setTimeout(() => settle(new Error(`the host did not come up within 180 s: ${tail(3)}`)), 180_000);
31
+ const read = (chunk) => {
32
+ output = (output + chunk.toString()).slice(-4000);
33
+ if (/waiting for a driver/.test(output))
34
+ settle();
35
+ else if (/Test suite failed/.test(output))
36
+ settle(new Error(`the host failed to start: ${tail(5)}`));
37
+ };
38
+ host.stdout?.on('data', read);
39
+ host.stderr?.on('data', read);
40
+ host.once('exit', (code) => settle(new Error(`the host exited (${code}): ${tail(3)}`)));
41
+ });
42
+ err('host: up');
43
+ return host;
44
+ };
45
+ const stopGroup = (pid) => {
46
+ try {
47
+ process.kill(-pid, 'SIGTERM');
48
+ }
49
+ catch { }
50
+ };
51
+ const describe = (report) => {
52
+ const counts = { passed: 0, failed: 0, skipped: 0 };
53
+ for (const s of report.steps)
54
+ counts[s.status] += 1;
55
+ const lines = [
56
+ `${report.result === 'passed' ? '✅ PASSED' : '❌ FAILED'} ✅ ${counts.passed}${counts.failed ? ` ❌ ${counts.failed}` : ''}${counts.skipped ? ` ⏭️ ${counts.skipped}` : ''} — ${(report.ms / 1000).toFixed(1)}s`,
57
+ ];
58
+ if (report.evidence?.screen)
59
+ lines.push('— screen at the failure:', ...report.evidence.screen.split('\n').map((l) => ` ${l}`));
60
+ if (report.evidence?.requests?.length)
61
+ lines.push('— requests:', ...report.evidence.requests.map((l) => ` ${l}`));
62
+ if (report.evidence?.why)
63
+ lines.push('— why:', ...report.evidence.why.split('\n').map((l) => ` ${l}`));
64
+ return lines.join('\n');
65
+ };
66
+ export const caseCommands = [
67
+ define({
68
+ name: 'case run',
69
+ group: 'runs',
70
+ summary: 'Run cases: setup through fixtures, steps against the app, the evidence when one fails',
71
+ details: 'A case is a markdown file with `setup`, `steps` and `expected` (`lynkeus case init` writes an example and the reference). Without files, every case in `cases.dir`. A failed case runs once more before it counts as failed, unless `--once`. `--host` hosts the app in Node for the run.',
72
+ positionals: [{ name: 'files', help: 'case files (default: every .md under cases.dir)', rest: true }],
73
+ flags: {
74
+ host: { type: 'boolean', help: 'start `headless start` for the run and stop it after' },
75
+ once: { type: 'boolean', help: 'no second chance for a case that fails' },
76
+ report: { type: 'string', help: 'append one JSON line per case', value: 'file' },
77
+ events: { type: 'string', help: 'keep what the app told its analytics, per case, as <dir>/<case id>/events.jsonl', value: 'dir' },
78
+ 'dry-run': { type: 'boolean', help: 'parse and list the steps without running them' },
79
+ },
80
+ needs: 'nothing',
81
+ session: false,
82
+ mcp: false,
83
+ run: async (ctx) => {
84
+ const config = ctx.config.cases ?? {};
85
+ const home = config.base ?? ctx.root;
86
+ const dir = path.resolve(home, config.dir ?? 'cases');
87
+ const files = ctx.args.length
88
+ ? ctx.args.map((f) => path.resolve(f))
89
+ : fs.existsSync(dir)
90
+ ? fs
91
+ .readdirSync(dir)
92
+ .filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f) && !f.startsWith('.'))
93
+ .sort()
94
+ .map((f) => path.join(dir, f))
95
+ : [];
96
+ if (files.length === 0)
97
+ return { text: `no cases under ${path.relative(ctx.root, dir) || '.'}; \`lynkeus case init\` writes one`, code: 1 };
98
+ const cases = files.flatMap(readCases);
99
+ const dry = !!ctx.flags['dry-run'];
100
+ const events = typeof ctx.flags.events === 'string';
101
+ const host = ctx.flags.host && !dry ? await startHost(ctx.root, ctx.err) : undefined;
102
+ const fixtures = config.fixtures && !dry ? new Fixtures(config.fixtures, home) : undefined;
103
+ const stop = () => {
104
+ fixtures?.close();
105
+ if (host?.pid)
106
+ stopGroup(host.pid);
107
+ };
108
+ const interrupted = () => {
109
+ stop();
110
+ process.exit(130);
111
+ };
112
+ process.once('SIGINT', interrupted);
113
+ process.once('SIGTERM', interrupted);
114
+ const reports = [];
115
+ try {
116
+ for (const c of cases) {
117
+ ctx.err(`▶ ${c.id}${c.title ? ` — ${c.title}` : ''}`);
118
+ let report = await runCase(c, { base: ctx, config, fixtures, dry, events, log: ctx.err });
119
+ ctx.err(describe(report));
120
+ if (report.result === 'failed' && !ctx.flags.once && !dry) {
121
+ ctx.err(` retrying ${c.id} once`);
122
+ report = { ...(await runCase(c, { base: ctx, config, fixtures, events, log: ctx.err })), retried: true };
123
+ ctx.err(describe(report));
124
+ }
125
+ if (events && report.events) {
126
+ const out = path.resolve(ctx.flags.events, c.id);
127
+ fs.mkdirSync(out, { recursive: true });
128
+ fs.writeFileSync(eventsFile(out), report.events.map((e) => `${JSON.stringify(e)}\n`).join(''));
129
+ }
130
+ reports.push(report);
131
+ if (ctx.flags.report)
132
+ fs.appendFileSync(path.resolve(ctx.flags.report), `${JSON.stringify(report)}\n`);
133
+ }
134
+ }
135
+ finally {
136
+ stop();
137
+ }
138
+ const passed = reports.filter((r) => r.result === 'passed').length;
139
+ const summary = [
140
+ ...reports.map((r) => `${r.result === 'passed' ? '✅' : '❌'} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}`),
141
+ `${passed}/${reports.length} passed`,
142
+ ].join('\n');
143
+ return { text: summary, json: reports, code: passed === reports.length ? 0 : 1 };
144
+ },
145
+ }),
146
+ define({
147
+ name: 'case init',
148
+ group: 'runs',
149
+ summary: 'Write an example case, the reference of the format and a fixtures server to start from',
150
+ flags: { force: { type: 'boolean', help: 'overwrite what is there' } },
151
+ needs: 'nothing',
152
+ session: false,
153
+ mcp: false,
154
+ run: async (ctx) => {
155
+ const dir = path.resolve(ctx.root, ctx.config.cases?.dir ?? 'cases');
156
+ fs.mkdirSync(dir, { recursive: true });
157
+ const notes = [];
158
+ const write = (file, content) => {
159
+ if (fs.existsSync(file) && !ctx.flags.force)
160
+ return notes.push(`kept ${path.relative(ctx.root, file)}`);
161
+ fs.writeFileSync(file, content);
162
+ notes.push(`wrote ${path.relative(ctx.root, file)}`);
163
+ };
164
+ write(path.join(dir, 'example.md'), EXAMPLE_CASE);
165
+ write(path.join(dir, 'README.md'), FORMAT);
166
+ write(path.join(dir, 'fixtures.mjs'), FIXTURES);
167
+ const file = configFile(ctx.root);
168
+ if (ctx.config.cases && !ctx.flags.force)
169
+ notes.push('kept cases in lynkeus.config.json');
170
+ else {
171
+ const current = fs.existsSync(file) ? readJson(file) : {};
172
+ writeJson(file, { ...current, cases: { ...CONFIG, dir: path.relative(ctx.root, dir) } });
173
+ notes.push('added cases to lynkeus.config.json');
174
+ }
175
+ notes.push('next: `lynkeus case run cases/example.md --dry-run`, then make fixtures.mjs talk to your backend');
176
+ return { text: notes.join('\n'), json: { dir } };
177
+ },
178
+ }),
179
+ ];
@@ -0,0 +1,36 @@
1
+ import type { Finding } from '../knowledge/lint.js';
2
+ import type { ScreenGraph } from '../knowledge/types.js';
3
+ export type GraphDiff = {
4
+ routesAdded: string[];
5
+ routesRemoved: string[];
6
+ edgesAdded: {
7
+ from: string;
8
+ to: string;
9
+ }[];
10
+ edgesRemoved: {
11
+ from: string;
12
+ to: string;
13
+ }[];
14
+ selectorsAdded: {
15
+ file: string;
16
+ id: string;
17
+ }[];
18
+ selectorsRemoved: {
19
+ file: string;
20
+ id: string;
21
+ }[];
22
+ };
23
+ export declare const diffGraphs: (base: ScreenGraph, head: ScreenGraph) => GraphDiff;
24
+ /** Lint findings per route, as `lynkeus lint --save` writes them. */
25
+ export type LintBaseline = Record<string, {
26
+ rule: string;
27
+ testId?: string;
28
+ message: string;
29
+ }[]>;
30
+ type LintedFinding = {
31
+ finding: Finding;
32
+ testId?: string;
33
+ };
34
+ export declare const newFindings: (baseline: LintBaseline, route: string, findings: LintedFinding[]) => LintedFinding[];
35
+ export declare const describeGraphDiff: (d: GraphDiff) => string;
36
+ export {};
@@ -0,0 +1,67 @@
1
+ const edgesOf = (g) => {
2
+ const fileRoute = new Map();
3
+ for (const s of Object.values(g.screens))
4
+ if (s.file)
5
+ fileRoute.set(s.file, s.route);
6
+ const out = new Set();
7
+ for (const [file, facts] of Object.entries(g.files)) {
8
+ const from = fileRoute.get(file) ?? file;
9
+ for (const n of facts.navigates)
10
+ if (n.confidence === 'static')
11
+ out.add(`${from} ${n.to}`);
12
+ }
13
+ return out;
14
+ };
15
+ const selectorsOf = (g) => {
16
+ const out = new Set();
17
+ for (const [file, facts] of Object.entries(g.files))
18
+ for (const s of facts.selectors)
19
+ out.add(`${file} ${s.id}`);
20
+ return out;
21
+ };
22
+ const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort();
23
+ const pair = (s) => {
24
+ const [from, to] = s.split(' ');
25
+ return { from: from, to: to };
26
+ };
27
+ const selector = (s) => {
28
+ const { from, to } = pair(s);
29
+ return { file: from, id: to };
30
+ };
31
+ export const diffGraphs = (base, head) => {
32
+ const br = new Set(Object.keys(base.screens));
33
+ const hr = new Set(Object.keys(head.screens));
34
+ const be = edgesOf(base);
35
+ const he = edgesOf(head);
36
+ const bs = selectorsOf(base);
37
+ const hs = selectorsOf(head);
38
+ return {
39
+ routesAdded: minus(hr, br),
40
+ routesRemoved: minus(br, hr),
41
+ edgesAdded: minus(he, be).map(pair),
42
+ edgesRemoved: minus(be, he).map(pair),
43
+ selectorsAdded: minus(hs, bs).map(selector),
44
+ selectorsRemoved: minus(bs, hs).map(selector),
45
+ };
46
+ };
47
+ const key = (rule, testId, message) => `${rule}|${testId ?? ''}|${message}`;
48
+ export const newFindings = (baseline, route, findings) => {
49
+ const seen = new Set((baseline[route] ?? []).map((b) => key(b.rule, b.testId, b.message)));
50
+ return findings.filter((f) => !seen.has(key(f.finding.rule, f.testId, f.finding.message)));
51
+ };
52
+ export const describeGraphDiff = (d) => {
53
+ const lines = [];
54
+ const list = (title, items) => {
55
+ if (items.length)
56
+ lines.push(` ${title}`, ...items.map((i) => ` ${i}`));
57
+ };
58
+ list('routes added', d.routesAdded);
59
+ list('routes removed', d.routesRemoved);
60
+ const edge = (e) => `${e.from} → ${e.to}`;
61
+ const selector = (s) => `#${s.id} (${s.file})`;
62
+ list('navigations added', d.edgesAdded.map(edge));
63
+ list('navigations removed', d.edgesRemoved.map(edge));
64
+ list('testIDs added', d.selectorsAdded.map(selector));
65
+ list('testIDs removed', d.selectorsRemoved.map(selector));
66
+ return lines.length ? lines.join('\n') : ' (the screen graph did not change)';
67
+ };
@@ -0,0 +1,20 @@
1
+ import type { Screen } from 'lynkeus-protocol';
2
+ import type { Device } from '../../device/driver.js';
3
+ import type { CrawlOptions } from '../crawl.js';
4
+ /** Fatal: exploring a logged-out app would record its intro screens as reachable from logged-in ones, and poison `plan`. */
5
+ export declare class RecoveryError extends Error {
6
+ }
7
+ type Context = {
8
+ device: Device;
9
+ options: CrawlOptions;
10
+ goTo: (route: string | undefined) => Promise<void>;
11
+ };
12
+ /** How the crawl gets back to where it was standing, or back to life. */
13
+ export declare const createReturns: ({ device, options, goTo }: Context) => {
14
+ dead: (s: Screen) => boolean;
15
+ recover: () => Promise<void>;
16
+ settle: () => Promise<Screen>;
17
+ revive: () => Promise<Screen>;
18
+ restore: (origin: Screen, layer?: string) => Promise<boolean>;
19
+ };
20
+ export {};