lynkeus 0.3.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.
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { stripVTControlCharacters } from 'node:util';
5
6
 
6
7
  import { Fixtures } from '../cases/fixtures.js';
7
8
  import { readCases } from '../cases/format.js';
@@ -29,6 +30,18 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
29
30
  });
30
31
  let output = '';
31
32
  const tail = (n: number) => output.trim().split('\n').slice(-n).join(' · ');
33
+ // `headless start` ends a crash with what it usually means; without that, the error line jest printed.
34
+ const why = () => {
35
+ const plain = stripVTControlCharacters(output);
36
+ const explained = plain.split('lynkeus: what this looks like')[1];
37
+ if (explained)
38
+ return explained
39
+ .trim()
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .join(' · ');
43
+ return plain.match(/^\s*(\w*Error\b.*|Cannot find module.*)$/m)?.[1]?.trim() ?? tail(3);
44
+ };
32
45
  await new Promise<void>((resolve, reject) => {
33
46
  const settle = (error?: Error) => {
34
47
  clearTimeout(timer);
@@ -39,11 +52,11 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
39
52
  const read = (chunk: Buffer) => {
40
53
  output = (output + chunk.toString()).slice(-4000);
41
54
  if (/waiting for a driver/.test(output)) settle();
42
- else if (/Test suite failed/.test(output)) settle(new Error(`the host failed to start: ${tail(5)}`));
43
55
  };
44
56
  host.stdout?.on('data', read);
45
57
  host.stderr?.on('data', read);
46
- host.once('exit', (code) => settle(new Error(`the host exited (${code}): ${tail(3)}`)));
58
+ // A suite that fails to load exits right after printing why, so its explanation is read in full here.
59
+ host.once('exit', (code) => settle(new Error(`${/Test suite failed/.test(output) ? 'the host failed to start' : `the host exited (${code})`}: ${why()}`)));
47
60
  });
48
61
  err('host: up');
49
62
  return host;
@@ -195,6 +208,12 @@ export const caseCommands = [
195
208
  events: { type: 'string', help: 'keep what the app told its analytics, per case, as <dir>/<case id>/events.jsonl', value: 'dir' },
196
209
  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' },
197
210
  screenshots: { type: 'boolean', help: 'with --out: a frame of the screen after every step of the app' },
211
+ settle: {
212
+ type: 'number',
213
+ help: 'with --screenshots: how long the screen must stay still before a frame, in ms; 700 for frames two runs can be compared by',
214
+ default: 150,
215
+ value: 'ms',
216
+ },
198
217
  video: { type: 'boolean', help: 'with --out: film each case, where the host films (the step says at what second it began)' },
199
218
  traces: {
200
219
  type: 'boolean',
@@ -204,6 +223,7 @@ export const caseCommands = [
204
223
  push: { type: 'boolean', help: 'with --out: upload the run to the reports service when it ends and print the report’s address' },
205
224
  project: { type: 'string', help: 'with --push: the project the run is filed under (default: the app’s folder name)', value: 'name' },
206
225
  'video-fps': { type: 'number', help: 'frames per second of the film', default: 30, value: 'n' },
226
+ 'video-scale': { type: 'number', help: 'pixels per point of the film, where the host takes one (fastsim: 1 to 3, 2 unless told)', value: 'n' },
207
227
  flakes: { type: 'string', help: 'the ledger of how each case did across runs (default .lynkeus/flakes.json)', value: 'file' },
208
228
  prepared: { type: 'string', help: 'where `case prepare` leaves setups made ahead; a case found there skips its setup', value: 'dir' },
209
229
  jobs: { type: 'number', help: 'cases at once, each on its own host (needs --ports)', value: 'n', default: 1 },
@@ -220,7 +240,30 @@ export const caseCommands = [
220
240
  },
221
241
  needs: 'nothing',
222
242
  session: false,
223
- mcp: false,
243
+ mcp: 'core',
244
+ mcpOmit: [
245
+ 'seen',
246
+ 'report',
247
+ 'events',
248
+ 'settle',
249
+ 'traces',
250
+ 'traces-port',
251
+ 'video-fps',
252
+ 'video-scale',
253
+ 'flakes',
254
+ 'prepared',
255
+ 'jobs',
256
+ 'ports',
257
+ 'host-command',
258
+ 'in-order',
259
+ 'ahead',
260
+ 'idle',
261
+ 'continue',
262
+ 'video',
263
+ 'project',
264
+ ],
265
+ mcpDetails:
266
+ 'Runs case files (default: all in cases.dir) against the attached app, or with `host` in Node. A case that fails comes back with its screen and reason. `out` + `screenshots` + `push` uploads the run and prints its address.',
224
267
  run: async (ctx) => {
225
268
  const config = ctx.config.cases ?? {};
226
269
  const home = config.base ?? ctx.root;
@@ -241,7 +284,10 @@ export const caseCommands = [
241
284
  ? {
242
285
  dir: out.evidenceDir(id, attempt),
243
286
  screenshots: !!ctx.flags.screenshots,
244
- ...(ctx.flags.video ? { video: { fps: Number(ctx.flags['video-fps'] ?? 30) } } : {}),
287
+ settleMs: Number(ctx.flags.settle ?? 150),
288
+ ...(ctx.flags.video
289
+ ? { video: { fps: Number(ctx.flags['video-fps'] ?? 30), ...(ctx.flags['video-scale'] ? { scale: Number(ctx.flags['video-scale']) } : {}) } }
290
+ : {}),
245
291
  }
246
292
  : undefined;
247
293
  const ledger = dry ? undefined : new Flakes(path.resolve(ctx.root, typeof ctx.flags.flakes === 'string' ? ctx.flags.flakes : '.lynkeus/flakes.json'));
@@ -414,7 +460,10 @@ export const caseCommands = [
414
460
  const unsupported = count('unsupported');
415
461
  const summary = [
416
462
  ...reports.map(
417
- (r) => `${{ passed: '✅', failed: '❌', unsupported: '🚫' }[r.result]} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}`,
463
+ (r) =>
464
+ `${{ passed: '✅', failed: '❌', unsupported: '🚫' }[r.result]} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}` +
465
+ // In a session or over MCP the caller reads this answer and nothing else: a case that did not pass brings its steps.
466
+ (ctx.attached && r.result !== 'passed' ? `\n${describe(r)}` : ''),
418
467
  ),
419
468
  `${count('passed')}/${reports.length} passed${unsupported ? `, ${unsupported} cannot run on this host` : ''}`,
420
469
  ...(ledger ? ledger.flaky(reports.map((r) => r.id)) : []),
@@ -481,7 +530,7 @@ export const caseCommands = [
481
530
  flags: { force: { type: 'boolean', help: 'overwrite what is there' } },
482
531
  needs: 'nothing',
483
532
  session: false,
484
- mcp: false,
533
+ mcp: 'core',
485
534
  run: async (ctx) => {
486
535
  const dir = path.resolve(ctx.root, ctx.config.cases?.dir ?? 'cases');
487
536
  fs.mkdirSync(dir, { recursive: true });
@@ -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
  }
@@ -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, mountAgent } 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,35 +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 = [
35
97
  define({
36
98
  name: 'init',
37
99
  group: 'drive',
38
- summary: "Mount the agent from the app's entry: React Native or Expo (Expo Router gets an entry of its own)",
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' } },
39
104
  needs: 'nothing',
105
+ mcp: 'core',
40
106
  run: async (ctx) => {
41
- const lines = mountAgent(ctx.root);
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');
42
109
  return { text: lines.join('\n'), json: lines };
43
110
  },
44
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
+ }),
45
157
  define({
46
158
  name: 'headless start',
47
159
  group: 'headless',
48
160
  summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
49
161
  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.',
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.",
51
163
  flags: ENTRY_FLAG,
52
164
  aliases: ['headless'],
53
165
  needs: 'nothing',
54
166
  session: false,
55
167
  mcp: false,
56
168
  run: async (ctx) => {
57
- const token = process.env.LYNKEUS_TOKEN;
169
+ const token = readToken(ctx.root);
58
170
  if (!token)
59
171
  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.',
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.',
61
173
  code: 1,
62
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 };
63
178
  const entry = entryOf(ctx);
64
179
  if (!fs.existsSync(path.join(ctx.root, entry)))
65
180
  return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
@@ -75,10 +190,10 @@ export const headlessCommands = [
75
190
  fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
76
191
  // Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
77
192
  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], {
193
+ const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), ...ctx.passthrough], {
79
194
  cwd: ctx.root,
80
195
  stdio: ['inherit', 'inherit', 'pipe'],
81
- env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
196
+ env: { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, CI: '1', NODE_OPTIONS: nodeOptions },
82
197
  });
83
198
  let tail = '';
84
199
  child.stderr?.on('data', (chunk: Buffer) => {
@@ -103,8 +218,13 @@ export const headlessCommands = [
103
218
  'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
104
219
  auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
105
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' },
106
222
  },
107
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`.',
108
228
  run: async (ctx) => {
109
229
  const lines = init(ctx.root, {
110
230
  entry: entryOf(ctx),
@@ -114,6 +234,7 @@ export const headlessCommands = [
114
234
  force: !!ctx.flags.force,
115
235
  locale: { ...ctx.config.locale, language: ctx.config.language },
116
236
  });
237
+ if (!ctx.flags['no-install']) lines.unshift(installHeadless(ctx.root));
117
238
  return { text: lines.join('\n'), json: lines };
118
239
  },
119
240
  }),
@@ -127,6 +248,10 @@ export const headlessCommands = [
127
248
  explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
128
249
  },
129
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.',
130
255
  run: async (ctx) => {
131
256
  if (ctx.flags.explain) {
132
257
  const found = explainError(ctx.flags.explain as string);