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.
- package/lib/cases/ahead.d.ts +31 -0
- package/lib/cases/ahead.js +90 -0
- package/lib/cases/fixtures.d.ts +12 -0
- package/lib/cases/fixtures.js +32 -0
- package/lib/cases/flakes.d.ts +28 -0
- package/lib/cases/flakes.js +61 -0
- package/lib/cases/format.d.ts +5 -0
- package/lib/cases/format.js +30 -4
- package/lib/cases/portClaim.d.ts +10 -0
- package/lib/cases/portClaim.js +55 -0
- package/lib/cases/runfile.d.ts +24 -0
- package/lib/cases/runfile.js +108 -0
- package/lib/cases/runner.d.ts +82 -3
- package/lib/cases/runner.js +394 -40
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +14 -4
- package/lib/cases/traces.d.ts +58 -0
- package/lib/cases/traces.js +250 -0
- package/lib/cli.js +28 -7
- package/lib/commands/cases.js +411 -42
- package/lib/commands/check.d.ts +6 -0
- package/lib/commands/check.js +8 -3
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +32 -8
- package/lib/commands/headless.js +132 -8
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +295 -33
- package/lib/commands/report.d.ts +97 -0
- package/lib/commands/report.js +228 -0
- package/lib/config.d.ts +26 -1
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +12 -0
- package/lib/device/devices.d.ts +5 -0
- package/lib/device/devices.js +27 -0
- package/lib/device/driver.js +2 -2
- package/lib/flow/runner.js +33 -1
- package/lib/flow/types.d.ts +2 -1
- package/lib/knowledge/contrast.d.ts +45 -0
- package/lib/knowledge/contrast.js +114 -0
- package/lib/knowledge/errors.js +1 -1
- package/lib/knowledge/lint.d.ts +2 -0
- package/lib/knowledge/lint.js +13 -3
- package/lib/mcp/server.js +31 -2
- package/lib/registry.d.ts +5 -0
- package/lib/targets.d.ts +10 -1
- package/lib/targets.js +16 -3
- package/package.json +10 -6
- package/src/cases/ahead.ts +111 -0
- package/src/cases/fixtures.ts +37 -0
- package/src/cases/flakes.ts +67 -0
- package/src/cases/format.ts +31 -6
- package/src/cases/portClaim.ts +52 -0
- package/src/cases/runfile.ts +119 -0
- package/src/cases/runner.ts +454 -46
- package/src/cases/templates.ts +14 -4
- package/src/cases/traces.ts +298 -0
- package/src/cli.ts +29 -7
- package/src/commands/cases.ts +428 -41
- package/src/commands/check.ts +11 -4
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +31 -8
- package/src/commands/headless.ts +143 -8
- package/src/commands/init.ts +338 -30
- package/src/commands/report.ts +272 -0
- package/src/config.ts +39 -2
- package/src/device/agentVersion.ts +15 -0
- package/src/device/devices.ts +28 -0
- package/src/device/driver.ts +2 -2
- package/src/flow/runner.ts +35 -1
- package/src/flow/types.ts +2 -2
- package/src/knowledge/contrast.ts +135 -0
- package/src/knowledge/errors.ts +1 -1
- package/src/knowledge/lint.ts +15 -4
- package/src/mcp/server.ts +31 -2
- package/src/registry.ts +2 -0
- package/src/targets.ts +17 -3
- package/lib/__tests__/args.test.d.ts +0 -1
- package/lib/__tests__/args.test.js +0 -69
- package/lib/__tests__/check.test.d.ts +0 -1
- package/lib/__tests__/check.test.js +0 -60
- package/lib/__tests__/init.test.d.ts +0 -1
- package/lib/__tests__/init.test.js +0 -42
- package/lib/__tests__/record.test.d.ts +0 -1
- package/lib/__tests__/record.test.js +0 -44
- package/lib/__tests__/snapshot.test.d.ts +0 -1
- package/lib/__tests__/snapshot.test.js +0 -47
- package/lib/__tests__/targets.test.d.ts +0 -1
- package/lib/__tests__/targets.test.js +0 -127
- package/lib/cases/__tests__/format.test.d.ts +0 -1
- package/lib/cases/__tests__/format.test.js +0 -61
- package/lib/cases/__tests__/runner.test.d.ts +0 -1
- package/lib/cases/__tests__/runner.test.js +0 -188
- package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
- package/lib/knowledge/__tests__/edges.test.js +0 -25
- package/lib/knowledge/__tests__/events.test.d.ts +0 -1
- package/lib/knowledge/__tests__/events.test.js +0 -39
- package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
- package/lib/knowledge/__tests__/extract.test.js +0 -69
- package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
- package/lib/knowledge/__tests__/frontier.test.js +0 -57
- package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
- package/lib/knowledge/__tests__/funnels.test.js +0 -40
- package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
- package/lib/knowledge/__tests__/lint.test.js +0 -53
- package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
- package/lib/knowledge/__tests__/perf.test.js +0 -34
- package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
- package/lib/knowledge/__tests__/plan.test.js +0 -35
- package/lib/knowledge/__tests__/query.test.d.ts +0 -1
- package/lib/knowledge/__tests__/query.test.js +0 -55
- package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
- package/lib/knowledge/__tests__/screens.test.js +0 -30
- package/lib/knowledge/__tests__/why.test.d.ts +0 -1
- package/lib/knowledge/__tests__/why.test.js +0 -28
- package/src/__tests__/args.test.ts +0 -82
- package/src/__tests__/check.test.ts +0 -71
- package/src/__tests__/init.test.ts +0 -52
- package/src/__tests__/record.test.ts +0 -49
- package/src/__tests__/snapshot.test.ts +0 -51
- package/src/__tests__/targets.test.ts +0 -132
- package/src/cases/__tests__/format.test.ts +0 -76
- package/src/cases/__tests__/runner.test.ts +0 -230
- package/src/knowledge/__tests__/edges.test.ts +0 -34
- package/src/knowledge/__tests__/events.test.ts +0 -45
- package/src/knowledge/__tests__/extract.test.ts +0 -88
- package/src/knowledge/__tests__/frontier.test.ts +0 -77
- package/src/knowledge/__tests__/funnels.test.ts +0 -58
- package/src/knowledge/__tests__/lint.test.ts +0 -66
- package/src/knowledge/__tests__/perf.test.ts +0 -41
- package/src/knowledge/__tests__/plan.test.ts +0 -52
- package/src/knowledge/__tests__/query.test.ts +0 -74
- package/src/knowledge/__tests__/screens.test.ts +0 -37
- package/src/knowledge/__tests__/why.test.ts +0 -40
package/lib/commands/drive.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
3
5
|
import { lynkeusDir } from '../config.js';
|
|
4
6
|
import { describeScreen, describeTrace } from '../device/driver.js';
|
|
5
7
|
import { readJson, writeJson } from '../files.js';
|
|
8
|
+
import { lintContrast } from '../knowledge/contrast.js';
|
|
6
9
|
import { describeFindings, lintScreen } from '../knowledge/lint.js';
|
|
7
10
|
import { describePerf, summarize, visitsFrom } from '../knowledge/perf.js';
|
|
8
11
|
import * as screens from '../knowledge/screens.js';
|
|
9
12
|
import { screenTree } from '../knowledge/tree.js';
|
|
10
13
|
import { define } from '../registry.js';
|
|
11
14
|
import { whyFor } from '../runtime.js';
|
|
12
|
-
import { expectedOf, isRoute, parseTarget, scopedTarget, TARGET_HELP } from '../targets.js';
|
|
15
|
+
import { expectedOf, isRoute, parseTarget, routeOf, scopedTarget, TARGET_HELP } from '../targets.js';
|
|
13
16
|
import { newFindings } from './check.js';
|
|
14
17
|
const SCOPE_FLAGS = {
|
|
15
18
|
in: { type: 'string', help: 'the match nearest this target', value: 'target' },
|
|
@@ -165,7 +168,7 @@ export const driveCommands = [
|
|
|
165
168
|
mcpOmit: ['nth'],
|
|
166
169
|
group: 'drive',
|
|
167
170
|
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 }],
|
|
171
|
+
positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP} | 800 (ms)`, required: true }],
|
|
169
172
|
flags: {
|
|
170
173
|
...SCOPE_FLAGS,
|
|
171
174
|
gone: { type: 'boolean', help: 'until it leaves the screen' },
|
|
@@ -181,7 +184,7 @@ export const driveCommands = [
|
|
|
181
184
|
}
|
|
182
185
|
return {
|
|
183
186
|
json: await (await ctx.device()).waitFor({
|
|
184
|
-
...(isRoute(raw) ? { route: raw } : { target: scopedTarget(raw, ctx.flags) }),
|
|
187
|
+
...(isRoute(raw) ? { route: routeOf(raw) } : { target: scopedTarget(raw, ctx.flags) }),
|
|
185
188
|
gone: ctx.flags.gone ? true : undefined,
|
|
186
189
|
scroll: ctx.flags.scroll ? true : undefined,
|
|
187
190
|
timeoutMs: ctx.flags.timeout,
|
|
@@ -206,7 +209,7 @@ export const driveCommands = [
|
|
|
206
209
|
name: 'see',
|
|
207
210
|
group: 'drive',
|
|
208
211
|
summary: 'Assert a route or element is on screen now (exit 1 if not)',
|
|
209
|
-
positionals: [{ name: 'what', help: `Route | ${TARGET_HELP}`, required: true }],
|
|
212
|
+
positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP}`, required: true }],
|
|
210
213
|
flags: {
|
|
211
214
|
absent: { type: 'boolean', help: 'assert it is not there' },
|
|
212
215
|
within: { type: 'number', help: 'allow it this long to appear', default: 0, value: 'ms' },
|
|
@@ -217,7 +220,7 @@ export const driveCommands = [
|
|
|
217
220
|
const device = await ctx.device();
|
|
218
221
|
try {
|
|
219
222
|
const result = await device.waitFor({
|
|
220
|
-
...(isRoute(raw) ? { route: raw } : { target: parseTarget(raw) }),
|
|
223
|
+
...(isRoute(raw) ? { route: routeOf(raw) } : { target: parseTarget(raw) }),
|
|
221
224
|
gone: ctx.flags.absent ? true : undefined,
|
|
222
225
|
timeoutMs: Math.max(1, ctx.flags.within),
|
|
223
226
|
});
|
|
@@ -341,9 +344,10 @@ export const driveCommands = [
|
|
|
341
344
|
define({
|
|
342
345
|
name: 'lint',
|
|
343
346
|
group: 'drive',
|
|
344
|
-
summary: 'Unlabeled controls, small targets, untranslated keys, covered or clipped elements',
|
|
347
|
+
summary: 'Unlabeled controls, small targets, low-contrast text, untranslated keys, covered or clipped elements',
|
|
345
348
|
flags: {
|
|
346
349
|
strict: { type: 'boolean', help: 'exit 1 on any warning' },
|
|
350
|
+
'no-contrast': { type: 'boolean', help: 'skip the contrast of text against its background (it takes a screenshot)' },
|
|
347
351
|
save: { type: 'string', help: "keep this screen's findings in a baseline file", value: 'file' },
|
|
348
352
|
baseline: { type: 'string', help: 'print only what is new against a baseline file', value: 'file' },
|
|
349
353
|
},
|
|
@@ -353,13 +357,33 @@ export const driveCommands = [
|
|
|
353
357
|
const { lint, lintBaseline } = ctx.config;
|
|
354
358
|
let findings = lintScreen(screen, { i18nKeyPatterns: lint?.i18nKeyPatterns?.map((p) => new RegExp(p, 'i')), minTouchTarget: lint?.minTouchTarget });
|
|
355
359
|
const route = screen.route ?? '?';
|
|
360
|
+
const notes = [];
|
|
361
|
+
if (lint?.contrast !== false && !ctx.flags['no-contrast']) {
|
|
362
|
+
// Contrast is read off the pixels, so it needs a host that can take a picture of the screen.
|
|
363
|
+
const file = path.join(os.tmpdir(), `lynkeus-lint-${process.pid}-${Date.now()}.png`);
|
|
364
|
+
try {
|
|
365
|
+
await (await ctx.device()).screenshot(file);
|
|
366
|
+
const picture = PNG.sync.read(fs.readFileSync(file));
|
|
367
|
+
const contrast = lint?.contrast;
|
|
368
|
+
findings = [
|
|
369
|
+
...findings,
|
|
370
|
+
...lintContrast(screen, picture, { normal: contrast?.normal, large: contrast?.large, ignore: contrast?.ignore?.map((p) => new RegExp(p)) }),
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
notes.push('contrast not measured: this host takes no screenshots');
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
fs.rmSync(file, { force: true });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
356
380
|
const withIds = findings.map((finding) => ({ finding, testId: screen.elements[finding.element]?.testId }));
|
|
357
381
|
const lines = [];
|
|
358
382
|
const save = ctx.flags.save;
|
|
359
383
|
const baselineFile = ctx.flags.baseline ?? lintBaseline;
|
|
360
384
|
if (save) {
|
|
361
385
|
const baseline = fs.existsSync(save) ? readJson(save) : {};
|
|
362
|
-
baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, message: f.finding.message }));
|
|
386
|
+
baseline[route] = withIds.map((f) => ({ rule: f.finding.rule, testId: f.testId, subject: f.finding.subject, message: f.finding.message }));
|
|
363
387
|
writeJson(save, baseline);
|
|
364
388
|
lines.push(`saved ${findings.length} finding(s) for ${route} → ${save}`);
|
|
365
389
|
}
|
|
@@ -367,7 +391,7 @@ export const driveCommands = [
|
|
|
367
391
|
findings = newFindings(readJson(baselineFile), route, withIds).map((f) => f.finding);
|
|
368
392
|
lines.push(findings.length ? `${findings.length} new against ${baselineFile}:` : `nothing new on ${route} against ${baselineFile}`);
|
|
369
393
|
}
|
|
370
|
-
lines.push(describeFindings(findings, screen.route));
|
|
394
|
+
lines.push(describeFindings(findings, screen.route), ...notes);
|
|
371
395
|
return { text: lines.join('\n'), json: findings, code: ctx.flags.strict && findings.some((f) => f.severity === 'warn') ? 1 : 0 };
|
|
372
396
|
},
|
|
373
397
|
}),
|
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 } 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,24 +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 = [
|
|
83
|
+
define({
|
|
84
|
+
name: 'init',
|
|
85
|
+
group: 'drive',
|
|
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' } },
|
|
89
|
+
needs: 'nothing',
|
|
90
|
+
mcp: 'core',
|
|
91
|
+
run: async (ctx) => {
|
|
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');
|
|
94
|
+
return { text: lines.join('\n'), json: lines };
|
|
95
|
+
},
|
|
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
|
+
}),
|
|
31
143
|
define({
|
|
32
144
|
name: 'headless start',
|
|
33
145
|
group: 'headless',
|
|
34
146
|
summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
|
|
35
|
-
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.",
|
|
36
148
|
flags: ENTRY_FLAG,
|
|
37
149
|
aliases: ['headless'],
|
|
38
150
|
needs: 'nothing',
|
|
39
151
|
session: false,
|
|
40
152
|
mcp: false,
|
|
41
153
|
run: async (ctx) => {
|
|
42
|
-
const token =
|
|
154
|
+
const token = readToken(ctx.root);
|
|
43
155
|
if (!token)
|
|
44
156
|
return {
|
|
45
|
-
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.',
|
|
46
158
|
code: 1,
|
|
47
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 };
|
|
48
163
|
const entry = entryOf(ctx);
|
|
49
164
|
if (!fs.existsSync(path.join(ctx.root, entry)))
|
|
50
165
|
return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
|
|
@@ -60,10 +175,10 @@ export const headlessCommands = [
|
|
|
60
175
|
fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
|
|
61
176
|
// Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
|
|
62
177
|
const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
|
|
63
|
-
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], {
|
|
64
179
|
cwd: ctx.root,
|
|
65
180
|
stdio: ['inherit', 'inherit', 'pipe'],
|
|
66
|
-
env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
|
|
181
|
+
env: { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, CI: '1', NODE_OPTIONS: nodeOptions },
|
|
67
182
|
});
|
|
68
183
|
let tail = '';
|
|
69
184
|
child.stderr?.on('data', (chunk) => {
|
|
@@ -89,8 +204,12 @@ export const headlessCommands = [
|
|
|
89
204
|
'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
|
|
90
205
|
auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
|
|
91
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' },
|
|
92
208
|
},
|
|
93
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`.',
|
|
94
213
|
run: async (ctx) => {
|
|
95
214
|
const lines = init(ctx.root, {
|
|
96
215
|
entry: entryOf(ctx),
|
|
@@ -100,6 +219,8 @@ export const headlessCommands = [
|
|
|
100
219
|
force: !!ctx.flags.force,
|
|
101
220
|
locale: { ...ctx.config.locale, language: ctx.config.language },
|
|
102
221
|
});
|
|
222
|
+
if (!ctx.flags['no-install'])
|
|
223
|
+
lines.unshift(installHeadless(ctx.root));
|
|
103
224
|
return { text: lines.join('\n'), json: lines };
|
|
104
225
|
},
|
|
105
226
|
}),
|
|
@@ -113,11 +234,14 @@ export const headlessCommands = [
|
|
|
113
234
|
explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
|
|
114
235
|
},
|
|
115
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.',
|
|
116
240
|
run: async (ctx) => {
|
|
117
241
|
if (ctx.flags.explain) {
|
|
118
242
|
const found = explainError(ctx.flags.explain);
|
|
119
243
|
return {
|
|
120
|
-
text: found.length ? describeExplanations(found).trim() : 'nothing in the
|
|
244
|
+
text: found.length ? describeExplanations(found).trim() : 'nothing in the catalog matches; paste the first line of the error, not the stack.',
|
|
121
245
|
json: found,
|
|
122
246
|
};
|
|
123
247
|
}
|
package/lib/commands/init.d.ts
CHANGED
|
@@ -10,4 +10,33 @@ export type InitOptions = {
|
|
|
10
10
|
language?: string;
|
|
11
11
|
};
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
|
|
15
|
+
* has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
|
|
16
|
+
* loads the router.
|
|
17
|
+
*/
|
|
18
|
+
export declare const mountAgent: (root: string) => string[];
|
|
13
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;
|