lynkeus 0.2.0 → 0.4.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 (134) 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 +82 -3
  14. package/lib/cases/runner.js +394 -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 +28 -7
  20. package/lib/commands/cases.js +411 -42
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/doctor.js +8 -2
  24. package/lib/commands/drive.js +32 -8
  25. package/lib/commands/headless.js +132 -8
  26. package/lib/commands/init.d.ts +29 -0
  27. package/lib/commands/init.js +295 -33
  28. package/lib/commands/report.d.ts +97 -0
  29. package/lib/commands/report.js +228 -0
  30. package/lib/config.d.ts +26 -1
  31. package/lib/config.js +30 -0
  32. package/lib/device/agentVersion.d.ts +7 -0
  33. package/lib/device/agentVersion.js +12 -0
  34. package/lib/device/devices.d.ts +5 -0
  35. package/lib/device/devices.js +27 -0
  36. package/lib/device/driver.js +2 -2
  37. package/lib/flow/runner.js +33 -1
  38. package/lib/flow/types.d.ts +2 -1
  39. package/lib/knowledge/contrast.d.ts +45 -0
  40. package/lib/knowledge/contrast.js +114 -0
  41. package/lib/knowledge/errors.js +1 -1
  42. package/lib/knowledge/lint.d.ts +2 -0
  43. package/lib/knowledge/lint.js +13 -3
  44. package/lib/mcp/server.js +31 -2
  45. package/lib/registry.d.ts +5 -0
  46. package/lib/targets.d.ts +10 -1
  47. package/lib/targets.js +16 -3
  48. package/package.json +10 -6
  49. package/src/cases/ahead.ts +111 -0
  50. package/src/cases/fixtures.ts +37 -0
  51. package/src/cases/flakes.ts +67 -0
  52. package/src/cases/format.ts +31 -6
  53. package/src/cases/portClaim.ts +52 -0
  54. package/src/cases/runfile.ts +119 -0
  55. package/src/cases/runner.ts +454 -46
  56. package/src/cases/templates.ts +14 -4
  57. package/src/cases/traces.ts +298 -0
  58. package/src/cli.ts +29 -7
  59. package/src/commands/cases.ts +428 -41
  60. package/src/commands/check.ts +11 -4
  61. package/src/commands/doctor.ts +9 -2
  62. package/src/commands/drive.ts +31 -8
  63. package/src/commands/headless.ts +143 -8
  64. package/src/commands/init.ts +338 -30
  65. package/src/commands/report.ts +272 -0
  66. package/src/config.ts +39 -2
  67. package/src/device/agentVersion.ts +15 -0
  68. package/src/device/devices.ts +28 -0
  69. package/src/device/driver.ts +2 -2
  70. package/src/flow/runner.ts +35 -1
  71. package/src/flow/types.ts +2 -2
  72. package/src/knowledge/contrast.ts +135 -0
  73. package/src/knowledge/errors.ts +1 -1
  74. package/src/knowledge/lint.ts +15 -4
  75. package/src/mcp/server.ts +31 -2
  76. package/src/registry.ts +2 -0
  77. package/src/targets.ts +17 -3
  78. package/lib/__tests__/args.test.d.ts +0 -1
  79. package/lib/__tests__/args.test.js +0 -69
  80. package/lib/__tests__/check.test.d.ts +0 -1
  81. package/lib/__tests__/check.test.js +0 -60
  82. package/lib/__tests__/init.test.d.ts +0 -1
  83. package/lib/__tests__/init.test.js +0 -42
  84. package/lib/__tests__/record.test.d.ts +0 -1
  85. package/lib/__tests__/record.test.js +0 -44
  86. package/lib/__tests__/snapshot.test.d.ts +0 -1
  87. package/lib/__tests__/snapshot.test.js +0 -47
  88. package/lib/__tests__/targets.test.d.ts +0 -1
  89. package/lib/__tests__/targets.test.js +0 -127
  90. package/lib/cases/__tests__/format.test.d.ts +0 -1
  91. package/lib/cases/__tests__/format.test.js +0 -61
  92. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  93. package/lib/cases/__tests__/runner.test.js +0 -188
  94. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  95. package/lib/knowledge/__tests__/edges.test.js +0 -25
  96. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  97. package/lib/knowledge/__tests__/events.test.js +0 -39
  98. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  99. package/lib/knowledge/__tests__/extract.test.js +0 -69
  100. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  101. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  102. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  103. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  104. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  105. package/lib/knowledge/__tests__/lint.test.js +0 -53
  106. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  107. package/lib/knowledge/__tests__/perf.test.js +0 -34
  108. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  109. package/lib/knowledge/__tests__/plan.test.js +0 -35
  110. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  111. package/lib/knowledge/__tests__/query.test.js +0 -55
  112. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  113. package/lib/knowledge/__tests__/screens.test.js +0 -30
  114. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  115. package/lib/knowledge/__tests__/why.test.js +0 -28
  116. package/src/__tests__/args.test.ts +0 -82
  117. package/src/__tests__/check.test.ts +0 -71
  118. package/src/__tests__/init.test.ts +0 -52
  119. package/src/__tests__/record.test.ts +0 -49
  120. package/src/__tests__/snapshot.test.ts +0 -51
  121. package/src/__tests__/targets.test.ts +0 -132
  122. package/src/cases/__tests__/format.test.ts +0 -76
  123. package/src/cases/__tests__/runner.test.ts +0 -230
  124. package/src/knowledge/__tests__/edges.test.ts +0 -34
  125. package/src/knowledge/__tests__/events.test.ts +0 -45
  126. package/src/knowledge/__tests__/extract.test.ts +0 -88
  127. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  128. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  129. package/src/knowledge/__tests__/lint.test.ts +0 -66
  130. package/src/knowledge/__tests__/perf.test.ts +0 -41
  131. package/src/knowledge/__tests__/plan.test.ts +0 -52
  132. package/src/knowledge/__tests__/query.test.ts +0 -74
  133. package/src/knowledge/__tests__/screens.test.ts +0 -37
  134. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -1,16 +1,20 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
 
5
+ import { PNG } from 'pngjs';
6
+
4
7
  import { lynkeusDir } from '../config.js';
5
8
  import { describeScreen, describeTrace } from '../device/driver.js';
6
9
  import { readJson, writeJson } from '../files.js';
10
+ import { lintContrast } from '../knowledge/contrast.js';
7
11
  import { describeFindings, lintScreen } from '../knowledge/lint.js';
8
12
  import { describePerf, summarize, visitsFrom } from '../knowledge/perf.js';
9
13
  import * as screens from '../knowledge/screens.js';
10
14
  import { screenTree } from '../knowledge/tree.js';
11
15
  import { define } from '../registry.js';
12
16
  import { whyFor } from '../runtime.js';
13
- import { expectedOf, isRoute, parseTarget, scopedTarget, TARGET_HELP } from '../targets.js';
17
+ import { expectedOf, isRoute, parseTarget, routeOf, scopedTarget, TARGET_HELP } from '../targets.js';
14
18
  import { type LintBaseline, newFindings } from './check.js';
15
19
 
16
20
  const SCOPE_FLAGS = {
@@ -165,7 +169,7 @@ export const driveCommands = [
165
169
  mcpOmit: ['nth'],
166
170
  group: 'drive',
167
171
  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 }],
172
+ positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP} | 800 (ms)`, required: true }],
169
173
  flags: {
170
174
  ...SCOPE_FLAGS,
171
175
  gone: { type: 'boolean', help: 'until it leaves the screen' },
@@ -181,7 +185,7 @@ export const driveCommands = [
181
185
  }
182
186
  return {
183
187
  json: await (await ctx.device()).waitFor({
184
- ...(isRoute(raw) ? { route: raw } : { target: scopedTarget(raw, ctx.flags) }),
188
+ ...(isRoute(raw) ? { route: routeOf(raw) } : { target: scopedTarget(raw, ctx.flags) }),
185
189
  gone: ctx.flags.gone ? true : undefined,
186
190
  scroll: ctx.flags.scroll ? true : undefined,
187
191
  timeoutMs: ctx.flags.timeout as number,
@@ -206,7 +210,7 @@ export const driveCommands = [
206
210
  name: 'see',
207
211
  group: 'drive',
208
212
  summary: 'Assert a route or element is on screen now (exit 1 if not)',
209
- positionals: [{ name: 'what', help: `Route | ${TARGET_HELP}`, required: true }],
213
+ positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP}`, required: true }],
210
214
  flags: {
211
215
  absent: { type: 'boolean', help: 'assert it is not there' },
212
216
  within: { type: 'number', help: 'allow it this long to appear', default: 0, value: 'ms' },
@@ -217,7 +221,7 @@ export const driveCommands = [
217
221
  const device = await ctx.device();
218
222
  try {
219
223
  const result = await device.waitFor({
220
- ...(isRoute(raw) ? { route: raw } : { target: parseTarget(raw) }),
224
+ ...(isRoute(raw) ? { route: routeOf(raw) } : { target: parseTarget(raw) }),
221
225
  gone: ctx.flags.absent ? true : undefined,
222
226
  timeoutMs: Math.max(1, ctx.flags.within as number),
223
227
  });
@@ -341,9 +345,10 @@ export const driveCommands = [
341
345
  define({
342
346
  name: 'lint',
343
347
  group: 'drive',
344
- summary: 'Unlabeled controls, small targets, untranslated keys, covered or clipped elements',
348
+ summary: 'Unlabeled controls, small targets, low-contrast text, untranslated keys, covered or clipped elements',
345
349
  flags: {
346
350
  strict: { type: 'boolean', help: 'exit 1 on any warning' },
351
+ 'no-contrast': { type: 'boolean', help: 'skip the contrast of text against its background (it takes a screenshot)' },
347
352
  save: { type: 'string', help: "keep this screen's findings in a baseline file", value: 'file' },
348
353
  baseline: { type: 'string', help: 'print only what is new against a baseline file', value: 'file' },
349
354
  },
@@ -353,20 +358,38 @@ export const driveCommands = [
353
358
  const { lint, lintBaseline } = ctx.config;
354
359
  let findings = lintScreen(screen, { i18nKeyPatterns: lint?.i18nKeyPatterns?.map((p) => new RegExp(p, 'i')), minTouchTarget: lint?.minTouchTarget });
355
360
  const route = screen.route ?? '?';
361
+ const notes: string[] = [];
362
+ if (lint?.contrast !== false && !ctx.flags['no-contrast']) {
363
+ // Contrast is read off the pixels, so it needs a host that can take a picture of the screen.
364
+ const file = path.join(os.tmpdir(), `lynkeus-lint-${process.pid}-${Date.now()}.png`);
365
+ try {
366
+ await (await ctx.device()).screenshot(file);
367
+ const picture = PNG.sync.read(fs.readFileSync(file));
368
+ const contrast = lint?.contrast;
369
+ findings = [
370
+ ...findings,
371
+ ...lintContrast(screen, picture, { normal: contrast?.normal, large: contrast?.large, ignore: contrast?.ignore?.map((p) => new RegExp(p)) }),
372
+ ];
373
+ } catch {
374
+ notes.push('contrast not measured: this host takes no screenshots');
375
+ } finally {
376
+ fs.rmSync(file, { force: true });
377
+ }
378
+ }
356
379
  const withIds = findings.map((finding) => ({ finding, testId: screen.elements[finding.element]?.testId }));
357
380
  const lines: string[] = [];
358
381
  const save = ctx.flags.save as string | undefined;
359
382
  const baselineFile = (ctx.flags.baseline as string | undefined) ?? lintBaseline;
360
383
  if (save) {
361
384
  const baseline = fs.existsSync(save) ? readJson<LintBaseline>(save) : {};
362
- baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, message: f.finding.message }));
385
+ baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, subject: f.finding.subject, message: f.finding.message }));
363
386
  writeJson(save, baseline);
364
387
  lines.push(`saved ${findings.length} finding(s) for ${route} → ${save}`);
365
388
  } else if (baselineFile && fs.existsSync(baselineFile)) {
366
389
  findings = newFindings(readJson<LintBaseline>(baselineFile), route, withIds).map((f) => f.finding);
367
390
  lines.push(findings.length ? `${findings.length} new against ${baselineFile}:` : `nothing new on ${route} against ${baselineFile}`);
368
391
  }
369
- lines.push(describeFindings(findings, screen.route));
392
+ lines.push(describeFindings(findings, screen.route), ...notes);
370
393
  return { text: lines.join('\n'), json: findings, code: ctx.flags.strict && findings.some((f) => f.severity === 'warn') ? 1 : 0 };
371
394
  },
372
395
  }),
@@ -2,11 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- import { type LynkeusConfig, lynkeusDir, portFromEnv } from '../config.js';
5
+ import { type LynkeusConfig, lynkeusDir, portFromEnv, readToken } from '../config.js';
6
6
  import { describeExplanations, explainError } from '../knowledge/errors.js';
7
7
  import { type Ctx, define } from '../registry.js';
8
8
  import { doctor } from './doctor.js';
9
- import { init } from './init.js';
9
+ import { headlessMissing, init, installAgent, installHeadless, jestPreset, mountAgent, setupChecks } from './init.js';
10
10
 
11
11
  const ENTRY_FLAG = { entry: { type: 'string' as const, help: 'the headless entry (or `entry` in lynkeus.config.json)', value: 'file' } };
12
12
  const entryOf = (ctx: Ctx) => (ctx.flags.entry as string | undefined) ?? ctx.config.entry ?? 'qa/headless.tsx';
@@ -31,25 +31,150 @@ hostApp(() => <entry.App />, { ${options.join(', ')} });
31
31
  `;
32
32
  };
33
33
 
34
+ const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'];
35
+
36
+ // React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
37
+ const apiFromConfig = (config: LynkeusConfig): Record<string, string> => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
38
+
39
+ const TRANSFORM_PACKAGES =
40
+ '((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)';
41
+
42
+ /**
43
+ * The import aliases in tsconfig.json's `paths`, as jest mappings, the longest first: \`@/assets/*\` has to be
44
+ * tried before \`@/*\`, which jest-expo does not do. The bundler reads them; jest knows nothing of them.
45
+ */
46
+ const aliasesOf = (root: string): Record<string, string> => {
47
+ let options: { baseUrl?: string; paths?: Record<string, string[]> } | undefined;
48
+ try {
49
+ const text = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
50
+ options = (JSON.parse(text) as { compilerOptions?: typeof options }).compilerOptions;
51
+ } catch {
52
+ return {};
53
+ }
54
+ const base = path.join(root, options?.baseUrl ?? '.');
55
+ const literal = (text: string) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
56
+ return Object.fromEntries(
57
+ Object.entries(options?.paths ?? {})
58
+ .filter(([alias, [target]]) => alias.endsWith('/*') && target?.endsWith('/*'))
59
+ .sort(([a], [b]) => b.length - a.length)
60
+ .map(([alias, [target]]) => [`^${literal(alias.slice(0, -1))}(.*)$`, `${path.join(base, target!.slice(0, -1))}$1`]),
61
+ );
62
+ };
63
+
64
+ /**
65
+ * 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:
66
+ * the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
67
+ */
68
+ const jestDefaults = (root: string): string[] => {
69
+ const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
70
+ let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
71
+ if (!file) {
72
+ try {
73
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { jest?: unknown };
74
+ config = manifest.jest ? JSON.stringify(manifest.jest) : '';
75
+ } catch {}
76
+ }
77
+ const flags: string[] = [];
78
+ if (!config) {
79
+ // A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
80
+ // behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
81
+ const stub = path.join(lynkeusDir(root), 'style-stub.js');
82
+ fs.mkdirSync(path.dirname(stub), { recursive: true });
83
+ fs.writeFileSync(stub, 'module.exports = {};\n');
84
+ flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root) })}`);
85
+ }
86
+ // Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
87
+ if (!config.includes('transformIgnorePatterns'))
88
+ flags.push(
89
+ `--transformIgnorePatterns=node_modules/(?!${TRANSFORM_PACKAGES}/)`,
90
+ '--transformIgnorePatterns=/node_modules/react-native-reanimated/plugin/',
91
+ '--transformIgnorePatterns=/node_modules/@react-native/babel-preset/',
92
+ );
93
+ return flags;
94
+ };
95
+
34
96
  export const headlessCommands = [
97
+ define({
98
+ name: 'init',
99
+ group: 'drive',
100
+ summary: "Set lynkeus up in this app: install the agent, mount it from the app's entry, make the project's token",
101
+ details:
102
+ '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.',
103
+ flags: { 'no-install': { type: 'boolean', help: 'do not add lynkeus-agent to the app; mount it only' } },
104
+ needs: 'nothing',
105
+ mcp: 'core',
106
+ run: async (ctx) => {
107
+ const lines = [...(ctx.flags['no-install'] ? [] : [installAgent(ctx.root)]), ...mountAgent(ctx.root)];
108
+ lines.push('next: build the app again and open it, then `lynkeus status` (over MCP: the status tool) to see that it attaches');
109
+ return { text: lines.join('\n'), json: lines };
110
+ },
111
+ }),
112
+ define({
113
+ name: 'status',
114
+ group: 'drive',
115
+ summary: 'What this project has of lynkeus and what is missing: the agent, the token, a running app, cases, the reports key',
116
+ 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.',
117
+ flags: { offline: { type: 'boolean', help: 'check the files only; do not wait for a running app' } },
118
+ needs: 'nothing',
119
+ mcp: 'core',
120
+ run: async (ctx) => {
121
+ const checks = setupChecks(ctx.root);
122
+ const ready = checks.every((c) => c.ok);
123
+ if (ready && !ctx.flags.offline) {
124
+ try {
125
+ const device = await ctx.device();
126
+ checks.push({ what: `an app is attached (${device.info.name})`, ok: true });
127
+ } catch {
128
+ checks.push({
129
+ what: 'an app is attached',
130
+ ok: false,
131
+ fix: 'open a development build made after `lynkeus init`, with Metro restarted (`--reset-cache`); or `lynkeus headless start` for no simulator',
132
+ });
133
+ }
134
+ }
135
+ const unloadable = headlessMissing(ctx.root);
136
+ checks.push({
137
+ what: unloadable.length ? 'the app can run headless, in Node with no simulator' : 'the app can run headless (jest, Testing Library, lynkeus-headless)',
138
+ ok: !unloadable.length && fs.existsSync(path.join(ctx.root, entryOf(ctx))),
139
+ fix: '`lynkeus headless init` installs what it needs and writes its entry; optional',
140
+ });
141
+ const dir = path.resolve(ctx.config.cases?.base ?? ctx.root, ctx.config.cases?.dir ?? 'cases');
142
+ const cases = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f)).length : 0;
143
+ checks.push({
144
+ what: cases ? `${cases} case${cases === 1 ? '' : 's'} in ${path.relative(ctx.root, dir) || '.'}` : 'cases to run',
145
+ ok: cases > 0,
146
+ fix: '`lynkeus case init` writes an example and the format',
147
+ });
148
+ checks.push({
149
+ what: 'runs can be pushed to the reports service',
150
+ ok: !!process.env.LYNKEUS_REPORTS_KEY,
151
+ fix: 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); optional',
152
+ });
153
+ const text = checks.map((c) => `${c.ok ? '✓' : '✗'} ${c.what}${c.ok || !c.fix ? '' : ` — ${c.fix}`}`).join('\n');
154
+ return { text, json: checks };
155
+ },
156
+ }),
35
157
  define({
36
158
  name: 'headless start',
37
159
  group: 'headless',
38
160
  summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
39
161
  details:
40
- '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.',
162
+ "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.",
41
163
  flags: ENTRY_FLAG,
42
164
  aliases: ['headless'],
43
165
  needs: 'nothing',
44
166
  session: false,
45
167
  mcp: false,
46
168
  run: async (ctx) => {
47
- const token = process.env.LYNKEUS_TOKEN;
169
+ const token = readToken(ctx.root);
48
170
  if (!token)
49
171
  return {
50
- 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.',
172
+ 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.',
51
173
  code: 1,
52
174
  };
175
+ const missing = headlessMissing(ctx.root);
176
+ if (missing.length)
177
+ return { text: `lynkeus headless runs the app under jest and needs ${missing.join(', ')}; \`lynkeus headless init\` installs them.`, code: 1 };
53
178
  const entry = entryOf(ctx);
54
179
  if (!fs.existsSync(path.join(ctx.root, entry)))
55
180
  return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
@@ -65,10 +190,10 @@ export const headlessCommands = [
65
190
  fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
66
191
  // Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
67
192
  const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
68
- const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...ctx.passthrough], {
193
+ const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), ...ctx.passthrough], {
69
194
  cwd: ctx.root,
70
195
  stdio: ['inherit', 'inherit', 'pipe'],
71
- env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
196
+ env: { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, CI: '1', NODE_OPTIONS: nodeOptions },
72
197
  });
73
198
  let tail = '';
74
199
  child.stderr?.on('data', (chunk: Buffer) => {
@@ -93,8 +218,13 @@ export const headlessCommands = [
93
218
  'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
94
219
  auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
95
220
  force: { type: 'boolean', help: 'overwrite an existing entry' },
221
+ 'no-install': { type: 'boolean', help: 'do not add jest, Testing Library and lynkeus-headless to the project' },
96
222
  },
97
223
  needs: 'nothing',
224
+ mcp: 'core',
225
+ mcpOmit: ['entry', 'auto', 'force'],
226
+ mcpDetails:
227
+ 'Prepares the app to run in Node, no simulator: installs jest, Testing Library and lynkeus-headless, writes the entry. Then `case_run` with `host`.',
98
228
  run: async (ctx) => {
99
229
  const lines = init(ctx.root, {
100
230
  entry: entryOf(ctx),
@@ -104,6 +234,7 @@ export const headlessCommands = [
104
234
  force: !!ctx.flags.force,
105
235
  locale: { ...ctx.config.locale, language: ctx.config.language },
106
236
  });
237
+ if (!ctx.flags['no-install']) lines.unshift(installHeadless(ctx.root));
107
238
  return { text: lines.join('\n'), json: lines };
108
239
  },
109
240
  }),
@@ -117,11 +248,15 @@ export const headlessCommands = [
117
248
  explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
118
249
  },
119
250
  needs: 'nothing',
251
+ mcp: 'core',
252
+ mcpOmit: ['entry'],
253
+ mcpDetails:
254
+ '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.',
120
255
  run: async (ctx) => {
121
256
  if (ctx.flags.explain) {
122
257
  const found = explainError(ctx.flags.explain as string);
123
258
  return {
124
- text: found.length ? describeExplanations(found).trim() : 'nothing in the catalogue matches; paste the first line of the error, not the stack.',
259
+ text: found.length ? describeExplanations(found).trim() : 'nothing in the catalog matches; paste the first line of the error, not the stack.',
125
260
  json: found,
126
261
  };
127
262
  }