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.
- package/lib/cases/runner.d.ts +2 -0
- package/lib/cases/runner.js +13 -6
- package/lib/cli.js +2 -2
- package/lib/commands/cases.js +53 -7
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/headless.js +123 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/report.js +2 -2
- package/lib/config.d.ts +16 -0
- package/lib/config.js +30 -0
- package/lib/device/driver.js +2 -2
- package/lib/mcp/server.js +31 -2
- package/package.json +3 -3
- package/src/cases/runner.ts +14 -6
- package/src/cli.ts +2 -2
- package/src/commands/cases.ts +55 -6
- package/src/commands/doctor.ts +9 -2
- package/src/commands/headless.ts +134 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/report.ts +2 -2
- package/src/config.ts +33 -0
- package/src/device/driver.ts +2 -2
- package/src/mcp/server.ts +31 -2
package/lib/cases/runner.d.ts
CHANGED
package/lib/cases/runner.js
CHANGED
|
@@ -379,8 +379,12 @@ export const runCase = async (c, options) => {
|
|
|
379
379
|
if (await scrollTo(async () => (await seen(params)) === undefined))
|
|
380
380
|
return;
|
|
381
381
|
}
|
|
382
|
-
else
|
|
383
|
-
|
|
382
|
+
else {
|
|
383
|
+
// A step that names its own timeout gets no more than what is left of it; one that does not may
|
|
384
|
+
// wait for a carousel to come round.
|
|
385
|
+
const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
|
|
386
|
+
await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
|
|
387
|
+
}
|
|
384
388
|
continue;
|
|
385
389
|
}
|
|
386
390
|
if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
|
|
@@ -733,7 +737,8 @@ export const runCase = async (c, options) => {
|
|
|
733
737
|
let filming = false;
|
|
734
738
|
if (film && evidence?.video) {
|
|
735
739
|
const d = await device();
|
|
736
|
-
|
|
740
|
+
// A host that films at one size only ignores the scale.
|
|
741
|
+
filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(() => true, () => false);
|
|
737
742
|
}
|
|
738
743
|
// What the app sent before the case began is not the case's.
|
|
739
744
|
if (capturing)
|
|
@@ -760,9 +765,11 @@ export const runCase = async (c, options) => {
|
|
|
760
765
|
if (evidence?.screenshots) {
|
|
761
766
|
// A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
|
|
762
767
|
// step left the app, so two runs' frames of the same step show the same screen.
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
|
|
768
|
+
// A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
|
|
769
|
+
// moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
|
|
770
|
+
// arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
|
|
771
|
+
const quietMs = evidence.settleMs ?? 150;
|
|
772
|
+
const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then((r) => r.idle, () => false);
|
|
766
773
|
const file = path.join(evidence.dir, `${String(index).padStart(2, '0')}-${report.step
|
|
767
774
|
.toLowerCase()
|
|
768
775
|
.replace(/[^a-z0-9]+/g, '-')
|
package/lib/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mapCommands } from './commands/map.js';
|
|
|
10
10
|
import { osCommands } from './commands/os.js';
|
|
11
11
|
import { reportCommands } from './commands/report.js';
|
|
12
12
|
import { runsCommands } from './commands/runs.js';
|
|
13
|
-
import { graphFile, loadConfig, loadGraph, portFromEnv } from './config.js';
|
|
13
|
+
import { graphFile, loadConfig, loadGraph, portFromEnv, readToken } from './config.js';
|
|
14
14
|
import { agentMismatch } from './device/agentVersion.js';
|
|
15
15
|
import { HEADLESS_HOST, pickAgentDevice, pickAndroidDevice } from './device/devices.js';
|
|
16
16
|
import { Device } from './device/driver.js';
|
|
@@ -51,7 +51,7 @@ const contextFor = (argv, attached = false, port, ownHost = false) => {
|
|
|
51
51
|
const appId = g.app ?? process.env.LYNKEUS_APP_ID ?? config.app;
|
|
52
52
|
const started = performance.now();
|
|
53
53
|
const attachedIn = (d) => err(`attached in ${Math.round(performance.now() - started)}ms (${d.info.name})`);
|
|
54
|
-
const server = new AgentServer({ token:
|
|
54
|
+
const server = new AgentServer({ token: readToken(root), port: port ?? portFromEnv(config) });
|
|
55
55
|
server.on('hello', (hello) => {
|
|
56
56
|
const told = mismatchTold ? undefined : agentMismatch(hello);
|
|
57
57
|
if (!told)
|
package/lib/commands/cases.js
CHANGED
|
@@ -2,6 +2,7 @@ import { 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
|
import { Fixtures } from '../cases/fixtures.js';
|
|
6
7
|
import { readCases } from '../cases/format.js';
|
|
7
8
|
import { missingFixtures, preparable, runCase } from '../cases/runner.js';
|
|
@@ -26,6 +27,18 @@ const startHost = async (root, err) => {
|
|
|
26
27
|
});
|
|
27
28
|
let output = '';
|
|
28
29
|
const tail = (n) => output.trim().split('\n').slice(-n).join(' · ');
|
|
30
|
+
// `headless start` ends a crash with what it usually means; without that, the error line jest printed.
|
|
31
|
+
const why = () => {
|
|
32
|
+
const plain = stripVTControlCharacters(output);
|
|
33
|
+
const explained = plain.split('lynkeus: what this looks like')[1];
|
|
34
|
+
if (explained)
|
|
35
|
+
return explained
|
|
36
|
+
.trim()
|
|
37
|
+
.split('\n')
|
|
38
|
+
.map((line) => line.trim())
|
|
39
|
+
.join(' · ');
|
|
40
|
+
return plain.match(/^\s*(\w*Error\b.*|Cannot find module.*)$/m)?.[1]?.trim() ?? tail(3);
|
|
41
|
+
};
|
|
29
42
|
await new Promise((resolve, reject) => {
|
|
30
43
|
const settle = (error) => {
|
|
31
44
|
clearTimeout(timer);
|
|
@@ -39,12 +52,11 @@ const startHost = async (root, err) => {
|
|
|
39
52
|
output = (output + chunk.toString()).slice(-4000);
|
|
40
53
|
if (/waiting for a driver/.test(output))
|
|
41
54
|
settle();
|
|
42
|
-
else if (/Test suite failed/.test(output))
|
|
43
|
-
settle(new Error(`the host failed to start: ${tail(5)}`));
|
|
44
55
|
};
|
|
45
56
|
host.stdout?.on('data', read);
|
|
46
57
|
host.stderr?.on('data', read);
|
|
47
|
-
|
|
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()}`)));
|
|
48
60
|
});
|
|
49
61
|
err('host: up');
|
|
50
62
|
return host;
|
|
@@ -190,6 +202,12 @@ export const caseCommands = [
|
|
|
190
202
|
events: { type: 'string', help: 'keep what the app told its analytics, per case, as <dir>/<case id>/events.jsonl', value: 'dir' },
|
|
191
203
|
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
204
|
screenshots: { type: 'boolean', help: 'with --out: a frame of the screen after every step of the app' },
|
|
205
|
+
settle: {
|
|
206
|
+
type: 'number',
|
|
207
|
+
help: 'with --screenshots: how long the screen must stay still before a frame, in ms; 700 for frames two runs can be compared by',
|
|
208
|
+
default: 150,
|
|
209
|
+
value: 'ms',
|
|
210
|
+
},
|
|
193
211
|
video: { type: 'boolean', help: 'with --out: film each case, where the host films (the step says at what second it began)' },
|
|
194
212
|
traces: {
|
|
195
213
|
type: 'boolean',
|
|
@@ -199,6 +217,7 @@ export const caseCommands = [
|
|
|
199
217
|
push: { type: 'boolean', help: 'with --out: upload the run to the reports service when it ends and print the report’s address' },
|
|
200
218
|
project: { type: 'string', help: 'with --push: the project the run is filed under (default: the app’s folder name)', value: 'name' },
|
|
201
219
|
'video-fps': { type: 'number', help: 'frames per second of the film', default: 30, value: 'n' },
|
|
220
|
+
'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' },
|
|
202
221
|
flakes: { type: 'string', help: 'the ledger of how each case did across runs (default .lynkeus/flakes.json)', value: 'file' },
|
|
203
222
|
prepared: { type: 'string', help: 'where `case prepare` leaves setups made ahead; a case found there skips its setup', value: 'dir' },
|
|
204
223
|
jobs: { type: 'number', help: 'cases at once, each on its own host (needs --ports)', value: 'n', default: 1 },
|
|
@@ -215,7 +234,29 @@ export const caseCommands = [
|
|
|
215
234
|
},
|
|
216
235
|
needs: 'nothing',
|
|
217
236
|
session: false,
|
|
218
|
-
mcp:
|
|
237
|
+
mcp: 'core',
|
|
238
|
+
mcpOmit: [
|
|
239
|
+
'seen',
|
|
240
|
+
'report',
|
|
241
|
+
'events',
|
|
242
|
+
'settle',
|
|
243
|
+
'traces',
|
|
244
|
+
'traces-port',
|
|
245
|
+
'video-fps',
|
|
246
|
+
'video-scale',
|
|
247
|
+
'flakes',
|
|
248
|
+
'prepared',
|
|
249
|
+
'jobs',
|
|
250
|
+
'ports',
|
|
251
|
+
'host-command',
|
|
252
|
+
'in-order',
|
|
253
|
+
'ahead',
|
|
254
|
+
'idle',
|
|
255
|
+
'continue',
|
|
256
|
+
'video',
|
|
257
|
+
'project',
|
|
258
|
+
],
|
|
259
|
+
mcpDetails: '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.',
|
|
219
260
|
run: async (ctx) => {
|
|
220
261
|
const config = ctx.config.cases ?? {};
|
|
221
262
|
const home = config.base ?? ctx.root;
|
|
@@ -238,7 +279,10 @@ export const caseCommands = [
|
|
|
238
279
|
? {
|
|
239
280
|
dir: out.evidenceDir(id, attempt),
|
|
240
281
|
screenshots: !!ctx.flags.screenshots,
|
|
241
|
-
|
|
282
|
+
settleMs: Number(ctx.flags.settle ?? 150),
|
|
283
|
+
...(ctx.flags.video
|
|
284
|
+
? { video: { fps: Number(ctx.flags['video-fps'] ?? 30), ...(ctx.flags['video-scale'] ? { scale: Number(ctx.flags['video-scale']) } : {}) } }
|
|
285
|
+
: {}),
|
|
242
286
|
}
|
|
243
287
|
: undefined;
|
|
244
288
|
const ledger = dry ? undefined : new Flakes(path.resolve(ctx.root, typeof ctx.flags.flakes === 'string' ? ctx.flags.flakes : '.lynkeus/flakes.json'));
|
|
@@ -409,7 +453,9 @@ export const caseCommands = [
|
|
|
409
453
|
const count = (result) => reports.filter((r) => r.result === result).length;
|
|
410
454
|
const unsupported = count('unsupported');
|
|
411
455
|
const summary = [
|
|
412
|
-
...reports.map((r) => `${{ passed: '✅', failed: '❌', unsupported: '🚫' }[r.result]} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}`
|
|
456
|
+
...reports.map((r) => `${{ passed: '✅', failed: '❌', unsupported: '🚫' }[r.result]} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}` +
|
|
457
|
+
// In a session or over MCP the caller reads this answer and nothing else: a case that did not pass brings its steps.
|
|
458
|
+
(ctx.attached && r.result !== 'passed' ? `\n${describe(r)}` : '')),
|
|
413
459
|
`${count('passed')}/${reports.length} passed${unsupported ? `, ${unsupported} cannot run on this host` : ''}`,
|
|
414
460
|
...(ledger ? ledger.flaky(reports.map((r) => r.id)) : []),
|
|
415
461
|
...(reportsTo && out
|
|
@@ -473,7 +519,7 @@ export const caseCommands = [
|
|
|
473
519
|
flags: { force: { type: 'boolean', help: 'overwrite what is there' } },
|
|
474
520
|
needs: 'nothing',
|
|
475
521
|
session: false,
|
|
476
|
-
mcp:
|
|
522
|
+
mcp: 'core',
|
|
477
523
|
run: async (ctx) => {
|
|
478
524
|
const dir = path.resolve(ctx.root, ctx.config.cases?.dir ?? 'cases');
|
|
479
525
|
fs.mkdirSync(dir, { recursive: true });
|
package/lib/commands/doctor.js
CHANGED
|
@@ -73,6 +73,12 @@ const PATTERNS = [
|
|
|
73
73
|
{ re: /requireNativeComponent\s*(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/g, how: 'requireNativeComponent' },
|
|
74
74
|
{ re: /codegenNativeComponent\s*<[^>]*>\(\s*['"]([^'"]+)['"]/g, how: 'codegenNativeComponent' },
|
|
75
75
|
];
|
|
76
|
+
/**
|
|
77
|
+
* The text with its comments blanked, lines kept where they were: a mock or a native module named in a comment
|
|
78
|
+
* (the generated entry shows `jest.mock('some-native-package', …)` as an example) is neither. Only comments that
|
|
79
|
+
* own their line are taken, so a `//` inside a string (a URL) stays.
|
|
80
|
+
*/
|
|
81
|
+
const withoutComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')).replace(/^[ \t]*\/\/.*$/gm, '');
|
|
76
82
|
const scanNativeRefs = (root) => {
|
|
77
83
|
const files = [];
|
|
78
84
|
for (const d of SCAN_DIRS)
|
|
@@ -81,7 +87,7 @@ const scanNativeRefs = (root) => {
|
|
|
81
87
|
for (const file of files) {
|
|
82
88
|
let text;
|
|
83
89
|
try {
|
|
84
|
-
text = fs.readFileSync(file, 'utf8');
|
|
90
|
+
text = withoutComments(fs.readFileSync(file, 'utf8'));
|
|
85
91
|
}
|
|
86
92
|
catch {
|
|
87
93
|
continue;
|
|
@@ -104,7 +110,7 @@ const entryMocks = (root, entry) => {
|
|
|
104
110
|
const entryPath = path.join(root, entry);
|
|
105
111
|
let text = '';
|
|
106
112
|
try {
|
|
107
|
-
text = fs.readFileSync(entryPath, 'utf8');
|
|
113
|
+
text = withoutComments(fs.readFileSync(entryPath, 'utf8'));
|
|
108
114
|
}
|
|
109
115
|
catch {
|
|
110
116
|
return { specs, files };
|
package/lib/commands/headless.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { lynkeusDir, portFromEnv } from '../config.js';
|
|
4
|
+
import { lynkeusDir, portFromEnv, readToken } 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, mountAgent } from './init.js';
|
|
8
|
+
import { headlessMissing, init, installAgent, installHeadless, jestPreset, mountAgent, setupChecks } 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. */
|
|
@@ -27,34 +27,139 @@ import * as entry from '${entryImport}';
|
|
|
27
27
|
hostApp(() => <entry.App />, { ${options.join(', ')} });
|
|
28
28
|
`;
|
|
29
29
|
};
|
|
30
|
+
const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'];
|
|
31
|
+
// React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
|
|
32
|
+
const apiFromConfig = (config) => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
|
|
33
|
+
const TRANSFORM_PACKAGES = '((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)';
|
|
34
|
+
/**
|
|
35
|
+
* The import aliases in tsconfig.json's `paths`, as jest mappings, the longest first: \`@/assets/*\` has to be
|
|
36
|
+
* tried before \`@/*\`, which jest-expo does not do. The bundler reads them; jest knows nothing of them.
|
|
37
|
+
*/
|
|
38
|
+
const aliasesOf = (root) => {
|
|
39
|
+
let options;
|
|
40
|
+
try {
|
|
41
|
+
const text = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
42
|
+
options = JSON.parse(text).compilerOptions;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
const base = path.join(root, options?.baseUrl ?? '.');
|
|
48
|
+
const literal = (text) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
return Object.fromEntries(Object.entries(options?.paths ?? {})
|
|
50
|
+
.filter(([alias, [target]]) => alias.endsWith('/*') && target?.endsWith('/*'))
|
|
51
|
+
.sort(([a], [b]) => b.length - a.length)
|
|
52
|
+
.map(([alias, [target]]) => [`^${literal(alias.slice(0, -1))}(.*)$`, `${path.join(base, target.slice(0, -1))}$1`]));
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 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:
|
|
56
|
+
* the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
|
|
57
|
+
*/
|
|
58
|
+
const jestDefaults = (root) => {
|
|
59
|
+
const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
|
|
60
|
+
let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
|
|
61
|
+
if (!file) {
|
|
62
|
+
try {
|
|
63
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
64
|
+
config = manifest.jest ? JSON.stringify(manifest.jest) : '';
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
const flags = [];
|
|
69
|
+
if (!config) {
|
|
70
|
+
// A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
|
|
71
|
+
// behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
|
|
72
|
+
const stub = path.join(lynkeusDir(root), 'style-stub.js');
|
|
73
|
+
fs.mkdirSync(path.dirname(stub), { recursive: true });
|
|
74
|
+
fs.writeFileSync(stub, 'module.exports = {};\n');
|
|
75
|
+
flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root) })}`);
|
|
76
|
+
}
|
|
77
|
+
// Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
|
|
78
|
+
if (!config.includes('transformIgnorePatterns'))
|
|
79
|
+
flags.push(`--transformIgnorePatterns=node_modules/(?!${TRANSFORM_PACKAGES}/)`, '--transformIgnorePatterns=/node_modules/react-native-reanimated/plugin/', '--transformIgnorePatterns=/node_modules/@react-native/babel-preset/');
|
|
80
|
+
return flags;
|
|
81
|
+
};
|
|
30
82
|
export const headlessCommands = [
|
|
31
83
|
define({
|
|
32
84
|
name: 'init',
|
|
33
85
|
group: 'drive',
|
|
34
|
-
summary: "
|
|
86
|
+
summary: "Set lynkeus up in this app: install the agent, mount it from the app's entry, make the project's token",
|
|
87
|
+
details: '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.',
|
|
88
|
+
flags: { 'no-install': { type: 'boolean', help: 'do not add lynkeus-agent to the app; mount it only' } },
|
|
35
89
|
needs: 'nothing',
|
|
90
|
+
mcp: 'core',
|
|
36
91
|
run: async (ctx) => {
|
|
37
|
-
const lines = mountAgent(ctx.root);
|
|
92
|
+
const lines = [...(ctx.flags['no-install'] ? [] : [installAgent(ctx.root)]), ...mountAgent(ctx.root)];
|
|
93
|
+
lines.push('next: build the app again and open it, then `lynkeus status` (over MCP: the status tool) to see that it attaches');
|
|
38
94
|
return { text: lines.join('\n'), json: lines };
|
|
39
95
|
},
|
|
40
96
|
}),
|
|
97
|
+
define({
|
|
98
|
+
name: 'status',
|
|
99
|
+
group: 'drive',
|
|
100
|
+
summary: 'What this project has of lynkeus and what is missing: the agent, the token, a running app, cases, the reports key',
|
|
101
|
+
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.',
|
|
102
|
+
flags: { offline: { type: 'boolean', help: 'check the files only; do not wait for a running app' } },
|
|
103
|
+
needs: 'nothing',
|
|
104
|
+
mcp: 'core',
|
|
105
|
+
run: async (ctx) => {
|
|
106
|
+
const checks = setupChecks(ctx.root);
|
|
107
|
+
const ready = checks.every((c) => c.ok);
|
|
108
|
+
if (ready && !ctx.flags.offline) {
|
|
109
|
+
try {
|
|
110
|
+
const device = await ctx.device();
|
|
111
|
+
checks.push({ what: `an app is attached (${device.info.name})`, ok: true });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
checks.push({
|
|
115
|
+
what: 'an app is attached',
|
|
116
|
+
ok: false,
|
|
117
|
+
fix: 'open a development build made after `lynkeus init`, with Metro restarted (`--reset-cache`); or `lynkeus headless start` for no simulator',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const unloadable = headlessMissing(ctx.root);
|
|
122
|
+
checks.push({
|
|
123
|
+
what: unloadable.length ? 'the app can run headless, in Node with no simulator' : 'the app can run headless (jest, Testing Library, lynkeus-headless)',
|
|
124
|
+
ok: !unloadable.length && fs.existsSync(path.join(ctx.root, entryOf(ctx))),
|
|
125
|
+
fix: '`lynkeus headless init` installs what it needs and writes its entry; optional',
|
|
126
|
+
});
|
|
127
|
+
const dir = path.resolve(ctx.config.cases?.base ?? ctx.root, ctx.config.cases?.dir ?? 'cases');
|
|
128
|
+
const cases = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f)).length : 0;
|
|
129
|
+
checks.push({
|
|
130
|
+
what: cases ? `${cases} case${cases === 1 ? '' : 's'} in ${path.relative(ctx.root, dir) || '.'}` : 'cases to run',
|
|
131
|
+
ok: cases > 0,
|
|
132
|
+
fix: '`lynkeus case init` writes an example and the format',
|
|
133
|
+
});
|
|
134
|
+
checks.push({
|
|
135
|
+
what: 'runs can be pushed to the reports service',
|
|
136
|
+
ok: !!process.env.LYNKEUS_REPORTS_KEY,
|
|
137
|
+
fix: 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); optional',
|
|
138
|
+
});
|
|
139
|
+
const text = checks.map((c) => `${c.ok ? '✓' : '✗'} ${c.what}${c.ok || !c.fix ? '' : ` — ${c.fix}`}`).join('\n');
|
|
140
|
+
return { text, json: checks };
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
41
143
|
define({
|
|
42
144
|
name: 'headless start',
|
|
43
145
|
group: 'headless',
|
|
44
146
|
summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
|
|
45
|
-
details:
|
|
147
|
+
details: "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.",
|
|
46
148
|
flags: ENTRY_FLAG,
|
|
47
149
|
aliases: ['headless'],
|
|
48
150
|
needs: 'nothing',
|
|
49
151
|
session: false,
|
|
50
152
|
mcp: false,
|
|
51
153
|
run: async (ctx) => {
|
|
52
|
-
const token =
|
|
154
|
+
const token = readToken(ctx.root);
|
|
53
155
|
if (!token)
|
|
54
156
|
return {
|
|
55
|
-
text: 'lynkeus headless needs
|
|
157
|
+
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.',
|
|
56
158
|
code: 1,
|
|
57
159
|
};
|
|
160
|
+
const missing = headlessMissing(ctx.root);
|
|
161
|
+
if (missing.length)
|
|
162
|
+
return { text: `lynkeus headless runs the app under jest and needs ${missing.join(', ')}; \`lynkeus headless init\` installs them.`, code: 1 };
|
|
58
163
|
const entry = entryOf(ctx);
|
|
59
164
|
if (!fs.existsSync(path.join(ctx.root, entry)))
|
|
60
165
|
return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
|
|
@@ -70,10 +175,10 @@ export const headlessCommands = [
|
|
|
70
175
|
fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
|
|
71
176
|
// Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
|
|
72
177
|
const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
|
|
73
|
-
const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...ctx.passthrough], {
|
|
178
|
+
const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), ...ctx.passthrough], {
|
|
74
179
|
cwd: ctx.root,
|
|
75
180
|
stdio: ['inherit', 'inherit', 'pipe'],
|
|
76
|
-
env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
|
|
181
|
+
env: { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, CI: '1', NODE_OPTIONS: nodeOptions },
|
|
77
182
|
});
|
|
78
183
|
let tail = '';
|
|
79
184
|
child.stderr?.on('data', (chunk) => {
|
|
@@ -99,8 +204,12 @@ export const headlessCommands = [
|
|
|
99
204
|
'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
|
|
100
205
|
auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
|
|
101
206
|
force: { type: 'boolean', help: 'overwrite an existing entry' },
|
|
207
|
+
'no-install': { type: 'boolean', help: 'do not add jest, Testing Library and lynkeus-headless to the project' },
|
|
102
208
|
},
|
|
103
209
|
needs: 'nothing',
|
|
210
|
+
mcp: 'core',
|
|
211
|
+
mcpOmit: ['entry', 'auto', 'force'],
|
|
212
|
+
mcpDetails: 'Prepares the app to run in Node, no simulator: installs jest, Testing Library and lynkeus-headless, writes the entry. Then `case_run` with `host`.',
|
|
104
213
|
run: async (ctx) => {
|
|
105
214
|
const lines = init(ctx.root, {
|
|
106
215
|
entry: entryOf(ctx),
|
|
@@ -110,6 +219,8 @@ export const headlessCommands = [
|
|
|
110
219
|
force: !!ctx.flags.force,
|
|
111
220
|
locale: { ...ctx.config.locale, language: ctx.config.language },
|
|
112
221
|
});
|
|
222
|
+
if (!ctx.flags['no-install'])
|
|
223
|
+
lines.unshift(installHeadless(ctx.root));
|
|
113
224
|
return { text: lines.join('\n'), json: lines };
|
|
114
225
|
},
|
|
115
226
|
}),
|
|
@@ -123,6 +234,9 @@ export const headlessCommands = [
|
|
|
123
234
|
explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
|
|
124
235
|
},
|
|
125
236
|
needs: 'nothing',
|
|
237
|
+
mcp: 'core',
|
|
238
|
+
mcpOmit: ['entry'],
|
|
239
|
+
mcpDetails: '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.',
|
|
126
240
|
run: async (ctx) => {
|
|
127
241
|
if (ctx.flags.explain) {
|
|
128
242
|
const found = explainError(ctx.flags.explain);
|
package/lib/commands/init.d.ts
CHANGED
|
@@ -17,3 +17,26 @@ export type InitOptions = {
|
|
|
17
17
|
*/
|
|
18
18
|
export declare const mountAgent: (root: string) => string[];
|
|
19
19
|
export declare const init: (root: string, opts: InitOptions) => string[];
|
|
20
|
+
/**
|
|
21
|
+
* What a failed install said, short enough for one line. npm ends its errors with where its logs are, so
|
|
22
|
+
* the last lines alone told nothing; the reason (a peer range that does not match, say) comes before them.
|
|
23
|
+
*/
|
|
24
|
+
export declare const installFailure: (ran: {
|
|
25
|
+
stderr?: string | null;
|
|
26
|
+
error?: Error;
|
|
27
|
+
}) => string;
|
|
28
|
+
/** Adds lynkeus-agent to the app when it is not a dependency yet. It has native code, so the app is built again after. */
|
|
29
|
+
export declare const installAgent: (root: string) => string;
|
|
30
|
+
export type SetupCheck = {
|
|
31
|
+
what: string;
|
|
32
|
+
ok: boolean;
|
|
33
|
+
fix?: string;
|
|
34
|
+
};
|
|
35
|
+
/** What a project has of lynkeus and what it still lacks, each with the one thing that fixes it. Reads files only. */
|
|
36
|
+
export declare const setupChecks: (root: string) => SetupCheck[];
|
|
37
|
+
/** The packages the headless host needs that this project cannot load yet. */
|
|
38
|
+
export declare const headlessMissing: (root: string) => string[];
|
|
39
|
+
/** The preset \`headless start\` gives jest when the app's own configuration names none. */
|
|
40
|
+
export declare const jestPreset: (root: string) => string;
|
|
41
|
+
/** Adds what the headless host needs as development dependencies; a line saying what happened. */
|
|
42
|
+
export declare const installHeadless: (root: string) => string;
|