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/src/commands/init.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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
5
|
|
|
4
|
-
import { packageName } from '../config.js';
|
|
6
|
+
import { ensureToken, packageName, readToken } from '../config.js';
|
|
5
7
|
|
|
6
8
|
const APP_CANDIDATES = ['App.tsx', 'App.jsx', 'App.js', 'app/App.tsx', 'src/App.tsx', 'src/App.jsx', 'src/App.js'];
|
|
7
9
|
const INDEX_CANDIDATES = ['index.js', 'index.tsx', 'index.ts'];
|
|
@@ -17,6 +19,16 @@ export type InitOptions = {
|
|
|
17
19
|
|
|
18
20
|
const findFirst = (root: string, candidates: string[]): string | undefined => candidates.find((c) => fs.existsSync(path.join(root, c)));
|
|
19
21
|
|
|
22
|
+
/** The file that already mounts the agent: the entry, or the App component of an app that renders `<Lynkeus>` itself. */
|
|
23
|
+
const mountedIn = (root: string): string | undefined =>
|
|
24
|
+
[...INDEX_CANDIDATES, ...APP_CANDIDATES].find((file) => {
|
|
25
|
+
try {
|
|
26
|
+
return /from ['"]lynkeus-agent(\/auto)?['"]/.test(fs.readFileSync(path.join(root, file), 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
20
32
|
const relImport = (fromFile: string, toFile: string): string => {
|
|
21
33
|
const rel = path
|
|
22
34
|
.relative(path.dirname(fromFile), toFile)
|
|
@@ -25,7 +37,44 @@ const relImport = (fromFile: string, toFile: string): string => {
|
|
|
25
37
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
26
38
|
};
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
/** The folder of an Expo Router app's routes, which is the app when there is no root component to import. */
|
|
41
|
+
const routesOf = (root: string): string | undefined => {
|
|
42
|
+
try {
|
|
43
|
+
createRequire(path.join(root, 'package.json')).resolve('expo-router/package.json');
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return ['src/app', 'app'].find(
|
|
48
|
+
(dir) =>
|
|
49
|
+
fs.existsSync(path.join(root, dir, '_layout.tsx')) ||
|
|
50
|
+
fs.existsSync(path.join(root, dir, '_layout.js')) ||
|
|
51
|
+
fs.existsSync(path.join(root, dir, 'index.tsx')),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** How the entry gets the App: the root component, or, for Expo Router, the router over the app's route files. */
|
|
56
|
+
type AppSource = { component: string } | { routes: string; dir: string };
|
|
57
|
+
|
|
58
|
+
const appBlock = (source: AppSource): string =>
|
|
59
|
+
'component' in source
|
|
60
|
+
? `// Whatever the app's index runs before rendering (polyfills, i18n, services):
|
|
61
|
+
// require it here too, then the App.
|
|
62
|
+
const { default: App } = require('${source.component}')`
|
|
63
|
+
: `// An Expo Router app has no root component: the router is the app, over the route
|
|
64
|
+
// files in ${source.dir}, read here as the bundler would read them. Whatever the
|
|
65
|
+
// app's root layout does not run before rendering (polyfills, i18n), require it first.
|
|
66
|
+
process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'
|
|
67
|
+
// Its testing library first: it stands in for what has no native side here (expo-linking's
|
|
68
|
+
// scheme, Reanimated), and the router must load after it.
|
|
69
|
+
const { getMockContext } = require('expo-router/testing-library')
|
|
70
|
+
const React = require('react')
|
|
71
|
+
const { ExpoRoot } = require('expo-router')
|
|
72
|
+
const routes = getMockContext(require('path').resolve(__dirname, '${source.routes}'))
|
|
73
|
+
// Mounted as a device build mounts it, so the agent finds the router's navigation by itself.
|
|
74
|
+
require('lynkeus-agent/auto').auto({ token: process.env.LYNKEUS_TOKEN })
|
|
75
|
+
const App = () => React.createElement(ExpoRoot, { context: routes })`;
|
|
76
|
+
|
|
77
|
+
const entryTemplate = (source: AppSource, apiEnv: string, appName: string, locale?: InitOptions['locale']): string => {
|
|
29
78
|
const LOCALE =
|
|
30
79
|
locale?.languageTag || locale?.country || locale?.language
|
|
31
80
|
? `\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 })},`
|
|
@@ -51,9 +100,7 @@ installDefaultMocks({
|
|
|
51
100
|
// lists the native modules the app references that nothing covers yet:
|
|
52
101
|
// jest.mock('some-native-package', () => ({ method: () => undefined }))
|
|
53
102
|
|
|
54
|
-
|
|
55
|
-
// require it here too, then the App.
|
|
56
|
-
const { default: App } = require('${appImport}')
|
|
103
|
+
${appBlock(source)}
|
|
57
104
|
|
|
58
105
|
export { App }
|
|
59
106
|
`;
|
|
@@ -87,14 +134,30 @@ const addTokenPlugin = (root: string): string => {
|
|
|
87
134
|
if (text.includes(TOKEN_PLUGIN)) return `${file} already has ${TOKEN_PLUGIN}`;
|
|
88
135
|
const plugins = /\bplugins\s*:\s*\[/.exec(text);
|
|
89
136
|
const presets = /^([ \t]*)presets\s*:/m.exec(text);
|
|
90
|
-
|
|
137
|
+
// A config written on one line has its presets after the brace, not at the start of a line.
|
|
138
|
+
const inline = plugins || presets ? null : /\bpresets\s*:/.exec(text);
|
|
139
|
+
if (!plugins && !presets && !inline) return byHand;
|
|
91
140
|
const next = plugins
|
|
92
141
|
? `${text.slice(0, plugins.index + plugins[0].length)}'${TOKEN_PLUGIN}', ${text.slice(plugins.index + plugins[0].length)}`
|
|
93
|
-
:
|
|
142
|
+
: presets
|
|
143
|
+
? `${text.slice(0, presets.index)}${presets[1]}plugins: ['${TOKEN_PLUGIN}'],\n${text.slice(presets.index)}`
|
|
144
|
+
: `${text.slice(0, inline!.index)}plugins: ['${TOKEN_PLUGIN}'], ${text.slice(inline!.index)}`;
|
|
94
145
|
fs.writeFileSync(path.join(root, file), next);
|
|
95
146
|
return `added ${TOKEN_PLUGIN} to ${file}`;
|
|
96
147
|
};
|
|
97
148
|
|
|
149
|
+
const EXPO_ENV = '.env.local';
|
|
150
|
+
const EXPO_TOKEN = 'EXPO_PUBLIC_LYNKEUS_TOKEN';
|
|
151
|
+
|
|
152
|
+
/** Expo passes the app only variables that start with EXPO_PUBLIC_, and reads them from `.env.local`, which its template keeps out of git. */
|
|
153
|
+
const giveExpoTheToken = (root: string, token: string): string => {
|
|
154
|
+
const file = path.join(root, EXPO_ENV);
|
|
155
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
156
|
+
if (new RegExp(`^${EXPO_TOKEN}=`, 'm').test(text)) return `${EXPO_ENV} already sets ${EXPO_TOKEN}`;
|
|
157
|
+
fs.writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${EXPO_TOKEN}=${token}\n`);
|
|
158
|
+
return `wrote ${EXPO_TOKEN} to ${EXPO_ENV} (check it is in .gitignore: Expo's template ignores .env*.local)`;
|
|
159
|
+
};
|
|
160
|
+
|
|
98
161
|
/**
|
|
99
162
|
* Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
|
|
100
163
|
* has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
|
|
@@ -106,7 +169,10 @@ export const mountAgent = (root: string): string[] => {
|
|
|
106
169
|
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
107
170
|
const index = findFirst(root, INDEX_CANDIDATES);
|
|
108
171
|
const routed = manifest.main === 'expo-router/entry';
|
|
109
|
-
|
|
172
|
+
const already = mountedIn(root);
|
|
173
|
+
if (already) {
|
|
174
|
+
notes.push(`${already} already mounts the agent`);
|
|
175
|
+
} else if (routed && !index) {
|
|
110
176
|
fs.writeFileSync(path.join(root, 'index.js'), `${mountLines(true)}\nimport 'expo-router/entry'\n`);
|
|
111
177
|
fs.writeFileSync(
|
|
112
178
|
path.join(root, 'package.json'),
|
|
@@ -119,25 +185,22 @@ export const mountAgent = (root: string): string[] => {
|
|
|
119
185
|
} else {
|
|
120
186
|
const indexPath = path.join(root, index);
|
|
121
187
|
const text = fs.readFileSync(indexPath, 'utf8');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const at = imports.length ? imports[imports.length - 1]!.index! + imports[imports.length - 1]![0].length + 1 : 0;
|
|
128
|
-
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
129
|
-
notes.push(`added auto() to ${index}`);
|
|
130
|
-
}
|
|
188
|
+
// After the last import, so the app's own polyfills still run first.
|
|
189
|
+
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
190
|
+
const at = imports.length ? imports[imports.length - 1]!.index! + imports[imports.length - 1]![0].length + 1 : 0;
|
|
191
|
+
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
192
|
+
notes.push(`added auto() to ${index}`);
|
|
131
193
|
}
|
|
194
|
+
const { token, made } = ensureToken(root);
|
|
195
|
+
if (made) 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');
|
|
132
196
|
if (!expo) {
|
|
133
197
|
notes.push(addTokenPlugin(root));
|
|
134
|
-
notes.push('
|
|
198
|
+
notes.push('restart Metro with --reset-cache so the bundle picks the token up; LYNKEUS_TOKEN in the environment still wins over the file');
|
|
135
199
|
}
|
|
136
200
|
if (expo) {
|
|
201
|
+
notes.push(giveExpoTheToken(root, token));
|
|
137
202
|
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');
|
|
138
|
-
notes.push(
|
|
139
|
-
'Expo: start the dev server with EXPO_PUBLIC_LYNKEUS_TOKEN set to the same value as LYNKEUS_TOKEN; Expo passes the app only variables with that prefix',
|
|
140
|
-
);
|
|
203
|
+
notes.push('Expo: restart the dev server so it reads .env.local; EXPO_PUBLIC_LYNKEUS_TOKEN in the environment still wins over the file');
|
|
141
204
|
}
|
|
142
205
|
return notes;
|
|
143
206
|
};
|
|
@@ -146,17 +209,198 @@ export const init = (root: string, opts: InitOptions): string[] => {
|
|
|
146
209
|
const notes: string[] = [];
|
|
147
210
|
const entryPath = path.join(root, opts.entry);
|
|
148
211
|
const app = opts.app ?? findFirst(root, APP_CANDIDATES);
|
|
149
|
-
|
|
212
|
+
const routes = app ? undefined : routesOf(root);
|
|
213
|
+
if (!app && !routes) throw new Error(`no App component found (${APP_CANDIDATES.join(', ')}) and no Expo Router routes; pass --component <path>`);
|
|
150
214
|
if (fs.existsSync(entryPath) && !opts.force) {
|
|
151
215
|
notes.push(`${opts.entry} exists; left as is (--force overwrites)`);
|
|
152
216
|
} else {
|
|
153
217
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
|
154
|
-
|
|
155
|
-
|
|
218
|
+
const source: AppSource = app
|
|
219
|
+
? { component: relImport(entryPath, path.join(root, app)) }
|
|
220
|
+
: { routes: path.relative(path.dirname(entryPath), path.join(root, routes!)).replace(/\\/g, '/'), dir: routes! };
|
|
221
|
+
fs.writeFileSync(entryPath, entryTemplate(source, opts.apiEnv, packageName(root), opts.locale));
|
|
222
|
+
notes.push(`wrote ${opts.entry} (${app ? `app: ${app}` : `Expo Router, routes in ${routes}`})`);
|
|
156
223
|
}
|
|
157
224
|
if (opts.auto) notes.push(...mountAgent(root));
|
|
158
|
-
|
|
159
|
-
notes.push(
|
|
225
|
+
// `headless start` refuses to run without one, and an app tested only headless never runs `lynkeus init`.
|
|
226
|
+
else if (ensureToken(root).made) notes.push('made a token for this project in .lynkeus/token (kept out of git); the CLI and the MCP server read it');
|
|
227
|
+
notes.push(
|
|
228
|
+
"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",
|
|
229
|
+
);
|
|
230
|
+
notes.push(
|
|
231
|
+
`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)`,
|
|
232
|
+
);
|
|
160
233
|
notes.push(' then, in another shell: lynkeus screen (or lynkeus headless doctor to see what still needs a mock)');
|
|
161
234
|
return notes;
|
|
162
235
|
};
|
|
236
|
+
|
|
237
|
+
const AGENT = 'lynkeus-agent';
|
|
238
|
+
|
|
239
|
+
/** How this project adds a package: Expo's own installer picks the version its SDK wants; otherwise the manager whose lockfile is there. */
|
|
240
|
+
type Manager = 'bun' | 'pnpm' | 'yarn' | 'npm';
|
|
241
|
+
|
|
242
|
+
/** The package manager whose lockfile is in the app's folder or above it: an app in a monorepo has it at the top. */
|
|
243
|
+
const managerOf = (root: string): Manager => {
|
|
244
|
+
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
|
245
|
+
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
|
|
246
|
+
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
247
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
|
|
248
|
+
if (fs.existsSync(path.join(dir, 'package-lock.json')) || fs.existsSync(path.join(dir, '.git')) || path.dirname(dir) === dir) return 'npm';
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const installCommand = (root: string, expo: boolean): string[] => {
|
|
253
|
+
if (expo) return ['npx', 'expo', 'install', AGENT];
|
|
254
|
+
const manager = managerOf(root);
|
|
255
|
+
return manager === 'npm' ? ['npm', 'install', AGENT] : [manager, 'add', AGENT];
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What a failed install said, short enough for one line. npm ends its errors with where its logs are, so
|
|
260
|
+
* the last lines alone told nothing; the reason (a peer range that does not match, say) comes before them.
|
|
261
|
+
*/
|
|
262
|
+
export const installFailure = (ran: { stderr?: string | null; error?: Error }): string =>
|
|
263
|
+
`${ran.stderr ?? ''}\n${ran.error?.message ?? ''}`
|
|
264
|
+
.split('\n')
|
|
265
|
+
.map((line) => line.replace(/^(npm (error|ERR!)|error|ERR_PNPM_\w+|YN\d{4}:)\s*/, '').trim())
|
|
266
|
+
.filter(
|
|
267
|
+
(line) => line && !/^code \w+$|^node_modules\/|_logs|complete log|full report|^Fix the upstream|^this command with|^to accept an incorrect/i.test(line),
|
|
268
|
+
)
|
|
269
|
+
.slice(0, 8)
|
|
270
|
+
.join('; ');
|
|
271
|
+
|
|
272
|
+
const hasAgent = (manifest: Manifest): boolean => !!(manifest.dependencies?.[AGENT] ?? manifest.devDependencies?.[AGENT]);
|
|
273
|
+
|
|
274
|
+
/** Adds lynkeus-agent to the app when it is not a dependency yet. It has native code, so the app is built again after. */
|
|
275
|
+
export const installAgent = (root: string): string => {
|
|
276
|
+
const manifest = readManifest(root);
|
|
277
|
+
if (hasAgent(manifest)) return `${AGENT} is already a dependency`;
|
|
278
|
+
const command = installCommand(root, !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo));
|
|
279
|
+
const ran = spawnSync(command[0]!, command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
280
|
+
if (ran.status === 0) return `installed ${AGENT} (${command.join(' ')}); it has native code, so build the app again`;
|
|
281
|
+
const said = installFailure(ran);
|
|
282
|
+
return `could not install ${AGENT} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself, then \`lynkeus init\` again`;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export type SetupCheck = { what: string; ok: boolean; fix?: string };
|
|
286
|
+
|
|
287
|
+
/** What a project has of lynkeus and what it still lacks, each with the one thing that fixes it. Reads files only. */
|
|
288
|
+
export const setupChecks = (root: string): SetupCheck[] => {
|
|
289
|
+
const manifest = readManifest(root);
|
|
290
|
+
if (!manifest.dependencies && !manifest.devDependencies)
|
|
291
|
+
return [{ what: 'a package.json here', ok: false, fix: 'run this from the app\u2019s folder (or pass --root)' }];
|
|
292
|
+
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
293
|
+
const checks: SetupCheck[] = [];
|
|
294
|
+
const depends = hasAgent(manifest);
|
|
295
|
+
checks.push({ what: `${AGENT} is a dependency`, ok: depends, fix: '`lynkeus init` installs it' });
|
|
296
|
+
if (depends)
|
|
297
|
+
checks.push({
|
|
298
|
+
what: `${AGENT} is installed`,
|
|
299
|
+
ok: fs.existsSync(path.join(root, 'node_modules', AGENT, 'package.json')),
|
|
300
|
+
fix: 'install the project\u2019s dependencies',
|
|
301
|
+
});
|
|
302
|
+
const mounted = mountedIn(root);
|
|
303
|
+
checks.push({ what: mounted ? `${mounted} mounts the agent` : 'the app mounts the agent', ok: !!mounted, fix: '`lynkeus init` adds it to the entry' });
|
|
304
|
+
if (!expo) {
|
|
305
|
+
const babel = findFirst(root, BABEL_CANDIDATES);
|
|
306
|
+
checks.push({
|
|
307
|
+
what: 'babel.config.js has lynkeus-agent/babel',
|
|
308
|
+
ok: !!babel && fs.readFileSync(path.join(root, babel), 'utf8').includes(TOKEN_PLUGIN),
|
|
309
|
+
fix: '`lynkeus init` adds it',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const token = readToken(root);
|
|
313
|
+
checks.push({ what: 'the project has a token', ok: !!token, fix: '`lynkeus init` makes one in .lynkeus/token' });
|
|
314
|
+
if (expo && token) {
|
|
315
|
+
const env = path.join(root, EXPO_ENV);
|
|
316
|
+
const given = !!process.env[EXPO_TOKEN] || (fs.existsSync(env) && new RegExp(`^${EXPO_TOKEN}=`, 'm').test(fs.readFileSync(env, 'utf8')));
|
|
317
|
+
checks.push({ what: `Expo is given the token (${EXPO_TOKEN})`, ok: given, fix: '`lynkeus init` writes it to .env.local' });
|
|
318
|
+
}
|
|
319
|
+
return checks;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** What `lynkeus headless start` runs on: jest hosts the app, Testing Library renders it, lynkeus-headless serves the agent. */
|
|
323
|
+
const HEADLESS_PACKAGES = ['jest', '@testing-library/react-native', 'lynkeus-headless'];
|
|
324
|
+
|
|
325
|
+
const resolvable = (root: string, name: string): boolean => {
|
|
326
|
+
try {
|
|
327
|
+
createRequire(path.join(root, 'package.json')).resolve(`${name}/package.json`);
|
|
328
|
+
return true;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// A package whose exports leave package.json out is still there.
|
|
331
|
+
return (error as { code?: string }).code === 'ERR_PACKAGE_PATH_NOT_EXPORTED';
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const JEST_PRESET = '@react-native/jest-preset';
|
|
336
|
+
|
|
337
|
+
/** The app's React Native version, when it is installed. */
|
|
338
|
+
const reactNativeVersion = (root: string): string | undefined => {
|
|
339
|
+
try {
|
|
340
|
+
const file = createRequire(path.join(root, 'package.json')).resolve('react-native/package.json');
|
|
341
|
+
return (JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: string }).version;
|
|
342
|
+
} catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// React Native 0.86 moved its jest preset out of `react-native`, which now throws when jest asks it for one.
|
|
348
|
+
const presetMoved = (version: string | undefined): boolean => {
|
|
349
|
+
const [major, minor] = (version ?? '').split('.').map(Number);
|
|
350
|
+
return major === 0 ? (minor ?? 0) >= 86 : (major ?? 0) > 0;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const JEST_EXPO = 'jest-expo';
|
|
354
|
+
|
|
355
|
+
const isExpo = (root: string): boolean => {
|
|
356
|
+
const manifest = readManifest(root);
|
|
357
|
+
return !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The jest preset the app needs and lacks: an Expo app's is jest-expo, which gives Expo's modules the native
|
|
362
|
+
* side they look for (\`globalThis.expo\`); React Native 0.86 and later keep theirs out of \`react-native\`.
|
|
363
|
+
*/
|
|
364
|
+
const presetNeeded = (root: string): string | undefined => (isExpo(root) ? JEST_EXPO : presetMoved(reactNativeVersion(root)) ? JEST_PRESET : undefined);
|
|
365
|
+
|
|
366
|
+
/** The packages the headless host needs that this project cannot load yet. */
|
|
367
|
+
export const headlessMissing = (root: string): string[] =>
|
|
368
|
+
[...HEADLESS_PACKAGES, ...[presetNeeded(root)].filter((p): p is string => !!p)].filter((name) => !resolvable(root, name));
|
|
369
|
+
|
|
370
|
+
/** The preset \`headless start\` gives jest when the app's own configuration names none. */
|
|
371
|
+
export const jestPreset = (root: string): string => (resolvable(root, JEST_EXPO) ? JEST_EXPO : resolvable(root, JEST_PRESET) ? JEST_PRESET : 'react-native');
|
|
372
|
+
|
|
373
|
+
const devInstallCommand = (root: string, names: string[]): string[] =>
|
|
374
|
+
({ bun: ['bun', 'add', '-d'], pnpm: ['pnpm', 'add', '-D'], yarn: ['yarn', 'add', '-D'], npm: ['npm', 'install', '--save-dev'] })[managerOf(root)].concat(
|
|
375
|
+
names,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const install = (root: string, command: string[], names: string[]): string => {
|
|
379
|
+
const ran = spawnSync(command[0]!, command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
380
|
+
if (ran.status === 0) return `installed ${names.join(', ')} (${command.join(' ')})`;
|
|
381
|
+
const said = installFailure(ran);
|
|
382
|
+
return `could not install ${names.join(', ')} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself`;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** Adds what the headless host needs as development dependencies; a line saying what happened. */
|
|
386
|
+
export const installHeadless = (root: string): string => {
|
|
387
|
+
const missing = headlessMissing(root);
|
|
388
|
+
if (!missing.length) return `jest, Testing Library, lynkeus-headless and the ${jestPreset(root)} preset are installed`;
|
|
389
|
+
const lines: string[] = [];
|
|
390
|
+
const plain = missing.filter((name) => name !== JEST_EXPO);
|
|
391
|
+
// The React Native preset is released with React Native, version for version.
|
|
392
|
+
if (plain.length)
|
|
393
|
+
lines.push(
|
|
394
|
+
install(
|
|
395
|
+
root,
|
|
396
|
+
devInstallCommand(
|
|
397
|
+
root,
|
|
398
|
+
plain.map((name) => (name === JEST_PRESET ? `${name}@${reactNativeVersion(root)}` : name)),
|
|
399
|
+
),
|
|
400
|
+
plain,
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
// Expo's installer picks the jest-expo of the app's SDK.
|
|
404
|
+
if (missing.includes(JEST_EXPO)) lines.push(install(root, ['npx', 'expo', 'install', JEST_EXPO, '--', '--save-dev'], [JEST_EXPO]));
|
|
405
|
+
return lines.join('\n');
|
|
406
|
+
};
|
package/src/commands/map.ts
CHANGED
|
@@ -30,6 +30,25 @@ const describeAffected = (affected: Affected[], chain: boolean): string[] => {
|
|
|
30
30
|
return lines;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/** Source files a branch changed against `base`, committed or not. */
|
|
34
|
+
export const changedSources = (root: string, base: string, err: (line: string) => void): string[] => {
|
|
35
|
+
const git = (...args: string[]) => execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
36
|
+
try {
|
|
37
|
+
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
38
|
+
return [
|
|
39
|
+
...new Set(
|
|
40
|
+
names
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((l) => l.trim())
|
|
43
|
+
.filter((l) => /\.[cm]?[jt]sx?$/.test(l)),
|
|
44
|
+
),
|
|
45
|
+
];
|
|
46
|
+
} catch (error) {
|
|
47
|
+
err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
33
52
|
export const mapCommands = [
|
|
34
53
|
define({
|
|
35
54
|
name: 'map build',
|
|
@@ -215,21 +234,7 @@ export const mapCommands = [
|
|
|
215
234
|
run: async (ctx) => {
|
|
216
235
|
const graph = ctx.graph();
|
|
217
236
|
const base = ctx.flags.base as string;
|
|
218
|
-
const
|
|
219
|
-
let changed: string[] = [];
|
|
220
|
-
try {
|
|
221
|
-
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
222
|
-
changed = [
|
|
223
|
-
...new Set(
|
|
224
|
-
names
|
|
225
|
-
.split('\n')
|
|
226
|
-
.map((l) => l.trim())
|
|
227
|
-
.filter((l) => /\.[cm]?[jt]sx?$/.test(l)),
|
|
228
|
-
),
|
|
229
|
-
];
|
|
230
|
-
} catch (error) {
|
|
231
|
-
ctx.err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
-
}
|
|
237
|
+
const changed = changedSources(ctx.root, base, ctx.err);
|
|
233
238
|
const affected = screensAffectedBy(graph, changed, ctx.flags.depth as number);
|
|
234
239
|
const lines = [`changed against ${base}: ${changed.length} source file(s)`, ...describeAffected(affected, true)];
|
|
235
240
|
if (!affected.length && changed.length) lines.push(' (no screen imports these files within the depth)');
|
package/src/commands/os.ts
CHANGED
|
@@ -35,11 +35,25 @@ export const osCommands = [
|
|
|
35
35
|
define({
|
|
36
36
|
name: 'os appstate',
|
|
37
37
|
group: 'os',
|
|
38
|
-
summary:
|
|
39
|
-
|
|
38
|
+
summary:
|
|
39
|
+
'AppState listeners see the change (background, then active: what refetches or re-locks); a host answers it, a simulator or an emulator does it for real',
|
|
40
|
+
positionals: [{ name: 'state', help: 'active | background | inactive (inactive: a host only)', required: true }],
|
|
41
|
+
flags: { app: { type: 'string', help: 'the app to bring back on a simulator or emulator (default: the config’s app)', value: 'bundleId' } },
|
|
40
42
|
aliases: ['appstate'],
|
|
41
43
|
needs: 'app',
|
|
42
|
-
run:
|
|
44
|
+
run: async (ctx) => {
|
|
45
|
+
const device = await ctx.device();
|
|
46
|
+
try {
|
|
47
|
+
return { json: await device.command('appstate', { state: ctx.args[0] }) };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// A host stands in for the OS; on a simulator or an emulator the OS itself moves the app.
|
|
50
|
+
if ((await device.resolved()).id === 'headless') throw error;
|
|
51
|
+
const done = await simulator('appstate')(ctx);
|
|
52
|
+
// Back in front, the app dials in again; what comes next reads its screen.
|
|
53
|
+
if (ctx.args[0] === 'active') await device.server.waitForApp(15_000);
|
|
54
|
+
return done;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
43
57
|
}),
|
|
44
58
|
define({
|
|
45
59
|
name: 'os network',
|
|
@@ -66,7 +80,7 @@ export const osCommands = [
|
|
|
66
80
|
define({
|
|
67
81
|
name: 'os biometrics',
|
|
68
82
|
group: 'os',
|
|
69
|
-
summary: '
|
|
83
|
+
summary: 'The sensor and what the next prompts answer; on a simulator, --available enrolls Face ID or Touch ID or not',
|
|
70
84
|
flags: {
|
|
71
85
|
available: { type: 'string', help: 'FaceID | TouchID | none', value: 'kind' },
|
|
72
86
|
next: { type: 'string', help: 'outcomes of the next prompts, in order', value: 'success,fail,cancel' },
|
|
@@ -74,11 +88,25 @@ export const osCommands = [
|
|
|
74
88
|
},
|
|
75
89
|
aliases: ['biometrics'],
|
|
76
90
|
needs: 'app',
|
|
77
|
-
run:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
run: async (ctx) => {
|
|
92
|
+
const device = await ctx.device();
|
|
93
|
+
const params = {
|
|
94
|
+
available: ctx.flags.available || undefined,
|
|
95
|
+
next: ctx.flags.next ? (ctx.flags.next as string).split(',') : undefined,
|
|
96
|
+
fallback: ctx.flags.fallback || undefined,
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
return { json: await device.command('biometrics', params) };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const info = await device.resolved();
|
|
102
|
+
if (info.id === 'headless') throw error;
|
|
103
|
+
// A device's prompts answer the person in front of it: only a simulator's enrollment is set from outside.
|
|
104
|
+
// Anything else stays the host's, and the runner says the step cannot run here.
|
|
105
|
+
if (info.platform !== 'ios' || !params.available || params.next || params.fallback) throw error;
|
|
106
|
+
ctx.args = [String(params.available)];
|
|
107
|
+
return simulator('biometrics')(ctx);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
82
110
|
}),
|
|
83
111
|
define({
|
|
84
112
|
name: 'os scan',
|
|
@@ -107,7 +135,7 @@ export const osCommands = [
|
|
|
107
135
|
define({
|
|
108
136
|
name: 'mock',
|
|
109
137
|
group: 'os',
|
|
110
|
-
summary: '
|
|
138
|
+
summary: 'Answer a request before the network: a host stands in for it, on a device the agent answers in the app (--sticky survives a host reset)',
|
|
111
139
|
positionals: [{ name: 'route', help: "'GET /path' (a bare path matches any method)" }],
|
|
112
140
|
flags: {
|
|
113
141
|
status: { type: 'number', help: 'HTTP status to answer', default: 200, value: 'code' },
|