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.
Files changed (89) hide show
  1. package/lib/cases/coverage.d.ts +31 -0
  2. package/lib/cases/coverage.js +82 -0
  3. package/lib/cases/draft.d.ts +21 -0
  4. package/lib/cases/draft.js +53 -0
  5. package/lib/cases/fixtures.d.ts +11 -0
  6. package/lib/cases/fixtures.js +59 -8
  7. package/lib/cases/frames.d.ts +6 -0
  8. package/lib/cases/frames.js +28 -0
  9. package/lib/cases/nativeLogs.d.ts +14 -0
  10. package/lib/cases/nativeLogs.js +111 -0
  11. package/lib/cases/runDirs.d.ts +13 -0
  12. package/lib/cases/runDirs.js +48 -0
  13. package/lib/cases/runfile.d.ts +5 -1
  14. package/lib/cases/runfile.js +67 -4
  15. package/lib/cases/runner.d.ts +58 -1
  16. package/lib/cases/runner.js +322 -39
  17. package/lib/cases/templates.d.ts +2 -2
  18. package/lib/cases/templates.js +20 -2
  19. package/lib/cases/traces.js +4 -1
  20. package/lib/cli.js +27 -4
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +154 -25
  23. package/lib/commands/ci.d.ts +13 -0
  24. package/lib/commands/ci.js +184 -0
  25. package/lib/commands/coverage.d.ts +1 -0
  26. package/lib/commands/coverage.js +151 -0
  27. package/lib/commands/device.js +32 -2
  28. package/lib/commands/doctor.js +8 -2
  29. package/lib/commands/drive.js +3 -1
  30. package/lib/commands/fixtures.d.ts +7 -0
  31. package/lib/commands/fixtures.js +94 -0
  32. package/lib/commands/headless.d.ts +11 -0
  33. package/lib/commands/headless.js +189 -9
  34. package/lib/commands/init.d.ts +23 -0
  35. package/lib/commands/init.js +231 -26
  36. package/lib/commands/map.d.ts +2 -0
  37. package/lib/commands/map.js +18 -14
  38. package/lib/commands/os.js +43 -10
  39. package/lib/commands/report.d.ts +4 -0
  40. package/lib/commands/report.js +156 -20
  41. package/lib/config.d.ts +41 -0
  42. package/lib/config.js +30 -0
  43. package/lib/device/agentVersion.d.ts +7 -0
  44. package/lib/device/agentVersion.js +14 -0
  45. package/lib/device/devices.js +6 -0
  46. package/lib/device/driver.js +2 -2
  47. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  48. package/lib/knowledge/analyticsTraffic.js +109 -0
  49. package/lib/knowledge/errors.js +6 -0
  50. package/lib/knowledge/events.d.ts +2 -0
  51. package/lib/knowledge/why.d.ts +2 -0
  52. package/lib/knowledge/why.js +1 -1
  53. package/lib/mcp/server.js +45 -4
  54. package/lib/runtime.d.ts +9 -1
  55. package/lib/runtime.js +25 -3
  56. package/package.json +7 -4
  57. package/src/cases/coverage.ts +99 -0
  58. package/src/cases/draft.ts +75 -0
  59. package/src/cases/fixtures.ts +57 -8
  60. package/src/cases/frames.ts +30 -0
  61. package/src/cases/nativeLogs.ts +112 -0
  62. package/src/cases/runDirs.ts +48 -0
  63. package/src/cases/runfile.ts +64 -4
  64. package/src/cases/runner.ts +372 -42
  65. package/src/cases/templates.ts +20 -2
  66. package/src/cases/traces.ts +3 -1
  67. package/src/cli.ts +27 -4
  68. package/src/commands/cases.ts +165 -23
  69. package/src/commands/ci.ts +189 -0
  70. package/src/commands/coverage.ts +147 -0
  71. package/src/commands/device.ts +27 -2
  72. package/src/commands/doctor.ts +9 -2
  73. package/src/commands/drive.ts +3 -1
  74. package/src/commands/fixtures.ts +102 -0
  75. package/src/commands/headless.ts +197 -9
  76. package/src/commands/init.ts +270 -26
  77. package/src/commands/map.ts +20 -15
  78. package/src/commands/os.ts +38 -10
  79. package/src/commands/report.ts +162 -18
  80. package/src/config.ts +48 -0
  81. package/src/device/agentVersion.ts +17 -0
  82. package/src/device/devices.ts +5 -0
  83. package/src/device/driver.ts +2 -2
  84. package/src/knowledge/analyticsTraffic.ts +142 -0
  85. package/src/knowledge/errors.ts +6 -0
  86. package/src/knowledge/events.ts +2 -1
  87. package/src/knowledge/why.ts +3 -1
  88. package/src/mcp/server.ts +45 -4
  89. package/src/runtime.ts +24 -3
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { define } from '../registry.js';
5
+ import { ageOf, megabytes, pruneRuns } from '../cases/runDirs.js';
6
+ import { keptOrDeleted } from './cases.js';
5
7
 
6
8
  type Fetch = typeof fetch;
7
9
 
@@ -22,11 +24,54 @@ const answer = async (response: Response, doing: string) => {
22
24
  throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
23
25
  };
24
26
 
27
+ const UPLOADS_AT_ONCE = 4;
28
+ const CASES_PART = 256 * 1024;
29
+ const UPLOAD_ATTEMPTS = 3;
30
+
31
+ // A ustar header holds a name of up to 100 bytes and a folder of up to 155 before it.
32
+ const fitsTar = (file: string) => {
33
+ const cut = file.lastIndexOf('/');
34
+ return Buffer.byteLength(file.slice(cut + 1)) <= 100 && Buffer.byteLength(cut > 0 ? file.slice(0, cut) : '') <= 155;
35
+ };
36
+
37
+ /** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
38
+ export const tarOf = (entries: [string, Uint8Array][]): Uint8Array => {
39
+ const blocks: Uint8Array[] = [];
40
+ for (const [file, data] of entries) {
41
+ const header = Buffer.alloc(512);
42
+ const cut = file.lastIndexOf('/');
43
+ header.write(file.slice(cut + 1), 0, 'utf8');
44
+ header.write('0000644\0', 100);
45
+ header.write('0000000\0', 108);
46
+ header.write('0000000\0', 116);
47
+ header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124);
48
+ header.write(
49
+ `${Math.floor(Date.now() / 1000)
50
+ .toString(8)
51
+ .padStart(11, '0')}\0`,
52
+ 136,
53
+ );
54
+ header.write('0', 156);
55
+ header.write('ustar\0', 257);
56
+ header.write('00', 263);
57
+ if (cut > 0) header.write(file.slice(0, cut), 345, 'utf8');
58
+ header.fill(' ', 148, 156);
59
+ const sum = header.reduce((n, b) => n + b, 0);
60
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
61
+ blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
62
+ }
63
+ blocks.push(Buffer.alloc(1024));
64
+ return Buffer.concat(blocks);
65
+ };
66
+
25
67
  /**
26
68
  * Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
27
69
  * film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
28
70
  */
29
- export const pushRun = async (dir: string, o: { url: string; key: string; project: string; fetch?: Fetch }): Promise<Pushed> => {
71
+ export const pushRun = async (
72
+ dir: string,
73
+ o: { url: string; key: string; project: string; fetch?: Fetch; onReady?: (url: string) => void; onProgress?: (line: string) => void },
74
+ ): Promise<Pushed> => {
30
75
  const call = o.fetch ?? fetch;
31
76
  const auth = { authorization: `Bearer ${o.key}` };
32
77
  const base = o.url.replace(/\/+$/, '');
@@ -42,31 +87,98 @@ export const pushRun = async (dir: string, o: { url: string; key: string; projec
42
87
  'creating the run',
43
88
  );
44
89
  const id = String(created.id);
45
- await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
90
+ // A dropped connection, a 5xx or a 429 is tried again, waiting longer each time.
91
+ const sendWithRetries = async (target: string, body: Uint8Array, doing: string): Promise<Response> => {
92
+ for (let attempt = 1; ; attempt++) {
93
+ const response = await call(target, { method: 'PUT', headers: auth, body }).catch((error: unknown) => {
94
+ if (attempt >= UPLOAD_ATTEMPTS) throw new Error(`${doing}: ${error instanceof Error ? error.message : String(error)}`);
95
+ return undefined;
96
+ });
97
+ if (response && ((response.status < 500 && response.status !== 429) || attempt >= UPLOAD_ATTEMPTS)) return response;
98
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
99
+ }
100
+ };
101
+ // The run file in parts of about 256 KB: with every request's headers and bodies it runs to megabytes, more
102
+ // than the service reads in one request. A service from before parts answers without `part`, and then
103
+ // takes the whole file in one.
104
+ const parts: string[] = [];
105
+ for (const line of cases.split('\n').filter((l) => l.trim())) {
106
+ const last = parts.length - 1;
107
+ if (last >= 0 && Buffer.byteLength(parts[last]!) + Buffer.byteLength(line) < CASES_PART) parts[last] += `\n${line}`;
108
+ else parts.push(line);
109
+ }
110
+ for (const [n, part] of parts.entries()) {
111
+ const sent = await answer(await sendWithRetries(`${base}/v1/runs/${id}/cases?part=${n}`, Buffer.from(part), 'sending its cases'), 'sending its cases');
112
+ if (sent.part === undefined) {
113
+ await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
114
+ break;
115
+ }
116
+ }
117
+ // The report opens as soon as its cases are in: their steps, results and requests show at once, and each
118
+ // frame as it arrives.
119
+ const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
120
+ o.onReady?.(String(finished.url));
46
121
 
47
- const named = new Set<string>();
122
+ // Per case: its frames, sent as one tar, and its film, sent on its own (a film can be bigger than a request).
123
+ const work: { frames: string[]; film?: string }[] = [];
48
124
  for (const line of cases.split('\n')) {
49
125
  if (!line.trim()) continue;
50
126
  const c = JSON.parse(line) as { video?: string; steps?: { screenshot?: string }[] };
51
- for (const p of [c.video, ...(c.steps ?? []).map((s) => s.screenshot)]) if (p) named.add(p);
127
+ const frames = (c.steps ?? []).map((s) => s.screenshot).filter((p): p is string => !!p && fs.existsSync(path.join(dir, p)));
128
+ const film = c.video && fs.existsSync(path.join(dir, c.video)) ? c.video : undefined;
129
+ work.push({ frames: [...new Set(frames)], ...(film ? { film } : {}) });
52
130
  }
53
131
  let files = 0;
54
132
  const refused: string[] = [];
55
- for (const file of named) {
56
- const local = path.join(dir, file);
57
- if (!fs.existsSync(local)) continue;
58
- const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
59
- method: 'PUT',
60
- headers: auth,
61
- body: fs.readFileSync(local),
62
- });
133
+ const sendFile = async (file: string) => {
134
+ const target = `${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`;
135
+ const response = await sendWithRetries(target, fs.readFileSync(path.join(dir, file)), `sending ${file}`);
63
136
  if (response.status === 402) refused.push(file);
64
137
  else {
65
138
  await answer(response, `sending ${file}`);
66
139
  files += 1;
67
140
  }
68
- }
69
- const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
141
+ };
142
+ // A suite with screenshots is thousands of frames: a request per frame took one over 20 minutes to send. A
143
+ // service from before bundles answers 404, and the frames then go one by one, as they used to.
144
+ let bundles = true;
145
+ const sendFrames = async (frames: string[]) => {
146
+ const fits = frames.filter(fitsTar);
147
+ if (bundles && fits.length > 0) {
148
+ const response = await sendWithRetries(
149
+ `${base}/v1/runs/${id}/bundle`,
150
+ tarOf(fits.map((f) => [f, fs.readFileSync(path.join(dir, f))])),
151
+ `sending ${fits.length} frames`,
152
+ );
153
+ if (response.status === 404 || response.status === 405) bundles = false;
154
+ else {
155
+ await answer(response, `sending ${fits.length} frames`);
156
+ files += fits.length;
157
+ for (const f of frames.filter((f) => !fitsTar(f))) await sendFile(f);
158
+ return;
159
+ }
160
+ }
161
+ for (const f of frames) await sendFile(f);
162
+ };
163
+ const queue = [...work];
164
+ const total = work.length;
165
+ let sent = 0;
166
+ let bytes = 0;
167
+ const started = Date.now();
168
+ const sizeOf = (c: { frames: string[]; film?: string }) =>
169
+ [...c.frames, ...(c.film ? [c.film] : [])].reduce((n, f) => n + fs.statSync(path.join(dir, f)).size, 0);
170
+ await Promise.all(
171
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, queue.length) }, async () => {
172
+ for (let c = queue.shift(); c !== undefined; c = queue.shift()) {
173
+ await sendFrames(c.frames);
174
+ if (c.film) await sendFile(c.film);
175
+ sent += 1;
176
+ bytes += sizeOf(c);
177
+ if (sent === total || sent % Math.max(1, Math.ceil(total / 10)) === 0)
178
+ o.onProgress?.(`frames: ${sent}/${total} cases, ${(bytes / 1024 / 1024).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(0)} s`);
179
+ }
180
+ }),
181
+ );
70
182
  const changes = finished.changes as Pushed['changes'] | undefined;
71
183
  return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
72
184
  };
@@ -118,12 +230,40 @@ export const plan = async (o: Service & { go?: 'upgrade' | 'manage' }) => {
118
230
  };
119
231
 
120
232
  export const service = (): Service | string => {
121
- const url = process.env.LYNKEUS_REPORTS_URL;
233
+ const url = process.env.LYNKEUS_REPORTS_URL || 'https://lynkeus.io';
122
234
  const key = process.env.LYNKEUS_REPORTS_KEY;
123
- return url && key ? { url, key } : 'set LYNKEUS_REPORTS_URL and LYNKEUS_REPORTS_KEY (the organization’s API key)';
235
+ return key ? { url, key } : 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); LYNKEUS_REPORTS_URL names another service';
124
236
  };
125
237
 
126
238
  export const reportCommands = [
239
+ define({
240
+ name: 'report prune',
241
+ group: 'runs',
242
+ summary: 'Delete the runs `case run --out` left in a folder that ended more than a while ago',
243
+ details:
244
+ 'Looks at the folder given, or each folder right under it, and deletes the ones that hold a run (`run.json` and `cases.jsonl`) older than --older. Nothing else is touched. Pushed reports stay on the service; this only frees the disk.',
245
+ positionals: [{ name: 'dir', help: 'a run folder, or the folder that holds them' }],
246
+ flags: {
247
+ older: { type: 'string', help: 'how old a run must be: 7d, 12h, 30m', value: 'age', default: '7d' },
248
+ 'dry-run': { type: 'boolean', help: 'list what would go, delete nothing' },
249
+ },
250
+ needs: 'nothing',
251
+ session: false,
252
+ mcp: false,
253
+ run: async (ctx) => {
254
+ const root = path.resolve(ctx.args[0] ?? '.');
255
+ if (!fs.existsSync(root)) return { text: `${ctx.args[0]}: no such folder`, code: 2 };
256
+ const older = ageOf(String(ctx.flags.older ?? '7d'));
257
+ if (older === undefined) return { text: `--older ${ctx.flags.older}: give it as 7d, 12h or 30m`, code: 2 };
258
+ const dry = !!ctx.flags['dry-run'];
259
+ const { removed, bytes } = pruneRuns(root, older, dry);
260
+ if (removed.length === 0) return { text: 'no run old enough to delete', json: { removed, bytes } };
261
+ return {
262
+ text: [...removed.map((d) => `${dry ? 'would delete' : 'deleted'} ${d}`), `${dry ? 'would free' : 'freed'} ${megabytes(bytes)}`].join('\n'),
263
+ json: { removed, bytes },
264
+ };
265
+ },
266
+ }),
127
267
  define({
128
268
  name: 'report push',
129
269
  group: 'runs',
@@ -145,8 +285,12 @@ export const reportCommands = [
145
285
  if (typeof to === 'string') return { text: to, code: 2 };
146
286
  const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
147
287
  try {
148
- const pushed = await pushRun(dir, { ...to, project });
149
- const notes = [...describeChanges(pushed.changes), ...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : [])];
288
+ const pushed = await pushRun(dir, { ...to, project, onReady: (url) => ctx.err(`report: ${url} (its frames are on their way)`), onProgress: ctx.err });
289
+ const notes = [
290
+ ...describeChanges(pushed.changes),
291
+ ...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : []),
292
+ keptOrDeleted(dir, ctx.config.report?.afterPush === 'delete' ? 'delete' : 'keep', pushed.refused.length === 0),
293
+ ];
150
294
  return { text: [pushed.url, ...notes].join('\n'), json: pushed };
151
295
  } catch (error) {
152
296
  return { text: (error as Error).message, code: 1 };
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
@@ -10,6 +11,33 @@ export const graphFile = (root?: string) => path.join(lynkeusDir(root), 'graph.j
10
11
  export const edgesFile = (root?: string) => path.join(lynkeusDir(root), 'edges.jsonl');
11
12
  export const frontierFile = (root?: string) => path.join(lynkeusDir(root), 'frontier.jsonl');
12
13
  export const runsDir = (root?: string) => path.join(lynkeusDir(root), 'runs');
14
+ export const tokenFile = (root?: string) => path.join(lynkeusDir(root), 'token');
15
+
16
+ /**
17
+ * The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
18
+ * `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
19
+ */
20
+ export const readToken = (root?: string): string | undefined => {
21
+ if (process.env.LYNKEUS_TOKEN) return process.env.LYNKEUS_TOKEN;
22
+ try {
23
+ return fs.readFileSync(tokenFile(root), 'utf8').trim() || undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ };
28
+
29
+ /** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
30
+ export const ensureToken = (root?: string): { token: string; made: boolean } => {
31
+ const had = readToken(root);
32
+ if (had) return { token: had, made: false };
33
+ const token = crypto.randomBytes(24).toString('hex');
34
+ fs.mkdirSync(lynkeusDir(root), { recursive: true });
35
+ fs.writeFileSync(tokenFile(root), `${token}\n`, { mode: 0o600 });
36
+ const ignore = path.join(lynkeusDir(root), '.gitignore');
37
+ const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
38
+ if (!ignored.split('\n').includes('token')) fs.writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith('\n') ? '\n' : ''}token\n`);
39
+ return { token, made: true };
40
+ };
13
41
 
14
42
  export const loadGraph = (root?: string): ScreenGraph | null => {
15
43
  const file = graphFile(root);
@@ -23,16 +51,25 @@ export const loadGraph = (root?: string): ScreenGraph | null => {
23
51
  */
24
52
  export type LynkeusConfig = {
25
53
  cases?: CasesConfig;
54
+ /** What `lynkeus ci` brings up around a suite: the project's backend and the app's hosts. */
55
+ ci?: CiConfig;
26
56
  /** The headless entry (default `qa/headless.tsx`). */
27
57
  entry?: string;
28
58
  /** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
29
59
  apiEnv?: string;
60
+ /**
61
+ * The local backend `headless start` hands the app under `apiEnv` when the environment sets none: an agent
62
+ * starting the host over MCP cannot export a variable.
63
+ */
64
+ apiUrl?: string;
30
65
  /** The driver port the app dials. */
31
66
  port?: number;
32
67
  /** Simulator udid or name. */
33
68
  device?: string;
34
69
  /** Bundle id or application id. */
35
70
  app?: string;
71
+ /** What a pushed run's local folder becomes: kept (the default), or deleted once the service has every file. */
72
+ report?: { afterPush?: 'keep' | 'delete' };
36
73
  /**
37
74
  * The language this app speaks, passed on and never interpreted: `os
38
75
  * language` defaults to it, the headless entry mocks this locale, and an MCP
@@ -93,6 +130,17 @@ export const loadConfig = (root?: string): LynkeusConfig => {
93
130
  return { ...own, ...extra };
94
131
  };
95
132
 
133
+ export type CiConfig = {
134
+ /** Shell commands that bring the project's backend up and down, and a URL that answers once it can serve. */
135
+ backend?: { up: string; down?: string; ready?: string; timeoutMs?: number };
136
+ /** How many hosts of the app, and the command that starts one (`{port}` is where it dials). Default: headless. */
137
+ hosts?: { count?: number; command?: string; timeoutMs?: number };
138
+ /** Upload the run (needs LYNKEUS_REPORTS_KEY). */
139
+ push?: boolean;
140
+ /** Extra `case run` flags, as on the command line. */
141
+ run?: string[];
142
+ };
143
+
96
144
  export const portFromEnv = (config: LynkeusConfig = {}): number | undefined => (process.env.LYNKEUS_PORT ? Number(process.env.LYNKEUS_PORT) : config.port);
97
145
 
98
146
  /** A list of regular expressions from the configuration, as one that matches any of them. */
@@ -13,3 +13,20 @@ export const agentMismatch = (hello: Pick<HelloEvent, 'agentVersion'>, cli = CLI
13
13
  const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
14
14
  return `⚠️ The app runs ${agent}, this CLI is lynkeus ${cli}: they can misread each other without an error. Install lynkeus-agent@${cli} in the app and rebuild it (or run the lynkeus CLI of the agent's version).`;
15
15
  };
16
+
17
+ /** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
18
+ export const helloIdentity = (hello: Pick<HelloEvent, 'platform' | 'native' | 'app'>): string => {
19
+ const app = hello.app ?? {};
20
+ const name = typeof app.name === 'string' ? app.name : 'an app';
21
+ const runtime = typeof app.runtime === 'string' ? app.runtime : hello.native ? 'native' : 'hosted';
22
+ return `${name} (${hello.platform}, ${runtime})`;
23
+ };
24
+
25
+ /**
26
+ * The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
27
+ * without an error, each step going to whichever came last: say so the first time a different one comes in.
28
+ */
29
+ export const appSwitched = (before: string | undefined, now: string, port: number): string | undefined =>
30
+ before && before !== now
31
+ ? `⚠️ Another app attached on port ${port}: ${before} gave way to ${now}. Two apps or hosts are dialing this port and steps go to whichever dialed last; stop one, or give each its own LYNKEUS_PORT.`
32
+ : undefined;
@@ -63,6 +63,11 @@ export const pickAgentDevice = async (wanted?: string): Promise<DeviceInfo> => {
63
63
  const devices = await bootedSimulators();
64
64
  const id = wanted ?? process.env.LYNKEUS_DEVICE;
65
65
  const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
66
+ if (!device && id) {
67
+ // An adb serial names an Android device; which platform the app is on is only known once it dials in.
68
+ const android = (await androidDevices()).find((d) => d.id === id);
69
+ if (android) return android;
70
+ }
66
71
  if (!device) {
67
72
  // A headless host dials the driver itself: nothing to boot or attach to.
68
73
  if (!id && devices.length === 0) return HEADLESS_HOST;
@@ -6,7 +6,7 @@
6
6
  import { AgentServer } from 'lynkeus-client';
7
7
  import type { CoreMethods, Screen, TraceEvent } from 'lynkeus-protocol';
8
8
 
9
- import { portFromEnv } from '../config.js';
9
+ import { portFromEnv, readToken } from '../config.js';
10
10
  import { withoutHost } from '../knowledge/problems.js';
11
11
  import type { DeviceInfo } from './devices.js';
12
12
  import { lifecycleFor, type PlatformLifecycle } from './platform.js';
@@ -23,7 +23,7 @@ export class Device {
23
23
  this.info = info;
24
24
  this.#resolving = options.resolving;
25
25
  // The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
26
- this.server = options.server ?? new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv() });
26
+ this.server = options.server ?? new AgentServer({ token: readToken(), port: portFromEnv() });
27
27
  this.#lifecycle = lifecycleFor(info.platform);
28
28
  this.#appId = options.appId ?? null;
29
29
  }
@@ -0,0 +1,142 @@
1
+ // The analytics, crash and attribution services an app talks to over HTTP, known by where it sends: a JS
2
+ // SDK (Segment, PostHog, Amplitude's browser SDK) never touches a native module, so its events show up only
3
+ // as requests. Each request becomes the events its body carries, or one event named after the call.
4
+ import type { RequestRecord } from 'lynkeus-protocol';
5
+
6
+ export type TrafficEvent = { provider: string; name: string; props?: Record<string, unknown> };
7
+
8
+ type Plain = Record<string, unknown>;
9
+ // `provider` is the service the request went to: a reader of one shape serves every service that copies it.
10
+ type Reader = (body: unknown, path: string, provider: string) => TrafficEvent[] | undefined;
11
+
12
+ const isPlain = (v: unknown): v is Plain => !!v && typeof v === 'object' && !Array.isArray(v);
13
+ const plain = (v: unknown): Plain | undefined => (isPlain(v) ? v : undefined);
14
+ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v === undefined ? [] : [v]);
15
+
16
+ const named = (provider: string, items: unknown[], name: (item: Plain) => unknown, props: (item: Plain) => unknown): TrafficEvent[] =>
17
+ items.filter(isPlain).flatMap((item) => {
18
+ const n = name(item);
19
+ if (typeof n !== 'string' || !n) return [];
20
+ const p = plain(props(item));
21
+ return [{ provider, name: n, ...(p ? { props: p } : {}) }];
22
+ });
23
+
24
+ const segment: Reader = (body, _path, provider) => {
25
+ const b = plain(body);
26
+ return named(
27
+ provider,
28
+ list(b?.batch ?? b),
29
+ (i) => i.event ?? (i.type === 'screen' || i.type === 'page' ? `${i.type}: ${i.name}` : i.type),
30
+ (i) => i.properties ?? i.traits,
31
+ );
32
+ };
33
+ const amplitude: Reader = (body) =>
34
+ named(
35
+ 'amplitude',
36
+ list(plain(body)?.events),
37
+ (i) => i.event_type,
38
+ (i) => i.event_properties,
39
+ );
40
+ const posthog: Reader = (body) => {
41
+ const b = plain(body);
42
+ return named(
43
+ 'posthog',
44
+ list(b?.batch ?? (Array.isArray(body) ? body : b)),
45
+ (i) => i.event,
46
+ (i) => i.properties,
47
+ );
48
+ };
49
+ const mixpanel: Reader = (body) =>
50
+ named(
51
+ 'mixpanel',
52
+ list(body),
53
+ (i) => i.event ?? (i.$set ? '$set' : undefined),
54
+ (i) => i.properties ?? i.$set,
55
+ );
56
+
57
+ /** Host pattern → the service, and how to read its body when there is a known shape. */
58
+ const SERVICES: { host: RegExp; provider: string; read?: Reader }[] = [
59
+ { host: /(^|\.)segment\.(io|com)$/, provider: 'segment', read: segment },
60
+ { host: /(^|\.)amplitude\.com$/, provider: 'amplitude', read: amplitude },
61
+ { host: /(^|\.)posthog\.com$/, provider: 'posthog', read: posthog },
62
+ { host: /(^|\.)mixpanel\.com$/, provider: 'mixpanel', read: mixpanel },
63
+ { host: /(^|\.)sentry\.io$|^sentry\./, provider: 'sentry' },
64
+ { host: /(^|\.)(datadoghq\.(com|eu)|ddog-gov\.com)$/, provider: 'datadog' },
65
+ { host: /(^|\.)app-measurement\.com$|^firebaselogging/, provider: 'firebase' },
66
+ { host: /(^|\.)appsflyer(sdk)?\.com$/, provider: 'appsflyer' },
67
+ { host: /(^|\.)branch\.io$/, provider: 'branch' },
68
+ { host: /(^|\.)braze\.(com|eu)$/, provider: 'braze' },
69
+ { host: /(^|\.)onesignal\.com$/, provider: 'onesignal' },
70
+ { host: /(^|\.)intercom\.(io|com)$/, provider: 'intercom' },
71
+ { host: /(^|\.)bugsnag\.com$/, provider: 'bugsnag' },
72
+ { host: /(^|\.)adjust\.(com|net|world)$/, provider: 'adjust' },
73
+ { host: /(^|\.)clevertap-prod\.com$|(^|\.)wzrkt\.com$/, provider: 'clevertap' },
74
+ { host: /(^|\.)heapanalytics\.com$/, provider: 'heap' },
75
+ { host: /(^|\.)statsig(api)?\.(com|net)$/, provider: 'statsig' },
76
+ { host: /(^|\.)launchdarkly\.com$/, provider: 'launchdarkly' },
77
+ // Segment-compatible: the same batch shape.
78
+ { host: /(^|\.)rudderstack\.com$|(^|\.)rudderlabs\.com$/, provider: 'rudderstack', read: segment },
79
+ { host: /^(track|cdp|track-eu|cdp-eu)\.customer\.io$/, provider: 'customerio', read: segment },
80
+ { host: /(^|\.)google-analytics\.com$|(^|\.)analytics\.google\.com$/, provider: 'googleanalytics' },
81
+ { host: /(^|\.)googletagmanager\.com$/, provider: 'googletagmanager' },
82
+ { host: /^graph\.facebook\.com$/, provider: 'meta' },
83
+ { host: /(^|\.)tiktok(v)?\.com$|(^|\.)tiktokapis\.com$/, provider: 'tiktok' },
84
+ { host: /(^|\.)iterable\.com$/, provider: 'iterable' },
85
+ { host: /(^|\.)moengage\.com$/, provider: 'moengage' },
86
+ { host: /(^|\.)kochava\.com$/, provider: 'kochava' },
87
+ { host: /(^|\.)singular\.net$/, provider: 'singular' },
88
+ { host: /(^|\.)emb-api\.com$|(^|\.)embrace\.io$/, provider: 'embrace' },
89
+ { host: /(^|\.)instabug\.com$|(^|\.)luciq\.ai$/, provider: 'instabug' },
90
+ { host: /(^|\.)smartlook\.(cloud|com)$/, provider: 'smartlook' },
91
+ { host: /(^|\.)urbanairship\.com$|(^|\.)airship\.(com|eu)$|(^|\.)asnapieu\.com$/, provider: 'airship' },
92
+ { host: /(^|\.)snplow\.net$|(^|\.)snowplowanalytics\.com$/, provider: 'snowplow' },
93
+ { host: /(^|\.)fullstory\.com$/, provider: 'fullstory' },
94
+ { host: /(^|\.)mparticle\.com$/, provider: 'mparticle' },
95
+ { host: /(^|\.)pendo\.io$/, provider: 'pendo' },
96
+ { host: /(^|\.)lr-ingest\.(io|com)$|(^|\.)logrocket\.(io|com)$/, provider: 'logrocket' },
97
+ { host: /(^|\.)newrelic\.com$|(^|\.)nr-data\.net$/, provider: 'newrelic' },
98
+ { host: /(^|\.)hotjar\.(com|io)$/, provider: 'hotjar' },
99
+ { host: /(^|\.)count\.ly$/, provider: 'countly' },
100
+ ];
101
+
102
+ const parse = (text: string | undefined): unknown => {
103
+ if (!text) return undefined;
104
+ try {
105
+ return JSON.parse(text);
106
+ } catch {
107
+ // Mixpanel's classic endpoint sends `data=<base64 JSON>` as a form.
108
+ const data = /(?:^|&)data=([^&]+)/.exec(text)?.[1];
109
+ if (!data) return undefined;
110
+ try {
111
+ const raw = decodeURIComponent(data);
112
+ return JSON.parse(raw.startsWith('[') || raw.startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ };
118
+
119
+ /** The service a request went to, when it is one of the known ones. */
120
+ export const serviceOf = (url: string): string | undefined => {
121
+ try {
122
+ const host = new URL(url).hostname.toLowerCase();
123
+ return SERVICES.find((s) => s.host.test(host))?.provider;
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ };
128
+
129
+ /** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
130
+ export const trafficEvents = (requests: RequestRecord[]): TrafficEvent[] =>
131
+ requests.flatMap((r) => {
132
+ let url: URL;
133
+ try {
134
+ url = new URL(r.url);
135
+ } catch {
136
+ return [];
137
+ }
138
+ const service = SERVICES.find((s) => s.host.test(url.hostname.toLowerCase()));
139
+ if (!service) return [];
140
+ const events = service.read?.(parse(r.request?.body), url.pathname, service.provider);
141
+ return events?.length ? events : [{ provider: service.provider, name: `${r.method} ${url.pathname}` }];
142
+ });
@@ -47,6 +47,12 @@ const RULES: Rule[] = [
47
47
  cause: (m) => `nothing is listening on ${m[1]}:${m[2]}`,
48
48
  fix: () => `start the backend (or the lynkeus driver, if this is the agent's socket) and check the port the entry dials.`,
49
49
  },
50
+ {
51
+ // Babel loads the project's config with require(); a plugin shipped as ESM only loads that way from Node 22.12.
52
+ re: /ERR_REQUIRE_ESM(?:[^\n]*?require\(\) of ES Module (\S+))?|require\(\) of ES Module (\S+)/,
53
+ cause: (m) => `Node ${process.versions.node} cannot require() an ES module${m[1] || m[2] ? ` (${m[1] || m[2]})` : ''}, which the babel config loads`,
54
+ fix: () => `run with Node 22.12 or newer (lynkeus-headless needs >=22), where require() loads ES modules.`,
55
+ },
50
56
  {
51
57
  re: /Test environment jest-environment-\w+ cannot be found|Unexpected token 'export'|SyntaxError: Cannot use import statement/,
52
58
  cause: () => `jest is not transforming a dependency (ESM in node_modules)`,
@@ -4,7 +4,8 @@ import path from 'node:path';
4
4
  import { readJsonl } from '../files.js';
5
5
 
6
6
  export type AppEvent = { name: string; props?: Record<string, unknown> };
7
- export type RunEvent = AppEvent & { step: number; route?: string };
7
+ /** `provider`: the service the app's own list feeds, when the project says (`cases.appEvents`). */
8
+ export type RunEvent = AppEvent & { step: number; route?: string; provider?: string };
8
9
 
9
10
  type Drainable = { command<T = unknown>(name: string, params?: unknown): Promise<T> };
10
11
 
@@ -14,6 +14,8 @@ export type Evidence = {
14
14
  trace?: TraceEvent[];
15
15
  /** What the failed command was looking for. */
16
16
  target?: { testId?: string; text?: string; route?: string };
17
+ /** Whether the app made any request before `requests` begins: `requests` may be only those of the failed command. */
18
+ fetchedBefore?: boolean;
17
19
  };
18
20
 
19
21
  export type Finding = { rule: string; message: string };
@@ -159,7 +161,7 @@ export const explain = (e: Evidence): Finding[] => {
159
161
  message: `${e.inFlight} request(s) still in flight when the command gave up: the screen had not finished loading. A longer timeout, or wait for the request it needs.`,
160
162
  });
161
163
 
162
- if (done.length === 0 && e.inFlight === 0 && e.target?.route)
164
+ if (done.length === 0 && e.inFlight === 0 && e.target?.route && !e.fetchedBefore)
163
165
  out.push({
164
166
  rule: 'no-requests',
165
167
  message: 'no request left the app while this ran: nothing was fetched. Is the user logged in, and is the app talking to the backend it should?',
package/src/mcp/server.ts CHANGED
@@ -60,9 +60,25 @@ const argvFor = (c: Command, input: Record<string, unknown>): string[] => {
60
60
  return av;
61
61
  };
62
62
 
63
+ // What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
64
+ // a test for this" need nothing more from the person asking.
65
+ const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
66
+
67
+ In a project you do not know, call status first: it lists what is set up and, for what is not, the one thing that fixes it.
68
+
69
+ To set lynkeus up: call init (it installs lynkeus-agent, mounts it from the app's entry and makes the project's token; nobody sets a variable). The agent has native code, so the app must then be built again (npx react-native run-ios or run-android; npx expo run:ios or run:android, never Expo Go) with Metro restarted with --reset-cache. Do that with your shell if you have one, or ask the person to. Then status until an app is attached.
70
+
71
+ To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there (press with settle: true answers with the screen once the app has settled; a screen read right after a press can still be the one it left); prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
72
+
73
+ To test without a simulator: call headless_init (it installs what the Node host needs and writes its entry; an Expo Router app works too), then headless_doctor for the native modules still to mock, then case_run with host. The backend the app talks to must be local: set apiUrl in lynkeus.config.json.
74
+
75
+ To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.
76
+
77
+ For a run with everything (frames, film, requests, analytics, backend): case_run with out, screenshots, video (film needs fastsim), traces and push. When the run ends it warns about evidence it could not keep: pass that on.`;
78
+
63
79
  export const serve = async (session: Session, options: { full?: boolean } = {}) => {
64
80
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
65
- const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
81
+ const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
66
82
  const served = commands().filter((c) => overMcp(c, !!options.full));
67
83
  const byName = new Map(served.map((c) => [identifier(c.name), c]));
68
84
  // So a model reading the app's screens knows their language before it starts guessing.
@@ -101,9 +117,34 @@ export const serve = async (session: Session, options: { full?: boolean } = {})
101
117
  if (request.params.name === 'app_locale' && locale) return text(locale);
102
118
  const c = byName.get(request.params.name);
103
119
  if (!c) return { ...text(`✗ no tool ${request.params.name}`), isError: true };
104
- const r = await execute(argvFor(c, (request.params.arguments ?? {}) as Record<string, unknown>), session.ctx);
120
+ const input = (request.params.arguments ?? {}) as Record<string, unknown>;
121
+ // An argument the tool does not take is dropped by argvFor; a run would then go on without it (every case
122
+ // instead of the one named), so it is refused, with what the tool does take.
123
+ const known = Object.keys(schemaFor(c).properties);
124
+ const unknown = Object.keys(input).filter((key) => !known.includes(key));
125
+ if (unknown.length)
126
+ return { ...text(`✗ ${request.params.name} takes no ${unknown.join(', ')}; it takes ${known.join(', ') || 'no arguments'}`), isError: true };
127
+ const r = await execute(argvFor(c, input), session.ctx);
105
128
  if (r.error) return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
106
- return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
129
+ const body = text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
130
+ // A command that ended with a non-zero code (a failed case, a server that does not keep the contract) is
131
+ // reported as such, with everything it said, so a model does not read it as success.
132
+ return r.code === 0 ? body : { ...body, isError: true };
107
133
  });
108
- await server.connect(new StdioServerTransport());
134
+ // The client going away ends the server: the session holds the driver's port open, and a server left behind
135
+ // keeps the next one (or a case run) from binding it.
136
+ const transport = new StdioServerTransport();
137
+ let stopping = false;
138
+ const stop = () => {
139
+ if (stopping) return;
140
+ stopping = true;
141
+ void session
142
+ .stop()
143
+ .catch(() => undefined)
144
+ .finally(() => process.exit(0));
145
+ };
146
+ process.stdin.once('end', stop);
147
+ process.stdin.once('close', stop);
148
+ transport.onclose = stop;
149
+ await server.connect(transport);
109
150
  };