lynkeus 0.2.0 → 0.3.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 (127) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +80 -3
  14. package/lib/cases/runner.js +387 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +27 -6
  20. package/lib/commands/cases.js +360 -37
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/drive.js +32 -8
  24. package/lib/commands/headless.js +12 -2
  25. package/lib/commands/init.d.ts +6 -0
  26. package/lib/commands/init.js +78 -21
  27. package/lib/commands/report.d.ts +97 -0
  28. package/lib/commands/report.js +228 -0
  29. package/lib/config.d.ts +10 -1
  30. package/lib/device/agentVersion.d.ts +7 -0
  31. package/lib/device/agentVersion.js +12 -0
  32. package/lib/device/devices.d.ts +5 -0
  33. package/lib/device/devices.js +27 -0
  34. package/lib/flow/runner.js +33 -1
  35. package/lib/flow/types.d.ts +2 -1
  36. package/lib/knowledge/contrast.d.ts +45 -0
  37. package/lib/knowledge/contrast.js +114 -0
  38. package/lib/knowledge/errors.js +1 -1
  39. package/lib/knowledge/lint.d.ts +2 -0
  40. package/lib/knowledge/lint.js +13 -3
  41. package/lib/registry.d.ts +5 -0
  42. package/lib/targets.d.ts +10 -1
  43. package/lib/targets.js +16 -3
  44. package/package.json +10 -6
  45. package/src/cases/ahead.ts +111 -0
  46. package/src/cases/fixtures.ts +37 -0
  47. package/src/cases/flakes.ts +67 -0
  48. package/src/cases/format.ts +31 -6
  49. package/src/cases/portClaim.ts +52 -0
  50. package/src/cases/runfile.ts +119 -0
  51. package/src/cases/runner.ts +446 -46
  52. package/src/cases/templates.ts +14 -4
  53. package/src/cases/traces.ts +298 -0
  54. package/src/cli.ts +28 -6
  55. package/src/commands/cases.ts +375 -37
  56. package/src/commands/check.ts +11 -4
  57. package/src/commands/drive.ts +31 -8
  58. package/src/commands/headless.ts +12 -2
  59. package/src/commands/init.ts +84 -20
  60. package/src/commands/report.ts +272 -0
  61. package/src/config.ts +6 -2
  62. package/src/device/agentVersion.ts +15 -0
  63. package/src/device/devices.ts +28 -0
  64. package/src/flow/runner.ts +35 -1
  65. package/src/flow/types.ts +2 -2
  66. package/src/knowledge/contrast.ts +135 -0
  67. package/src/knowledge/errors.ts +1 -1
  68. package/src/knowledge/lint.ts +15 -4
  69. package/src/registry.ts +2 -0
  70. package/src/targets.ts +17 -3
  71. package/lib/__tests__/args.test.d.ts +0 -1
  72. package/lib/__tests__/args.test.js +0 -69
  73. package/lib/__tests__/check.test.d.ts +0 -1
  74. package/lib/__tests__/check.test.js +0 -60
  75. package/lib/__tests__/init.test.d.ts +0 -1
  76. package/lib/__tests__/init.test.js +0 -42
  77. package/lib/__tests__/record.test.d.ts +0 -1
  78. package/lib/__tests__/record.test.js +0 -44
  79. package/lib/__tests__/snapshot.test.d.ts +0 -1
  80. package/lib/__tests__/snapshot.test.js +0 -47
  81. package/lib/__tests__/targets.test.d.ts +0 -1
  82. package/lib/__tests__/targets.test.js +0 -127
  83. package/lib/cases/__tests__/format.test.d.ts +0 -1
  84. package/lib/cases/__tests__/format.test.js +0 -61
  85. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  86. package/lib/cases/__tests__/runner.test.js +0 -188
  87. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  88. package/lib/knowledge/__tests__/edges.test.js +0 -25
  89. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  90. package/lib/knowledge/__tests__/events.test.js +0 -39
  91. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  92. package/lib/knowledge/__tests__/extract.test.js +0 -69
  93. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  94. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  95. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  96. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  97. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  98. package/lib/knowledge/__tests__/lint.test.js +0 -53
  99. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  100. package/lib/knowledge/__tests__/perf.test.js +0 -34
  101. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  102. package/lib/knowledge/__tests__/plan.test.js +0 -35
  103. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  104. package/lib/knowledge/__tests__/query.test.js +0 -55
  105. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  106. package/lib/knowledge/__tests__/screens.test.js +0 -30
  107. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  108. package/lib/knowledge/__tests__/why.test.js +0 -28
  109. package/src/__tests__/args.test.ts +0 -82
  110. package/src/__tests__/check.test.ts +0 -71
  111. package/src/__tests__/init.test.ts +0 -52
  112. package/src/__tests__/record.test.ts +0 -49
  113. package/src/__tests__/snapshot.test.ts +0 -51
  114. package/src/__tests__/targets.test.ts +0 -132
  115. package/src/cases/__tests__/format.test.ts +0 -76
  116. package/src/cases/__tests__/runner.test.ts +0 -230
  117. package/src/knowledge/__tests__/edges.test.ts +0 -34
  118. package/src/knowledge/__tests__/events.test.ts +0 -45
  119. package/src/knowledge/__tests__/extract.test.ts +0 -88
  120. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  121. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  122. package/src/knowledge/__tests__/lint.test.ts +0 -66
  123. package/src/knowledge/__tests__/perf.test.ts +0 -41
  124. package/src/knowledge/__tests__/plan.test.ts +0 -52
  125. package/src/knowledge/__tests__/query.test.ts +0 -74
  126. package/src/knowledge/__tests__/screens.test.ts +0 -37
  127. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -1,14 +1,21 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { Fixtures } from '../cases/fixtures.js';
5
6
  import { readCases } from '../cases/format.js';
6
- import { runCase } from '../cases/runner.js';
7
+ import { missingFixtures, preparable, runCase } from '../cases/runner.js';
7
8
  import { CONFIG, EXAMPLE_CASE, FIXTURES, FORMAT } from '../cases/templates.js';
8
- import { configFile } from '../config.js';
9
+ import { claimPorts } from '../cases/portClaim.js';
10
+ import { configFile, portFromEnv } from '../config.js';
9
11
  import { readJson, writeJson } from '../files.js';
12
+ import { Ahead, InMemory } from '../cases/ahead.js';
13
+ import { Flakes } from '../cases/flakes.js';
14
+ import { RunFile } from '../cases/runfile.js';
10
15
  import { eventsFile } from '../knowledge/events.js';
11
16
  import { define } from '../registry.js';
17
+ import { describeChanges, pushRun, service } from './report.js';
18
+ import { TraceSink } from '../cases/traces.js';
12
19
  const startHost = async (root, err) => {
13
20
  // 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
21
  const host = spawn(process.execPath, [process.argv[1], 'headless', 'start'], {
@@ -48,12 +55,32 @@ const stopGroup = (pid) => {
48
55
  }
49
56
  catch { }
50
57
  };
58
+ /**
59
+ * Stops a host and waits until it is gone. The next case's host dials the same port, and a host still on
60
+ * its way out dials it too: whichever reaches the driver first is the one the case talks to.
61
+ */
62
+ const stopHost = async (host) => {
63
+ if (!host.pid || host.exitCode !== null || host.signalCode !== null)
64
+ return;
65
+ const gone = new Promise((resolve) => host.once('exit', () => resolve()));
66
+ host.stdin?.end();
67
+ stopGroup(host.pid);
68
+ const late = setTimeout(() => {
69
+ try {
70
+ process.kill(-host.pid, 'SIGKILL');
71
+ }
72
+ catch { }
73
+ }, 2000);
74
+ await gone;
75
+ clearTimeout(late);
76
+ };
51
77
  const describe = (report) => {
52
- const counts = { passed: 0, failed: 0, skipped: 0 };
78
+ const counts = { passed: 0, failed: 0, skipped: 0, unsupported: 0 };
53
79
  for (const s of report.steps)
54
80
  counts[s.status] += 1;
81
+ const headline = { passed: '✅ PASSED', failed: '❌ FAILED', unsupported: '🚫 NOT ON THIS HOST' }[report.result];
55
82
  const lines = [
56
- `${report.result === 'passed' ? '✅ PASSED' : '❌ FAILED'} ✅ ${counts.passed}${counts.failed ? ` ❌ ${counts.failed}` : ''}${counts.skipped ? ` ⏭️ ${counts.skipped}` : ''} — ${(report.ms / 1000).toFixed(1)}s`,
83
+ `${headline} ✅ ${counts.passed}${counts.failed ? ` ❌ ${counts.failed}` : ''}${counts.unsupported ? ` 🚫 ${counts.unsupported}` : ''}${counts.skipped ? ` ⏭️ ${counts.skipped}` : ''} — ${(report.ms / 1000).toFixed(1)}s`,
57
84
  ];
58
85
  if (report.evidence?.screen)
59
86
  lines.push('— screen at the failure:', ...report.evidence.screen.split('\n').map((l) => ` ${l}`));
@@ -63,6 +90,90 @@ const describe = (report) => {
63
90
  lines.push('— why:', ...report.evidence.why.split('\n').map((l) => ` ${l}`));
64
91
  return lines.join('\n');
65
92
  };
93
+ /** The share of the machine's CPU that sat idle over the next 300 ms. */
94
+ const idleShare = async () => {
95
+ const total = () => os.cpus().reduce((sum, cpu) => {
96
+ const t = cpu.times;
97
+ return { idle: sum.idle + t.idle, all: sum.all + t.idle + t.user + t.sys + t.nice + t.irq };
98
+ }, { idle: 0, all: 0 });
99
+ const before = total();
100
+ await new Promise((r) => setTimeout(r, 300));
101
+ const after = total();
102
+ return after.all > before.all ? (after.idle - before.idle) / (after.all - before.all) : 1;
103
+ };
104
+ /** The case files named, or every case under the cases directory; a sentence when there are none. */
105
+ const caseFiles = (args, home, casesDir) => {
106
+ const dir = path.resolve(home, casesDir ?? 'cases');
107
+ const files = args.length
108
+ ? args.map((f) => path.resolve(f))
109
+ : fs.existsSync(dir)
110
+ ? fs
111
+ .readdirSync(dir)
112
+ .filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f) && !f.startsWith('.'))
113
+ .sort()
114
+ .map((f) => path.join(dir, f))
115
+ : [];
116
+ return files.length > 0 ? files : `no cases under ${path.relative(home, dir) || '.'}; \`lynkeus case init\` writes one`;
117
+ };
118
+ /**
119
+ * Runs the setups of `cases` ahead of them, in order, `jobs` at a time: never more than `limit` waiting
120
+ * to be taken, and only while the machine has `idle` of its CPU to spare. A case that started first is skipped.
121
+ */
122
+ const prepareAhead = async (cases, o) => {
123
+ let next = 0;
124
+ let made = 0;
125
+ let skipped = 0;
126
+ // Far enough ahead, or the machine busy: a setup made then only takes the CPU from the cases
127
+ // running now, and the suite gains nothing from having it early.
128
+ const room = async () => {
129
+ if (o.store.waiting() < o.limit)
130
+ return (await idleShare()) >= o.idle;
131
+ // A timer, not a bare check: the jobs that take the waiting setups may run in this same process,
132
+ // and a loop that never yields to the event loop would keep them from ever taking one.
133
+ await new Promise((r) => setTimeout(r, 100));
134
+ return false;
135
+ };
136
+ const worker = async () => {
137
+ const fixtures = o.open();
138
+ await fixtures.ahead();
139
+ try {
140
+ for (let c = cases[next++]; c; c = cases[next++]) {
141
+ while (!o.store.started(c) && !(await room()))
142
+ ;
143
+ if (o.store.started(c)) {
144
+ skipped++;
145
+ continue;
146
+ }
147
+ const done = c;
148
+ await runCase(done, { base: o.base, config: o.config, fixtures, setupOnly: { keep: (p) => o.store.keep(done, p) } });
149
+ made++;
150
+ }
151
+ }
152
+ finally {
153
+ fixtures.close();
154
+ }
155
+ };
156
+ await Promise.all(Array.from({ length: Math.max(1, o.jobs) }, worker));
157
+ return { made, skipped };
158
+ };
159
+ /** A host for one case, started by the project's own command: `{port}` is where it dials the driver. */
160
+ const spawnHost = (command, port, job, cwd) => spawn('/bin/sh', ['-c', command.replaceAll('{port}', String(port)).replaceAll('{job}', String(job))], {
161
+ cwd,
162
+ env: process.env,
163
+ // stdin stays open for the host's life: a host that serves until its stdin closes stops with the case.
164
+ stdio: ['pipe', 'ignore', 'ignore'],
165
+ detached: true,
166
+ });
167
+ /** A run that could not be uploaded still ran: the failure is said, not raised. */
168
+ const pushed = async (dir, to, project) => {
169
+ try {
170
+ const run = await pushRun(dir, { ...to, project });
171
+ return [`report: ${run.url}${run.refused.length ? ` (not kept on this plan: ${run.refused.join(', ')})` : ''}`, ...describeChanges(run.changes)].join('\n');
172
+ }
173
+ catch (error) {
174
+ return `the report was not uploaded: ${error.message}`;
175
+ }
176
+ };
66
177
  export const caseCommands = [
67
178
  define({
68
179
  name: 'case run',
@@ -73,8 +184,33 @@ export const caseCommands = [
73
184
  flags: {
74
185
  host: { type: 'boolean', help: 'start `headless start` for the run and stop it after' },
75
186
  once: { type: 'boolean', help: 'no second chance for a case that fails' },
187
+ continue: { type: 'boolean', help: "keep running a case's steps after one fails (a failed setup still stops it)" },
188
+ seen: { type: 'string', help: 'write <dir>/<case id>.json with the screens each passing case went through, for a coverage map', value: 'dir' },
76
189
  report: { type: 'string', help: 'append one JSON line per case', value: 'file' },
77
190
  events: { type: 'string', help: 'keep what the app told its analytics, per case, as <dir>/<case id>/events.jsonl', value: 'dir' },
191
+ 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' },
192
+ screenshots: { type: 'boolean', help: 'with --out: a frame of the screen after every step of the app' },
193
+ video: { type: 'boolean', help: 'with --out: film each case, where the host films (the step says at what second it began)' },
194
+ traces: {
195
+ type: 'boolean',
196
+ help: 'listen for the backend’s OpenTelemetry traces (OTLP over HTTP) and keep, under each step, what the backend did for it; point the backend’s OTEL_EXPORTER_OTLP_ENDPOINT at http://localhost:<traces-port>',
197
+ },
198
+ 'traces-port': { type: 'number', help: 'where --traces listens', default: 4318, value: 'port' },
199
+ push: { type: 'boolean', help: 'with --out: upload the run to the reports service when it ends and print the report’s address' },
200
+ project: { type: 'string', help: 'with --push: the project the run is filed under (default: the app’s folder name)', value: 'name' },
201
+ 'video-fps': { type: 'number', help: 'frames per second of the film', default: 30, value: 'n' },
202
+ flakes: { type: 'string', help: 'the ledger of how each case did across runs (default .lynkeus/flakes.json)', value: 'file' },
203
+ prepared: { type: 'string', help: 'where `case prepare` leaves setups made ahead; a case found there skips its setup', value: 'dir' },
204
+ jobs: { type: 'number', help: 'cases at once, each on its own host (needs --ports)', value: 'n', default: 1 },
205
+ ports: { type: 'string', help: 'the driver port of each job, comma-separated: where its host dials in', value: 'list' },
206
+ 'host-command': {
207
+ type: 'string',
208
+ help: 'starts a fresh host for every case and stops it after; {port} is where the host dials, {job} the job',
209
+ value: 'command',
210
+ },
211
+ 'in-order': { type: 'boolean', help: 'with --jobs: take the cases in the order given, not the longest first' },
212
+ ahead: { type: 'number', help: 'make up to this many setups ahead of their cases while others run (0: none)', value: 'n', default: 0 },
213
+ idle: { type: 'number', help: 'with --ahead: start a setup only while this share of the CPU is idle', value: 'share', default: 0.25 },
78
214
  'dry-run': { type: 'boolean', help: 'parse and list the steps without running them' },
79
215
  },
80
216
  needs: 'nothing',
@@ -83,24 +219,58 @@ export const caseCommands = [
83
219
  run: async (ctx) => {
84
220
  const config = ctx.config.cases ?? {};
85
221
  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 };
222
+ const files = caseFiles(ctx.args, home, config.dir);
223
+ if (typeof files === 'string')
224
+ return { text: files, code: 1 };
98
225
  const cases = files.flatMap(readCases);
99
226
  const dry = !!ctx.flags['dry-run'];
100
- const events = typeof ctx.flags.events === 'string';
227
+ const seenDir = typeof ctx.flags.seen === 'string' && !dry ? path.resolve(ctx.flags.seen) : undefined;
228
+ const out = typeof ctx.flags.out === 'string' && !dry ? new RunFile(path.resolve(ctx.flags.out), ctx.root) : undefined;
229
+ if ((ctx.flags.screenshots || ctx.flags.video) && !out)
230
+ return { text: '--screenshots and --video keep what they take in the run directory: add --out <dir>', code: 2 };
231
+ // Checked before the run, so a missing key does not surface only after every case has run.
232
+ const reportsTo = ctx.flags.push && out ? service() : undefined;
233
+ if (ctx.flags.push && !out)
234
+ return { text: '--push uploads the run directory: add --out <dir>', code: 2 };
235
+ if (typeof reportsTo === 'string')
236
+ return { text: reportsTo, code: 2 };
237
+ const evidenceOf = (id, attempt) => out && (ctx.flags.screenshots || ctx.flags.video)
238
+ ? {
239
+ dir: out.evidenceDir(id, attempt),
240
+ screenshots: !!ctx.flags.screenshots,
241
+ ...(ctx.flags.video ? { video: { fps: Number(ctx.flags['video-fps'] ?? 30) } } : {}),
242
+ }
243
+ : undefined;
244
+ const ledger = dry ? undefined : new Flakes(path.resolve(ctx.root, typeof ctx.flags.flakes === 'string' ? ctx.flags.flakes : '.lynkeus/flakes.json'));
245
+ // A run file files each event under its step, so it needs them collected.
246
+ const events = typeof ctx.flags.events === 'string' || out !== undefined;
247
+ const claimed = dry
248
+ ? undefined
249
+ : claimPorts(typeof ctx.flags.ports === 'string'
250
+ ? ctx.flags.ports
251
+ .split(',')
252
+ .map(Number)
253
+ .filter(Number.isInteger)
254
+ .slice(0, Math.max(1, Number(ctx.flags.jobs ?? 1)))
255
+ : [portFromEnv(ctx.config) ?? 8123]);
256
+ if (claimed && 'refused' in claimed)
257
+ return { text: claimed.refused, code: 1 };
101
258
  const host = ctx.flags.host && !dry ? await startHost(ctx.root, ctx.err) : undefined;
102
259
  const fixtures = config.fixtures && !dry ? new Fixtures(config.fixtures, home) : undefined;
260
+ const traces = ctx.flags.traces && !dry ? new TraceSink(Number(ctx.flags['traces-port'] ?? 4318)) : undefined;
261
+ if (traces) {
262
+ try {
263
+ await traces.start();
264
+ }
265
+ catch (error) {
266
+ fixtures?.close();
267
+ return { text: `--traces could not listen on port ${traces.port}: ${error.message}`, code: 2 };
268
+ }
269
+ ctx.err(`backend traces: listening on http://localhost:${traces.port} (the backend's OTEL_EXPORTER_OTLP_ENDPOINT)`);
270
+ }
103
271
  const stop = () => {
272
+ traces?.close();
273
+ claimed?.release();
104
274
  fixtures?.close();
105
275
  if (host?.pid)
106
276
  stopGroup(host.pid);
@@ -113,34 +283,187 @@ export const caseCommands = [
113
283
  process.once('SIGTERM', interrupted);
114
284
  const reports = [];
115
285
  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));
286
+ // A case that names a fixture the server does not have fails halfway, after it has changed the backend. Better not to start.
287
+ const described = await fixtures?.describe();
288
+ if (described?.down)
289
+ return { text: `the fixtures server cannot serve: ${described.down}`, code: 1 };
290
+ if (described) {
291
+ const missing = cases.flatMap((c) => missingFixtures(c, config, described.commands).map((name) => `${c.id}: no fixture ${name}`));
292
+ if (missing.length > 0)
293
+ return { text: [...missing, `the fixtures command answers: ${described.commands.join(', ')}`].join('\n'), code: 1 };
294
+ }
295
+ const jobs = Math.max(1, Number(ctx.flags.jobs ?? 1));
296
+ const ports = typeof ctx.flags.ports === 'string' ? ctx.flags.ports.split(',').map(Number) : [];
297
+ const hostCommand = typeof ctx.flags['host-command'] === 'string' && !dry ? ctx.flags['host-command'] : undefined;
298
+ if ((jobs > 1 || hostCommand) && (ports.length < jobs || ports.some((p) => !Number.isInteger(p)) || !ctx.forPort))
299
+ return { text: `--jobs and --host-command need a driver port per job: --ports with ${jobs}`, code: 2 };
300
+ // Several jobs finish together only if the longest cases start first: one left for the end runs alone
301
+ // while the other jobs wait. A case the ledger has not timed goes first too, as it may be long.
302
+ if (jobs > 1 && ledger && !ctx.flags['in-order']) {
303
+ const took = (id) => ledger.lastMs(id) ?? Number.POSITIVE_INFINITY;
304
+ cases.sort((a, b) => took(b.id) - took(a.id));
305
+ }
306
+ const limit = Number(ctx.flags.ahead ?? 0);
307
+ const store = typeof ctx.flags.prepared === 'string' && !dry ? new Ahead(path.resolve(ctx.flags.prepared)) : limit > 0 && fixtures ? new InMemory() : undefined;
308
+ const declared = config.fixtures;
309
+ // The setups of the cases to come, made while these run, in the order the jobs will take them.
310
+ const preparing = limit > 0 && declared && store && !dry
311
+ ? prepareAhead(cases.filter((c) => preparable(c, config)), {
312
+ base: ctx,
313
+ config,
314
+ store,
315
+ open: () => new Fixtures(declared, home),
316
+ limit,
317
+ idle: Number(ctx.flags.idle ?? 0.25),
318
+ jobs: 1,
319
+ })
320
+ : undefined;
321
+ let next = 0;
322
+ const job = async (index) => {
323
+ // Each job keeps its own connection to the fixtures server, warm from one case to the next.
324
+ const own = index === 0 ? fixtures : declared && !dry ? new Fixtures(declared, home) : undefined;
325
+ const port = ports[index];
326
+ try {
327
+ for (let c = cases[next++]; c; c = cases[next++]) {
328
+ const lines = [];
329
+ // With several jobs a case's lines are held and printed together, not interleaved.
330
+ const log = jobs > 1 ? (line) => lines.push(line) : ctx.err;
331
+ log(`▶ ${c.id}${c.title ? ` — ${c.title}` : ''}`);
332
+ store?.start(c);
333
+ const attempt = async (n, prepared) => {
334
+ const host = hostCommand && port !== undefined ? spawnHost(hostCommand, port, index, ctx.root) : undefined;
335
+ const scoped = port !== undefined && ctx.forPort ? ctx.forPort(port) : { base: ctx, stop: async () => undefined };
336
+ const startedAt = new Date();
337
+ try {
338
+ // A host just started is still drawing its first screens; with the setup made ahead nothing
339
+ // else gives it that moment before the first press.
340
+ if (host)
341
+ await scoped.base
342
+ .device()
343
+ .then((d) => d.idle({ quietMs: 300, timeoutMs: 5000 }))
344
+ .catch(() => undefined);
345
+ const report = await runCase(c, {
346
+ base: scoped.base,
347
+ config,
348
+ fixtures: own,
349
+ dry,
350
+ events,
351
+ evidence: evidenceOf(c.id, n),
352
+ log,
353
+ prepared,
354
+ continue: !!ctx.flags.continue,
355
+ routes: seenDir ? (host ? 'fresh' : true) : false,
356
+ traces,
357
+ });
358
+ if (seenDir && report.result === 'passed' && report.routes?.length) {
359
+ fs.mkdirSync(seenDir, { recursive: true });
360
+ fs.writeFileSync(path.join(seenDir, `${c.id}.json`), `${JSON.stringify({ id: c.id, routes: report.routes }, null, 2)}\n`);
361
+ }
362
+ return { report, startedAt, hello: (await scoped.base.device().catch(() => undefined))?.server.hello ?? undefined };
363
+ }
364
+ finally {
365
+ if (host)
366
+ await stopHost(host);
367
+ await scoped.stop();
368
+ }
369
+ };
370
+ let { report, startedAt, hello, } = await attempt(1, store?.take(c));
371
+ seen ??= hello;
372
+ log(describe(report));
373
+ out?.add(c, { report, attempt: 1, startedAt });
374
+ if (report.result === 'failed' && !ctx.flags.once && !dry) {
375
+ log(` retrying ${c.id} once`);
376
+ const again = await attempt(2);
377
+ report = { ...again.report, retried: true };
378
+ startedAt = again.startedAt;
379
+ log(describe(report));
380
+ out?.add(c, { report, attempt: 2, startedAt });
381
+ }
382
+ if (jobs > 1)
383
+ ctx.err(lines.join('\n'));
384
+ ledger?.note(report);
385
+ if (typeof ctx.flags.events === 'string' && report.events) {
386
+ const dir = path.resolve(ctx.flags.events, c.id);
387
+ fs.mkdirSync(dir, { recursive: true });
388
+ fs.writeFileSync(eventsFile(dir), report.events.map((e) => `${JSON.stringify(e)}\n`).join(''));
389
+ }
390
+ reports.push(report);
391
+ if (ctx.flags.report)
392
+ fs.appendFileSync(path.resolve(ctx.flags.report), `${JSON.stringify(report)}\n`);
393
+ }
124
394
  }
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(''));
395
+ finally {
396
+ if (own !== fixtures)
397
+ own?.close();
129
398
  }
130
- reports.push(report);
131
- if (ctx.flags.report)
132
- fs.appendFileSync(path.resolve(ctx.flags.report), `${JSON.stringify(report)}\n`);
133
- }
399
+ };
400
+ let seen;
401
+ await Promise.all(Array.from({ length: jobs }, (_, i) => job(i)));
402
+ await preparing;
403
+ if (out)
404
+ out.finish({ platform: seen?.platform, native: seen?.native });
134
405
  }
135
406
  finally {
136
407
  stop();
137
408
  }
138
- const passed = reports.filter((r) => r.result === 'passed').length;
409
+ const count = (result) => reports.filter((r) => r.result === result).length;
410
+ const unsupported = count('unsupported');
139
411
  const summary = [
140
- ...reports.map((r) => `${r.result === 'passed' ? '✅' : '❌'} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}`),
141
- `${passed}/${reports.length} passed`,
412
+ ...reports.map((r) => `${{ passed: '✅', failed: '❌', unsupported: '🚫' }[r.result]} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}`),
413
+ `${count('passed')}/${reports.length} passed${unsupported ? `, ${unsupported} cannot run on this host` : ''}`,
414
+ ...(ledger ? ledger.flaky(reports.map((r) => r.id)) : []),
415
+ ...(reportsTo && out
416
+ ? [
417
+ await pushed(path.resolve(ctx.flags.out), reportsTo, typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root)),
418
+ ]
419
+ : []),
142
420
  ].join('\n');
143
- return { text: summary, json: reports, code: passed === reports.length ? 0 : 1 };
421
+ ledger?.save();
422
+ // A case this host cannot run is not a failure of the app; only a failed case fails the run.
423
+ return { text: summary, json: reports, code: count('failed') === 0 ? 0 : 1 };
424
+ },
425
+ }),
426
+ define({
427
+ name: 'case prepare',
428
+ group: 'runs',
429
+ summary: 'Run the setup of cases ahead of them, so each starts at its first step',
430
+ details: 'For a suite that starts `case run --prepared <dir>` once per case: start this first, with the same cases in the order they will run, and leave it running. It runs every setup made only of the project fixtures and leaves what each produced in <dir>; a case that starts before its setup is ready runs its own, and this skips it. It asks the fixtures server for a lower priority (`lynkeus.ahead`), so it takes what the running cases leave of the machine.',
431
+ positionals: [{ name: 'files', help: 'case files, in the order they will run (default: every .md under cases.dir)', rest: true }],
432
+ flags: {
433
+ prepared: { type: 'string', help: 'where to leave the setups (emptied first)', value: 'dir', default: '.lynkeus/prepared' },
434
+ jobs: { type: 'number', help: 'setups made at once', value: 'n', default: 1 },
435
+ ahead: { type: 'number', help: 'setups kept ready and not yet taken, at most', value: 'n', default: 4 },
436
+ idle: { type: 'number', help: 'start a setup only while at least this share of the CPU is idle (0 to 1)', value: 'share', default: 0.25 },
437
+ },
438
+ needs: 'nothing',
439
+ session: false,
440
+ mcp: false,
441
+ run: async (ctx) => {
442
+ const config = ctx.config.cases ?? {};
443
+ if (!config.fixtures)
444
+ return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json): nothing to prepare', code: 1 };
445
+ const declared = config.fixtures;
446
+ const home = config.base ?? ctx.root;
447
+ const files = caseFiles(ctx.args, home, config.dir);
448
+ if (typeof files === 'string')
449
+ return { text: files, code: 1 };
450
+ const cases = files.flatMap(readCases).filter((c) => preparable(c, config));
451
+ const probe = new Fixtures(declared, home);
452
+ const described = await probe.describe().finally(() => probe.close());
453
+ if (described?.down)
454
+ return { text: `the fixtures server cannot serve: ${described.down}`, code: 1 };
455
+ const store = new Ahead(path.resolve(ctx.root, String(ctx.flags.prepared ?? '.lynkeus/prepared')));
456
+ store.clear();
457
+ const { made, skipped } = await prepareAhead(cases, {
458
+ base: ctx,
459
+ config,
460
+ store,
461
+ open: () => new Fixtures(declared, home),
462
+ limit: Math.max(1, Number(ctx.flags.ahead ?? 4)),
463
+ idle: Number(ctx.flags.idle ?? 0.25),
464
+ jobs: Number(ctx.flags.jobs ?? 1),
465
+ });
466
+ return { text: `${made} setups made ahead, ${skipped} cases had started first`, json: { made, skipped } };
144
467
  },
145
468
  }),
146
469
  define({
@@ -25,12 +25,18 @@ export declare const diffGraphs: (base: ScreenGraph, head: ScreenGraph) => Graph
25
25
  export type LintBaseline = Record<string, {
26
26
  rule: string;
27
27
  testId?: string;
28
+ subject?: string;
28
29
  message: string;
29
30
  }[]>;
30
31
  type LintedFinding = {
31
32
  finding: Finding;
32
33
  testId?: string;
33
34
  };
35
+ /**
36
+ * A finding is known by its rule and what it is about, not by what was measured: a target that went from
37
+ * 32 to 30 points is the finding the baseline already has. A baseline saved before findings named their
38
+ * subject has only the message to go by.
39
+ */
34
40
  export declare const newFindings: (baseline: LintBaseline, route: string, findings: LintedFinding[]) => LintedFinding[];
35
41
  export declare const describeGraphDiff: (d: GraphDiff) => string;
36
42
  export {};
@@ -44,10 +44,15 @@ export const diffGraphs = (base, head) => {
44
44
  selectorsRemoved: minus(bs, hs).map(selector),
45
45
  };
46
46
  };
47
- const key = (rule, testId, message) => `${rule}|${testId ?? ''}|${message}`;
47
+ const key = (rule, testId, what) => `${rule}|${testId ?? ''}|${what}`;
48
+ /**
49
+ * A finding is known by its rule and what it is about, not by what was measured: a target that went from
50
+ * 32 to 30 points is the finding the baseline already has. A baseline saved before findings named their
51
+ * subject has only the message to go by.
52
+ */
48
53
  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)));
54
+ const seen = new Set((baseline[route] ?? []).map((b) => key(b.rule, b.testId, b.subject ?? b.message)));
55
+ return findings.filter((f) => !seen.has(key(f.finding.rule, f.testId, f.finding.subject ?? f.finding.message)) && !seen.has(key(f.finding.rule, f.testId, f.finding.message)));
51
56
  };
52
57
  export const describeGraphDiff = (d) => {
53
58
  const lines = [];
@@ -1,15 +1,18 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
4
+ import { PNG } from 'pngjs';
3
5
  import { lynkeusDir } from '../config.js';
4
6
  import { describeScreen, describeTrace } from '../device/driver.js';
5
7
  import { readJson, writeJson } from '../files.js';
8
+ import { lintContrast } from '../knowledge/contrast.js';
6
9
  import { describeFindings, lintScreen } from '../knowledge/lint.js';
7
10
  import { describePerf, summarize, visitsFrom } from '../knowledge/perf.js';
8
11
  import * as screens from '../knowledge/screens.js';
9
12
  import { screenTree } from '../knowledge/tree.js';
10
13
  import { define } from '../registry.js';
11
14
  import { whyFor } from '../runtime.js';
12
- import { expectedOf, isRoute, parseTarget, scopedTarget, TARGET_HELP } from '../targets.js';
15
+ import { expectedOf, isRoute, parseTarget, routeOf, scopedTarget, TARGET_HELP } from '../targets.js';
13
16
  import { newFindings } from './check.js';
14
17
  const SCOPE_FLAGS = {
15
18
  in: { type: 'string', help: 'the match nearest this target', value: 'target' },
@@ -165,7 +168,7 @@ export const driveCommands = [
165
168
  mcpOmit: ['nth'],
166
169
  group: 'drive',
167
170
  summary: 'Until a route or element appears (or leaves), or a number of ms',
168
- positionals: [{ name: 'what', help: `Route | ${TARGET_HELP} | 800 (ms)`, required: true }],
171
+ positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP} | 800 (ms)`, required: true }],
169
172
  flags: {
170
173
  ...SCOPE_FLAGS,
171
174
  gone: { type: 'boolean', help: 'until it leaves the screen' },
@@ -181,7 +184,7 @@ export const driveCommands = [
181
184
  }
182
185
  return {
183
186
  json: await (await ctx.device()).waitFor({
184
- ...(isRoute(raw) ? { route: raw } : { target: scopedTarget(raw, ctx.flags) }),
187
+ ...(isRoute(raw) ? { route: routeOf(raw) } : { target: scopedTarget(raw, ctx.flags) }),
185
188
  gone: ctx.flags.gone ? true : undefined,
186
189
  scroll: ctx.flags.scroll ? true : undefined,
187
190
  timeoutMs: ctx.flags.timeout,
@@ -206,7 +209,7 @@ export const driveCommands = [
206
209
  name: 'see',
207
210
  group: 'drive',
208
211
  summary: 'Assert a route or element is on screen now (exit 1 if not)',
209
- positionals: [{ name: 'what', help: `Route | ${TARGET_HELP}`, required: true }],
212
+ positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP}`, required: true }],
210
213
  flags: {
211
214
  absent: { type: 'boolean', help: 'assert it is not there' },
212
215
  within: { type: 'number', help: 'allow it this long to appear', default: 0, value: 'ms' },
@@ -217,7 +220,7 @@ export const driveCommands = [
217
220
  const device = await ctx.device();
218
221
  try {
219
222
  const result = await device.waitFor({
220
- ...(isRoute(raw) ? { route: raw } : { target: parseTarget(raw) }),
223
+ ...(isRoute(raw) ? { route: routeOf(raw) } : { target: parseTarget(raw) }),
221
224
  gone: ctx.flags.absent ? true : undefined,
222
225
  timeoutMs: Math.max(1, ctx.flags.within),
223
226
  });
@@ -341,9 +344,10 @@ export const driveCommands = [
341
344
  define({
342
345
  name: 'lint',
343
346
  group: 'drive',
344
- summary: 'Unlabeled controls, small targets, untranslated keys, covered or clipped elements',
347
+ summary: 'Unlabeled controls, small targets, low-contrast text, untranslated keys, covered or clipped elements',
345
348
  flags: {
346
349
  strict: { type: 'boolean', help: 'exit 1 on any warning' },
350
+ 'no-contrast': { type: 'boolean', help: 'skip the contrast of text against its background (it takes a screenshot)' },
347
351
  save: { type: 'string', help: "keep this screen's findings in a baseline file", value: 'file' },
348
352
  baseline: { type: 'string', help: 'print only what is new against a baseline file', value: 'file' },
349
353
  },
@@ -353,13 +357,33 @@ export const driveCommands = [
353
357
  const { lint, lintBaseline } = ctx.config;
354
358
  let findings = lintScreen(screen, { i18nKeyPatterns: lint?.i18nKeyPatterns?.map((p) => new RegExp(p, 'i')), minTouchTarget: lint?.minTouchTarget });
355
359
  const route = screen.route ?? '?';
360
+ const notes = [];
361
+ if (lint?.contrast !== false && !ctx.flags['no-contrast']) {
362
+ // Contrast is read off the pixels, so it needs a host that can take a picture of the screen.
363
+ const file = path.join(os.tmpdir(), `lynkeus-lint-${process.pid}-${Date.now()}.png`);
364
+ try {
365
+ await (await ctx.device()).screenshot(file);
366
+ const picture = PNG.sync.read(fs.readFileSync(file));
367
+ const contrast = lint?.contrast;
368
+ findings = [
369
+ ...findings,
370
+ ...lintContrast(screen, picture, { normal: contrast?.normal, large: contrast?.large, ignore: contrast?.ignore?.map((p) => new RegExp(p)) }),
371
+ ];
372
+ }
373
+ catch {
374
+ notes.push('contrast not measured: this host takes no screenshots');
375
+ }
376
+ finally {
377
+ fs.rmSync(file, { force: true });
378
+ }
379
+ }
356
380
  const withIds = findings.map((finding) => ({ finding, testId: screen.elements[finding.element]?.testId }));
357
381
  const lines = [];
358
382
  const save = ctx.flags.save;
359
383
  const baselineFile = ctx.flags.baseline ?? lintBaseline;
360
384
  if (save) {
361
385
  const baseline = fs.existsSync(save) ? readJson(save) : {};
362
- baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, message: f.finding.message }));
386
+ baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, subject: f.finding.subject, message: f.finding.message }));
363
387
  writeJson(save, baseline);
364
388
  lines.push(`saved ${findings.length} finding(s) for ${route} → ${save}`);
365
389
  }
@@ -367,7 +391,7 @@ export const driveCommands = [
367
391
  findings = newFindings(readJson(baselineFile), route, withIds).map((f) => f.finding);
368
392
  lines.push(findings.length ? `${findings.length} new against ${baselineFile}:` : `nothing new on ${route} against ${baselineFile}`);
369
393
  }
370
- lines.push(describeFindings(findings, screen.route));
394
+ lines.push(describeFindings(findings, screen.route), ...notes);
371
395
  return { text: lines.join('\n'), json: findings, code: ctx.flags.strict && findings.some((f) => f.severity === 'warn') ? 1 : 0 };
372
396
  },
373
397
  }),
@@ -5,7 +5,7 @@ import { lynkeusDir, portFromEnv } from '../config.js';
5
5
  import { describeExplanations, explainError } from '../knowledge/errors.js';
6
6
  import { define } from '../registry.js';
7
7
  import { doctor } from './doctor.js';
8
- import { init } from './init.js';
8
+ import { init, mountAgent } from './init.js';
9
9
  const ENTRY_FLAG = { entry: { type: 'string', help: 'the headless entry (or `entry` in lynkeus.config.json)', value: 'file' } };
10
10
  const entryOf = (ctx) => ctx.flags.entry ?? ctx.config.entry ?? 'qa/headless.tsx';
11
11
  /** The jest test that hosts the app. The port is written in, not read from the environment there: babel would inline the first run's value into jest's transform cache. */
@@ -28,6 +28,16 @@ hostApp(() => <entry.App />, { ${options.join(', ')} });
28
28
  `;
29
29
  };
30
30
  export const headlessCommands = [
31
+ define({
32
+ name: 'init',
33
+ group: 'drive',
34
+ summary: "Mount the agent from the app's entry: React Native or Expo (Expo Router gets an entry of its own)",
35
+ needs: 'nothing',
36
+ run: async (ctx) => {
37
+ const lines = mountAgent(ctx.root);
38
+ return { text: lines.join('\n'), json: lines };
39
+ },
40
+ }),
31
41
  define({
32
42
  name: 'headless start',
33
43
  group: 'headless',
@@ -117,7 +127,7 @@ export const headlessCommands = [
117
127
  if (ctx.flags.explain) {
118
128
  const found = explainError(ctx.flags.explain);
119
129
  return {
120
- text: found.length ? describeExplanations(found).trim() : 'nothing in the catalogue matches; paste the first line of the error, not the stack.',
130
+ text: found.length ? describeExplanations(found).trim() : 'nothing in the catalog matches; paste the first line of the error, not the stack.',
121
131
  json: found,
122
132
  };
123
133
  }