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/src/commands/cases.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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 });
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/headless.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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 =
|
|
169
|
+
const token = readToken(ctx.root);
|
|
58
170
|
if (!token)
|
|
59
171
|
return {
|
|
60
|
-
text: 'lynkeus headless needs
|
|
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);
|