lynkeus 0.4.0 → 0.5.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 (81) hide show
  1. package/lib/cases/coverage.d.ts +31 -0
  2. package/lib/cases/coverage.js +82 -0
  3. package/lib/cases/draft.d.ts +21 -0
  4. package/lib/cases/draft.js +53 -0
  5. package/lib/cases/fixtures.d.ts +11 -0
  6. package/lib/cases/fixtures.js +59 -8
  7. package/lib/cases/frames.d.ts +6 -0
  8. package/lib/cases/frames.js +28 -0
  9. package/lib/cases/nativeLogs.d.ts +14 -0
  10. package/lib/cases/nativeLogs.js +111 -0
  11. package/lib/cases/runDirs.d.ts +13 -0
  12. package/lib/cases/runDirs.js +48 -0
  13. package/lib/cases/runfile.d.ts +5 -1
  14. package/lib/cases/runfile.js +67 -4
  15. package/lib/cases/runner.d.ts +56 -1
  16. package/lib/cases/runner.js +309 -33
  17. package/lib/cases/templates.d.ts +2 -2
  18. package/lib/cases/templates.js +20 -2
  19. package/lib/cases/traces.js +4 -1
  20. package/lib/cli.js +25 -2
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +102 -19
  23. package/lib/commands/ci.d.ts +13 -0
  24. package/lib/commands/ci.js +184 -0
  25. package/lib/commands/coverage.d.ts +1 -0
  26. package/lib/commands/coverage.js +151 -0
  27. package/lib/commands/device.js +32 -2
  28. package/lib/commands/drive.js +3 -1
  29. package/lib/commands/fixtures.d.ts +7 -0
  30. package/lib/commands/fixtures.js +94 -0
  31. package/lib/commands/headless.d.ts +11 -0
  32. package/lib/commands/headless.js +69 -3
  33. package/lib/commands/map.d.ts +2 -0
  34. package/lib/commands/map.js +18 -14
  35. package/lib/commands/os.js +43 -10
  36. package/lib/commands/report.d.ts +4 -0
  37. package/lib/commands/report.js +154 -18
  38. package/lib/config.d.ts +25 -0
  39. package/lib/device/agentVersion.d.ts +7 -0
  40. package/lib/device/agentVersion.js +14 -0
  41. package/lib/device/devices.js +6 -0
  42. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  43. package/lib/knowledge/analyticsTraffic.js +109 -0
  44. package/lib/knowledge/errors.js +6 -0
  45. package/lib/knowledge/events.d.ts +2 -0
  46. package/lib/knowledge/why.d.ts +2 -0
  47. package/lib/knowledge/why.js +1 -1
  48. package/lib/mcp/server.js +16 -4
  49. package/lib/runtime.d.ts +9 -1
  50. package/lib/runtime.js +25 -3
  51. package/package.json +7 -4
  52. package/src/cases/coverage.ts +99 -0
  53. package/src/cases/draft.ts +75 -0
  54. package/src/cases/fixtures.ts +57 -8
  55. package/src/cases/frames.ts +30 -0
  56. package/src/cases/nativeLogs.ts +112 -0
  57. package/src/cases/runDirs.ts +48 -0
  58. package/src/cases/runfile.ts +64 -4
  59. package/src/cases/runner.ts +359 -37
  60. package/src/cases/templates.ts +20 -2
  61. package/src/cases/traces.ts +3 -1
  62. package/src/cli.ts +25 -2
  63. package/src/commands/cases.ts +111 -18
  64. package/src/commands/ci.ts +189 -0
  65. package/src/commands/coverage.ts +147 -0
  66. package/src/commands/device.ts +27 -2
  67. package/src/commands/drive.ts +3 -1
  68. package/src/commands/fixtures.ts +102 -0
  69. package/src/commands/headless.ts +66 -3
  70. package/src/commands/map.ts +20 -15
  71. package/src/commands/os.ts +38 -10
  72. package/src/commands/report.ts +160 -16
  73. package/src/config.ts +15 -0
  74. package/src/device/agentVersion.ts +17 -0
  75. package/src/device/devices.ts +5 -0
  76. package/src/knowledge/analyticsTraffic.ts +142 -0
  77. package/src/knowledge/errors.ts +6 -0
  78. package/src/knowledge/events.ts +2 -1
  79. package/src/knowledge/why.ts +3 -1
  80. package/src/mcp/server.ts +16 -4
  81. package/src/runtime.ts +24 -3
package/src/cli.ts CHANGED
@@ -11,16 +11,30 @@ import { driveCommands } from './commands/drive.js';
11
11
  import { headlessCommands } from './commands/headless.js';
12
12
  import { mapCommands } from './commands/map.js';
13
13
  import { osCommands } from './commands/os.js';
14
+ import { ciCommands } from './commands/ci.js';
15
+ import { coverageCommands } from './commands/coverage.js';
16
+ import { fixturesCommands } from './commands/fixtures.js';
14
17
  import { reportCommands } from './commands/report.js';
15
18
  import { runsCommands } from './commands/runs.js';
16
19
  import { graphFile, loadConfig, loadGraph, portFromEnv, readToken } from './config.js';
17
- import { agentMismatch } from './device/agentVersion.js';
20
+ import { agentMismatch, appSwitched, helloIdentity } from './device/agentVersion.js';
18
21
  import { HEADLESS_HOST, pickAgentDevice, pickAndroidDevice } from './device/devices.js';
19
22
  import { Device } from './device/driver.js';
20
23
  import { type Base, commands, find, helpFor, inSession, referenceMarkdown, register, resolve, usage } from './registry.js';
21
24
  import { execute } from './runtime.js';
22
25
 
23
- register([...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...caseCommands, ...reportCommands, ...headlessCommands]);
26
+ register([
27
+ ...driveCommands,
28
+ ...osCommands,
29
+ ...mapCommands,
30
+ ...runsCommands,
31
+ ...caseCommands,
32
+ ...coverageCommands,
33
+ ...ciCommands,
34
+ ...fixturesCommands,
35
+ ...reportCommands,
36
+ ...headlessCommands,
37
+ ]);
24
38
 
25
39
  /** The global flags, read leniently from the whole argv before any command parses its own. */
26
40
  const globals = (argv: string[]) => {
@@ -42,6 +56,8 @@ export type Session = { ctx: Base; stop: () => Promise<void> };
42
56
 
43
57
  // Once per process: a suite reattaches for every case.
44
58
  let mismatchTold = false;
59
+ let switchTold = false;
60
+ let lastApp: string | undefined;
45
61
 
46
62
  // `ownHost`: the port belongs to a host this run started for a case; whatever dials in is the device.
47
63
  const contextFor = (argv: string[], attached = false, port?: number, ownHost = false): Session => {
@@ -58,6 +74,13 @@ const contextFor = (argv: string[], attached = false, port?: number, ownHost = f
58
74
  const attachedIn = (d: Device) => err(`attached in ${Math.round(performance.now() - started)}ms (${d.info.name})`);
59
75
  const server = new AgentServer({ token: readToken(root), port: port ?? portFromEnv(config) });
60
76
  server.on('hello', (hello: HelloEvent) => {
77
+ const identity = helloIdentity(hello);
78
+ const switched = switchTold ? undefined : appSwitched(lastApp, identity, server.port);
79
+ lastApp = identity;
80
+ if (switched) {
81
+ switchTold = true;
82
+ err(switched);
83
+ }
61
84
  const told = mismatchTold ? undefined : agentMismatch(hello);
62
85
  if (!told) return;
63
86
  mismatchTold = true;
@@ -19,6 +19,8 @@ import { eventsFile } from '../knowledge/events.js';
19
19
  import { type Base, define } from '../registry.js';
20
20
  import { describeChanges, pushRun, service } from './report.js';
21
21
  import { TraceSink } from '../cases/traces.js';
22
+ import { megabytes, sizeOf } from '../cases/runDirs.js';
23
+ import { type NativeLogs, startNativeLogs } from '../cases/nativeLogs.js';
22
24
 
23
25
  const startHost = async (root: string, err: (line: string) => void): Promise<ChildProcess> => {
24
26
  // Its own process group: the host starts a test runner of its own, and stopping one without the other leaves an app dialing in.
@@ -28,6 +30,11 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
28
30
  stdio: ['ignore', 'pipe', 'pipe'],
29
31
  detached: true,
30
32
  });
33
+ // Everything the host prints, for the render errors and warnings a failed case points at; the console keeps the run's own lines.
34
+ const logFile = path.join(os.tmpdir(), `lynkeus-headless-${process.pid}.log`);
35
+ const logStream = fs.createWriteStream(logFile);
36
+ logStream.on('error', () => {});
37
+ host.once('exit', () => logStream.end());
31
38
  let output = '';
32
39
  const tail = (n: number) => output.trim().split('\n').slice(-n).join(' · ');
33
40
  // `headless start` ends a crash with what it usually means; without that, the error line jest printed.
@@ -40,7 +47,9 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
40
47
  .split('\n')
41
48
  .map((line) => line.trim())
42
49
  .join(' · ');
43
- return plain.match(/^\s*(\w*Error\b.*|Cannot find module.*)$/m)?.[1]?.trim() ?? tail(3);
50
+ // Jest prints a suite that throws while loading as "● Test suite failed to run", then the thrown message.
51
+ const thrown = plain.match(/Test suite failed to run\s*\n\s*\n?\s*(\S.*)$/m)?.[1]?.trim();
52
+ return thrown ?? plain.match(/^\s*(\w*Error\b.*|Cannot find module.*)$/m)?.[1]?.trim() ?? tail(3);
44
53
  };
45
54
  await new Promise<void>((resolve, reject) => {
46
55
  const settle = (error?: Error) => {
@@ -50,6 +59,7 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
50
59
  };
51
60
  const timer = setTimeout(() => settle(new Error(`the host did not come up within 180 s: ${tail(3)}`)), 180_000);
52
61
  const read = (chunk: Buffer) => {
62
+ logStream.write(chunk);
53
63
  output = (output + chunk.toString()).slice(-4000);
54
64
  if (/waiting for a driver/.test(output)) settle();
55
65
  };
@@ -58,7 +68,8 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
58
68
  // A suite that fails to load exits right after printing why, so its explanation is read in full here.
59
69
  host.once('exit', (code) => settle(new Error(`${/Test suite failed/.test(output) ? 'the host failed to start' : `the host exited (${code})`}: ${why()}`)));
60
70
  });
61
- err('host: up');
71
+ // The listeners above stay: the log takes all the host prints until it stops.
72
+ err(`host: up (its log: ${logFile})`);
62
73
  return host;
63
74
  };
64
75
 
@@ -115,19 +126,27 @@ const idleShare = async (): Promise<number> => {
115
126
  return after.all > before.all ? (after.idle - before.idle) / (after.all - before.all) : 1;
116
127
  };
117
128
 
118
- /** The case files named, or every case under the cases directory; a sentence when there are none. */
119
- const caseFiles = (args: string[], home: string, casesDir: string | undefined): string[] | string => {
129
+ /** Every case under a directory and its subdirectories, in path order, so a suite can group its cases in folders. */
130
+ const casesIn = (dir: string): string[] =>
131
+ fs
132
+ .readdirSync(dir, { withFileTypes: true })
133
+ .filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
134
+ .sort((a, b) => a.name.localeCompare(b.name))
135
+ .flatMap((e) => {
136
+ const full = path.join(dir, e.name);
137
+ if (e.isDirectory()) return casesIn(full);
138
+ return e.name.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(e.name) ? [full] : [];
139
+ });
140
+
141
+ /** The case files named (a directory stands for every case under it), or every case under the cases directory; a sentence when there are none. */
142
+ export const caseFiles = (args: string[], home: string, casesDir: string | undefined): string[] | string => {
120
143
  const dir = path.resolve(home, casesDir ?? 'cases');
121
- const files = args.length
122
- ? args.map((f) => path.resolve(f))
123
- : fs.existsSync(dir)
124
- ? fs
125
- .readdirSync(dir)
126
- .filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f) && !f.startsWith('.'))
127
- .sort()
128
- .map((f) => path.join(dir, f))
129
- : [];
130
- return files.length > 0 ? files : `no cases under ${path.relative(home, dir) || '.'}; \`lynkeus case init\` writes one`;
144
+ const named = args.map((f) => path.resolve(f));
145
+ const missing = named.find((f) => !fs.existsSync(f));
146
+ if (missing) return `no such case file: ${path.relative(process.cwd(), missing) || missing}`;
147
+ const files = named.length ? named.flatMap((f) => (fs.statSync(f).isDirectory() ? casesIn(f) : [f])) : fs.existsSync(dir) ? casesIn(dir) : [];
148
+ const where = named.length === 1 ? path.relative(home, named[0]!) : path.relative(home, dir);
149
+ return files.length > 0 ? files : `no cases under ${where || '.'}; \`lynkeus case init\` writes one`;
131
150
  };
132
151
 
133
152
  type PrepareOptions = { base: Base; config: CasesConfig; store: Store; open: () => Fixtures; limit: number; idle: number; jobs: number };
@@ -182,15 +201,48 @@ const spawnHost = (command: string, port: number, job: number, cwd: string): Chi
182
201
  });
183
202
 
184
203
  /** A run that could not be uploaded still ran: the failure is said, not raised. */
185
- const pushed = async (dir: string, to: Exclude<ReturnType<typeof service>, string>, project: string) => {
204
+ // A run that kept evidence but none of a kind says so when it ends, not only as a gap in the report.
205
+ export const missingEvidence = (reports: CaseReport[], asked: { video: boolean }): string[] => {
206
+ if (reports.length === 0) return [];
207
+ const steps = reports.flatMap((r) => r.steps);
208
+ const fromApp = steps.some((s) => s.sent?.some((e) => e.via !== 'server')) || reports.some((r) => (r.events?.length ?? 0) > 0);
209
+ return [
210
+ ...(fromApp ? [] : ['⚠ no case kept an analytics event from the app (the agent keeps the calls that reach Mixpanel, AppsFlyer and Firebase Analytics).']),
211
+ ...(asked.video && !reports.some((r) => r.video)
212
+ ? ['⚠ --video kept no film: this host does not film (fastsim does; a simulator, an emulator or headless keep frames only).']
213
+ : []),
214
+ ];
215
+ };
216
+
217
+ const pushed = async (
218
+ dir: string,
219
+ to: Exclude<ReturnType<typeof service>, string>,
220
+ project: string,
221
+ err: (line: string) => void,
222
+ afterPush: 'keep' | 'delete' = 'keep',
223
+ ) => {
186
224
  try {
187
- const run = await pushRun(dir, { ...to, project });
188
- return [`report: ${run.url}${run.refused.length ? ` (not kept on this plan: ${run.refused.join(', ')})` : ''}`, ...describeChanges(run.changes)].join('\n');
225
+ const run = await pushRun(dir, { ...to, project, onReady: (url) => err(`report: ${url} (its frames are on their way)`), onProgress: err });
226
+ return [
227
+ `report: ${run.url}${run.refused.length ? ` (not kept on this plan: ${run.refused.join(', ')})` : ''}`,
228
+ ...describeChanges(run.changes),
229
+ keptOrDeleted(dir, afterPush, run.refused.length === 0),
230
+ ].join('\n');
189
231
  } catch (error) {
190
232
  return `the report was not uploaded: ${(error as Error).message}`;
191
233
  }
192
234
  };
193
235
 
236
+ // After a push: the folder deleted when the config asks and the service took every file, else how much it holds.
237
+ export const keptOrDeleted = (dir: string, afterPush: 'keep' | 'delete', complete: boolean): string => {
238
+ const bytes = sizeOf(dir);
239
+ if (afterPush === 'delete' && complete) {
240
+ fs.rmSync(dir, { recursive: true, force: true });
241
+ return `run folder deleted after the push (${megabytes(bytes)})`;
242
+ }
243
+ return `run folder: ${megabytes(bytes)} on disk${afterPush === 'delete' ? ', kept because some files were not taken' : ''} · \`lynkeus report prune ${path.dirname(dir)} --older 7d\` frees old ones`;
244
+ };
245
+
194
246
  export const caseCommands = [
195
247
  define({
196
248
  name: 'case run',
@@ -207,6 +259,10 @@ export const caseCommands = [
207
259
  report: { type: 'string', help: 'append one JSON line per case', value: 'file' },
208
260
  events: { type: 'string', help: 'keep what the app told its analytics, per case, as <dir>/<case id>/events.jsonl', value: 'dir' },
209
261
  out: { type: 'string', help: 'write the run as a directory a report can read: run.json and cases.jsonl, a line per attempt', value: 'dir' },
262
+ 'no-app-events': {
263
+ type: 'boolean',
264
+ help: "with --out: leave the app's own event list unread. Reading it can turn on the app's tracking debug, which may draw on the screen; frames two runs compare should not have it",
265
+ },
210
266
  screenshots: { type: 'boolean', help: 'with --out: a frame of the screen after every step of the app' },
211
267
  settle: {
212
268
  type: 'number',
@@ -214,6 +270,21 @@ export const caseCommands = [
214
270
  default: 150,
215
271
  value: 'ms',
216
272
  },
273
+ 'frames-on-change': {
274
+ type: 'boolean',
275
+ help: 'with --screenshots: a step that only looks (app.see, app.wait) on an unchanged screen shares the last frame',
276
+ },
277
+ still: {
278
+ type: 'number',
279
+ help: 'with --screenshots, on a simulator or emulator: take each frame again until two in a row match, this many ms apart (a native transition the agent cannot see)',
280
+ value: 'ms',
281
+ },
282
+ frames: {
283
+ type: 'string',
284
+ help: "with --screenshots: avif, the default and a few percent of the bytes (a simulator's or emulator's PNG is encoded again), or png, exact, only for frames two runs are compared by pixel for pixel",
285
+ default: 'avif',
286
+ value: 'avif|png',
287
+ },
217
288
  video: { type: 'boolean', help: 'with --out: film each case, where the host films (the step says at what second it began)' },
218
289
  traces: {
219
290
  type: 'boolean',
@@ -246,6 +317,9 @@ export const caseCommands = [
246
317
  'report',
247
318
  'events',
248
319
  'settle',
320
+ 'still',
321
+ 'frames-on-change',
322
+ 'frames',
249
323
  'traces',
250
324
  'traces-port',
251
325
  'video-fps',
@@ -285,6 +359,9 @@ export const caseCommands = [
285
359
  dir: out.evidenceDir(id, attempt),
286
360
  screenshots: !!ctx.flags.screenshots,
287
361
  settleMs: Number(ctx.flags.settle ?? 150),
362
+ ...(ctx.flags.still ? { stillMs: Number(ctx.flags.still) } : {}),
363
+ ...(ctx.flags['frames-on-change'] ? { onChange: true } : {}),
364
+ frames: ctx.flags.frames === 'png' ? ('png' as const) : ('avif' as const),
288
365
  ...(ctx.flags.video
289
366
  ? { video: { fps: Number(ctx.flags['video-fps'] ?? 30), ...(ctx.flags['video-scale'] ? { scale: Number(ctx.flags['video-scale']) } : {}) } }
290
367
  : {}),
@@ -292,7 +369,7 @@ export const caseCommands = [
292
369
  : undefined;
293
370
  const ledger = dry ? undefined : new Flakes(path.resolve(ctx.root, typeof ctx.flags.flakes === 'string' ? ctx.flags.flakes : '.lynkeus/flakes.json'));
294
371
  // A run file files each event under its step, so it needs them collected.
295
- const events = typeof ctx.flags.events === 'string' || out !== undefined;
372
+ const events = typeof ctx.flags.events === 'string' || (out !== undefined && !ctx.flags['no-app-events']);
296
373
  const claimed = dry
297
374
  ? undefined
298
375
  : claimPorts(
@@ -343,6 +420,15 @@ export const caseCommands = [
343
420
  const hostCommand = typeof ctx.flags['host-command'] === 'string' && !dry ? ctx.flags['host-command'] : undefined;
344
421
  if ((jobs > 1 || hostCommand) && (ports.length < jobs || ports.some((p) => !Number.isInteger(p)) || !ctx.forPort))
345
422
  return { text: `--jobs and --host-command need a driver port per job: --ports with ${jobs}`, code: 2 };
423
+ // One simulator or emulator, one log: with several jobs a line could not be told to its case.
424
+ const native: NativeLogs | undefined =
425
+ out && jobs === 1 && !hostCommand && !ctx.flags.host && ctx.config.app
426
+ ? await ctx
427
+ .device()
428
+ .then((d) => d.resolved())
429
+ .then((info) => startNativeLogs(info, ctx.config.app as string))
430
+ .catch(() => undefined)
431
+ : undefined;
346
432
  // Several jobs finish together only if the longest cases start first: one left for the end runs alone
347
433
  // while the other jobs wait. A case the ledger has not timed goes first too, as it may be long.
348
434
  if (jobs > 1 && ledger && !ctx.flags['in-order']) {
@@ -405,6 +491,7 @@ export const caseCommands = [
405
491
  continue: !!ctx.flags.continue,
406
492
  routes: seenDir ? (host ? 'fresh' : true) : false,
407
493
  traces,
494
+ nativeLogs: native,
408
495
  });
409
496
  if (seenDir && report.result === 'passed' && report.routes?.length) {
410
497
  fs.mkdirSync(seenDir, { recursive: true });
@@ -453,6 +540,7 @@ export const caseCommands = [
453
540
  await Promise.all(Array.from({ length: jobs }, (_, i) => job(i)));
454
541
  await preparing;
455
542
  if (out) out.finish({ platform: seen?.platform, native: seen?.native });
543
+ native?.stop();
456
544
  } finally {
457
545
  stop();
458
546
  }
@@ -466,6 +554,9 @@ export const caseCommands = [
466
554
  (ctx.attached && r.result !== 'passed' ? `\n${describe(r)}` : ''),
467
555
  ),
468
556
  `${count('passed')}/${reports.length} passed${unsupported ? `, ${unsupported} cannot run on this host` : ''}`,
557
+ // Its full path: a relative --out resolves against where the CLI was started, which over MCP is the app's folder.
558
+ ...(out ? [`run kept in ${path.resolve(ctx.flags.out as string)}`] : []),
559
+ ...(out ? missingEvidence(reports, { video: !!ctx.flags.video }) : []),
469
560
  ...(ledger ? ledger.flaky(reports.map((r) => r.id)) : []),
470
561
  ...(reportsTo && out
471
562
  ? [
@@ -473,6 +564,8 @@ export const caseCommands = [
473
564
  path.resolve(ctx.flags.out as string),
474
565
  reportsTo,
475
566
  typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root),
567
+ ctx.err,
568
+ ctx.config.report?.afterPush === 'delete' ? 'delete' : 'keep',
476
569
  ),
477
570
  ]
478
571
  : []),
@@ -0,0 +1,189 @@
1
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { coverageOf, impactOf, readSeen } from '../cases/coverage.js';
6
+ import { readCases } from '../cases/format.js';
7
+ import { screensAffectedBy } from '../knowledge/query.js';
8
+ import { define } from '../registry.js';
9
+ import { caseFiles } from './cases.js';
10
+ import { changedSources } from './map.js';
11
+
12
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
13
+
14
+ /** The case results of a run folder, by each case's last attempt. */
15
+ export const resultsOf = (dir: string): { id: string; result: string; title?: string }[] => {
16
+ const file = path.join(dir, 'cases.jsonl');
17
+ if (!fs.existsSync(file)) return [];
18
+ const last = new Map<string, { id: string; result: string; title?: string }>();
19
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
20
+ if (!line.trim()) continue;
21
+ const c = JSON.parse(line) as { id: string; result: string; title?: string };
22
+ last.set(c.id, c);
23
+ }
24
+ return [...last.values()];
25
+ };
26
+
27
+ /** The summary a CI job shows: how many passed, which failed, and the report. */
28
+ export const summaryOf = (results: { id: string; result: string; title?: string }[], report: string | undefined, picked: string): string => {
29
+ const failed = results.filter((r) => r.result === 'failed');
30
+ const passed = results.filter((r) => r.result === 'passed').length;
31
+ return [
32
+ `### lynkeus: ${passed}/${results.length} cases passed${failed.length ? `, ${failed.length} failed` : ''}`,
33
+ '',
34
+ picked,
35
+ ...(report ? ['', `[Open the report](${report})`] : []),
36
+ ...(failed.length ? ['', '| Failed | |', '|---|---|', ...failed.map((f) => `| \`${f.id}\` | ${f.title ?? ''} |`)] : []),
37
+ '',
38
+ ].join('\n');
39
+ };
40
+
41
+ const waitFor = async (ok: () => boolean | Promise<boolean>, timeoutMs: number, every = 3000): Promise<boolean> => {
42
+ const until = Date.now() + timeoutMs;
43
+ for (;;) {
44
+ if (await ok()) return true;
45
+ if (Date.now() > until) return false;
46
+ await sleep(every);
47
+ }
48
+ };
49
+
50
+ export const ciCommands = [
51
+ define({
52
+ name: 'ci',
53
+ group: 'runs',
54
+ summary: 'Run the suite the way a CI job does: bring the backend and N hosts up, run the cases across them, report, tear down',
55
+ details:
56
+ 'Reads `ci` in lynkeus.config.json: `backend.up` / `backend.down` (the project’s commands; lynkeus knows no backend) and `backend.ready` (a URL to wait for), `hosts.count` and `hosts.command` (default: a headless host; `{port}` is where it dials), `push`, and extra `run` flags. --affected runs only the cases a branch’s changes reach (as `case impact` finds them). Writes the job summary to $GITHUB_STEP_SUMMARY when it is set. The exit code is the suite’s.',
57
+ flags: {
58
+ affected: { type: 'boolean', help: 'only the cases the changes against --base reach' },
59
+ base: { type: 'string', help: 'git ref --affected diffs against', default: 'origin/main', value: 'ref' },
60
+ jobs: { type: 'number', help: 'how many hosts and jobs (default ci.hosts.count, else 2)', value: 'n' },
61
+ out: { type: 'string', help: 'where the run goes', default: 'lynkeus-run', value: 'dir' },
62
+ 'keep-up': { type: 'boolean', help: 'leave the backend and the hosts up afterwards' },
63
+ 'dry-run': { type: 'boolean', help: 'say what it would do and run nothing' },
64
+ },
65
+ needs: 'nothing',
66
+ session: false,
67
+ mcp: false,
68
+ run: async (ctx) => {
69
+ const ci = ctx.config.ci ?? {};
70
+ const config = ctx.config.cases ?? {};
71
+ const home = config.base ?? ctx.root;
72
+ const self = process.argv[1]!;
73
+ const say = (line: string) => ctx.err(`▸ ${line}`);
74
+ const jobs = Math.max(1, Number(ctx.flags.jobs ?? ci.hosts?.count ?? 2));
75
+ const out = path.resolve(ctx.root, String(ctx.flags.out));
76
+
77
+ // Which cases: all, or the ones the branch's changes reach.
78
+ const all = caseFiles([], home, config.dir);
79
+ if (typeof all === 'string') return { text: all, code: 1 };
80
+ let files = all;
81
+ let picked = `All ${all.length} case files.`;
82
+ if (ctx.flags.affected) {
83
+ const graphFile = path.join(ctx.root, '.lynkeus', 'graph.json');
84
+ if (!fs.existsSync(graphFile)) spawnSync(process.execPath, [self, 'map', 'build'], { cwd: ctx.root, stdio: 'inherit' });
85
+ const cases = all.flatMap(readCases);
86
+ const graph = ctx.graph();
87
+ const coverage = coverageOf(cases, Object.keys(graph.screens).sort(), readSeen(path.join(ctx.root, '.lynkeus', 'seen')));
88
+ const changed = changedSources(ctx.root, String(ctx.flags.base), ctx.err);
89
+ const impact = impactOf(screensAffectedBy(graph, changed), coverage);
90
+ files = [...new Set(impact.cases.map((c) => c.file))];
91
+ picked = `${files.length} of ${all.length} case files: the ones the ${changed.length} changed source file(s) against ${ctx.flags.base} reach.`;
92
+ if (files.length === 0) {
93
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### lynkeus: no case to run\n\n${picked}\n`);
94
+ return { text: `no case to run: ${picked}`, json: { files } };
95
+ }
96
+ }
97
+ const ports = Array.from({ length: jobs }, (_, i) => 8123 + i);
98
+ const hostCommand = ci.hosts?.command ?? `LYNKEUS_PORT={port} "${process.execPath}" "${self}" headless start`;
99
+ const runArgs = [
100
+ 'case',
101
+ 'run',
102
+ ...files,
103
+ '--jobs',
104
+ String(jobs),
105
+ '--ports',
106
+ ports.join(','),
107
+ '--out',
108
+ out,
109
+ '--screenshots',
110
+ ...(ci.push ? ['--push'] : []),
111
+ ...(ci.run ?? []),
112
+ ];
113
+ if (ctx.flags['dry-run']) {
114
+ const plan = [
115
+ picked,
116
+ ci.backend ? `backend: \`${ci.backend.up}\`${ci.backend.ready ? `, ready at ${ci.backend.ready}` : ''}` : 'backend: none declared (ci.backend)',
117
+ `hosts: ${jobs} × \`${hostCommand}\` on ${ports.join(', ')}`,
118
+ `run: lynkeus ${runArgs.slice(0, 2).join(' ')} … ${runArgs.slice(2 + files.length).join(' ')}`,
119
+ ];
120
+ return { text: plan.join('\n'), json: { files, ports, hostCommand, runArgs } };
121
+ }
122
+
123
+ const hosts: ChildProcess[] = [];
124
+ const down = () => {
125
+ if (ctx.flags['keep-up']) return say('left up (--keep-up)');
126
+ for (const h of hosts)
127
+ if (h.pid)
128
+ try {
129
+ process.kill(-h.pid, 'SIGTERM');
130
+ } catch {}
131
+ if (ci.backend?.down) {
132
+ say('backend down');
133
+ spawnSync('/bin/sh', ['-c', ci.backend.down], { cwd: ctx.root, stdio: 'inherit' });
134
+ }
135
+ };
136
+ try {
137
+ if (ci.backend) {
138
+ say(`backend up: ${ci.backend.up}`);
139
+ const up = spawnSync('/bin/sh', ['-c', ci.backend.up], { cwd: ctx.root, stdio: 'inherit' });
140
+ if (up.status !== 0) return { text: `the backend did not come up (\`${ci.backend.up}\` exited ${up.status})`, code: 1 };
141
+ if (ci.backend.ready) {
142
+ const url = ci.backend.ready;
143
+ say(`waiting for ${url}`);
144
+ const ready = await waitFor(
145
+ () =>
146
+ fetch(url).then(
147
+ (r) => r.ok,
148
+ () => false,
149
+ ),
150
+ ci.backend.timeoutMs ?? 300_000,
151
+ );
152
+ if (!ready) return { text: `${url} never answered`, code: 1 };
153
+ }
154
+ }
155
+ say(`${jobs} host${jobs === 1 ? '' : 's'}: ${hostCommand}`);
156
+ for (const port of ports)
157
+ hosts.push(
158
+ spawn('/bin/sh', ['-c', hostCommand.replaceAll('{port}', String(port))], { cwd: ctx.root, stdio: ['pipe', 'ignore', 'ignore'], detached: true }),
159
+ );
160
+ // A host compiles the app before it answers: the slow part.
161
+ for (const port of ports) {
162
+ const answered = () =>
163
+ spawnSync(process.execPath, [self, 'screen'], {
164
+ cwd: ctx.root,
165
+ env: { ...process.env, LYNKEUS_PORT: String(port) },
166
+ stdio: 'ignore',
167
+ timeout: 20_000,
168
+ }).status === 0;
169
+ if (!(await waitFor(answered, ci.hosts?.timeoutMs ?? 600_000, 5000))) return { text: `the host on ${port} never attached`, code: 1 };
170
+ say(`host on ${port} ready`);
171
+ }
172
+ say(`running ${files.length} case file(s) on ${jobs} host(s)`);
173
+ const run = spawnSync(process.execPath, [self, ...runArgs], {
174
+ cwd: ctx.root,
175
+ encoding: 'utf8',
176
+ stdio: ['ignore', 'pipe', 'inherit'],
177
+ maxBuffer: 64 * 1024 * 1024,
178
+ });
179
+ process.stdout.write(run.stdout ?? '');
180
+ const report = /report: (https?:\/\/\S+)/.exec(run.stdout ?? '')?.[1];
181
+ const summary = summaryOf(resultsOf(out), report, picked);
182
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
183
+ return { text: summary, code: run.status ?? 1 };
184
+ } finally {
185
+ down();
186
+ }
187
+ },
188
+ }),
189
+ ];
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { coverageOf, impactOf, readSeen } from '../cases/coverage.js';
5
+ import { draftCase } from '../cases/draft.js';
6
+ import { Fixtures } from '../cases/fixtures.js';
7
+ import { readCases } from '../cases/format.js';
8
+ import { edgesFile } from '../config.js';
9
+ import * as edges from '../knowledge/edges.js';
10
+ import { plan } from '../knowledge/plan.js';
11
+ import { screensAffectedBy } from '../knowledge/query.js';
12
+ import { type Base, define } from '../registry.js';
13
+ import { caseFiles } from './cases.js';
14
+ import { changedSources } from './map.js';
15
+
16
+ // The project's cases and what they cover, read the way `case run` reads them.
17
+ const coverageFor = (ctx: Base & { graph: () => { screens: Record<string, unknown> } }, seenFlag: unknown) => {
18
+ const config = ctx.config.cases ?? {};
19
+ const home = config.base ?? ctx.root;
20
+ const files = caseFiles([], home, config.dir);
21
+ if (typeof files === 'string') return files;
22
+ const cases = files.flatMap(readCases);
23
+ const seenDir = path.resolve(ctx.root, typeof seenFlag === 'string' ? seenFlag : '.lynkeus/seen');
24
+ return { cases, coverage: coverageOf(cases, Object.keys(ctx.graph().screens).sort(), readSeen(seenDir)) };
25
+ };
26
+
27
+ export const coverageCommands = [
28
+ define({
29
+ name: 'case coverage',
30
+ group: 'runs',
31
+ summary: 'Which of the app’s screens the cases touch, which none does, and which a case names that the app no longer has',
32
+ details:
33
+ 'A screen counts as touched when a case goes to it (`app.nav`), waits for it (`app.wait`), checks it (`app.see: { route }`), or went through it on its last passing run (`case run --seen <dir>`, read from .lynkeus/seen unless --seen says where). The screens come from the route graph (`lynkeus map build`).',
34
+ flags: {
35
+ route: { type: 'string', help: 'only this screen: the cases that touch it', value: 'Route' },
36
+ seen: { type: 'string', help: 'where `case run --seen` kept the screens of passing cases (default .lynkeus/seen)', value: 'dir' },
37
+ },
38
+ needs: 'graph',
39
+ session: false,
40
+ mcp: 'core',
41
+ run: async (ctx) => {
42
+ const read = coverageFor(ctx, ctx.flags.seen);
43
+ if (typeof read === 'string') return { text: read, code: 1 };
44
+ const { cases, coverage } = read;
45
+ if (typeof ctx.flags.route === 'string') {
46
+ const ids = coverage.byRoute[ctx.flags.route] ?? [];
47
+ return { text: `${ctx.flags.route}: ${ids.length ? ids.join(', ') : 'no case'}`, json: { route: ctx.flags.route, cases: ids } };
48
+ }
49
+ const pct = coverage.routes ? Math.round((100 * coverage.covered.length) / coverage.routes) : 0;
50
+ const top = Object.entries(coverage.byRoute)
51
+ .filter(([r]) => !coverage.unknown.includes(r))
52
+ .sort((a, b) => b[1].length - a[1].length)
53
+ .slice(0, 8);
54
+ const lines = [
55
+ `${coverage.covered.length} of ${coverage.routes} screens touched by a case (${pct}%), from ${cases.length} case${cases.length === 1 ? '' : 's'}`,
56
+ ...(top.length ? ['most covered:', ...top.map(([r, ids]) => ` ${r.padEnd(34)} ${ids.length}`)] : []),
57
+ ...(coverage.unknown.length
58
+ ? ['named by a case, unknown to the app (renamed? a typo?):', ...coverage.unknown.map((r) => ` ${r} ← ${coverage.byRoute[r]!.join(', ')}`)]
59
+ : []),
60
+ `${coverage.uncovered.length} screen${coverage.uncovered.length === 1 ? '' : 's'} no case touches${coverage.uncovered.length ? ':' : ''}`,
61
+ ...coverage.uncovered.slice(0, 30).map((r) => ` ${r}`),
62
+ ...(coverage.uncovered.length > 30 ? [` … ${coverage.uncovered.length - 30} more (--json)`] : []),
63
+ ];
64
+ return { text: lines.join('\n'), json: coverage };
65
+ },
66
+ }),
67
+ define({
68
+ name: 'case impact',
69
+ group: 'runs',
70
+ summary: 'The cases a branch’s changes reach, to run only those, and the reached screens no case covers',
71
+ details:
72
+ 'The screens the diff against --base touches or imports (as `map check` finds them), crossed with what each case covers (as `case coverage` finds it). --files prints one case file per line: `lynkeus case run $(lynkeus case impact --files)`.',
73
+ flags: {
74
+ base: { type: 'string', help: 'git ref to diff against', default: 'main', value: 'ref' },
75
+ depth: { type: 'number', help: 'how many imports away a change still reaches a screen', default: 4, value: 'n' },
76
+ seen: { type: 'string', help: 'where `case run --seen` kept the screens of passing cases (default .lynkeus/seen)', value: 'dir' },
77
+ files: { type: 'boolean', help: 'print only the case files, one per line' },
78
+ },
79
+ needs: 'graph',
80
+ session: false,
81
+ mcp: 'core',
82
+ run: async (ctx) => {
83
+ const read = coverageFor(ctx, ctx.flags.seen);
84
+ if (typeof read === 'string') return { text: read, code: 1 };
85
+ const base = ctx.flags.base as string;
86
+ const changed = changedSources(ctx.root, base, ctx.err);
87
+ const affected = screensAffectedBy(ctx.graph(), changed, ctx.flags.depth as number);
88
+ const impact = impactOf(affected, read.coverage);
89
+ if (ctx.flags.files) return { text: [...new Set(impact.cases.map((c) => c.file))].join('\n'), json: impact };
90
+ const lines = [
91
+ `${changed.length} source file${changed.length === 1 ? '' : 's'} changed against ${base}; they reach ${new Set(affected.map((a) => a.route)).size} screen(s)`,
92
+ ...(impact.cases.length
93
+ ? [`cases to run (${impact.cases.length}):`, ...impact.cases.map((c) => ` ${c.id.padEnd(46)} ${c.routes.join(', ')} (via ${c.via})`)]
94
+ : ['no case touches them']),
95
+ ...(impact.untested.length ? [`reached, and touched by no case (${impact.untested.length}):`, ...impact.untested.map((r) => ` ${r}`)] : []),
96
+ ];
97
+ return { text: lines.join('\n'), json: { base, changed, ...impact } };
98
+ },
99
+ }),
100
+ define({
101
+ name: 'case new',
102
+ group: 'runs',
103
+ summary: 'Draft a case for a screen: the way there, the backend setup to pick from, and the checks left to write',
104
+ details:
105
+ 'Prints a case to finish: the frontmatter with the questions a reader needs answered, the setup the project’s fixtures server offers (from `lynkeus.describe`, commented out: what state the case needs is the author’s call), the presses from --from (default `cases.start`) to the screen as `map plan` knows them, and the checks to write. --write saves it as <cases dir>/<id>.md, never over a file that is there.',
106
+ positionals: [{ name: 'id', help: 'the case id, which is also its file name', required: true }],
107
+ flags: {
108
+ to: { type: 'string', help: 'the screen the case is about', value: 'Route' },
109
+ from: { type: 'string', help: 'where it begins (default: cases.start in lynkeus.config.json)', value: 'Route' },
110
+ title: { type: 'string', help: 'what a person can do there, as the report will title it', value: 'text' },
111
+ write: { type: 'boolean', help: 'save it in the cases folder instead of printing it' },
112
+ },
113
+ needs: 'graph',
114
+ session: false,
115
+ mcp: 'core',
116
+ run: async (ctx) => {
117
+ const config = ctx.config.cases ?? {};
118
+ const id = ctx.args[0]!;
119
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) return { text: `${id}: an id is lower case letters, digits, dots, dashes and underscores`, code: 2 };
120
+ const to = ctx.flags.to as string | undefined;
121
+ const from = (ctx.flags.from as string | undefined) ?? config.start;
122
+ if (!to) return { text: 'case new needs --to <Route>, the screen the case is about', code: 2 };
123
+ if (!from) return { text: 'case new needs --from <Route>, or cases.start in lynkeus.config.json', code: 2 };
124
+ const graph = ctx.graph();
125
+ if (!graph.screens[to]) return { text: `${to}: the app has no such screen (lynkeus map routes lists them)`, code: 1 };
126
+ const server = config.fixtures ? new Fixtures(config.fixtures, config.base ?? ctx.root) : undefined;
127
+ const described = server ? await server.describe().finally(() => server.close()) : undefined;
128
+ const text = draftCase({
129
+ id,
130
+ title: ctx.flags.title as string | undefined,
131
+ from,
132
+ to,
133
+ path: plan(graph, from, to, edges.load(edgesFile(ctx.root))) ?? undefined,
134
+ fixtures: described?.details,
135
+ fixturesSilent: !!server && !described,
136
+ macros: Object.keys(config.macros ?? {}),
137
+ });
138
+ if (!ctx.flags.write) return { text, json: { id, text } };
139
+ const dir = path.resolve(config.base ?? ctx.root, config.dir ?? 'cases');
140
+ const file = path.join(dir, `${id}.md`);
141
+ if (fs.existsSync(file)) return { text: `${path.relative(ctx.root, file)} is there already; pick another id or edit it`, code: 1 };
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ fs.writeFileSync(file, text);
144
+ return { text: `wrote ${path.relative(ctx.root, file)}`, json: { id, file } };
145
+ },
146
+ }),
147
+ ];