lynkeus 0.3.0 → 0.5.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/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +58 -1
- package/lib/cases/runner.js +322 -39
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +27 -4
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +154 -25
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +189 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +156 -20
- package/lib/config.d.ts +41 -0
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/device/driver.js +2 -2
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +45 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +372 -42
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +27 -4
- package/src/commands/cases.ts +165 -23
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +197 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +162 -18
- package/src/config.ts +48 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/device/driver.ts +2 -2
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +45 -4
- package/src/runtime.ts +24 -3
package/lib/commands/headless.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
3
5
|
import path from 'node:path';
|
|
4
|
-
import { lynkeusDir, portFromEnv } from '../config.js';
|
|
6
|
+
import { lynkeusDir, portFromEnv, readToken } from '../config.js';
|
|
5
7
|
import { describeExplanations, explainError } from '../knowledge/errors.js';
|
|
6
8
|
import { define } from '../registry.js';
|
|
7
9
|
import { doctor } from './doctor.js';
|
|
8
|
-
import { init, mountAgent } from './init.js';
|
|
10
|
+
import { headlessMissing, init, installAgent, installHeadless, jestPreset, mountAgent, setupChecks } from './init.js';
|
|
9
11
|
const ENTRY_FLAG = { entry: { type: 'string', help: 'the headless entry (or `entry` in lynkeus.config.json)', value: 'file' } };
|
|
10
12
|
const entryOf = (ctx) => ctx.flags.entry ?? ctx.config.entry ?? 'qa/headless.tsx';
|
|
11
13
|
/** 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 +29,176 @@ import * as entry from '${entryImport}';
|
|
|
27
29
|
hostApp(() => <entry.App />, { ${options.join(', ')} });
|
|
28
30
|
`;
|
|
29
31
|
};
|
|
32
|
+
const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'];
|
|
33
|
+
// React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
|
|
34
|
+
const apiFromConfig = (config) => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
|
|
35
|
+
/**
|
|
36
|
+
* A jest cache of its own for each API base. Babel inlines `process.env` into the code it transforms and jest
|
|
37
|
+
* keeps that output, so a host started with another API address kept calling the first one it was started with.
|
|
38
|
+
*/
|
|
39
|
+
export const cacheFor = (root, env, apiEnv = 'API_URL') => {
|
|
40
|
+
const key = createHash('sha256')
|
|
41
|
+
.update(`${apiEnv}=${env[apiEnv] ?? ''}`)
|
|
42
|
+
.digest('hex')
|
|
43
|
+
.slice(0, 12);
|
|
44
|
+
return path.join(lynkeusDir(root), 'jest-cache', key);
|
|
45
|
+
};
|
|
46
|
+
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)';
|
|
47
|
+
/**
|
|
48
|
+
* The import aliases in tsconfig.json's `paths`, as jest mappings, the longest first: \`@/assets/*\` has to be
|
|
49
|
+
* tried before \`@/*\`, which jest-expo does not do. The bundler reads them; jest knows nothing of them.
|
|
50
|
+
*/
|
|
51
|
+
const aliasesOf = (root) => {
|
|
52
|
+
let options;
|
|
53
|
+
try {
|
|
54
|
+
const text = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
55
|
+
options = JSON.parse(text).compilerOptions;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
const base = path.join(root, options?.baseUrl ?? '.');
|
|
61
|
+
const literal = (text) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
62
|
+
return Object.fromEntries(Object.entries(options?.paths ?? {})
|
|
63
|
+
.filter(([alias, [target]]) => alias.endsWith('/*') && target?.endsWith('/*'))
|
|
64
|
+
.sort(([a], [b]) => b.length - a.length)
|
|
65
|
+
.map(([alias, [target]]) => [`^${literal(alias.slice(0, -1))}(.*)$`, `${path.join(base, target.slice(0, -1))}$1`]));
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* 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:
|
|
69
|
+
* the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
|
|
70
|
+
*/
|
|
71
|
+
/** The app's react folder, when lynkeus-headless would load another copy; undefined when they are one. */
|
|
72
|
+
export const duplicateReact = (root) => {
|
|
73
|
+
const require = createRequire(import.meta.url);
|
|
74
|
+
try {
|
|
75
|
+
// Its exports leave out package.json: from its entry up to the folder that holds one.
|
|
76
|
+
let headless = path.dirname(fs.realpathSync(require.resolve('lynkeus-headless', { paths: [root] })));
|
|
77
|
+
while (!fs.existsSync(path.join(headless, 'package.json')) && path.dirname(headless) !== headless)
|
|
78
|
+
headless = path.dirname(headless);
|
|
79
|
+
const ours = path.dirname(require.resolve('react/package.json', { paths: [root] }));
|
|
80
|
+
const theirs = path.dirname(require.resolve('react/package.json', { paths: [headless] }));
|
|
81
|
+
return fs.realpathSync(ours) === fs.realpathSync(theirs) ? undefined : ours;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const jestDefaults = (root) => {
|
|
88
|
+
const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
|
|
89
|
+
let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
|
|
90
|
+
if (!file) {
|
|
91
|
+
try {
|
|
92
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
93
|
+
config = manifest.jest ? JSON.stringify(manifest.jest) : '';
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
const flags = [];
|
|
98
|
+
const react = duplicateReact(root);
|
|
99
|
+
// lynkeus-headless with a React of its own (a linked package, a workspace, pnpm) breaks every hook: the app and
|
|
100
|
+
// the renderer must share the app's copy.
|
|
101
|
+
const sameReact = react ? { '^react$': react, '^react/(.*)$': `${react}/$1` } : {};
|
|
102
|
+
if (!config) {
|
|
103
|
+
// A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
|
|
104
|
+
// behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
|
|
105
|
+
const stub = path.join(lynkeusDir(root), 'style-stub.js');
|
|
106
|
+
fs.mkdirSync(path.dirname(stub), { recursive: true });
|
|
107
|
+
fs.writeFileSync(stub, 'module.exports = {};\n');
|
|
108
|
+
flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root), ...sameReact })}`);
|
|
109
|
+
}
|
|
110
|
+
else if (react) {
|
|
111
|
+
if (config.includes('moduleNameMapper'))
|
|
112
|
+
process.stderr.write(`lynkeus: lynkeus-headless finds another copy of react than the app's; add to moduleNameMapper in the app's jest config: '^react$': '${react}', '^react/(.*)$': '${react}/$1'\n`);
|
|
113
|
+
else
|
|
114
|
+
flags.push(`--moduleNameMapper=${JSON.stringify(sameReact)}`);
|
|
115
|
+
}
|
|
116
|
+
// Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
|
|
117
|
+
if (!config.includes('transformIgnorePatterns'))
|
|
118
|
+
flags.push(`--transformIgnorePatterns=node_modules/(?!${TRANSFORM_PACKAGES}/)`, '--transformIgnorePatterns=/node_modules/react-native-reanimated/plugin/', '--transformIgnorePatterns=/node_modules/@react-native/babel-preset/');
|
|
119
|
+
return flags;
|
|
120
|
+
};
|
|
30
121
|
export const headlessCommands = [
|
|
31
122
|
define({
|
|
32
123
|
name: 'init',
|
|
33
124
|
group: 'drive',
|
|
34
|
-
summary: "
|
|
125
|
+
summary: "Set lynkeus up in this app: install the agent, mount it from the app's entry, make the project's token",
|
|
126
|
+
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.',
|
|
127
|
+
flags: { 'no-install': { type: 'boolean', help: 'do not add lynkeus-agent to the app; mount it only' } },
|
|
35
128
|
needs: 'nothing',
|
|
129
|
+
mcp: 'core',
|
|
36
130
|
run: async (ctx) => {
|
|
37
|
-
const lines = mountAgent(ctx.root);
|
|
131
|
+
const lines = [...(ctx.flags['no-install'] ? [] : [installAgent(ctx.root)]), ...mountAgent(ctx.root)];
|
|
132
|
+
lines.push('next: build the app again and open it, then `lynkeus status` (over MCP: the status tool) to see that it attaches');
|
|
38
133
|
return { text: lines.join('\n'), json: lines };
|
|
39
134
|
},
|
|
40
135
|
}),
|
|
136
|
+
define({
|
|
137
|
+
name: 'status',
|
|
138
|
+
group: 'drive',
|
|
139
|
+
summary: 'What this project has of lynkeus and what is missing: the agent, the token, a running app, cases, the reports key',
|
|
140
|
+
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.',
|
|
141
|
+
flags: { offline: { type: 'boolean', help: 'check the files only; do not wait for a running app' } },
|
|
142
|
+
needs: 'nothing',
|
|
143
|
+
mcp: 'core',
|
|
144
|
+
run: async (ctx) => {
|
|
145
|
+
const checks = setupChecks(ctx.root);
|
|
146
|
+
const ready = checks.every((c) => c.ok);
|
|
147
|
+
if (ready && !ctx.flags.offline) {
|
|
148
|
+
try {
|
|
149
|
+
const device = await ctx.device();
|
|
150
|
+
checks.push({ what: `an app is attached (${device.info.name})`, ok: true });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
checks.push({
|
|
154
|
+
what: 'an app is attached',
|
|
155
|
+
ok: false,
|
|
156
|
+
fix: 'open a development build made after `lynkeus init`, with Metro restarted (`--reset-cache`); or `lynkeus headless start` for no simulator',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const unloadable = headlessMissing(ctx.root);
|
|
161
|
+
checks.push({
|
|
162
|
+
what: unloadable.length ? 'the app can run headless, in Node with no simulator' : 'the app can run headless (jest, Testing Library, lynkeus-headless)',
|
|
163
|
+
ok: !unloadable.length && fs.existsSync(path.join(ctx.root, entryOf(ctx))),
|
|
164
|
+
fix: '`lynkeus headless init` installs what it needs and writes its entry; optional',
|
|
165
|
+
});
|
|
166
|
+
const dir = path.resolve(ctx.config.cases?.base ?? ctx.root, ctx.config.cases?.dir ?? 'cases');
|
|
167
|
+
const cases = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f)).length : 0;
|
|
168
|
+
checks.push({
|
|
169
|
+
what: cases ? `${cases} case${cases === 1 ? '' : 's'} in ${path.relative(ctx.root, dir) || '.'}` : 'cases to run',
|
|
170
|
+
ok: cases > 0,
|
|
171
|
+
fix: '`lynkeus case init` writes an example and the format',
|
|
172
|
+
});
|
|
173
|
+
checks.push({
|
|
174
|
+
what: 'runs can be pushed to the reports service',
|
|
175
|
+
ok: !!process.env.LYNKEUS_REPORTS_KEY,
|
|
176
|
+
fix: 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); optional',
|
|
177
|
+
});
|
|
178
|
+
const text = checks.map((c) => `${c.ok ? '✓' : '✗'} ${c.what}${c.ok || !c.fix ? '' : ` — ${c.fix}`}`).join('\n');
|
|
179
|
+
return { text, json: checks };
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
41
182
|
define({
|
|
42
183
|
name: 'headless start',
|
|
43
184
|
group: 'headless',
|
|
44
185
|
summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
|
|
45
|
-
details:
|
|
186
|
+
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
187
|
flags: ENTRY_FLAG,
|
|
47
188
|
aliases: ['headless'],
|
|
48
189
|
needs: 'nothing',
|
|
49
190
|
session: false,
|
|
50
191
|
mcp: false,
|
|
51
192
|
run: async (ctx) => {
|
|
52
|
-
const token =
|
|
193
|
+
const token = readToken(ctx.root);
|
|
53
194
|
if (!token)
|
|
54
195
|
return {
|
|
55
|
-
text: 'lynkeus headless needs
|
|
196
|
+
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
197
|
code: 1,
|
|
57
198
|
};
|
|
199
|
+
const missing = headlessMissing(ctx.root);
|
|
200
|
+
if (missing.length)
|
|
201
|
+
return { text: `lynkeus headless runs the app under jest and needs ${missing.join(', ')}; \`lynkeus headless init\` installs them.`, code: 1 };
|
|
58
202
|
const entry = entryOf(ctx);
|
|
59
203
|
if (!fs.existsSync(path.join(ctx.root, entry)))
|
|
60
204
|
return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
|
|
@@ -70,17 +214,44 @@ export const headlessCommands = [
|
|
|
70
214
|
fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
|
|
71
215
|
// Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
|
|
72
216
|
const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
|
|
73
|
-
|
|
217
|
+
// LYNKEUS_HEADLESS tells the app's jest setup a host is starting, not its unit tests: a setup that stubs a native
|
|
218
|
+
// SDK whole can leave it real there, so the call reaches the stand-in `nativeMocks` shows.
|
|
219
|
+
const env = { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, LYNKEUS_HEADLESS: '1', CI: '1', NODE_OPTIONS: nodeOptions };
|
|
220
|
+
const cache = `--cacheDirectory=${cacheFor(ctx.root, env, ctx.config.apiEnv)}`;
|
|
221
|
+
const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), cache, ...ctx.passthrough], {
|
|
74
222
|
cwd: ctx.root,
|
|
75
223
|
stdio: ['inherit', 'inherit', 'pipe'],
|
|
76
|
-
env
|
|
224
|
+
env,
|
|
225
|
+
// Its own process group: npx, jest and the host it runs end together. Stopping only npx left the
|
|
226
|
+
// host dialing the port, where a later run's driver took it for the app it meant to drive.
|
|
227
|
+
detached: true,
|
|
228
|
+
});
|
|
229
|
+
const stop = (signal) => {
|
|
230
|
+
try {
|
|
231
|
+
if (child.pid)
|
|
232
|
+
process.kill(-child.pid, signal);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// already gone
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
239
|
+
const forward = signals.map((signal) => {
|
|
240
|
+
const handler = () => stop(signal);
|
|
241
|
+
process.on(signal, handler);
|
|
242
|
+
return () => process.off(signal, handler);
|
|
77
243
|
});
|
|
244
|
+
const onExit = () => stop('SIGTERM');
|
|
245
|
+
process.on('exit', onExit);
|
|
78
246
|
let tail = '';
|
|
79
247
|
child.stderr?.on('data', (chunk) => {
|
|
80
248
|
process.stderr.write(chunk);
|
|
81
249
|
tail = (tail + chunk.toString()).slice(-20000);
|
|
82
250
|
});
|
|
83
251
|
const code = await new Promise((resolve) => child.on('exit', (c) => resolve(c ?? 0)));
|
|
252
|
+
for (const off of forward)
|
|
253
|
+
off();
|
|
254
|
+
process.off('exit', onExit);
|
|
84
255
|
if (code) {
|
|
85
256
|
const found = explainError(tail);
|
|
86
257
|
if (found.length)
|
|
@@ -99,8 +270,12 @@ export const headlessCommands = [
|
|
|
99
270
|
'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
|
|
100
271
|
auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
|
|
101
272
|
force: { type: 'boolean', help: 'overwrite an existing entry' },
|
|
273
|
+
'no-install': { type: 'boolean', help: 'do not add jest, Testing Library and lynkeus-headless to the project' },
|
|
102
274
|
},
|
|
103
275
|
needs: 'nothing',
|
|
276
|
+
mcp: 'core',
|
|
277
|
+
mcpOmit: ['entry', 'auto', 'force'],
|
|
278
|
+
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
279
|
run: async (ctx) => {
|
|
105
280
|
const lines = init(ctx.root, {
|
|
106
281
|
entry: entryOf(ctx),
|
|
@@ -110,6 +285,8 @@ export const headlessCommands = [
|
|
|
110
285
|
force: !!ctx.flags.force,
|
|
111
286
|
locale: { ...ctx.config.locale, language: ctx.config.language },
|
|
112
287
|
});
|
|
288
|
+
if (!ctx.flags['no-install'])
|
|
289
|
+
lines.unshift(installHeadless(ctx.root));
|
|
113
290
|
return { text: lines.join('\n'), json: lines };
|
|
114
291
|
},
|
|
115
292
|
}),
|
|
@@ -123,6 +300,9 @@ export const headlessCommands = [
|
|
|
123
300
|
explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
|
|
124
301
|
},
|
|
125
302
|
needs: 'nothing',
|
|
303
|
+
mcp: 'core',
|
|
304
|
+
mcpOmit: ['entry'],
|
|
305
|
+
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
306
|
run: async (ctx) => {
|
|
127
307
|
if (ctx.flags.explain) {
|
|
128
308
|
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;
|
package/lib/commands/init.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import { packageName } from '../config.js';
|
|
5
|
+
import { ensureToken, packageName, readToken } from '../config.js';
|
|
4
6
|
const APP_CANDIDATES = ['App.tsx', 'App.jsx', 'App.js', 'app/App.tsx', 'src/App.tsx', 'src/App.jsx', 'src/App.js'];
|
|
5
7
|
const INDEX_CANDIDATES = ['index.js', 'index.tsx', 'index.ts'];
|
|
6
8
|
const findFirst = (root, candidates) => candidates.find((c) => fs.existsSync(path.join(root, c)));
|
|
9
|
+
/** The file that already mounts the agent: the entry, or the App component of an app that renders `<Lynkeus>` itself. */
|
|
10
|
+
const mountedIn = (root) => [...INDEX_CANDIDATES, ...APP_CANDIDATES].find((file) => {
|
|
11
|
+
try {
|
|
12
|
+
return /from ['"]lynkeus-agent(\/auto)?['"]/.test(fs.readFileSync(path.join(root, file), 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
7
18
|
const relImport = (fromFile, toFile) => {
|
|
8
19
|
const rel = path
|
|
9
20
|
.relative(path.dirname(fromFile), toFile)
|
|
@@ -11,7 +22,36 @@ const relImport = (fromFile, toFile) => {
|
|
|
11
22
|
.replace(/\.(tsx|ts|jsx|js)$/, '');
|
|
12
23
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
13
24
|
};
|
|
14
|
-
|
|
25
|
+
/** The folder of an Expo Router app's routes, which is the app when there is no root component to import. */
|
|
26
|
+
const routesOf = (root) => {
|
|
27
|
+
try {
|
|
28
|
+
createRequire(path.join(root, 'package.json')).resolve('expo-router/package.json');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return ['src/app', 'app'].find((dir) => fs.existsSync(path.join(root, dir, '_layout.tsx')) ||
|
|
34
|
+
fs.existsSync(path.join(root, dir, '_layout.js')) ||
|
|
35
|
+
fs.existsSync(path.join(root, dir, 'index.tsx')));
|
|
36
|
+
};
|
|
37
|
+
const appBlock = (source) => 'component' in source
|
|
38
|
+
? `// Whatever the app's index runs before rendering (polyfills, i18n, services):
|
|
39
|
+
// require it here too, then the App.
|
|
40
|
+
const { default: App } = require('${source.component}')`
|
|
41
|
+
: `// An Expo Router app has no root component: the router is the app, over the route
|
|
42
|
+
// files in ${source.dir}, read here as the bundler would read them. Whatever the
|
|
43
|
+
// app's root layout does not run before rendering (polyfills, i18n), require it first.
|
|
44
|
+
process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'
|
|
45
|
+
// Its testing library first: it stands in for what has no native side here (expo-linking's
|
|
46
|
+
// scheme, Reanimated), and the router must load after it.
|
|
47
|
+
const { getMockContext } = require('expo-router/testing-library')
|
|
48
|
+
const React = require('react')
|
|
49
|
+
const { ExpoRoot } = require('expo-router')
|
|
50
|
+
const routes = getMockContext(require('path').resolve(__dirname, '${source.routes}'))
|
|
51
|
+
// Mounted as a device build mounts it, so the agent finds the router's navigation by itself.
|
|
52
|
+
require('lynkeus-agent/auto').auto({ token: process.env.LYNKEUS_TOKEN })
|
|
53
|
+
const App = () => React.createElement(ExpoRoot, { context: routes })`;
|
|
54
|
+
const entryTemplate = (source, apiEnv, appName, locale) => {
|
|
15
55
|
const LOCALE = locale?.languageTag || locale?.country || locale?.language
|
|
16
56
|
? `\n // The locale this app is built for, from lynkeus.config.json.\n locale: ${JSON.stringify({ country: locale.country, languageTag: locale.languageTag, languageCode: locale.language })},`
|
|
17
57
|
: "\n // locale: { country: 'DE', languageTag: 'de-DE', languageCode: 'de' },";
|
|
@@ -36,9 +76,7 @@ installDefaultMocks({
|
|
|
36
76
|
// lists the native modules the app references that nothing covers yet:
|
|
37
77
|
// jest.mock('some-native-package', () => ({ method: () => undefined }))
|
|
38
78
|
|
|
39
|
-
|
|
40
|
-
// require it here too, then the App.
|
|
41
|
-
const { default: App } = require('${appImport}')
|
|
79
|
+
${appBlock(source)}
|
|
42
80
|
|
|
43
81
|
export { App }
|
|
44
82
|
`;
|
|
@@ -67,14 +105,29 @@ const addTokenPlugin = (root) => {
|
|
|
67
105
|
return `${file} already has ${TOKEN_PLUGIN}`;
|
|
68
106
|
const plugins = /\bplugins\s*:\s*\[/.exec(text);
|
|
69
107
|
const presets = /^([ \t]*)presets\s*:/m.exec(text);
|
|
70
|
-
|
|
108
|
+
// A config written on one line has its presets after the brace, not at the start of a line.
|
|
109
|
+
const inline = plugins || presets ? null : /\bpresets\s*:/.exec(text);
|
|
110
|
+
if (!plugins && !presets && !inline)
|
|
71
111
|
return byHand;
|
|
72
112
|
const next = plugins
|
|
73
113
|
? `${text.slice(0, plugins.index + plugins[0].length)}'${TOKEN_PLUGIN}', ${text.slice(plugins.index + plugins[0].length)}`
|
|
74
|
-
:
|
|
114
|
+
: presets
|
|
115
|
+
? `${text.slice(0, presets.index)}${presets[1]}plugins: ['${TOKEN_PLUGIN}'],\n${text.slice(presets.index)}`
|
|
116
|
+
: `${text.slice(0, inline.index)}plugins: ['${TOKEN_PLUGIN}'], ${text.slice(inline.index)}`;
|
|
75
117
|
fs.writeFileSync(path.join(root, file), next);
|
|
76
118
|
return `added ${TOKEN_PLUGIN} to ${file}`;
|
|
77
119
|
};
|
|
120
|
+
const EXPO_ENV = '.env.local';
|
|
121
|
+
const EXPO_TOKEN = 'EXPO_PUBLIC_LYNKEUS_TOKEN';
|
|
122
|
+
/** Expo passes the app only variables that start with EXPO_PUBLIC_, and reads them from `.env.local`, which its template keeps out of git. */
|
|
123
|
+
const giveExpoTheToken = (root, token) => {
|
|
124
|
+
const file = path.join(root, EXPO_ENV);
|
|
125
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
126
|
+
if (new RegExp(`^${EXPO_TOKEN}=`, 'm').test(text))
|
|
127
|
+
return `${EXPO_ENV} already sets ${EXPO_TOKEN}`;
|
|
128
|
+
fs.writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${EXPO_TOKEN}=${token}\n`);
|
|
129
|
+
return `wrote ${EXPO_TOKEN} to ${EXPO_ENV} (check it is in .gitignore: Expo's template ignores .env*.local)`;
|
|
130
|
+
};
|
|
78
131
|
/**
|
|
79
132
|
* Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
|
|
80
133
|
* has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
|
|
@@ -86,7 +139,11 @@ export const mountAgent = (root) => {
|
|
|
86
139
|
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
87
140
|
const index = findFirst(root, INDEX_CANDIDATES);
|
|
88
141
|
const routed = manifest.main === 'expo-router/entry';
|
|
89
|
-
|
|
142
|
+
const already = mountedIn(root);
|
|
143
|
+
if (already) {
|
|
144
|
+
notes.push(`${already} already mounts the agent`);
|
|
145
|
+
}
|
|
146
|
+
else if (routed && !index) {
|
|
90
147
|
fs.writeFileSync(path.join(root, 'index.js'), `${mountLines(true)}\nimport 'expo-router/entry'\n`);
|
|
91
148
|
fs.writeFileSync(path.join(root, 'package.json'), `${JSON.stringify({ ...JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')), main: 'index.js' }, null, 2)}\n`);
|
|
92
149
|
notes.push("wrote index.js (mounts the agent, then loads expo-router) and pointed package.json's main at it");
|
|
@@ -98,24 +155,23 @@ export const mountAgent = (root) => {
|
|
|
98
155
|
else {
|
|
99
156
|
const indexPath = path.join(root, index);
|
|
100
157
|
const text = fs.readFileSync(indexPath, 'utf8');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
107
|
-
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length + 1 : 0;
|
|
108
|
-
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
109
|
-
notes.push(`added auto() to ${index}`);
|
|
110
|
-
}
|
|
158
|
+
// After the last import, so the app's own polyfills still run first.
|
|
159
|
+
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
160
|
+
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length + 1 : 0;
|
|
161
|
+
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
162
|
+
notes.push(`added auto() to ${index}`);
|
|
111
163
|
}
|
|
164
|
+
const { token, made } = ensureToken(root);
|
|
165
|
+
if (made)
|
|
166
|
+
notes.push('made a token for this project in .lynkeus/token (kept out of git); the CLI, the MCP server and the app all read it');
|
|
112
167
|
if (!expo) {
|
|
113
168
|
notes.push(addTokenPlugin(root));
|
|
114
|
-
notes.push('
|
|
169
|
+
notes.push('restart Metro with --reset-cache so the bundle picks the token up; LYNKEUS_TOKEN in the environment still wins over the file');
|
|
115
170
|
}
|
|
116
171
|
if (expo) {
|
|
172
|
+
notes.push(giveExpoTheToken(root, token));
|
|
117
173
|
notes.push('Expo: the agent has native code, so it runs in a development build (npx expo run:ios, npx expo run:android), not in Expo Go');
|
|
118
|
-
notes.push('Expo:
|
|
174
|
+
notes.push('Expo: restart the dev server so it reads .env.local; EXPO_PUBLIC_LYNKEUS_TOKEN in the environment still wins over the file');
|
|
119
175
|
}
|
|
120
176
|
return notes;
|
|
121
177
|
};
|
|
@@ -123,20 +179,169 @@ export const init = (root, opts) => {
|
|
|
123
179
|
const notes = [];
|
|
124
180
|
const entryPath = path.join(root, opts.entry);
|
|
125
181
|
const app = opts.app ?? findFirst(root, APP_CANDIDATES);
|
|
126
|
-
|
|
127
|
-
|
|
182
|
+
const routes = app ? undefined : routesOf(root);
|
|
183
|
+
if (!app && !routes)
|
|
184
|
+
throw new Error(`no App component found (${APP_CANDIDATES.join(', ')}) and no Expo Router routes; pass --component <path>`);
|
|
128
185
|
if (fs.existsSync(entryPath) && !opts.force) {
|
|
129
186
|
notes.push(`${opts.entry} exists; left as is (--force overwrites)`);
|
|
130
187
|
}
|
|
131
188
|
else {
|
|
132
189
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
|
133
|
-
|
|
134
|
-
|
|
190
|
+
const source = app
|
|
191
|
+
? { component: relImport(entryPath, path.join(root, app)) }
|
|
192
|
+
: { routes: path.relative(path.dirname(entryPath), path.join(root, routes)).replace(/\\/g, '/'), dir: routes };
|
|
193
|
+
fs.writeFileSync(entryPath, entryTemplate(source, opts.apiEnv, packageName(root), opts.locale));
|
|
194
|
+
notes.push(`wrote ${opts.entry} (${app ? `app: ${app}` : `Expo Router, routes in ${routes}`})`);
|
|
135
195
|
}
|
|
136
196
|
if (opts.auto)
|
|
137
197
|
notes.push(...mountAgent(root));
|
|
138
|
-
|
|
139
|
-
|
|
198
|
+
// `headless start` refuses to run without one, and an app tested only headless never runs `lynkeus init`.
|
|
199
|
+
else if (ensureToken(root).made)
|
|
200
|
+
notes.push('made a token for this project in .lynkeus/token (kept out of git); the CLI and the MCP server read it');
|
|
201
|
+
notes.push("jest: when the app has no jest configuration, `headless start` passes the preset it needs (jest-expo for Expo, React Native's otherwise) and the packages to transpile; an app that sets its own lets 'lynkeus-[^/]+' through, next to react-native");
|
|
202
|
+
notes.push(`next: ${opts.apiEnv}=http://127.0.0.1:3000/ lynkeus headless start (or put "apiUrl": "http://127.0.0.1:3000/" in lynkeus.config.json; the token is the project's, in .lynkeus/token)`);
|
|
140
203
|
notes.push(' then, in another shell: lynkeus screen (or lynkeus headless doctor to see what still needs a mock)');
|
|
141
204
|
return notes;
|
|
142
205
|
};
|
|
206
|
+
const AGENT = 'lynkeus-agent';
|
|
207
|
+
/** The package manager whose lockfile is in the app's folder or above it: an app in a monorepo has it at the top. */
|
|
208
|
+
const managerOf = (root) => {
|
|
209
|
+
for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
|
|
210
|
+
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock')))
|
|
211
|
+
return 'bun';
|
|
212
|
+
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')))
|
|
213
|
+
return 'pnpm';
|
|
214
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock')))
|
|
215
|
+
return 'yarn';
|
|
216
|
+
if (fs.existsSync(path.join(dir, 'package-lock.json')) || fs.existsSync(path.join(dir, '.git')) || path.dirname(dir) === dir)
|
|
217
|
+
return 'npm';
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const installCommand = (root, expo) => {
|
|
221
|
+
if (expo)
|
|
222
|
+
return ['npx', 'expo', 'install', AGENT];
|
|
223
|
+
const manager = managerOf(root);
|
|
224
|
+
return manager === 'npm' ? ['npm', 'install', AGENT] : [manager, 'add', AGENT];
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* What a failed install said, short enough for one line. npm ends its errors with where its logs are, so
|
|
228
|
+
* the last lines alone told nothing; the reason (a peer range that does not match, say) comes before them.
|
|
229
|
+
*/
|
|
230
|
+
export const installFailure = (ran) => `${ran.stderr ?? ''}\n${ran.error?.message ?? ''}`
|
|
231
|
+
.split('\n')
|
|
232
|
+
.map((line) => line.replace(/^(npm (error|ERR!)|error|ERR_PNPM_\w+|YN\d{4}:)\s*/, '').trim())
|
|
233
|
+
.filter((line) => line && !/^code \w+$|^node_modules\/|_logs|complete log|full report|^Fix the upstream|^this command with|^to accept an incorrect/i.test(line))
|
|
234
|
+
.slice(0, 8)
|
|
235
|
+
.join('; ');
|
|
236
|
+
const hasAgent = (manifest) => !!(manifest.dependencies?.[AGENT] ?? manifest.devDependencies?.[AGENT]);
|
|
237
|
+
/** Adds lynkeus-agent to the app when it is not a dependency yet. It has native code, so the app is built again after. */
|
|
238
|
+
export const installAgent = (root) => {
|
|
239
|
+
const manifest = readManifest(root);
|
|
240
|
+
if (hasAgent(manifest))
|
|
241
|
+
return `${AGENT} is already a dependency`;
|
|
242
|
+
const command = installCommand(root, !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo));
|
|
243
|
+
const ran = spawnSync(command[0], command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
244
|
+
if (ran.status === 0)
|
|
245
|
+
return `installed ${AGENT} (${command.join(' ')}); it has native code, so build the app again`;
|
|
246
|
+
const said = installFailure(ran);
|
|
247
|
+
return `could not install ${AGENT} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself, then \`lynkeus init\` again`;
|
|
248
|
+
};
|
|
249
|
+
/** What a project has of lynkeus and what it still lacks, each with the one thing that fixes it. Reads files only. */
|
|
250
|
+
export const setupChecks = (root) => {
|
|
251
|
+
const manifest = readManifest(root);
|
|
252
|
+
if (!manifest.dependencies && !manifest.devDependencies)
|
|
253
|
+
return [{ what: 'a package.json here', ok: false, fix: 'run this from the app\u2019s folder (or pass --root)' }];
|
|
254
|
+
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
255
|
+
const checks = [];
|
|
256
|
+
const depends = hasAgent(manifest);
|
|
257
|
+
checks.push({ what: `${AGENT} is a dependency`, ok: depends, fix: '`lynkeus init` installs it' });
|
|
258
|
+
if (depends)
|
|
259
|
+
checks.push({
|
|
260
|
+
what: `${AGENT} is installed`,
|
|
261
|
+
ok: fs.existsSync(path.join(root, 'node_modules', AGENT, 'package.json')),
|
|
262
|
+
fix: 'install the project\u2019s dependencies',
|
|
263
|
+
});
|
|
264
|
+
const mounted = mountedIn(root);
|
|
265
|
+
checks.push({ what: mounted ? `${mounted} mounts the agent` : 'the app mounts the agent', ok: !!mounted, fix: '`lynkeus init` adds it to the entry' });
|
|
266
|
+
if (!expo) {
|
|
267
|
+
const babel = findFirst(root, BABEL_CANDIDATES);
|
|
268
|
+
checks.push({
|
|
269
|
+
what: 'babel.config.js has lynkeus-agent/babel',
|
|
270
|
+
ok: !!babel && fs.readFileSync(path.join(root, babel), 'utf8').includes(TOKEN_PLUGIN),
|
|
271
|
+
fix: '`lynkeus init` adds it',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const token = readToken(root);
|
|
275
|
+
checks.push({ what: 'the project has a token', ok: !!token, fix: '`lynkeus init` makes one in .lynkeus/token' });
|
|
276
|
+
if (expo && token) {
|
|
277
|
+
const env = path.join(root, EXPO_ENV);
|
|
278
|
+
const given = !!process.env[EXPO_TOKEN] || (fs.existsSync(env) && new RegExp(`^${EXPO_TOKEN}=`, 'm').test(fs.readFileSync(env, 'utf8')));
|
|
279
|
+
checks.push({ what: `Expo is given the token (${EXPO_TOKEN})`, ok: given, fix: '`lynkeus init` writes it to .env.local' });
|
|
280
|
+
}
|
|
281
|
+
return checks;
|
|
282
|
+
};
|
|
283
|
+
/** What `lynkeus headless start` runs on: jest hosts the app, Testing Library renders it, lynkeus-headless serves the agent. */
|
|
284
|
+
const HEADLESS_PACKAGES = ['jest', '@testing-library/react-native', 'lynkeus-headless'];
|
|
285
|
+
const resolvable = (root, name) => {
|
|
286
|
+
try {
|
|
287
|
+
createRequire(path.join(root, 'package.json')).resolve(`${name}/package.json`);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
// A package whose exports leave package.json out is still there.
|
|
292
|
+
return error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED';
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const JEST_PRESET = '@react-native/jest-preset';
|
|
296
|
+
/** The app's React Native version, when it is installed. */
|
|
297
|
+
const reactNativeVersion = (root) => {
|
|
298
|
+
try {
|
|
299
|
+
const file = createRequire(path.join(root, 'package.json')).resolve('react-native/package.json');
|
|
300
|
+
return JSON.parse(fs.readFileSync(file, 'utf8')).version;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
// React Native 0.86 moved its jest preset out of `react-native`, which now throws when jest asks it for one.
|
|
307
|
+
const presetMoved = (version) => {
|
|
308
|
+
const [major, minor] = (version ?? '').split('.').map(Number);
|
|
309
|
+
return major === 0 ? (minor ?? 0) >= 86 : (major ?? 0) > 0;
|
|
310
|
+
};
|
|
311
|
+
const JEST_EXPO = 'jest-expo';
|
|
312
|
+
const isExpo = (root) => {
|
|
313
|
+
const manifest = readManifest(root);
|
|
314
|
+
return !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* The jest preset the app needs and lacks: an Expo app's is jest-expo, which gives Expo's modules the native
|
|
318
|
+
* side they look for (\`globalThis.expo\`); React Native 0.86 and later keep theirs out of \`react-native\`.
|
|
319
|
+
*/
|
|
320
|
+
const presetNeeded = (root) => (isExpo(root) ? JEST_EXPO : presetMoved(reactNativeVersion(root)) ? JEST_PRESET : undefined);
|
|
321
|
+
/** The packages the headless host needs that this project cannot load yet. */
|
|
322
|
+
export const headlessMissing = (root) => [...HEADLESS_PACKAGES, ...[presetNeeded(root)].filter((p) => !!p)].filter((name) => !resolvable(root, name));
|
|
323
|
+
/** The preset \`headless start\` gives jest when the app's own configuration names none. */
|
|
324
|
+
export const jestPreset = (root) => (resolvable(root, JEST_EXPO) ? JEST_EXPO : resolvable(root, JEST_PRESET) ? JEST_PRESET : 'react-native');
|
|
325
|
+
const devInstallCommand = (root, names) => ({ bun: ['bun', 'add', '-d'], pnpm: ['pnpm', 'add', '-D'], yarn: ['yarn', 'add', '-D'], npm: ['npm', 'install', '--save-dev'] })[managerOf(root)].concat(names);
|
|
326
|
+
const install = (root, command, names) => {
|
|
327
|
+
const ran = spawnSync(command[0], command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
328
|
+
if (ran.status === 0)
|
|
329
|
+
return `installed ${names.join(', ')} (${command.join(' ')})`;
|
|
330
|
+
const said = installFailure(ran);
|
|
331
|
+
return `could not install ${names.join(', ')} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself`;
|
|
332
|
+
};
|
|
333
|
+
/** Adds what the headless host needs as development dependencies; a line saying what happened. */
|
|
334
|
+
export const installHeadless = (root) => {
|
|
335
|
+
const missing = headlessMissing(root);
|
|
336
|
+
if (!missing.length)
|
|
337
|
+
return `jest, Testing Library, lynkeus-headless and the ${jestPreset(root)} preset are installed`;
|
|
338
|
+
const lines = [];
|
|
339
|
+
const plain = missing.filter((name) => name !== JEST_EXPO);
|
|
340
|
+
// The React Native preset is released with React Native, version for version.
|
|
341
|
+
if (plain.length)
|
|
342
|
+
lines.push(install(root, devInstallCommand(root, plain.map((name) => (name === JEST_PRESET ? `${name}@${reactNativeVersion(root)}` : name))), plain));
|
|
343
|
+
// Expo's installer picks the jest-expo of the app's SDK.
|
|
344
|
+
if (missing.includes(JEST_EXPO))
|
|
345
|
+
lines.push(install(root, ['npx', 'expo', 'install', JEST_EXPO, '--', '--save-dev'], [JEST_EXPO]));
|
|
346
|
+
return lines.join('\n');
|
|
347
|
+
};
|
package/lib/commands/map.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
/** Source files a branch changed against `base`, committed or not. */
|
|
2
|
+
export declare const changedSources: (root: string, base: string, err: (line: string) => void) => string[];
|
|
1
3
|
export declare const mapCommands: import("../registry.js").Command[];
|
|
2
4
|
export declare const mapCheckWorkflow: (depth: number) => string;
|