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
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { Affected } from '../knowledge/query.js';
5
+ import type { Case, Step } from './format.js';
6
+
7
+ const ROUTE = /^[A-Z][\w.]+$/;
8
+
9
+ // A step names a route when it goes there, waits for it or checks it is on screen.
10
+ const routeOf = (step: Step): string | undefined => {
11
+ const v = step.value;
12
+ const named = (x: unknown) => (typeof x === 'string' && ROUTE.test(x) ? x : undefined);
13
+ if (step.verb === 'app.nav' || step.verb === 'app.wait')
14
+ return (
15
+ named(v) ??
16
+ (v && typeof v === 'object' && !Array.isArray(v) ? named((v as Record<string, unknown>).route ?? (v as Record<string, unknown>).what) : undefined)
17
+ );
18
+ if (step.verb === 'app.see') return v && typeof v === 'object' && !Array.isArray(v) ? named((v as Record<string, unknown>).route) : undefined;
19
+ return undefined;
20
+ };
21
+
22
+ /** The routes a case names in its steps. */
23
+ export const routesNamed = (c: Case): string[] => [...new Set([...c.setup, ...c.steps].map(routeOf).filter((r): r is string => !!r))];
24
+
25
+ /** What `case run --seen <dir>` kept: each passing case's screens, by case id. */
26
+ export const readSeen = (dir: string | undefined): Record<string, string[]> => {
27
+ if (!dir || !fs.existsSync(dir)) return {};
28
+ const seen: Record<string, string[]> = {};
29
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
30
+ try {
31
+ const s = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) as { id?: string; routes?: unknown };
32
+ if (typeof s.id === 'string' && Array.isArray(s.routes)) seen[s.id] = s.routes.filter((r): r is string => typeof r === 'string');
33
+ } catch {}
34
+ }
35
+ return seen;
36
+ };
37
+
38
+ export type Coverage = {
39
+ routes: number;
40
+ covered: string[];
41
+ uncovered: string[];
42
+ /** Routes a case names that the graph does not know: renamed, or a typo. */
43
+ unknown: string[];
44
+ byRoute: Record<string, string[]>;
45
+ files: Record<string, string>;
46
+ };
47
+
48
+ /** Which cases touch which of the app's routes, by what they name and what they went through last time they passed. */
49
+ export const coverageOf = (cases: Case[], graphRoutes: string[], seen: Record<string, string[]> = {}): Coverage => {
50
+ const byRoute: Record<string, string[]> = {};
51
+ const files: Record<string, string> = {};
52
+ const add = (route: string, id: string) => {
53
+ byRoute[route] ??= [];
54
+ if (!byRoute[route].includes(id)) byRoute[route].push(id);
55
+ };
56
+ for (const c of cases) {
57
+ files[c.id] = c.file;
58
+ for (const r of routesNamed(c)) add(r, c.id);
59
+ for (const r of seen[c.id] ?? []) add(r, c.id);
60
+ }
61
+ const known = new Set(graphRoutes);
62
+ const covered = graphRoutes.filter((r) => byRoute[r]?.length);
63
+ return {
64
+ routes: graphRoutes.length,
65
+ covered,
66
+ uncovered: graphRoutes.filter((r) => !byRoute[r]?.length),
67
+ unknown: Object.keys(byRoute)
68
+ .filter((r) => !known.has(r))
69
+ .sort(),
70
+ byRoute,
71
+ files,
72
+ };
73
+ };
74
+
75
+ export type Impact = {
76
+ /** Each case to run, with the reached routes it touches, nearest first. */
77
+ cases: { id: string; file: string; routes: string[]; distance: number; via: string }[];
78
+ /** Routes the change reaches that no case touches. */
79
+ untested: string[];
80
+ };
81
+
82
+ /** The cases a change reaches: the screens it touches or imports, crossed with what each case covers. */
83
+ export const impactOf = (affected: Affected[], coverage: Coverage): Impact => {
84
+ const nearest = new Map<string, Affected>();
85
+ for (const a of affected) if (!nearest.has(a.route) || nearest.get(a.route)!.distance > a.distance) nearest.set(a.route, a);
86
+ const byCase = new Map<string, Impact['cases'][number]>();
87
+ const untested: string[] = [];
88
+ for (const [route, hit] of nearest) {
89
+ const ids = coverage.byRoute[route] ?? [];
90
+ if (!ids.length) untested.push(route);
91
+ for (const id of ids) {
92
+ const c = byCase.get(id) ?? { id, file: coverage.files[id] ?? '', routes: [], distance: hit.distance, via: hit.via };
93
+ c.routes.push(route);
94
+ if (hit.distance < c.distance) Object.assign(c, { distance: hit.distance, via: hit.via });
95
+ byCase.set(id, c);
96
+ }
97
+ }
98
+ return { cases: [...byCase.values()].sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id)), untested: untested.sort() };
99
+ };
@@ -0,0 +1,75 @@
1
+ import type { FixtureCommand } from 'lynkeus-protocol';
2
+
3
+ import type { PlanStep } from '../knowledge/plan.js';
4
+
5
+ export type DraftInput = {
6
+ id: string;
7
+ title?: string;
8
+ from: string;
9
+ to: string;
10
+ /** The way from `from` to `to`, as `map plan` finds it; undefined when no path is known. */
11
+ path: PlanStep[] | undefined;
12
+ /** What the project's fixtures server says it can do, offered as the setup to pick from. */
13
+ fixtures?: FixtureCommand[];
14
+ /** The project's macros (`app.login`…), offered too. */
15
+ macros?: string[];
16
+ /** The project declares a fixtures server that did not describe itself. */
17
+ fixturesSilent?: boolean;
18
+ };
19
+
20
+ const quote = (s: string) => JSON.stringify(s);
21
+
22
+ const paramsHint = (c: FixtureCommand) => {
23
+ const params = Object.entries(c.params ?? {}).map(([k, p]) => `${k}${p.values ? ` (${p.values.join('|')})` : ''}`);
24
+ return params.length ? ` — ${params.join(', ')}` : '';
25
+ };
26
+
27
+ /**
28
+ * A case to finish: its frontmatter with the questions a reader needs answered, the backend's setup offered (never
29
+ * chosen: what state the case needs is the author's call), the way to the screen, and the checks left to write.
30
+ */
31
+ export const draftCase = (d: DraftInput): string => {
32
+ const lines = [
33
+ '---',
34
+ `id: ${d.id}`,
35
+ `title: ${quote(d.title ?? `[What a person can do on ${d.to}]`)}`,
36
+ 'description: >',
37
+ ' [What this case proves, in a sentence or two.]',
38
+ 'why: >',
39
+ ' [What breaks for a person if it fails.]',
40
+ 'given: >',
41
+ ` [Who the user is and what is true when it starts, on ${d.from}.]`,
42
+ '---',
43
+ 'setup:',
44
+ ];
45
+ if (d.fixtures?.length) {
46
+ lines.push(' # Pick what the case needs; the backend answers these (lynkeus fixtures describe):');
47
+ for (const c of d.fixtures) {
48
+ const example = c.example ? ` ${JSON.stringify(c.example).replace(/"(\w+)":/g, '$1: ')}` : ' {}';
49
+ lines.push(` # - ${c.name}:${example}${c.summary ? ` # ${c.summary}` : ''}${paramsHint(c)}`);
50
+ }
51
+ } else if (d.fixturesSilent)
52
+ lines.push(' # The fixtures server did not answer lynkeus.describe (lynkeus fixtures check says why): the setup options are not listed.');
53
+ else lines.push(' # The state the case needs, through the project’s fixtures (cases.fixtures).');
54
+ lines.push('', 'steps:');
55
+ if (d.macros?.length) lines.push(` # The project's own steps: ${d.macros.join(', ')}`);
56
+ if (!d.path) {
57
+ lines.push(` # No known way from ${d.from} to ${d.to}: \`lynkeus crawl --to ${d.to}\` learns one.`);
58
+ lines.push(` - app.nav: ${d.to}`);
59
+ } else {
60
+ for (const s of d.path) {
61
+ if (s.press) lines.push(` - app.press: ${quote(`#${s.press}`)}${s.source === 'static' ? ' # from the source: confirm it on screen' : ''}`);
62
+ else lines.push(` # ${s.from} → ${s.to}: ${s.hint ?? 'find the control that leads here'}`);
63
+ lines.push(` - app.wait: { what: ${s.opens ? quote(s.opens) : s.to}, timeoutMs: 10000 }`);
64
+ }
65
+ }
66
+ lines.push(
67
+ ` - app.see: { route: ${d.to} }`,
68
+ ' # [What the person does here, and what must be on screen after.]',
69
+ '',
70
+ 'expected: |',
71
+ ' [What must be true when it ends, on screen and in the backend.]',
72
+ '',
73
+ );
74
+ return lines.join('\n');
75
+ };
@@ -13,7 +13,8 @@
13
13
  *
14
14
  * {"command": "lynkeus.describe", "params": {}, "env": {}}
15
15
  *
16
- * — and a server that knows answers `{"commands": ["create_user", …]}`. With
16
+ * — and a server that knows answers `{"commands": ["create_user", …]}`, each entry a name or a description
17
+ * (`FixtureCommand` in lynkeus-protocol: summary, params, example). The contract is docs/FIXTURES.md. With
17
18
  * that, a case naming a fixture nobody answers is refused before anything
18
19
  * runs. A server that answers `"ok": false`, as one written before this did,
19
20
  * is taken to describe nothing, and its cases run unchecked. A server that is
@@ -26,9 +27,12 @@
26
27
  * answer anything.
27
28
  */
28
29
  import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
30
+ import fs from 'node:fs';
29
31
  import path from 'node:path';
30
32
  import readline from 'node:readline';
31
33
 
34
+ import type { FixtureCommand } from 'lynkeus-protocol';
35
+
32
36
  export type FixturesConfig = { command: string; cwd?: string; env?: Record<string, string>; timeoutMs?: number };
33
37
 
34
38
  export type Answer = Record<string, unknown>;
@@ -36,7 +40,23 @@ export type Answer = Record<string, unknown>;
36
40
  /** The one command that is lynkeus's own and not the project's. */
37
41
  export const DESCRIBE = 'lynkeus.describe';
38
42
 
39
- export type Description = { commands: string[]; down?: string };
43
+ /** What the server answers: the names, with each command's description where it gave one. */
44
+ export type Description = { commands: string[]; details: FixtureCommand[]; down?: string; protocol?: number };
45
+
46
+ /** A description as the server sent it, names and descriptions mixed, made one shape; anything else is no description. */
47
+ export const readDescription = (answer: Record<string, unknown> | undefined): Description | undefined => {
48
+ const list = answer?.commands;
49
+ if (!Array.isArray(list)) return undefined;
50
+ const details: FixtureCommand[] = [];
51
+ for (const c of list) {
52
+ if (typeof c === 'string' && c) details.push({ name: c });
53
+ else if (c && typeof c === 'object' && typeof (c as FixtureCommand).name === 'string' && (c as FixtureCommand).name) details.push(c as FixtureCommand);
54
+ else return undefined;
55
+ }
56
+ const down = typeof answer?.down === 'string' && answer.down ? answer.down : undefined;
57
+ const protocol = typeof answer?.protocol === 'number' ? answer.protocol : undefined;
58
+ return { commands: details.map((d) => d.name), details, ...(down ? { down } : {}), ...(protocol !== undefined ? { protocol } : {}) };
59
+ };
40
60
 
41
61
  /** lynkeus's second own command: this connection works ahead of the cases. */
42
62
  export const AHEAD = 'lynkeus.ahead';
@@ -48,6 +68,18 @@ const envOf = (params: Record<string, unknown>): Record<string, string> =>
48
68
  .map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), typeof v === 'object' ? JSON.stringify(v) : String(v)]),
49
69
  );
50
70
 
71
+ const describedFile = (root: string) => path.join(root, '.lynkeus', 'fixtures-described.json');
72
+
73
+ /** What the server last said it answers, and how long ago; undefined when it never did for this command. */
74
+ export const lastDescription = (root: string, command: string): { at: number; description: Description } | undefined => {
75
+ try {
76
+ const kept = JSON.parse(fs.readFileSync(describedFile(root), 'utf8')) as { at: number; command: string; description: Description };
77
+ return kept.command === command ? { at: kept.at, description: kept.description } : undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ };
82
+
51
83
  export class Fixtures {
52
84
  #config: FixturesConfig;
53
85
  #root: string;
@@ -55,6 +87,10 @@ export class Fixtures {
55
87
  #lines: string[] = [];
56
88
  #waiting: ((line: string | null) => void) | null = null;
57
89
  #stderr = '';
90
+ // Ends in a row without one answer. A command that cannot start (a wrong path, a backend that is not
91
+ // there) would otherwise be started again for every case, and each case would wait out its own start.
92
+ #silentEnds = 0;
93
+ #broken: string | null = null;
58
94
 
59
95
  constructor(config: FixturesConfig, root: string) {
60
96
  this.#config = config;
@@ -70,12 +106,15 @@ export class Fixtures {
70
106
  cwd: path.resolve(this.#root, this.#config.cwd ?? '.'),
71
107
  env: { ...process.env, ...this.#config.env },
72
108
  });
109
+ // Writing to a command that already ended fails here; its close says so to whoever waits.
110
+ child.stdin.on('error', () => undefined);
73
111
  child.stderr.on('data', (chunk: Buffer) => {
74
112
  this.#stderr = (this.#stderr + chunk.toString()).slice(-4000);
75
113
  });
76
114
  const reader = readline.createInterface({ input: child.stdout });
77
115
  reader.on('line', (line) => {
78
116
  if (!line.trim().startsWith('{')) return;
117
+ this.#silentEnds = 0;
79
118
  if (this.#waiting) {
80
119
  const resolve = this.#waiting;
81
120
  this.#waiting = null;
@@ -83,6 +122,7 @@ export class Fixtures {
83
122
  } else this.#lines.push(line);
84
123
  });
85
124
  child.on('close', () => {
125
+ if (this.#child === child) this.#silentEnds += 1;
86
126
  this.#child = null;
87
127
  this.#waiting?.(null);
88
128
  this.#waiting = null;
@@ -92,6 +132,7 @@ export class Fixtures {
92
132
  }
93
133
 
94
134
  async call(command: string, params: Record<string, unknown>): Promise<Answer> {
135
+ if (this.#broken) throw new Error(`${command}: ${this.#broken}`);
95
136
  const child = this.#start();
96
137
  child.stdin.write(`${JSON.stringify({ command, params, env: envOf(params) })}\n`);
97
138
  const timeoutMs = this.#config.timeoutMs ?? 120_000;
@@ -107,8 +148,11 @@ export class Fixtures {
107
148
  resolve(answer);
108
149
  };
109
150
  });
110
- if (line === null)
111
- throw new Error(`${command}: the fixtures command ended${this.#stderr ? `: ${this.#stderr.trim().split('\n').slice(-3).join(' · ')}` : ''}`);
151
+ if (line === null) {
152
+ const ended = `the fixtures command ended${this.#stderr ? `: ${this.#stderr.trim().split('\n').slice(-3).join(' · ')}` : ''}`;
153
+ if (this.#silentEnds >= 2) this.#broken = `${ended} (it ended twice without answering, so it is not started again in this run)`;
154
+ throw new Error(`${command}: ${ended}`);
155
+ }
112
156
  const answer = JSON.parse(line) as Answer;
113
157
  if (answer.ok === false) throw new Error(`${command}: ${String(answer.error ?? answer.message ?? 'ok=false')}`);
114
158
  return answer;
@@ -116,10 +160,15 @@ export class Fixtures {
116
160
 
117
161
  /** What the server says it answers, or nothing when it does not say. */
118
162
  async describe(): Promise<Description | undefined> {
119
- const answer = await this.call(DESCRIBE, {}).catch(() => undefined);
120
- const commands = answer?.commands;
121
- if (!Array.isArray(commands) || !commands.every((c) => typeof c === 'string')) return undefined;
122
- return typeof answer?.down === 'string' && answer.down ? { commands, down: answer.down } : { commands };
163
+ const d = readDescription(await this.call(DESCRIBE, {}).catch(() => undefined));
164
+ // Kept for `fixtures describe`: a server that boots a backend's framework takes longer to start than an MCP
165
+ // client waits, and what it answers changes only with its code.
166
+ if (d && !d.down)
167
+ try {
168
+ fs.mkdirSync(path.dirname(describedFile(this.#root)), { recursive: true });
169
+ fs.writeFileSync(describedFile(this.#root), JSON.stringify({ at: Date.now(), command: this.#config.command, description: d }));
170
+ } catch {}
171
+ return d;
123
172
  }
124
173
 
125
174
  /** Says this connection works ahead of the cases being run. Whatever the server answers, it goes on. */
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs';
2
+
3
+ type Encoder = (input: string) => { avif: (o: { quality: number; effort: number }) => { toFile: (path: string) => Promise<unknown> } };
4
+
5
+ let encoder: Promise<Encoder | null> | undefined;
6
+ // sharp is optional: a CLI installed without it keeps the PNG the host took.
7
+ const load = () => {
8
+ const name = 'sharp';
9
+ encoder ??= import(name).then((m: { default: Encoder }) => m.default).catch(() => null);
10
+ return encoder;
11
+ };
12
+
13
+ /**
14
+ * A simulator or emulator gives its frames as PNG (`simctl io screenshot` cannot write AVIF or WebP, nor can
15
+ * `screencap`): a report frame is encoded again as AVIF, about 4% of the PNG's bytes in some 70 ms, and the
16
+ * PNG removed. Answers the frame's path, the PNG's when it could not be encoded.
17
+ */
18
+ export const compactFrame = async (png: string): Promise<string> => {
19
+ const sharp = await load();
20
+ if (!sharp || !png.endsWith('.png')) return png;
21
+ const avif = `${png.slice(0, -'.png'.length)}.avif`;
22
+ try {
23
+ await sharp(png).avif({ quality: 60, effort: 2 }).toFile(avif);
24
+ fs.unlinkSync(png);
25
+ return avif;
26
+ } catch {
27
+ fs.rmSync(avif, { force: true });
28
+ return png;
29
+ }
30
+ };
@@ -0,0 +1,112 @@
1
+ import { type ChildProcess, execFile, spawn } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ import type { DeviceInfo } from '../device/devices.js';
5
+ import type { LogLine } from './runner.js';
6
+
7
+ const run = promisify(execFile);
8
+ const KEEP = 5000;
9
+ const LONG = 4000;
10
+
11
+ /** The app's native log while a run goes on: `take` hands over what came since the last take. */
12
+ export type NativeLogs = { take: () => LogLine[]; stop: () => void };
13
+
14
+ const cut = (text: string) => (text.length > LONG ? `${text.slice(0, LONG)}…` : text);
15
+
16
+ // `log stream --style ndjson`: one JSON object a line. React Native's own console lines (category javascript)
17
+ // come from the agent already, with their stacks.
18
+ export const parseOsLog = (line: string): LogLine | undefined => {
19
+ if (!line.startsWith('{')) return undefined;
20
+ let e: { timestamp?: string; messageType?: string; eventMessage?: string; subsystem?: string; category?: string };
21
+ try {
22
+ e = JSON.parse(line);
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ if (!e.eventMessage || !e.timestamp) return undefined;
27
+ if (e.subsystem === 'com.facebook.react.log' && e.category === 'javascript') return undefined;
28
+ // "2026-10-16 14:55:01.123456-0300" → an instant.
29
+ const at = Date.parse(
30
+ e.timestamp
31
+ .replace(' ', 'T')
32
+ .replace(/(\.\d{3})\d*/, '$1')
33
+ .replace(/([+-]\d\d)(\d\d)$/, '$1:$2'),
34
+ );
35
+ if (Number.isNaN(at)) return undefined;
36
+ const type = (e.messageType ?? '').toLowerCase();
37
+ const level = type === 'error' || type === 'fault' ? 'error' : type === 'debug' ? 'debug' : 'info';
38
+ return { at, level, source: 'native', text: cut(e.eventMessage) };
39
+ };
40
+
41
+ // `logcat -v epoch,uid`: " 1760637301.123 10123 4521 4533 E Tag: message". ReactNativeJS repeats the console.
42
+ export const parseLogcat = (line: string, uid: string): LogLine | undefined => {
43
+ const m = /^\s*(\d+\.\d+)\s+(\S+)\s+\d+\s+\d+\s+([VDIWEF])\s+([^:]*?)\s*:\s?(.*)$/.exec(line);
44
+ if (!m || m[2] !== uid) return undefined;
45
+ const [, seconds, , priority, tag, message] = m;
46
+ if (tag === 'ReactNativeJS') return undefined;
47
+ const level = priority === 'E' || priority === 'F' ? 'error' : priority === 'W' ? 'warn' : priority === 'I' ? 'info' : 'debug';
48
+ return { at: Math.round(Number(seconds) * 1000), level, source: 'native', text: cut(`${tag}: ${message}`) };
49
+ };
50
+
51
+ const executable = async (udid: string, bundleId: string): Promise<string | undefined> => {
52
+ const { stdout } = await run('xcrun', ['simctl', 'appinfo', udid, bundleId]);
53
+ return /CFBundleExecutable = "?([^";\n]+)"?;/.exec(stdout)?.[1];
54
+ };
55
+
56
+ const androidUid = async (serial: string, appId: string): Promise<string | undefined> => {
57
+ const { stdout } = await run('adb', ['-s', serial, 'shell', 'cmd', 'package', 'list', 'packages', '-U', appId]);
58
+ const line = stdout.split('\n').find((l) => l.startsWith(`package:${appId} `));
59
+ return line ? /uid:(\d+)/.exec(line)?.[1] : undefined;
60
+ };
61
+
62
+ /**
63
+ * Streams the app's native log on a simulator or an emulator. A device lynkeus cannot read a log from, or an app it
64
+ * cannot find there, gives none: the run goes on without it.
65
+ */
66
+ export const startNativeLogs = async (device: DeviceInfo, appId: string): Promise<NativeLogs | undefined> => {
67
+ if (device.type === 'physical' || device.id === 'headless') return undefined;
68
+ const lines: LogLine[] = [];
69
+ let child: ChildProcess;
70
+ let parse: (line: string) => LogLine | undefined;
71
+ try {
72
+ if (device.platform === 'ios') {
73
+ const name = await executable(device.id, appId);
74
+ if (!name) return undefined;
75
+ // Inside the app's process every Apple framework logs too, thousands of lines a launch: keep its errors, and
76
+ // the rest only from the app's own code (NSLog and print have no subsystem) and React Native's native side.
77
+ const own = `subsystem == "" OR subsystem BEGINSWITH "${appId.replaceAll('"', '')}" OR subsystem BEGINSWITH "com.facebook.react"`;
78
+ const predicate = `process == "${name.replaceAll('"', '')}" AND (messageType == error OR messageType == fault OR ${own})`;
79
+ child = spawn('xcrun', ['simctl', 'spawn', device.id, 'log', 'stream', '--style', 'ndjson', '--level', 'default', '--predicate', predicate], {
80
+ stdio: ['ignore', 'pipe', 'ignore'],
81
+ });
82
+ parse = parseOsLog;
83
+ } else {
84
+ const uid = await androidUid(device.id, appId);
85
+ if (!uid) return undefined;
86
+ // From now on: `-T 1` starts at the newest line instead of replaying the buffer.
87
+ child = spawn('adb', ['-s', device.id, 'logcat', '-v', 'epoch,uid', '-T', '1'], { stdio: ['ignore', 'pipe', 'ignore'] });
88
+ parse = (line) => parseLogcat(line, uid);
89
+ }
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ let rest = '';
94
+ child.stdout?.on('data', (chunk: Buffer) => {
95
+ const text = rest + chunk.toString('utf8');
96
+ const parts = text.split('\n');
97
+ rest = parts.pop() ?? '';
98
+ for (const part of parts) {
99
+ const line = parse(part);
100
+ if (!line) continue;
101
+ lines.push(line);
102
+ if (lines.length > KEEP) lines.shift();
103
+ }
104
+ });
105
+ child.on('error', () => undefined);
106
+ return {
107
+ take: () => lines.splice(0, lines.length),
108
+ stop: () => {
109
+ child.kill();
110
+ },
111
+ };
112
+ };
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** Bytes under a directory, its files and their subfolders'. */
5
+ export const sizeOf = (dir: string): number => {
6
+ let total = 0;
7
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
8
+ const at = path.join(dir, entry.name);
9
+ if (entry.isDirectory()) total += sizeOf(at);
10
+ else if (entry.isFile()) total += fs.statSync(at).size;
11
+ }
12
+ return total;
13
+ };
14
+
15
+ export const megabytes = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
16
+
17
+ /** `7d`, `12h`, `30m` as milliseconds; anything else is refused. */
18
+ export const ageOf = (text: string): number | undefined => {
19
+ const m = /^(\d+)([dhm])$/.exec(text.trim());
20
+ if (!m) return undefined;
21
+ return Number(m[1]) * { d: 86_400_000, h: 3_600_000, m: 60_000 }[m[2] as 'd' | 'h' | 'm'];
22
+ };
23
+
24
+ /** A directory `case run --out` wrote: it holds the run's manifest and its cases. */
25
+ const isRun = (dir: string) => fs.existsSync(path.join(dir, 'run.json')) && fs.existsSync(path.join(dir, 'cases.jsonl'));
26
+
27
+ /**
28
+ * The runs `case run --out` left, at `root` itself or one level under it, that ended longer than `olderMs` ago.
29
+ * Only folders that hold a run are touched; `dry` lists them without removing anything.
30
+ */
31
+ export const pruneRuns = (root: string, olderMs: number, dry = false, now = Date.now()): { removed: string[]; bytes: number } => {
32
+ const candidates = isRun(root)
33
+ ? [root]
34
+ : fs
35
+ .readdirSync(root, { withFileTypes: true })
36
+ .filter((e) => e.isDirectory())
37
+ .map((e) => path.join(root, e.name))
38
+ .filter(isRun);
39
+ const removed: string[] = [];
40
+ let bytes = 0;
41
+ for (const dir of candidates) {
42
+ if (now - fs.statSync(path.join(dir, 'run.json')).mtimeMs < olderMs) continue;
43
+ bytes += sizeOf(dir);
44
+ removed.push(dir);
45
+ if (!dry) fs.rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ return { removed, bytes };
48
+ };
@@ -22,8 +22,19 @@ export const toRunCase = (c: Case, { report, attempt, startedAt }: Attempt, root
22
22
  // The runner numbers an event's step from 1, in the order the steps ran.
23
23
  const told = (report.events ?? [])
24
24
  .filter((e) => e.step === index + 1)
25
- .map((e) => ({ origin: 'app' as const, name: e.name, ...(e.props ? { props: e.props } : {}), ...(e.route ? { route: e.route } : {}) }));
26
- const sent = (s.sent ?? []).map((e) => ({ origin: 'app' as const, provider: e.provider, name: e.name, ...(e.props ? { props: e.props } : {}) }));
25
+ .map((e) => ({
26
+ origin: 'app' as const,
27
+ ...(e.provider ? { provider: e.provider } : {}),
28
+ name: e.name,
29
+ ...(e.props ? { props: e.props } : {}),
30
+ ...(e.route ? { route: e.route } : {}),
31
+ }));
32
+ const sent = (s.sent ?? []).map((e) => ({
33
+ origin: e.via === 'server' ? ('server' as const) : ('app' as const),
34
+ provider: e.provider,
35
+ name: e.name,
36
+ ...(e.props ? { props: e.props } : {}),
37
+ }));
27
38
  const events = [...told, ...sent];
28
39
  const screenshot = within(dir, s.screenshot);
29
40
  return {
@@ -36,6 +47,8 @@ export const toRunCase = (c: Case, { report, attempt, startedAt }: Attempt, root
36
47
  ...(screenshot ? { screenshot } : {}),
37
48
  ...(s.videoS !== undefined ? { videoS: s.videoS } : {}),
38
49
  ...(s.backend?.length ? { backend: s.backend } : {}),
50
+ ...(s.requests?.length ? { requests: s.requests } : {}),
51
+ ...(s.logs?.length ? { logs: s.logs } : {}),
39
52
  };
40
53
  });
41
54
  const description = typeof c.meta.description === 'string' ? c.meta.description : undefined;
@@ -44,6 +57,7 @@ export const toRunCase = (c: Case, { report, attempt, startedAt }: Attempt, root
44
57
  id: report.id,
45
58
  ...(report.title ? { title: report.title } : {}),
46
59
  ...(description ? { description } : {}),
60
+ ...(report.about ? { about: report.about } : {}),
47
61
  file: path.relative(root, path.resolve(root, report.file)),
48
62
  attempt,
49
63
  result: report.result,
@@ -57,7 +71,7 @@ export const toRunCase = (c: Case, { report, attempt, startedAt }: Attempt, root
57
71
  };
58
72
 
59
73
  /** The branch and commit the run was made from, so a report can say which code it tested. */
60
- const gitOf = (root: string): RunManifest['git'] => {
74
+ export const gitOf = (root: string): RunManifest['git'] => {
61
75
  const ask = (...args: string[]) => {
62
76
  try {
63
77
  return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
@@ -68,7 +82,53 @@ const gitOf = (root: string): RunManifest['git'] => {
68
82
  const commit = ask('rev-parse', '--short', 'HEAD');
69
83
  if (!commit) return undefined;
70
84
  const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || ask('rev-parse', '--abbrev-ref', 'HEAD');
71
- return { commit, ...(branch && branch !== 'HEAD' ? { branch } : {}) };
85
+ const remote = webOf(ask('remote', 'get-url', 'origin'));
86
+ // What the run had that no commit has: a report says the app was not exactly the commit it names.
87
+ const changed = (ask('status', '--porcelain', '--untracked-files=no') ?? '')
88
+ .split('\n')
89
+ .map((l) => l.slice(3).trim())
90
+ .filter(Boolean);
91
+ // A branch that lives only on this machine names nothing a reader can open: the run names the remote branch
92
+ // that has its commit instead (the one it tracks first, else any), and keeps the local name beside it.
93
+ const local = branch && branch !== 'HEAD' ? branch : undefined;
94
+ const onRemote = (name: string) => !!ask('rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`);
95
+ const holding = remote && local && !onRemote(local) ? remoteBranchWith(ask, commit) : undefined;
96
+ const shown = holding ?? local;
97
+ const pushed = remote ? { branch: !!shown && (shown === holding || onRemote(shown)), commit: !!ask('branch', '--remotes', '--contains', commit) } : undefined;
98
+ return {
99
+ commit,
100
+ ...(shown ? { branch: shown } : {}),
101
+ ...(holding && local ? { localBranch: local } : {}),
102
+ ...(remote ? { remote } : {}),
103
+ ...(pushed ? { pushed } : {}),
104
+ ...(changed.length ? { changed: changed.slice(0, 20), changedCount: changed.length } : {}),
105
+ };
106
+ };
107
+
108
+ /** The origin branch that holds `commit`: the one the checkout tracks when it does, else the first. */
109
+ const remoteBranchWith = (ask: (...args: string[]) => string | undefined, commit: string): string | undefined => {
110
+ const strip = (ref: string) => ref.replace(/^origin\//, '');
111
+ const upstream = ask('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}');
112
+ const holders = (ask('branch', '--remotes', '--contains', commit, '--format=%(refname:short)') ?? '')
113
+ .split('\n')
114
+ .map((l) => l.trim())
115
+ .filter((l) => l.startsWith('origin/') && l !== 'origin/HEAD' && l !== 'origin');
116
+ if (upstream && holders.includes(upstream)) return strip(upstream);
117
+ return holders[0] ? strip(holders[0]) : undefined;
118
+ };
119
+
120
+ /** A remote as a web address a report can link commits to, without any credentials it carried. */
121
+ export const webOf = (url: string | undefined): string | undefined => {
122
+ if (!url) return undefined;
123
+ const ssh = url.match(/^[\w.-]+@([\w.-]+):(.+?)(\.git)?$/);
124
+ if (ssh) return `https://${ssh[1]}/${ssh[2]}`;
125
+ try {
126
+ const u = new URL(url);
127
+ if (u.protocol !== 'https:' && u.protocol !== 'http:' && u.protocol !== 'ssh:') return undefined;
128
+ return `https://${u.hostname}${u.pathname.replace(/\.git$/, '')}`;
129
+ } catch {
130
+ return undefined;
131
+ }
72
132
  };
73
133
 
74
134
  export class RunFile {