lynkeus 0.4.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 (81) 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 +56 -1
  16. package/lib/cases/runner.js +309 -33
  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 +25 -2
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +102 -19
  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/drive.js +3 -1
  29. package/lib/commands/fixtures.d.ts +7 -0
  30. package/lib/commands/fixtures.js +94 -0
  31. package/lib/commands/headless.d.ts +11 -0
  32. package/lib/commands/headless.js +69 -3
  33. package/lib/commands/map.d.ts +2 -0
  34. package/lib/commands/map.js +18 -14
  35. package/lib/commands/os.js +43 -10
  36. package/lib/commands/report.d.ts +4 -0
  37. package/lib/commands/report.js +154 -18
  38. package/lib/config.d.ts +25 -0
  39. package/lib/device/agentVersion.d.ts +7 -0
  40. package/lib/device/agentVersion.js +14 -0
  41. package/lib/device/devices.js +6 -0
  42. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  43. package/lib/knowledge/analyticsTraffic.js +109 -0
  44. package/lib/knowledge/errors.js +6 -0
  45. package/lib/knowledge/events.d.ts +2 -0
  46. package/lib/knowledge/why.d.ts +2 -0
  47. package/lib/knowledge/why.js +1 -1
  48. package/lib/mcp/server.js +16 -4
  49. package/lib/runtime.d.ts +9 -1
  50. package/lib/runtime.js +25 -3
  51. package/package.json +7 -4
  52. package/src/cases/coverage.ts +99 -0
  53. package/src/cases/draft.ts +75 -0
  54. package/src/cases/fixtures.ts +57 -8
  55. package/src/cases/frames.ts +30 -0
  56. package/src/cases/nativeLogs.ts +112 -0
  57. package/src/cases/runDirs.ts +48 -0
  58. package/src/cases/runfile.ts +64 -4
  59. package/src/cases/runner.ts +359 -37
  60. package/src/cases/templates.ts +20 -2
  61. package/src/cases/traces.ts +3 -1
  62. package/src/cli.ts +25 -2
  63. package/src/commands/cases.ts +111 -18
  64. package/src/commands/ci.ts +189 -0
  65. package/src/commands/coverage.ts +147 -0
  66. package/src/commands/device.ts +27 -2
  67. package/src/commands/drive.ts +3 -1
  68. package/src/commands/fixtures.ts +102 -0
  69. package/src/commands/headless.ts +66 -3
  70. package/src/commands/map.ts +20 -15
  71. package/src/commands/os.ts +38 -10
  72. package/src/commands/report.ts +160 -16
  73. package/src/config.ts +15 -0
  74. package/src/device/agentVersion.ts +17 -0
  75. package/src/device/devices.ts +5 -0
  76. package/src/knowledge/analyticsTraffic.ts +142 -0
  77. package/src/knowledge/errors.ts +6 -0
  78. package/src/knowledge/events.ts +2 -1
  79. package/src/knowledge/why.ts +3 -1
  80. package/src/mcp/server.ts +16 -4
  81. package/src/runtime.ts +24 -3
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { define } from '../registry.js';
4
+ import { ageOf, megabytes, pruneRuns } from '../cases/runDirs.js';
5
+ import { keptOrDeleted } from './cases.js';
4
6
  /** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
5
7
  export const describeChanges = (changes) => changes
6
8
  ? [
@@ -14,6 +16,41 @@ const answer = async (response, doing) => {
14
16
  const body = (await response.json().catch(() => ({})));
15
17
  throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
16
18
  };
19
+ const UPLOADS_AT_ONCE = 4;
20
+ const CASES_PART = 256 * 1024;
21
+ const UPLOAD_ATTEMPTS = 3;
22
+ // A ustar header holds a name of up to 100 bytes and a folder of up to 155 before it.
23
+ const fitsTar = (file) => {
24
+ const cut = file.lastIndexOf('/');
25
+ return Buffer.byteLength(file.slice(cut + 1)) <= 100 && Buffer.byteLength(cut > 0 ? file.slice(0, cut) : '') <= 155;
26
+ };
27
+ /** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
28
+ export const tarOf = (entries) => {
29
+ const blocks = [];
30
+ for (const [file, data] of entries) {
31
+ const header = Buffer.alloc(512);
32
+ const cut = file.lastIndexOf('/');
33
+ header.write(file.slice(cut + 1), 0, 'utf8');
34
+ header.write('0000644\0', 100);
35
+ header.write('0000000\0', 108);
36
+ header.write('0000000\0', 116);
37
+ header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124);
38
+ header.write(`${Math.floor(Date.now() / 1000)
39
+ .toString(8)
40
+ .padStart(11, '0')}\0`, 136);
41
+ header.write('0', 156);
42
+ header.write('ustar\0', 257);
43
+ header.write('00', 263);
44
+ if (cut > 0)
45
+ header.write(file.slice(0, cut), 345, 'utf8');
46
+ header.fill(' ', 148, 156);
47
+ const sum = header.reduce((n, b) => n + b, 0);
48
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
49
+ blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
50
+ }
51
+ blocks.push(Buffer.alloc(1024));
52
+ return Buffer.concat(blocks);
53
+ };
17
54
  /**
18
55
  * Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
19
56
  * film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
@@ -30,35 +67,100 @@ export const pushRun = async (dir, o) => {
30
67
  body: JSON.stringify({ project: o.project, run }),
31
68
  }), 'creating the run');
32
69
  const id = String(created.id);
33
- await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
34
- const named = new Set();
70
+ // A dropped connection, a 5xx or a 429 is tried again, waiting longer each time.
71
+ const sendWithRetries = async (target, body, doing) => {
72
+ for (let attempt = 1;; attempt++) {
73
+ const response = await call(target, { method: 'PUT', headers: auth, body }).catch((error) => {
74
+ if (attempt >= UPLOAD_ATTEMPTS)
75
+ throw new Error(`${doing}: ${error instanceof Error ? error.message : String(error)}`);
76
+ return undefined;
77
+ });
78
+ if (response && ((response.status < 500 && response.status !== 429) || attempt >= UPLOAD_ATTEMPTS))
79
+ return response;
80
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
81
+ }
82
+ };
83
+ // The run file in parts of about 256 KB: with every request's headers and bodies it runs to megabytes, more
84
+ // than the service reads in one request. A service from before parts answers without `part`, and then
85
+ // takes the whole file in one.
86
+ const parts = [];
87
+ for (const line of cases.split('\n').filter((l) => l.trim())) {
88
+ const last = parts.length - 1;
89
+ if (last >= 0 && Buffer.byteLength(parts[last]) + Buffer.byteLength(line) < CASES_PART)
90
+ parts[last] += `\n${line}`;
91
+ else
92
+ parts.push(line);
93
+ }
94
+ for (const [n, part] of parts.entries()) {
95
+ const sent = await answer(await sendWithRetries(`${base}/v1/runs/${id}/cases?part=${n}`, Buffer.from(part), 'sending its cases'), 'sending its cases');
96
+ if (sent.part === undefined) {
97
+ await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
98
+ break;
99
+ }
100
+ }
101
+ // The report opens as soon as its cases are in: their steps, results and requests show at once, and each
102
+ // frame as it arrives.
103
+ const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
104
+ o.onReady?.(String(finished.url));
105
+ // Per case: its frames, sent as one tar, and its film, sent on its own (a film can be bigger than a request).
106
+ const work = [];
35
107
  for (const line of cases.split('\n')) {
36
108
  if (!line.trim())
37
109
  continue;
38
110
  const c = JSON.parse(line);
39
- for (const p of [c.video, ...(c.steps ?? []).map((s) => s.screenshot)])
40
- if (p)
41
- named.add(p);
111
+ const frames = (c.steps ?? []).map((s) => s.screenshot).filter((p) => !!p && fs.existsSync(path.join(dir, p)));
112
+ const film = c.video && fs.existsSync(path.join(dir, c.video)) ? c.video : undefined;
113
+ work.push({ frames: [...new Set(frames)], ...(film ? { film } : {}) });
42
114
  }
43
115
  let files = 0;
44
116
  const refused = [];
45
- for (const file of named) {
46
- const local = path.join(dir, file);
47
- if (!fs.existsSync(local))
48
- continue;
49
- const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
50
- method: 'PUT',
51
- headers: auth,
52
- body: fs.readFileSync(local),
53
- });
117
+ const sendFile = async (file) => {
118
+ const target = `${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`;
119
+ const response = await sendWithRetries(target, fs.readFileSync(path.join(dir, file)), `sending ${file}`);
54
120
  if (response.status === 402)
55
121
  refused.push(file);
56
122
  else {
57
123
  await answer(response, `sending ${file}`);
58
124
  files += 1;
59
125
  }
60
- }
61
- const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
126
+ };
127
+ // A suite with screenshots is thousands of frames: a request per frame took one over 20 minutes to send. A
128
+ // service from before bundles answers 404, and the frames then go one by one, as they used to.
129
+ let bundles = true;
130
+ const sendFrames = async (frames) => {
131
+ const fits = frames.filter(fitsTar);
132
+ if (bundles && fits.length > 0) {
133
+ const response = await sendWithRetries(`${base}/v1/runs/${id}/bundle`, tarOf(fits.map((f) => [f, fs.readFileSync(path.join(dir, f))])), `sending ${fits.length} frames`);
134
+ if (response.status === 404 || response.status === 405)
135
+ bundles = false;
136
+ else {
137
+ await answer(response, `sending ${fits.length} frames`);
138
+ files += fits.length;
139
+ for (const f of frames.filter((f) => !fitsTar(f)))
140
+ await sendFile(f);
141
+ return;
142
+ }
143
+ }
144
+ for (const f of frames)
145
+ await sendFile(f);
146
+ };
147
+ const queue = [...work];
148
+ const total = work.length;
149
+ let sent = 0;
150
+ let bytes = 0;
151
+ const started = Date.now();
152
+ const sizeOf = (c) => [...c.frames, ...(c.film ? [c.film] : [])].reduce((n, f) => n + fs.statSync(path.join(dir, f)).size, 0);
153
+ await Promise.all(Array.from({ length: Math.min(UPLOADS_AT_ONCE, queue.length) }, async () => {
154
+ for (let c = queue.shift(); c !== undefined; c = queue.shift()) {
155
+ await sendFrames(c.frames);
156
+ if (c.film)
157
+ await sendFile(c.film);
158
+ sent += 1;
159
+ bytes += sizeOf(c);
160
+ if (sent === total || sent % Math.max(1, Math.ceil(total / 10)) === 0)
161
+ o.onProgress?.(`frames: ${sent}/${total} cases, ${(bytes / 1024 / 1024).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(0)} s`);
162
+ }
163
+ }));
62
164
  const changes = finished.changes;
63
165
  return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
64
166
  };
@@ -80,6 +182,36 @@ export const service = () => {
80
182
  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';
81
183
  };
82
184
  export const reportCommands = [
185
+ define({
186
+ name: 'report prune',
187
+ group: 'runs',
188
+ summary: 'Delete the runs `case run --out` left in a folder that ended more than a while ago',
189
+ details: '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.',
190
+ positionals: [{ name: 'dir', help: 'a run folder, or the folder that holds them' }],
191
+ flags: {
192
+ older: { type: 'string', help: 'how old a run must be: 7d, 12h, 30m', value: 'age', default: '7d' },
193
+ 'dry-run': { type: 'boolean', help: 'list what would go, delete nothing' },
194
+ },
195
+ needs: 'nothing',
196
+ session: false,
197
+ mcp: false,
198
+ run: async (ctx) => {
199
+ const root = path.resolve(ctx.args[0] ?? '.');
200
+ if (!fs.existsSync(root))
201
+ return { text: `${ctx.args[0]}: no such folder`, code: 2 };
202
+ const older = ageOf(String(ctx.flags.older ?? '7d'));
203
+ if (older === undefined)
204
+ return { text: `--older ${ctx.flags.older}: give it as 7d, 12h or 30m`, code: 2 };
205
+ const dry = !!ctx.flags['dry-run'];
206
+ const { removed, bytes } = pruneRuns(root, older, dry);
207
+ if (removed.length === 0)
208
+ return { text: 'no run old enough to delete', json: { removed, bytes } };
209
+ return {
210
+ text: [...removed.map((d) => `${dry ? 'would delete' : 'deleted'} ${d}`), `${dry ? 'would free' : 'freed'} ${megabytes(bytes)}`].join('\n'),
211
+ json: { removed, bytes },
212
+ };
213
+ },
214
+ }),
83
215
  define({
84
216
  name: 'report push',
85
217
  group: 'runs',
@@ -101,8 +233,12 @@ export const reportCommands = [
101
233
  return { text: to, code: 2 };
102
234
  const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
103
235
  try {
104
- const pushed = await pushRun(dir, { ...to, project });
105
- const notes = [...describeChanges(pushed.changes), ...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : [])];
236
+ const pushed = await pushRun(dir, { ...to, project, onReady: (url) => ctx.err(`report: ${url} (its frames are on their way)`), onProgress: ctx.err });
237
+ const notes = [
238
+ ...describeChanges(pushed.changes),
239
+ ...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : []),
240
+ keptOrDeleted(dir, ctx.config.report?.afterPush === 'delete' ? 'delete' : 'keep', pushed.refused.length === 0),
241
+ ];
106
242
  return { text: [pushed.url, ...notes].join('\n'), json: pushed };
107
243
  }
108
244
  catch (error) {
package/lib/config.d.ts CHANGED
@@ -24,6 +24,8 @@ export declare const loadGraph: (root?: string) => ScreenGraph | null;
24
24
  */
25
25
  export type LynkeusConfig = {
26
26
  cases?: CasesConfig;
27
+ /** What `lynkeus ci` brings up around a suite: the project's backend and the app's hosts. */
28
+ ci?: CiConfig;
27
29
  /** The headless entry (default `qa/headless.tsx`). */
28
30
  entry?: string;
29
31
  /** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
@@ -39,6 +41,10 @@ export type LynkeusConfig = {
39
41
  device?: string;
40
42
  /** Bundle id or application id. */
41
43
  app?: string;
44
+ /** What a pushed run's local folder becomes: kept (the default), or deleted once the service has every file. */
45
+ report?: {
46
+ afterPush?: 'keep' | 'delete';
47
+ };
42
48
  /**
43
49
  * The language this app speaks, passed on and never interpreted: `os
44
50
  * language` defaults to it, the headless entry mocks this locale, and an MCP
@@ -91,6 +97,25 @@ export type LynkeusConfig = {
91
97
  export declare const configFile: (root?: string) => string;
92
98
  /** The app's configuration, under the one `LYNKEUS_CONFIG` names: cases and fixtures often live in a repository of their own. */
93
99
  export declare const loadConfig: (root?: string) => LynkeusConfig;
100
+ export type CiConfig = {
101
+ /** Shell commands that bring the project's backend up and down, and a URL that answers once it can serve. */
102
+ backend?: {
103
+ up: string;
104
+ down?: string;
105
+ ready?: string;
106
+ timeoutMs?: number;
107
+ };
108
+ /** How many hosts of the app, and the command that starts one (`{port}` is where it dials). Default: headless. */
109
+ hosts?: {
110
+ count?: number;
111
+ command?: string;
112
+ timeoutMs?: number;
113
+ };
114
+ /** Upload the run (needs LYNKEUS_REPORTS_KEY). */
115
+ push?: boolean;
116
+ /** Extra `case run` flags, as on the command line. */
117
+ run?: string[];
118
+ };
94
119
  export declare const portFromEnv: (config?: LynkeusConfig) => number | undefined;
95
120
  /** A list of regular expressions from the configuration, as one that matches any of them. */
96
121
  export declare const anyOf: (patterns: string[] | undefined) => RegExp | undefined;
@@ -5,3 +5,10 @@ export declare const CLI_VERSION: string;
5
5
  * nothing): what to tell the person running it, or nothing when the two match.
6
6
  */
7
7
  export declare const agentMismatch: (hello: Pick<HelloEvent, "agentVersion">, cli?: string) => string | undefined;
8
+ /** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
9
+ export declare const helloIdentity: (hello: Pick<HelloEvent, "platform" | "native" | "app">) => string;
10
+ /**
11
+ * The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
12
+ * without an error, each step going to whichever came last: say so the first time a different one comes in.
13
+ */
14
+ export declare const appSwitched: (before: string | undefined, now: string, port: number) => string | undefined;
@@ -10,3 +10,17 @@ export const agentMismatch = (hello, cli = CLI_VERSION) => {
10
10
  const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
11
11
  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).`;
12
12
  };
13
+ /** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
14
+ export const helloIdentity = (hello) => {
15
+ const app = hello.app ?? {};
16
+ const name = typeof app.name === 'string' ? app.name : 'an app';
17
+ const runtime = typeof app.runtime === 'string' ? app.runtime : hello.native ? 'native' : 'hosted';
18
+ return `${name} (${hello.platform}, ${runtime})`;
19
+ };
20
+ /**
21
+ * The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
22
+ * without an error, each step going to whichever came last: say so the first time a different one comes in.
23
+ */
24
+ export const appSwitched = (before, now, port) => before && before !== now
25
+ ? `⚠️ 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.`
26
+ : undefined;
@@ -50,6 +50,12 @@ export const pickAgentDevice = async (wanted) => {
50
50
  const devices = await bootedSimulators();
51
51
  const id = wanted ?? process.env.LYNKEUS_DEVICE;
52
52
  const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
53
+ if (!device && id) {
54
+ // An adb serial names an Android device; which platform the app is on is only known once it dials in.
55
+ const android = (await androidDevices()).find((d) => d.id === id);
56
+ if (android)
57
+ return android;
58
+ }
53
59
  if (!device) {
54
60
  // A headless host dials the driver itself: nothing to boot or attach to.
55
61
  if (!id && devices.length === 0)
@@ -0,0 +1,10 @@
1
+ import type { RequestRecord } from 'lynkeus-protocol';
2
+ export type TrafficEvent = {
3
+ provider: string;
4
+ name: string;
5
+ props?: Record<string, unknown>;
6
+ };
7
+ /** The service a request went to, when it is one of the known ones. */
8
+ export declare const serviceOf: (url: string) => string | undefined;
9
+ /** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
10
+ export declare const trafficEvents: (requests: RequestRecord[]) => TrafficEvent[];
@@ -0,0 +1,109 @@
1
+ const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
+ const plain = (v) => (isPlain(v) ? v : undefined);
3
+ const list = (v) => (Array.isArray(v) ? v : v === undefined ? [] : [v]);
4
+ const named = (provider, items, name, props) => items.filter(isPlain).flatMap((item) => {
5
+ const n = name(item);
6
+ if (typeof n !== 'string' || !n)
7
+ return [];
8
+ const p = plain(props(item));
9
+ return [{ provider, name: n, ...(p ? { props: p } : {}) }];
10
+ });
11
+ const segment = (body, _path, provider) => {
12
+ const b = plain(body);
13
+ return named(provider, list(b?.batch ?? b), (i) => i.event ?? (i.type === 'screen' || i.type === 'page' ? `${i.type}: ${i.name}` : i.type), (i) => i.properties ?? i.traits);
14
+ };
15
+ const amplitude = (body) => named('amplitude', list(plain(body)?.events), (i) => i.event_type, (i) => i.event_properties);
16
+ const posthog = (body) => {
17
+ const b = plain(body);
18
+ return named('posthog', list(b?.batch ?? (Array.isArray(body) ? body : b)), (i) => i.event, (i) => i.properties);
19
+ };
20
+ const mixpanel = (body) => named('mixpanel', list(body), (i) => i.event ?? (i.$set ? '$set' : undefined), (i) => i.properties ?? i.$set);
21
+ /** Host pattern → the service, and how to read its body when there is a known shape. */
22
+ const SERVICES = [
23
+ { host: /(^|\.)segment\.(io|com)$/, provider: 'segment', read: segment },
24
+ { host: /(^|\.)amplitude\.com$/, provider: 'amplitude', read: amplitude },
25
+ { host: /(^|\.)posthog\.com$/, provider: 'posthog', read: posthog },
26
+ { host: /(^|\.)mixpanel\.com$/, provider: 'mixpanel', read: mixpanel },
27
+ { host: /(^|\.)sentry\.io$|^sentry\./, provider: 'sentry' },
28
+ { host: /(^|\.)(datadoghq\.(com|eu)|ddog-gov\.com)$/, provider: 'datadog' },
29
+ { host: /(^|\.)app-measurement\.com$|^firebaselogging/, provider: 'firebase' },
30
+ { host: /(^|\.)appsflyer(sdk)?\.com$/, provider: 'appsflyer' },
31
+ { host: /(^|\.)branch\.io$/, provider: 'branch' },
32
+ { host: /(^|\.)braze\.(com|eu)$/, provider: 'braze' },
33
+ { host: /(^|\.)onesignal\.com$/, provider: 'onesignal' },
34
+ { host: /(^|\.)intercom\.(io|com)$/, provider: 'intercom' },
35
+ { host: /(^|\.)bugsnag\.com$/, provider: 'bugsnag' },
36
+ { host: /(^|\.)adjust\.(com|net|world)$/, provider: 'adjust' },
37
+ { host: /(^|\.)clevertap-prod\.com$|(^|\.)wzrkt\.com$/, provider: 'clevertap' },
38
+ { host: /(^|\.)heapanalytics\.com$/, provider: 'heap' },
39
+ { host: /(^|\.)statsig(api)?\.(com|net)$/, provider: 'statsig' },
40
+ { host: /(^|\.)launchdarkly\.com$/, provider: 'launchdarkly' },
41
+ // Segment-compatible: the same batch shape.
42
+ { host: /(^|\.)rudderstack\.com$|(^|\.)rudderlabs\.com$/, provider: 'rudderstack', read: segment },
43
+ { host: /^(track|cdp|track-eu|cdp-eu)\.customer\.io$/, provider: 'customerio', read: segment },
44
+ { host: /(^|\.)google-analytics\.com$|(^|\.)analytics\.google\.com$/, provider: 'googleanalytics' },
45
+ { host: /(^|\.)googletagmanager\.com$/, provider: 'googletagmanager' },
46
+ { host: /^graph\.facebook\.com$/, provider: 'meta' },
47
+ { host: /(^|\.)tiktok(v)?\.com$|(^|\.)tiktokapis\.com$/, provider: 'tiktok' },
48
+ { host: /(^|\.)iterable\.com$/, provider: 'iterable' },
49
+ { host: /(^|\.)moengage\.com$/, provider: 'moengage' },
50
+ { host: /(^|\.)kochava\.com$/, provider: 'kochava' },
51
+ { host: /(^|\.)singular\.net$/, provider: 'singular' },
52
+ { host: /(^|\.)emb-api\.com$|(^|\.)embrace\.io$/, provider: 'embrace' },
53
+ { host: /(^|\.)instabug\.com$|(^|\.)luciq\.ai$/, provider: 'instabug' },
54
+ { host: /(^|\.)smartlook\.(cloud|com)$/, provider: 'smartlook' },
55
+ { host: /(^|\.)urbanairship\.com$|(^|\.)airship\.(com|eu)$|(^|\.)asnapieu\.com$/, provider: 'airship' },
56
+ { host: /(^|\.)snplow\.net$|(^|\.)snowplowanalytics\.com$/, provider: 'snowplow' },
57
+ { host: /(^|\.)fullstory\.com$/, provider: 'fullstory' },
58
+ { host: /(^|\.)mparticle\.com$/, provider: 'mparticle' },
59
+ { host: /(^|\.)pendo\.io$/, provider: 'pendo' },
60
+ { host: /(^|\.)lr-ingest\.(io|com)$|(^|\.)logrocket\.(io|com)$/, provider: 'logrocket' },
61
+ { host: /(^|\.)newrelic\.com$|(^|\.)nr-data\.net$/, provider: 'newrelic' },
62
+ { host: /(^|\.)hotjar\.(com|io)$/, provider: 'hotjar' },
63
+ { host: /(^|\.)count\.ly$/, provider: 'countly' },
64
+ ];
65
+ const parse = (text) => {
66
+ if (!text)
67
+ return undefined;
68
+ try {
69
+ return JSON.parse(text);
70
+ }
71
+ catch {
72
+ // Mixpanel's classic endpoint sends `data=<base64 JSON>` as a form.
73
+ const data = /(?:^|&)data=([^&]+)/.exec(text)?.[1];
74
+ if (!data)
75
+ return undefined;
76
+ try {
77
+ const raw = decodeURIComponent(data);
78
+ return JSON.parse(raw.startsWith('[') || raw.startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ };
85
+ /** The service a request went to, when it is one of the known ones. */
86
+ export const serviceOf = (url) => {
87
+ try {
88
+ const host = new URL(url).hostname.toLowerCase();
89
+ return SERVICES.find((s) => s.host.test(host))?.provider;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ };
95
+ /** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
96
+ export const trafficEvents = (requests) => requests.flatMap((r) => {
97
+ let url;
98
+ try {
99
+ url = new URL(r.url);
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ const service = SERVICES.find((s) => s.host.test(url.hostname.toLowerCase()));
105
+ if (!service)
106
+ return [];
107
+ const events = service.read?.(parse(r.request?.body), url.pathname, service.provider);
108
+ return events?.length ? events : [{ provider: service.provider, name: `${r.method} ${url.pathname}` }];
109
+ });
@@ -39,6 +39,12 @@ const RULES = [
39
39
  cause: (m) => `nothing is listening on ${m[1]}:${m[2]}`,
40
40
  fix: () => `start the backend (or the lynkeus driver, if this is the agent's socket) and check the port the entry dials.`,
41
41
  },
42
+ {
43
+ // Babel loads the project's config with require(); a plugin shipped as ESM only loads that way from Node 22.12.
44
+ re: /ERR_REQUIRE_ESM(?:[^\n]*?require\(\) of ES Module (\S+))?|require\(\) of ES Module (\S+)/,
45
+ cause: (m) => `Node ${process.versions.node} cannot require() an ES module${m[1] || m[2] ? ` (${m[1] || m[2]})` : ''}, which the babel config loads`,
46
+ fix: () => `run with Node 22.12 or newer (lynkeus-headless needs >=22), where require() loads ES modules.`,
47
+ },
42
48
  {
43
49
  re: /Test environment jest-environment-\w+ cannot be found|Unexpected token 'export'|SyntaxError: Cannot use import statement/,
44
50
  cause: () => `jest is not transforming a dependency (ESM in node_modules)`,
@@ -2,9 +2,11 @@ export type AppEvent = {
2
2
  name: string;
3
3
  props?: Record<string, unknown>;
4
4
  };
5
+ /** `provider`: the service the app's own list feeds, when the project says (`cases.appEvents`). */
5
6
  export type RunEvent = AppEvent & {
6
7
  step: number;
7
8
  route?: string;
9
+ provider?: string;
8
10
  };
9
11
  type Drainable = {
10
12
  command<T = unknown>(name: string, params?: unknown): Promise<T>;
@@ -15,6 +15,8 @@ export type Evidence = {
15
15
  text?: string;
16
16
  route?: string;
17
17
  };
18
+ /** Whether the app made any request before `requests` begins: `requests` may be only those of the failed command. */
19
+ fetchedBefore?: boolean;
18
20
  };
19
21
  export type Finding = {
20
22
  rule: string;
@@ -128,7 +128,7 @@ export const explain = (e) => {
128
128
  rule: 'in-flight',
129
129
  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.`,
130
130
  });
131
- if (done.length === 0 && e.inFlight === 0 && e.target?.route)
131
+ if (done.length === 0 && e.inFlight === 0 && e.target?.route && !e.fetchedBefore)
132
132
  out.push({
133
133
  rule: 'no-requests',
134
134
  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/lib/mcp/server.js CHANGED
@@ -67,11 +67,13 @@ In a project you do not know, call status first: it lists what is set up and, fo
67
67
 
68
68
  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.
69
69
 
70
- 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; 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.
70
+ 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.
71
71
 
72
72
  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.
73
73
 
74
- 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.`;
74
+ 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.
75
+
76
+ 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.`;
75
77
  export const serve = async (session, options = {}) => {
76
78
  const { version } = createRequire(import.meta.url)('../../package.json');
77
79
  const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
@@ -115,10 +117,20 @@ export const serve = async (session, options = {}) => {
115
117
  const c = byName.get(request.params.name);
116
118
  if (!c)
117
119
  return { ...text(`✗ no tool ${request.params.name}`), isError: true };
118
- const r = await execute(argvFor(c, (request.params.arguments ?? {})), session.ctx);
120
+ const input = (request.params.arguments ?? {});
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);
119
128
  if (r.error)
120
129
  return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
121
- return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
130
+ const body = text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
131
+ // A command that ended with a non-zero code (a failed case, a server that does not keep the contract) is
132
+ // reported as such, with everything it said, so a model does not read it as success.
133
+ return r.code === 0 ? body : { ...body, isError: true };
122
134
  });
123
135
  // The client going away ends the server: the session holds the driver's port open, and a server left behind
124
136
  // keeps the next one (or a case run) from binding it.
package/lib/runtime.d.ts CHANGED
@@ -8,5 +8,13 @@ export type Outcome = {
8
8
  why?: string;
9
9
  };
10
10
  /** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
11
- export declare const whyFor: (base: Base, expected?: Expected) => Promise<string>;
11
+ export declare const whyFor: (base: Base, expected?: Expected, startedAt?: number) => Promise<string>;
12
+ /**
13
+ * The requests made while the failed command ran, and a little before: a 403 from minutes ago is not why a wait
14
+ * failed now. Only when the app's clock reads like this machine's (a simulator, an emulator); a host on a clock of
15
+ * its own keeps them all.
16
+ */
17
+ export declare const duringCommand: <R extends {
18
+ startedAt: number;
19
+ }>(requests: R[], startedAt: number | undefined, now?: number) => R[];
12
20
  export declare const execute: (argv: string[], base: Base) => Promise<Outcome>;
package/lib/runtime.js CHANGED
@@ -8,16 +8,37 @@ import { describeWhy, explain } from './knowledge/why.js';
8
8
  import { helpFor, parseFor, resolve } from './registry.js';
9
9
  import { expectedOf } from './targets.js';
10
10
  /** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
11
- export const whyFor = async (base, expected) => {
11
+ export const whyFor = async (base, expected, startedAt) => {
12
12
  try {
13
13
  const device = await base.device();
14
14
  const [screen, requests, trace] = await Promise.all([device.screen(), device.requests(), device.trace()]);
15
- return describeWhy(explain({ screen, requests: requests.requests.slice(-40), inFlight: requests.inFlight, trace: trace.events.slice(-40), target: expected }));
15
+ const during = duringCommand(requests.requests, startedAt);
16
+ return describeWhy(explain({
17
+ screen,
18
+ requests: during.slice(-40),
19
+ inFlight: requests.inFlight,
20
+ trace: trace.events.slice(-40),
21
+ target: expected,
22
+ fetchedBefore: during.length < requests.requests.length,
23
+ }));
16
24
  }
17
25
  catch {
18
26
  return '';
19
27
  }
20
28
  };
29
+ /**
30
+ * The requests made while the failed command ran, and a little before: a 403 from minutes ago is not why a wait
31
+ * failed now. Only when the app's clock reads like this machine's (a simulator, an emulator); a host on a clock of
32
+ * its own keeps them all.
33
+ */
34
+ export const duringCommand = (requests, startedAt, now = Date.now()) => {
35
+ if (startedAt === undefined || requests.length === 0)
36
+ return requests;
37
+ const latest = Math.max(...requests.map((r) => r.startedAt));
38
+ if (Math.abs(latest - now) > 10 * 60_000)
39
+ return requests;
40
+ return requests.filter((r) => r.startedAt >= startedAt - 3000);
41
+ };
21
42
  const TARGET_ARG = { press: 0, see: 0, wait: 0, swipe: 1, nav: 0, type: 1 };
22
43
  export const execute = async (argv, base) => {
23
44
  const found = resolve(argv);
@@ -35,6 +56,7 @@ export const execute = async (argv, base) => {
35
56
  }
36
57
  if (parsed.flags.help)
37
58
  return { code: 0, text: helpFor(command) };
59
+ const startedAt = Date.now();
38
60
  try {
39
61
  const result = await command.run({ ...base, ...parsed });
40
62
  if (result === undefined)
@@ -47,7 +69,7 @@ export const execute = async (argv, base) => {
47
69
  const out = { code: 1, error: error instanceof Error ? error.message : String(error) };
48
70
  const at = TARGET_ARG[command.name];
49
71
  if (at !== undefined) {
50
- const why = await whyFor(base, expectedOf(parsed.args[at]));
72
+ const why = await whyFor(base, expectedOf(parsed.args[at]), startedAt);
51
73
  if (why)
52
74
  out.why = why;
53
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkeus",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Drive a React Native app running lynkeus-agent from the terminal, a test runner or an MCP client: screens, flows, smoke, crawl, and the app's screen graph.",
5
5
  "type": "module",
6
6
  "bin": "./lib/cli.js",
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
- "lynkeus-client": "^0.4.0",
39
- "lynkeus-protocol": "^0.4.0",
38
+ "lynkeus-client": "^0.5.0",
39
+ "lynkeus-protocol": "^0.5.0",
40
40
  "pngjs": "^7.0.0",
41
41
  "ts-morph": "^24.0.0",
42
42
  "yaml": "^2"
@@ -47,5 +47,8 @@
47
47
  "tsx": "^4.19.2",
48
48
  "typescript": "^6.0.3"
49
49
  },
50
- "homepage": "https://github.com/lynkeushq/lynkeus#readme"
50
+ "homepage": "https://github.com/lynkeushq/lynkeus#readme",
51
+ "optionalDependencies": {
52
+ "sharp": "^0.34.4"
53
+ }
51
54
  }