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
@@ -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 {