lynkeus 0.3.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 (89) 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 +58 -1
  16. package/lib/cases/runner.js +322 -39
  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 +27 -4
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +154 -25
  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/doctor.js +8 -2
  29. package/lib/commands/drive.js +3 -1
  30. package/lib/commands/fixtures.d.ts +7 -0
  31. package/lib/commands/fixtures.js +94 -0
  32. package/lib/commands/headless.d.ts +11 -0
  33. package/lib/commands/headless.js +189 -9
  34. package/lib/commands/init.d.ts +23 -0
  35. package/lib/commands/init.js +231 -26
  36. package/lib/commands/map.d.ts +2 -0
  37. package/lib/commands/map.js +18 -14
  38. package/lib/commands/os.js +43 -10
  39. package/lib/commands/report.d.ts +4 -0
  40. package/lib/commands/report.js +156 -20
  41. package/lib/config.d.ts +41 -0
  42. package/lib/config.js +30 -0
  43. package/lib/device/agentVersion.d.ts +7 -0
  44. package/lib/device/agentVersion.js +14 -0
  45. package/lib/device/devices.js +6 -0
  46. package/lib/device/driver.js +2 -2
  47. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  48. package/lib/knowledge/analyticsTraffic.js +109 -0
  49. package/lib/knowledge/errors.js +6 -0
  50. package/lib/knowledge/events.d.ts +2 -0
  51. package/lib/knowledge/why.d.ts +2 -0
  52. package/lib/knowledge/why.js +1 -1
  53. package/lib/mcp/server.js +45 -4
  54. package/lib/runtime.d.ts +9 -1
  55. package/lib/runtime.js +25 -3
  56. package/package.json +7 -4
  57. package/src/cases/coverage.ts +99 -0
  58. package/src/cases/draft.ts +75 -0
  59. package/src/cases/fixtures.ts +57 -8
  60. package/src/cases/frames.ts +30 -0
  61. package/src/cases/nativeLogs.ts +112 -0
  62. package/src/cases/runDirs.ts +48 -0
  63. package/src/cases/runfile.ts +64 -4
  64. package/src/cases/runner.ts +372 -42
  65. package/src/cases/templates.ts +20 -2
  66. package/src/cases/traces.ts +3 -1
  67. package/src/cli.ts +27 -4
  68. package/src/commands/cases.ts +165 -23
  69. package/src/commands/ci.ts +189 -0
  70. package/src/commands/coverage.ts +147 -0
  71. package/src/commands/device.ts +27 -2
  72. package/src/commands/doctor.ts +9 -2
  73. package/src/commands/drive.ts +3 -1
  74. package/src/commands/fixtures.ts +102 -0
  75. package/src/commands/headless.ts +197 -9
  76. package/src/commands/init.ts +270 -26
  77. package/src/commands/map.ts +20 -15
  78. package/src/commands/os.ts +38 -10
  79. package/src/commands/report.ts +162 -18
  80. package/src/config.ts +48 -0
  81. package/src/device/agentVersion.ts +17 -0
  82. package/src/device/devices.ts +5 -0
  83. package/src/device/driver.ts +2 -2
  84. package/src/knowledge/analyticsTraffic.ts +142 -0
  85. package/src/knowledge/errors.ts +6 -0
  86. package/src/knowledge/events.ts +2 -1
  87. package/src/knowledge/why.ts +3 -1
  88. package/src/mcp/server.ts +45 -4
  89. package/src/runtime.ts +24 -3
@@ -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
+ ];
@@ -76,8 +76,26 @@ export const simulatorArgv = (platform: Platform, deviceId: string, sub: string,
76
76
  { bin: 'xcrun', args: ['simctl', 'launch', deviceId, app, '-AppleLanguages', `(${tag})`, '-AppleLocale', locale] },
77
77
  ];
78
78
  }
79
+ case 'biometrics': {
80
+ // The simulator's Face ID / Touch ID enrollment, as Features ▸ Face ID ▸ Enrolled toggles it; which of the two
81
+ // the device has is its model's.
82
+ const [available] = args;
83
+ if (!available) throw new Error('lynkeus os biometrics --available FaceID|TouchID|none on a simulator');
84
+ const enrolled = available === 'none' ? '0' : '1';
85
+ return [
86
+ { bin: 'xcrun', args: ['simctl', 'spawn', deviceId, 'notifyutil', '-s', 'com.apple.BiometricKit.enrollmentChanged', enrolled] },
87
+ { bin: 'xcrun', args: ['simctl', 'spawn', deviceId, 'notifyutil', '-p', 'com.apple.BiometricKit.enrollmentChanged'] },
88
+ ];
89
+ }
90
+ case 'appstate': {
91
+ // Another app in front puts this one in the background; launching it again brings it back as it was.
92
+ const [state] = args;
93
+ if (state === 'background') return [{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, 'com.apple.Preferences'] }];
94
+ if (state === 'active') return [{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, needApp()] }];
95
+ throw new Error('lynkeus os appstate background|active on a simulator');
96
+ }
79
97
  default:
80
- throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language`);
98
+ throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language, appstate`);
81
99
  }
82
100
  }
83
101
  const adb = (a: string[]): Invocation => ({ bin: 'adb', args: ['-s', deviceId, ...a] });
@@ -99,8 +117,15 @@ export const simulatorArgv = (platform: Platform, deviceId: string, sub: string,
99
117
  const [lat, lon] = where.split(',');
100
118
  return [adb(['emu', 'geo', 'fix', lon!, lat!])];
101
119
  }
120
+ case 'appstate': {
121
+ // Home sends the app to the background; its launcher intent brings the same task back, not a new one.
122
+ const [state] = args;
123
+ if (state === 'background') return [adb(['shell', 'input', 'keyevent', 'KEYCODE_HOME'])];
124
+ if (state === 'active') return [adb(['shell', 'monkey', '--pct-syskeys', '0', '-p', needApp(), '-c', 'android.intent.category.LAUNCHER', '1'])];
125
+ throw new Error('lynkeus os appstate background|active on an emulator or a phone');
126
+ }
102
127
  default:
103
- throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance and location are`);
128
+ throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance, location and appstate are`);
104
129
  }
105
130
  };
106
131
 
@@ -78,6 +78,13 @@ const PATTERNS: { re: RegExp; how: string }[] = [
78
78
  { re: /codegenNativeComponent\s*<[^>]*>\(\s*['"]([^'"]+)['"]/g, how: 'codegenNativeComponent' },
79
79
  ];
80
80
 
81
+ /**
82
+ * The text with its comments blanked, lines kept where they were: a mock or a native module named in a comment
83
+ * (the generated entry shows `jest.mock('some-native-package', …)` as an example) is neither. Only comments that
84
+ * own their line are taken, so a `//` inside a string (a URL) stays.
85
+ */
86
+ const withoutComments = (text: string): string => text.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')).replace(/^[ \t]*\/\/.*$/gm, '');
87
+
81
88
  const scanNativeRefs = (root: string): NativeRef[] => {
82
89
  const files: string[] = [];
83
90
  for (const d of SCAN_DIRS) walk(path.join(root, d), files);
@@ -85,7 +92,7 @@ const scanNativeRefs = (root: string): NativeRef[] => {
85
92
  for (const file of files) {
86
93
  let text: string;
87
94
  try {
88
- text = fs.readFileSync(file, 'utf8');
95
+ text = withoutComments(fs.readFileSync(file, 'utf8'));
89
96
  } catch {
90
97
  continue;
91
98
  }
@@ -107,7 +114,7 @@ const entryMocks = (root: string, entry: string): { specs: Set<string>; files: S
107
114
  const entryPath = path.join(root, entry);
108
115
  let text = '';
109
116
  try {
110
- text = fs.readFileSync(entryPath, 'utf8');
117
+ text = withoutComments(fs.readFileSync(entryPath, 'utf8'));
111
118
  } catch {
112
119
  return { specs, files };
113
120
  }
@@ -166,7 +166,7 @@ export const driveCommands = [
166
166
  define({
167
167
  name: 'wait',
168
168
  mcp: 'core',
169
- mcpOmit: ['nth'],
169
+ mcpOmit: ['nth', 'since'],
170
170
  group: 'drive',
171
171
  summary: 'Until a route or element appears (or leaves), or a number of ms',
172
172
  positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP} | 800 (ms)`, required: true }],
@@ -175,6 +175,7 @@ export const driveCommands = [
175
175
  gone: { type: 'boolean', help: 'until it leaves the screen' },
176
176
  scroll: { type: 'boolean', help: 'scroll until it is in the window' },
177
177
  timeout: { type: 'number', help: 'give up after', default: 10000, value: 'ms' },
178
+ since: { type: 'number', help: 'a trace mark: a route reached after it counts, even if already left', value: 'seq' },
178
179
  },
179
180
  needs: 'app',
180
181
  run: async (ctx) => {
@@ -189,6 +190,7 @@ export const driveCommands = [
189
190
  gone: ctx.flags.gone ? true : undefined,
190
191
  scroll: ctx.flags.scroll ? true : undefined,
191
192
  timeoutMs: ctx.flags.timeout as number,
193
+ since: ctx.flags.since as number | undefined,
192
194
  }),
193
195
  };
194
196
  },
@@ -0,0 +1,102 @@
1
+ import { FIXTURES_PROTOCOL, type FixtureCommand } from 'lynkeus-protocol';
2
+
3
+ import { DESCRIBE, type Description, Fixtures, lastDescription, readDescription } from '../cases/fixtures.js';
4
+ import { define } from '../registry.js';
5
+
6
+ const paramsOf = (c: FixtureCommand) =>
7
+ Object.entries(c.params ?? {})
8
+ .map(([k, p]) => `${k}${p.required ? '' : '?'}${p.values ? `=${p.values.join('|')}` : p.type ? `:${p.type}` : ''}`)
9
+ .join(' ');
10
+
11
+ /** What a server keeps of the contract and what it leaves out, as lines a person can act on. */
12
+ export const checkDescription = (d: Description | undefined): { ok: boolean; lines: string[] } => {
13
+ if (!d) return { ok: false, lines: ['✗ `lynkeus.describe` got no list of commands: answer {"commands": [...]} (docs/FIXTURES.md)'] };
14
+ const lines: string[] = [];
15
+ let ok = true;
16
+ if (d.down) {
17
+ ok = false;
18
+ lines.push(`✗ the server runs but cannot serve: ${d.down}`);
19
+ }
20
+ if (d.protocol !== undefined && d.protocol > FIXTURES_PROTOCOL) {
21
+ ok = false;
22
+ lines.push(`✗ it speaks fixtures protocol ${d.protocol}; this lynkeus knows ${FIXTURES_PROTOCOL}`);
23
+ }
24
+ const names = new Set<string>();
25
+ for (const c of d.details) {
26
+ if (names.has(c.name)) {
27
+ ok = false;
28
+ lines.push(`✗ ${c.name} is listed twice`);
29
+ }
30
+ names.add(c.name);
31
+ }
32
+ const bare = d.details.filter((c) => !c.summary);
33
+ lines.push(`✓ ${d.commands.length} command${d.commands.length === 1 ? '' : 's'}`);
34
+ if (bare.length)
35
+ lines.push(
36
+ `· ${bare.length} without a summary (${bare
37
+ .slice(0, 6)
38
+ .map((c) => c.name)
39
+ .join(', ')}${bare.length > 6 ? ', …' : ''}): a summary and params let authors and models pick a setup`,
40
+ );
41
+ return { ok, lines };
42
+ };
43
+
44
+ export const fixturesCommands = [
45
+ define({
46
+ name: 'fixtures describe',
47
+ group: 'runs',
48
+ summary: 'List what the project’s fixtures server can do: each command, what it makes or reads, its parameters',
49
+ details:
50
+ 'Starts the command `cases.fixtures` names, asks `lynkeus.describe`, and prints each command with its summary and parameters, for whoever writes a case. A server that answers names only shows names. What it answered last (here or before a run) is kept in .lynkeus and shown for a day without starting it; --fresh asks again.',
51
+ flags: { fresh: { type: 'boolean', help: 'ask the server even when it described itself in the last day' } },
52
+ needs: 'nothing',
53
+ session: false,
54
+ mcp: 'core',
55
+ run: async (ctx) => {
56
+ const config = ctx.config.cases ?? {};
57
+ if (!config.fixtures) return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json)', code: 1 };
58
+ const root = config.base ?? ctx.root;
59
+ const kept = ctx.flags.fresh ? undefined : lastDescription(root, config.fixtures.command);
60
+ const recent = kept && Date.now() - kept.at < 24 * 3600_000 ? kept : undefined;
61
+ let d = recent?.description;
62
+ if (!d) {
63
+ const server = new Fixtures(config.fixtures, root);
64
+ d = await server.describe().finally(() => server.close());
65
+ }
66
+ if (!d) return { text: 'the fixtures server did not describe itself (it answers `lynkeus.describe` with {"commands": [...]})', code: 1 };
67
+ const text = d.details.map((c) => `${c.name}${c.params ? ` ${paramsOf(c)}` : ''}${c.summary ? `\n ${c.summary}` : ''}`).join('\n');
68
+ const age = recent ? `\n(as the server described itself ${Math.round((Date.now() - recent.at) / 60_000)} min ago; --fresh asks it again)` : '';
69
+ return { text: d.down ? `${text}\n(the server cannot serve now: ${d.down})` : `${text}${age}`, json: d };
70
+ },
71
+ }),
72
+ define({
73
+ name: 'fixtures check',
74
+ group: 'runs',
75
+ summary: 'Tell whether the project’s fixtures server keeps the fixtures protocol',
76
+ details:
77
+ 'Starts the server, asks `lynkeus.describe`, and checks the answer against the contract in docs/FIXTURES.md: a list of commands, no name twice, a protocol version this lynkeus knows, and whether it is up. It changes nothing in the backend.',
78
+ needs: 'nothing',
79
+ session: false,
80
+ mcp: 'core',
81
+ run: async (ctx) => {
82
+ const config = ctx.config.cases ?? {};
83
+ if (!config.fixtures) return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json)', code: 1 };
84
+ const server = new Fixtures(config.fixtures, config.base ?? ctx.root);
85
+ // Its own call, not describe(): a server that never answers is told apart from one that answers wrong.
86
+ const answered = await server.call(DESCRIBE, {}).then(
87
+ (answer) => ({ answer }),
88
+ (error: Error) => ({ error: error.message }),
89
+ );
90
+ server.close();
91
+ if ('error' in answered)
92
+ return {
93
+ text: `✗ the fixtures server gave no answer to lynkeus.describe: ${answered.error}\n (is its backend up? the command is \`${config.fixtures.command}\`)`,
94
+ json: { ok: false, error: answered.error },
95
+ code: 1,
96
+ };
97
+ const d = readDescription(answered.answer);
98
+ const { ok, lines } = checkDescription(d);
99
+ return { text: lines.join('\n'), json: { ok, description: d }, code: ok ? 0 : 1 };
100
+ },
101
+ }),
102
+ ];
@@ -1,12 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import fs from 'node:fs';
4
+ import { createRequire } from 'node:module';
3
5
  import path from 'node:path';
4
6
 
5
- import { type LynkeusConfig, lynkeusDir, portFromEnv } from '../config.js';
7
+ import { type LynkeusConfig, lynkeusDir, portFromEnv, readToken } from '../config.js';
6
8
  import { describeExplanations, explainError } from '../knowledge/errors.js';
7
9
  import { type Ctx, define } from '../registry.js';
8
10
  import { doctor } from './doctor.js';
9
- import { init, mountAgent } from './init.js';
11
+ import { headlessMissing, init, installAgent, installHeadless, jestPreset, mountAgent, setupChecks } from './init.js';
10
12
 
11
13
  const ENTRY_FLAG = { entry: { type: 'string' as const, help: 'the headless entry (or `entry` in lynkeus.config.json)', value: 'file' } };
12
14
  const entryOf = (ctx: Ctx) => (ctx.flags.entry as string | undefined) ?? ctx.config.entry ?? 'qa/headless.tsx';
@@ -31,35 +33,187 @@ hostApp(() => <entry.App />, { ${options.join(', ')} });
31
33
  `;
32
34
  };
33
35
 
36
+ const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'];
37
+
38
+ // React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
39
+ const apiFromConfig = (config: LynkeusConfig): Record<string, string> => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
40
+
41
+ /**
42
+ * A jest cache of its own for each API base. Babel inlines `process.env` into the code it transforms and jest
43
+ * keeps that output, so a host started with another API address kept calling the first one it was started with.
44
+ */
45
+ export const cacheFor = (root: string, env: Record<string, string | undefined>, apiEnv = 'API_URL'): string => {
46
+ const key = createHash('sha256')
47
+ .update(`${apiEnv}=${env[apiEnv] ?? ''}`)
48
+ .digest('hex')
49
+ .slice(0, 12);
50
+ return path.join(lynkeusDir(root), 'jest-cache', key);
51
+ };
52
+
53
+ const TRANSFORM_PACKAGES =
54
+ '((jest-)?react-native[^/]*|@react-native[^/]*|@react-navigation|react-navigation|expo(nent)?|@expo(nent)?/.*|expo-.*|@unimodules|unimodules|@shopify|@gorhom|@sentry/react-native|lynkeus-[^/]+|yoga-layout|@testing-library|immer|@reduxjs/toolkit|react-redux|redux|reselect|@expo-google-fonts|native-base|standard-navigation)';
55
+
56
+ /**
57
+ * The import aliases in tsconfig.json's `paths`, as jest mappings, the longest first: \`@/assets/*\` has to be
58
+ * tried before \`@/*\`, which jest-expo does not do. The bundler reads them; jest knows nothing of them.
59
+ */
60
+ const aliasesOf = (root: string): Record<string, string> => {
61
+ let options: { baseUrl?: string; paths?: Record<string, string[]> } | undefined;
62
+ try {
63
+ const text = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
64
+ options = (JSON.parse(text) as { compilerOptions?: typeof options }).compilerOptions;
65
+ } catch {
66
+ return {};
67
+ }
68
+ const base = path.join(root, options?.baseUrl ?? '.');
69
+ const literal = (text: string) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
70
+ return Object.fromEntries(
71
+ Object.entries(options?.paths ?? {})
72
+ .filter(([alias, [target]]) => alias.endsWith('/*') && target?.endsWith('/*'))
73
+ .sort(([a], [b]) => b.length - a.length)
74
+ .map(([alias, [target]]) => [`^${literal(alias.slice(0, -1))}(.*)$`, `${path.join(base, target!.slice(0, -1))}$1`]),
75
+ );
76
+ };
77
+
78
+ /**
79
+ * What jest is told when the app has not said it itself, so an app with no jest set up runs headless as it is:
80
+ * the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
81
+ */
82
+ /** The app's react folder, when lynkeus-headless would load another copy; undefined when they are one. */
83
+ export const duplicateReact = (root: string): string | undefined => {
84
+ const require = createRequire(import.meta.url);
85
+ try {
86
+ // Its exports leave out package.json: from its entry up to the folder that holds one.
87
+ let headless = path.dirname(fs.realpathSync(require.resolve('lynkeus-headless', { paths: [root] })));
88
+ while (!fs.existsSync(path.join(headless, 'package.json')) && path.dirname(headless) !== headless) headless = path.dirname(headless);
89
+ const ours = path.dirname(require.resolve('react/package.json', { paths: [root] }));
90
+ const theirs = path.dirname(require.resolve('react/package.json', { paths: [headless] }));
91
+ return fs.realpathSync(ours) === fs.realpathSync(theirs) ? undefined : ours;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ };
96
+
97
+ const jestDefaults = (root: string): string[] => {
98
+ const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
99
+ let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
100
+ if (!file) {
101
+ try {
102
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { jest?: unknown };
103
+ config = manifest.jest ? JSON.stringify(manifest.jest) : '';
104
+ } catch {}
105
+ }
106
+ const flags: string[] = [];
107
+ const react = duplicateReact(root);
108
+ // lynkeus-headless with a React of its own (a linked package, a workspace, pnpm) breaks every hook: the app and
109
+ // the renderer must share the app's copy.
110
+ const sameReact = react ? { '^react$': react, '^react/(.*)$': `${react}/$1` } : {};
111
+ if (!config) {
112
+ // A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
113
+ // behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
114
+ const stub = path.join(lynkeusDir(root), 'style-stub.js');
115
+ fs.mkdirSync(path.dirname(stub), { recursive: true });
116
+ fs.writeFileSync(stub, 'module.exports = {};\n');
117
+ flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root), ...sameReact })}`);
118
+ } else if (react) {
119
+ if (config.includes('moduleNameMapper'))
120
+ process.stderr.write(
121
+ `lynkeus: lynkeus-headless finds another copy of react than the app's; add to moduleNameMapper in the app's jest config: '^react$': '${react}', '^react/(.*)$': '${react}/$1'\n`,
122
+ );
123
+ else flags.push(`--moduleNameMapper=${JSON.stringify(sameReact)}`);
124
+ }
125
+ // Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
126
+ if (!config.includes('transformIgnorePatterns'))
127
+ flags.push(
128
+ `--transformIgnorePatterns=node_modules/(?!${TRANSFORM_PACKAGES}/)`,
129
+ '--transformIgnorePatterns=/node_modules/react-native-reanimated/plugin/',
130
+ '--transformIgnorePatterns=/node_modules/@react-native/babel-preset/',
131
+ );
132
+ return flags;
133
+ };
134
+
34
135
  export const headlessCommands = [
35
136
  define({
36
137
  name: 'init',
37
138
  group: 'drive',
38
- summary: "Mount the agent from the app's entry: React Native or Expo (Expo Router gets an entry of its own)",
139
+ summary: "Set lynkeus up in this app: install the agent, mount it from the app's entry, make the project's token",
140
+ details:
141
+ 'React Native or Expo (an Expo Router app gets an entry of its own). Safe to run again: it changes only what is missing. The agent has native code, so the app is built again afterwards (`npx react-native run-ios`, `npx expo run:ios`) and Metro restarted with `--reset-cache`; `lynkeus status` says what is left.',
142
+ flags: { 'no-install': { type: 'boolean', help: 'do not add lynkeus-agent to the app; mount it only' } },
39
143
  needs: 'nothing',
144
+ mcp: 'core',
40
145
  run: async (ctx) => {
41
- const lines = mountAgent(ctx.root);
146
+ const lines = [...(ctx.flags['no-install'] ? [] : [installAgent(ctx.root)]), ...mountAgent(ctx.root)];
147
+ lines.push('next: build the app again and open it, then `lynkeus status` (over MCP: the status tool) to see that it attaches');
42
148
  return { text: lines.join('\n'), json: lines };
43
149
  },
44
150
  }),
151
+ define({
152
+ name: 'status',
153
+ group: 'drive',
154
+ summary: 'What this project has of lynkeus and what is missing: the agent, the token, a running app, cases, the reports key',
155
+ details: 'The first thing to call in a project you do not know: each line that fails says what fixes it. `--offline` skips waiting for the app.',
156
+ flags: { offline: { type: 'boolean', help: 'check the files only; do not wait for a running app' } },
157
+ needs: 'nothing',
158
+ mcp: 'core',
159
+ run: async (ctx) => {
160
+ const checks = setupChecks(ctx.root);
161
+ const ready = checks.every((c) => c.ok);
162
+ if (ready && !ctx.flags.offline) {
163
+ try {
164
+ const device = await ctx.device();
165
+ checks.push({ what: `an app is attached (${device.info.name})`, ok: true });
166
+ } catch {
167
+ checks.push({
168
+ what: 'an app is attached',
169
+ ok: false,
170
+ fix: 'open a development build made after `lynkeus init`, with Metro restarted (`--reset-cache`); or `lynkeus headless start` for no simulator',
171
+ });
172
+ }
173
+ }
174
+ const unloadable = headlessMissing(ctx.root);
175
+ checks.push({
176
+ what: unloadable.length ? 'the app can run headless, in Node with no simulator' : 'the app can run headless (jest, Testing Library, lynkeus-headless)',
177
+ ok: !unloadable.length && fs.existsSync(path.join(ctx.root, entryOf(ctx))),
178
+ fix: '`lynkeus headless init` installs what it needs and writes its entry; optional',
179
+ });
180
+ const dir = path.resolve(ctx.config.cases?.base ?? ctx.root, ctx.config.cases?.dir ?? 'cases');
181
+ const cases = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f)).length : 0;
182
+ checks.push({
183
+ what: cases ? `${cases} case${cases === 1 ? '' : 's'} in ${path.relative(ctx.root, dir) || '.'}` : 'cases to run',
184
+ ok: cases > 0,
185
+ fix: '`lynkeus case init` writes an example and the format',
186
+ });
187
+ checks.push({
188
+ what: 'runs can be pushed to the reports service',
189
+ ok: !!process.env.LYNKEUS_REPORTS_KEY,
190
+ fix: 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); optional',
191
+ });
192
+ const text = checks.map((c) => `${c.ok ? '✓' : '✗'} ${c.what}${c.ok || !c.fix ? '' : ` — ${c.fix}`}`).join('\n');
193
+ return { text, json: checks };
194
+ },
195
+ }),
45
196
  define({
46
197
  name: 'headless start',
47
198
  group: 'headless',
48
199
  summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
49
200
  details:
50
- 'Needs LYNKEUS_TOKEN in the environment (the value the app reads for its token). LYNKEUS_PORT moves the host and its driver together; LYNKEUS_HOST says where the driver is listening, for a host that is not in the same place as its driver (a container dialling out, say). Arguments after `--` go to jest. When the host crashes, the last lines say what the error usually means.',
201
+ "Needs the project's token: the one `lynkeus init` wrote to .lynkeus/token, or LYNKEUS_TOKEN in the environment. The app's API base comes from the environment variable `apiEnv` names, or else from `apiUrl` in lynkeus.config.json. LYNKEUS_PORT moves the host and its driver together; LYNKEUS_HOST says where the driver is listening, for a host that is not in the same place as its driver (a container dialling out, say). Arguments after `--` go to jest. When the host crashes, the last lines say what the error usually means.",
51
202
  flags: ENTRY_FLAG,
52
203
  aliases: ['headless'],
53
204
  needs: 'nothing',
54
205
  session: false,
55
206
  mcp: false,
56
207
  run: async (ctx) => {
57
- const token = process.env.LYNKEUS_TOKEN;
208
+ const token = readToken(ctx.root);
58
209
  if (!token)
59
210
  return {
60
- text: 'lynkeus headless needs LYNKEUS_TOKEN in the environment (the same value the app reads for its token): a hosted app talks to a real backend, and without one any local process could drive it.',
211
+ text: 'lynkeus headless needs a token (`lynkeus init` makes one in .lynkeus/token, or set LYNKEUS_TOKEN): a hosted app talks to a real backend, and without one any local process could drive it.',
61
212
  code: 1,
62
213
  };
214
+ const missing = headlessMissing(ctx.root);
215
+ if (missing.length)
216
+ return { text: `lynkeus headless runs the app under jest and needs ${missing.join(', ')}; \`lynkeus headless init\` installs them.`, code: 1 };
63
217
  const entry = entryOf(ctx);
64
218
  if (!fs.existsSync(path.join(ctx.root, entry)))
65
219
  return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
@@ -75,17 +229,41 @@ export const headlessCommands = [
75
229
  fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
76
230
  // Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
77
231
  const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
78
- const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...ctx.passthrough], {
232
+ // LYNKEUS_HEADLESS tells the app's jest setup a host is starting, not its unit tests: a setup that stubs a native
233
+ // SDK whole can leave it real there, so the call reaches the stand-in `nativeMocks` shows.
234
+ const env = { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, LYNKEUS_HEADLESS: '1', CI: '1', NODE_OPTIONS: nodeOptions };
235
+ const cache = `--cacheDirectory=${cacheFor(ctx.root, env, ctx.config.apiEnv)}`;
236
+ const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), cache, ...ctx.passthrough], {
79
237
  cwd: ctx.root,
80
238
  stdio: ['inherit', 'inherit', 'pipe'],
81
- env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
239
+ env,
240
+ // Its own process group: npx, jest and the host it runs end together. Stopping only npx left the
241
+ // host dialing the port, where a later run's driver took it for the app it meant to drive.
242
+ detached: true,
243
+ });
244
+ const stop = (signal: NodeJS.Signals) => {
245
+ try {
246
+ if (child.pid) process.kill(-child.pid, signal);
247
+ } catch {
248
+ // already gone
249
+ }
250
+ };
251
+ const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
252
+ const forward = signals.map((signal) => {
253
+ const handler = () => stop(signal);
254
+ process.on(signal, handler);
255
+ return () => process.off(signal, handler);
82
256
  });
257
+ const onExit = () => stop('SIGTERM');
258
+ process.on('exit', onExit);
83
259
  let tail = '';
84
260
  child.stderr?.on('data', (chunk: Buffer) => {
85
261
  process.stderr.write(chunk);
86
262
  tail = (tail + chunk.toString()).slice(-20000);
87
263
  });
88
264
  const code = await new Promise<number>((resolve) => child.on('exit', (c) => resolve(c ?? 0)));
265
+ for (const off of forward) off();
266
+ process.off('exit', onExit);
89
267
  if (code) {
90
268
  const found = explainError(tail);
91
269
  if (found.length) ctx.err(describeExplanations(found));
@@ -103,8 +281,13 @@ export const headlessCommands = [
103
281
  'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
104
282
  auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
105
283
  force: { type: 'boolean', help: 'overwrite an existing entry' },
284
+ 'no-install': { type: 'boolean', help: 'do not add jest, Testing Library and lynkeus-headless to the project' },
106
285
  },
107
286
  needs: 'nothing',
287
+ mcp: 'core',
288
+ mcpOmit: ['entry', 'auto', 'force'],
289
+ mcpDetails:
290
+ 'Prepares the app to run in Node, no simulator: installs jest, Testing Library and lynkeus-headless, writes the entry. Then `case_run` with `host`.',
108
291
  run: async (ctx) => {
109
292
  const lines = init(ctx.root, {
110
293
  entry: entryOf(ctx),
@@ -114,6 +297,7 @@ export const headlessCommands = [
114
297
  force: !!ctx.flags.force,
115
298
  locale: { ...ctx.config.locale, language: ctx.config.language },
116
299
  });
300
+ if (!ctx.flags['no-install']) lines.unshift(installHeadless(ctx.root));
117
301
  return { text: lines.join('\n'), json: lines };
118
302
  },
119
303
  }),
@@ -127,6 +311,10 @@ export const headlessCommands = [
127
311
  explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
128
312
  },
129
313
  needs: 'nothing',
314
+ mcp: 'core',
315
+ mcpOmit: ['entry'],
316
+ mcpDetails:
317
+ 'Which native modules the app uses that nothing mocks yet, so the headless host would fail on them; `fix` writes stubs for them into the entry. `explain` turns the first line of a jest error into its cause.',
130
318
  run: async (ctx) => {
131
319
  if (ctx.flags.explain) {
132
320
  const found = explainError(ctx.flags.explain as string);