lynkeus 0.2.0 → 0.4.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 (134) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +82 -3
  14. package/lib/cases/runner.js +394 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +28 -7
  20. package/lib/commands/cases.js +411 -42
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/doctor.js +8 -2
  24. package/lib/commands/drive.js +32 -8
  25. package/lib/commands/headless.js +132 -8
  26. package/lib/commands/init.d.ts +29 -0
  27. package/lib/commands/init.js +295 -33
  28. package/lib/commands/report.d.ts +97 -0
  29. package/lib/commands/report.js +228 -0
  30. package/lib/config.d.ts +26 -1
  31. package/lib/config.js +30 -0
  32. package/lib/device/agentVersion.d.ts +7 -0
  33. package/lib/device/agentVersion.js +12 -0
  34. package/lib/device/devices.d.ts +5 -0
  35. package/lib/device/devices.js +27 -0
  36. package/lib/device/driver.js +2 -2
  37. package/lib/flow/runner.js +33 -1
  38. package/lib/flow/types.d.ts +2 -1
  39. package/lib/knowledge/contrast.d.ts +45 -0
  40. package/lib/knowledge/contrast.js +114 -0
  41. package/lib/knowledge/errors.js +1 -1
  42. package/lib/knowledge/lint.d.ts +2 -0
  43. package/lib/knowledge/lint.js +13 -3
  44. package/lib/mcp/server.js +31 -2
  45. package/lib/registry.d.ts +5 -0
  46. package/lib/targets.d.ts +10 -1
  47. package/lib/targets.js +16 -3
  48. package/package.json +10 -6
  49. package/src/cases/ahead.ts +111 -0
  50. package/src/cases/fixtures.ts +37 -0
  51. package/src/cases/flakes.ts +67 -0
  52. package/src/cases/format.ts +31 -6
  53. package/src/cases/portClaim.ts +52 -0
  54. package/src/cases/runfile.ts +119 -0
  55. package/src/cases/runner.ts +454 -46
  56. package/src/cases/templates.ts +14 -4
  57. package/src/cases/traces.ts +298 -0
  58. package/src/cli.ts +29 -7
  59. package/src/commands/cases.ts +428 -41
  60. package/src/commands/check.ts +11 -4
  61. package/src/commands/doctor.ts +9 -2
  62. package/src/commands/drive.ts +31 -8
  63. package/src/commands/headless.ts +143 -8
  64. package/src/commands/init.ts +338 -30
  65. package/src/commands/report.ts +272 -0
  66. package/src/config.ts +39 -2
  67. package/src/device/agentVersion.ts +15 -0
  68. package/src/device/devices.ts +28 -0
  69. package/src/device/driver.ts +2 -2
  70. package/src/flow/runner.ts +35 -1
  71. package/src/flow/types.ts +2 -2
  72. package/src/knowledge/contrast.ts +135 -0
  73. package/src/knowledge/errors.ts +1 -1
  74. package/src/knowledge/lint.ts +15 -4
  75. package/src/mcp/server.ts +31 -2
  76. package/src/registry.ts +2 -0
  77. package/src/targets.ts +17 -3
  78. package/lib/__tests__/args.test.d.ts +0 -1
  79. package/lib/__tests__/args.test.js +0 -69
  80. package/lib/__tests__/check.test.d.ts +0 -1
  81. package/lib/__tests__/check.test.js +0 -60
  82. package/lib/__tests__/init.test.d.ts +0 -1
  83. package/lib/__tests__/init.test.js +0 -42
  84. package/lib/__tests__/record.test.d.ts +0 -1
  85. package/lib/__tests__/record.test.js +0 -44
  86. package/lib/__tests__/snapshot.test.d.ts +0 -1
  87. package/lib/__tests__/snapshot.test.js +0 -47
  88. package/lib/__tests__/targets.test.d.ts +0 -1
  89. package/lib/__tests__/targets.test.js +0 -127
  90. package/lib/cases/__tests__/format.test.d.ts +0 -1
  91. package/lib/cases/__tests__/format.test.js +0 -61
  92. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  93. package/lib/cases/__tests__/runner.test.js +0 -188
  94. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  95. package/lib/knowledge/__tests__/edges.test.js +0 -25
  96. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  97. package/lib/knowledge/__tests__/events.test.js +0 -39
  98. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  99. package/lib/knowledge/__tests__/extract.test.js +0 -69
  100. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  101. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  102. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  103. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  104. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  105. package/lib/knowledge/__tests__/lint.test.js +0 -53
  106. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  107. package/lib/knowledge/__tests__/perf.test.js +0 -34
  108. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  109. package/lib/knowledge/__tests__/plan.test.js +0 -35
  110. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  111. package/lib/knowledge/__tests__/query.test.js +0 -55
  112. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  113. package/lib/knowledge/__tests__/screens.test.js +0 -30
  114. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  115. package/lib/knowledge/__tests__/why.test.js +0 -28
  116. package/src/__tests__/args.test.ts +0 -82
  117. package/src/__tests__/check.test.ts +0 -71
  118. package/src/__tests__/init.test.ts +0 -52
  119. package/src/__tests__/record.test.ts +0 -49
  120. package/src/__tests__/snapshot.test.ts +0 -51
  121. package/src/__tests__/targets.test.ts +0 -132
  122. package/src/cases/__tests__/format.test.ts +0 -76
  123. package/src/cases/__tests__/runner.test.ts +0 -230
  124. package/src/knowledge/__tests__/edges.test.ts +0 -34
  125. package/src/knowledge/__tests__/events.test.ts +0 -45
  126. package/src/knowledge/__tests__/extract.test.ts +0 -88
  127. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  128. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  129. package/src/knowledge/__tests__/lint.test.ts +0 -66
  130. package/src/knowledge/__tests__/perf.test.ts +0 -41
  131. package/src/knowledge/__tests__/plan.test.ts +0 -52
  132. package/src/knowledge/__tests__/query.test.ts +0 -74
  133. package/src/knowledge/__tests__/screens.test.ts +0 -37
  134. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -36,20 +36,20 @@ report carries the screen, the requests and the reason.
36
36
 
37
37
  | Step | Value | |
38
38
  |---|---|---|
39
- | \`app.press\` | target, or \`{ target, within?, nth?, optional?, hold? }\` | Waits for the control to be there and enabled. \`optional\` presses only if it is on screen. |
40
- | \`app.type\` | \`{ text, target?, submit?, clear? }\` | \`target\` is tapped first; \`submit\` ends with the return key. |
39
+ | \`app.press\` | target, or \`{ target, within?, nth?, optional?, hold?, timeoutMs? }\` | Waits for the control to be there and enabled. \`optional\` presses only if it is on screen. |
40
+ | \`app.type\` | \`{ text, target?, submit?, clear?, timeoutMs? }\` | \`target\` is tapped first, once it is there (\`timeoutMs\` is how long that may take); \`submit\` ends with the return key. |
41
41
  | \`app.swipe\` | \`{ direction, target?, distance? }\` or \`{ from, to }\` | |
42
42
  | \`app.wait\` | route, target, ms, \`{ target, scroll? }\` or \`{ request, status? }\` | \`scroll\` swipes until the target is in the window. A \`request\` is \`METHOD /path\`. |
43
43
  | \`app.see\` | target, or \`{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }\` | An assertion on what the app shows or did. Everything named must hold. |
44
44
  | \`app.nav\` | route, or \`{ route, params }\` | A teleport, for getting to where the case starts. |
45
45
  | \`app.back\` | | |
46
- | \`app.requests\` | \`mark\` | From here on, \`request\` only counts what the app asks for. |
46
+ | \`app.requests\` | \`mark\` | A \`request\` counts only what the app asked for after the step before it began; this marks further back, until a step has looked for a request. |
47
47
  | \`app.call\` | \`{ command, params?, expect?, as? }\` | A command the app registered with \`qa.register\`. |
48
48
  | \`app.run\` | \`{ flow, vars? }\` | A lynkeus flow, from the flows folder. |
49
49
  | \`app.events\` / \`app.event\` | \`clear\` / \`{ name, props?, absent? }\` | What the app told its analytics. |
50
50
  | \`app.<command>\` | its arguments by name | Any other lynkeus command: \`app.mock\`, \`app.network\`, \`app.appstate\`, \`app.deeplink\`… |
51
51
 
52
- A target is \`#testId\`, \`"text"\`, an index or \`x,y\`.
52
+ A target is \`#testId\`, a text, an index or \`x,y\`. Where a step takes \`target\` it also takes \`testId\`. A text inside quotes of its own (\`'"Next"'\`) is always a text, where \`app.wait: Next\` would wait for a route called \`Next\`.
53
53
 
54
54
  A case that runs over several inputs declares \`matrix: { locale: [en, fr] }\`
55
55
  in its frontmatter and runs once per combination; \`{{matrix.locale}}\` is that
@@ -74,6 +74,11 @@ Any other step name is a **fixture**: lynkeus sends it to the command in
74
74
  ← {"ok": true, "user_id": "u_1", "name": "Ana"}
75
75
  \`\`\`
76
76
 
77
+ Before the first case lynkeus sends \`{"command": "lynkeus.describe"}\`. A
78
+ server that answers \`{"commands": ["create_user", …]}\` gets its cases checked
79
+ first: one that names a fixture missing from the list is refused before
80
+ anything runs. A server that answers \`"ok": false\` is not checked.
81
+
77
82
  \`as: u\` keeps the answer; \`{{u.user_id}}\` reads it later. \`assert\` calls
78
83
  the \`inspect\` fixture and checks one path of its answer with \`equals\`,
79
84
  \`includes\`, \`gte\`, \`lte\`, \`near\`, \`matches\`, \`present\` or \`absent\`.
@@ -139,6 +144,11 @@ const fixtures = {
139
144
  for await (const line of readline.createInterface({ input: process.stdin })) {
140
145
  if (!line.trim()) continue;
141
146
  const { command, params } = JSON.parse(line);
147
+ // lynkeus asks once what this server answers, and refuses a case that names anything else.
148
+ if (command === 'lynkeus.describe') {
149
+ console.log(JSON.stringify({ commands: Object.keys(fixtures) }));
150
+ continue;
151
+ }
142
152
  try {
143
153
  const fixture = fixtures[command];
144
154
  if (!fixture) throw new Error(\`no fixture \${command}\`);
@@ -0,0 +1,298 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import http from 'node:http';
3
+
4
+ /** A span as a backend exported it over OTLP, reduced to what a report shows. */
5
+ export type Span = {
6
+ traceId: string;
7
+ spanId: string;
8
+ parentSpanId?: string;
9
+ name: string;
10
+ kind: number;
11
+ startNs: bigint;
12
+ endNs: bigint;
13
+ attributes: Record<string, string | number | boolean>;
14
+ error?: string;
15
+ service?: string;
16
+ };
17
+
18
+ /** What a step did on the backend, as the run file keeps it: one line per span, children indented by depth. */
19
+ export type BackendSpan = {
20
+ name: string;
21
+ /** server, client, internal, producer or consumer */
22
+ kind: string;
23
+ /** ms after the step began */
24
+ at: number;
25
+ ms: number;
26
+ depth: number;
27
+ service?: string;
28
+ error?: string;
29
+ attributes?: Record<string, string | number | boolean>;
30
+ };
31
+
32
+ const KINDS = ['unspecified', 'internal', 'server', 'client', 'producer', 'consumer'];
33
+
34
+ export const newTraceId = () => randomBytes(16).toString('hex');
35
+ export const newSpanId = () => randomBytes(8).toString('hex');
36
+ export const traceparent = (traceId: string, spanId: string) => `00-${traceId}-${spanId}-01`;
37
+
38
+ // OTLP over HTTP sends protobuf by default (Ruby's exporter sends nothing else). This reads the few
39
+ // messages of ExportTraceServiceRequest a report needs, straight from the wire format.
40
+ type Field = { no: number; wire: number; value: bigint | Uint8Array };
41
+
42
+ const fields = (buf: Uint8Array): Field[] => {
43
+ const out: Field[] = [];
44
+ let i = 0;
45
+ const varint = () => {
46
+ let result = 0n;
47
+ let shift = 0n;
48
+ for (;;) {
49
+ const b = buf[i++]!;
50
+ result |= BigInt(b & 0x7f) << shift;
51
+ if (!(b & 0x80)) return result;
52
+ shift += 7n;
53
+ }
54
+ };
55
+ while (i < buf.length) {
56
+ const tag = Number(varint());
57
+ const no = tag >> 3;
58
+ const wire = tag & 7;
59
+ if (wire === 0) out.push({ no, wire, value: varint() });
60
+ else if (wire === 1) {
61
+ out.push({ no, wire, value: buf.slice(i, i + 8) });
62
+ i += 8;
63
+ } else if (wire === 2) {
64
+ const length = Number(varint());
65
+ out.push({ no, wire, value: buf.subarray(i, i + length) });
66
+ i += length;
67
+ } else if (wire === 5) {
68
+ out.push({ no, wire, value: buf.slice(i, i + 4) });
69
+ i += 4;
70
+ } else throw new Error(`unknown protobuf wire type ${wire}`);
71
+ }
72
+ return out;
73
+ };
74
+
75
+ const text = (v: bigint | Uint8Array) => (v instanceof Uint8Array ? Buffer.from(v).toString('utf8') : '');
76
+ const hex = (v: bigint | Uint8Array) => (v instanceof Uint8Array ? Buffer.from(v).toString('hex') : '');
77
+ const fixed64 = (v: bigint | Uint8Array) => (v instanceof Uint8Array ? Buffer.from(v).readBigUInt64LE(0) : 0n);
78
+
79
+ const anyValue = (buf: Uint8Array): string | number | boolean => {
80
+ for (const f of fields(buf)) {
81
+ if (f.no === 1) return text(f.value);
82
+ if (f.no === 2) return f.value === 1n;
83
+ if (f.no === 3) return Number(BigInt.asIntN(64, f.value as bigint));
84
+ if (f.no === 4 && f.value instanceof Uint8Array) return Buffer.from(f.value).readDoubleLE(0);
85
+ }
86
+ return '';
87
+ };
88
+
89
+ const keyValues = (list: Field[], no: number) => {
90
+ const out: Record<string, string | number | boolean> = {};
91
+ for (const f of list) {
92
+ if (f.no !== no || !(f.value instanceof Uint8Array)) continue;
93
+ let key = '';
94
+ let value: string | number | boolean = '';
95
+ for (const kv of fields(f.value)) {
96
+ if (kv.no === 1) key = text(kv.value);
97
+ if (kv.no === 2 && kv.value instanceof Uint8Array) value = anyValue(kv.value);
98
+ }
99
+ if (key) out[key] = value;
100
+ }
101
+ return out;
102
+ };
103
+
104
+ export const decodeProtobuf = (buf: Uint8Array): Span[] => {
105
+ const spans: Span[] = [];
106
+ for (const rs of fields(buf)) {
107
+ if (rs.no !== 1 || !(rs.value instanceof Uint8Array)) continue;
108
+ const parts = fields(rs.value);
109
+ const resource = parts.find((p) => p.no === 1);
110
+ const service = resource?.value instanceof Uint8Array ? keyValues(fields(resource.value), 1)['service.name'] : undefined;
111
+ for (const ss of parts) {
112
+ if (ss.no !== 2 || !(ss.value instanceof Uint8Array)) continue;
113
+ for (const sp of fields(ss.value)) {
114
+ if (sp.no !== 2 || !(sp.value instanceof Uint8Array)) continue;
115
+ const s = fields(sp.value);
116
+ const get = (no: number) => s.find((f) => f.no === no)?.value;
117
+ const status = get(15);
118
+ let error: string | undefined;
119
+ if (status instanceof Uint8Array) {
120
+ const st = fields(status);
121
+ const code = st.find((f) => f.no === 3)?.value;
122
+ if (code === 2n) error = text(st.find((f) => f.no === 2)?.value ?? new Uint8Array()) || 'error';
123
+ }
124
+ const parent = get(4);
125
+ spans.push({
126
+ traceId: hex(get(1) ?? new Uint8Array()),
127
+ spanId: hex(get(2) ?? new Uint8Array()),
128
+ parentSpanId: parent instanceof Uint8Array && parent.length ? hex(parent) : undefined,
129
+ name: text(get(5) ?? new Uint8Array()),
130
+ kind: Number(get(6) ?? 0n),
131
+ startNs: fixed64(get(7) ?? new Uint8Array(8)),
132
+ endNs: fixed64(get(8) ?? new Uint8Array(8)),
133
+ attributes: keyValues(s, 9),
134
+ ...(error ? { error } : {}),
135
+ ...(typeof service === 'string' ? { service } : {}),
136
+ });
137
+ }
138
+ }
139
+ }
140
+ return spans;
141
+ };
142
+
143
+ type JsonValue = { stringValue?: string; boolValue?: boolean; intValue?: string | number; doubleValue?: number };
144
+ type JsonAttrs = { key: string; value: JsonValue }[] | undefined;
145
+
146
+ const jsonAttrs = (list: JsonAttrs) =>
147
+ Object.fromEntries(
148
+ (list ?? []).map(({ key, value }) => [
149
+ key,
150
+ value.stringValue ?? value.boolValue ?? (value.intValue !== undefined ? Number(value.intValue) : (value.doubleValue ?? '')),
151
+ ]),
152
+ );
153
+
154
+ export const decodeJson = (body: unknown): Span[] => {
155
+ const spans: Span[] = [];
156
+ const request = body as {
157
+ resourceSpans?: {
158
+ resource?: { attributes?: JsonAttrs };
159
+ scopeSpans?: {
160
+ spans?: {
161
+ traceId: string;
162
+ spanId: string;
163
+ parentSpanId?: string;
164
+ name: string;
165
+ kind?: number;
166
+ startTimeUnixNano: string;
167
+ endTimeUnixNano: string;
168
+ attributes?: JsonAttrs;
169
+ status?: { code?: number; message?: string };
170
+ }[];
171
+ }[];
172
+ }[];
173
+ };
174
+ for (const rs of request.resourceSpans ?? []) {
175
+ const service = jsonAttrs(rs.resource?.attributes)['service.name'];
176
+ for (const ss of rs.scopeSpans ?? [])
177
+ for (const s of ss.spans ?? [])
178
+ spans.push({
179
+ traceId: s.traceId.toLowerCase(),
180
+ spanId: s.spanId.toLowerCase(),
181
+ parentSpanId: s.parentSpanId ? s.parentSpanId.toLowerCase() : undefined,
182
+ name: s.name,
183
+ kind: s.kind ?? 0,
184
+ startNs: BigInt(s.startTimeUnixNano),
185
+ endNs: BigInt(s.endTimeUnixNano),
186
+ attributes: jsonAttrs(s.attributes),
187
+ ...(s.status?.code === 2 ? { error: s.status.message || 'error' } : {}),
188
+ ...(typeof service === 'string' ? { service } : {}),
189
+ });
190
+ }
191
+ return spans;
192
+ };
193
+
194
+ // What a report keeps of a span's attributes: what it did, not every label an instrumentation adds.
195
+ const KEEP =
196
+ /^(http\.(request\.)?method|http\.(response\.)?status_code|http\.route|http\.target|url\.(path|full)|server\.address|net\.peer\.name|db\.(system|statement|operation|sql\.table)|messaging\.|code\.(namespace|function)|rpc\.|exception\.message|enduser\.id)/;
197
+
198
+ /**
199
+ * Listens for OTLP traces while a run goes on: a backend with OTEL_EXPORTER_OTLP_ENDPOINT pointed here sends
200
+ * what each request did, and the steps that sent those requests (their ids travel in `traceparent`) get it.
201
+ */
202
+ export class TraceSink {
203
+ private readonly spans = new Map<string, Span[]>();
204
+ private server?: http.Server;
205
+ private readonly arrived = new Map<string, number>();
206
+
207
+ constructor(readonly port: number) {}
208
+
209
+ async start(): Promise<void> {
210
+ this.server = http.createServer((req, res) => {
211
+ if (req.method !== 'POST' || !req.url?.startsWith('/v1/traces')) {
212
+ res.writeHead(404).end();
213
+ return;
214
+ }
215
+ const chunks: Buffer[] = [];
216
+ req.on('data', (c: Buffer) => chunks.push(c));
217
+ req.on('end', () => {
218
+ try {
219
+ const body = Buffer.concat(chunks);
220
+ const spans = (req.headers['content-type'] ?? '').includes('json') ? decodeJson(JSON.parse(body.toString('utf8'))) : decodeProtobuf(body);
221
+ this.add(spans);
222
+ res.writeHead(200, { 'content-type': req.headers['content-type'] ?? 'application/x-protobuf' }).end();
223
+ } catch (error) {
224
+ res.writeHead(400).end(String(error));
225
+ }
226
+ });
227
+ });
228
+ await new Promise<void>((resolve, reject) => {
229
+ this.server!.once('error', reject);
230
+ this.server!.listen(this.port, '127.0.0.1', () => resolve());
231
+ });
232
+ }
233
+
234
+ add(spans: Span[]) {
235
+ for (const span of spans) {
236
+ this.arrived.set(span.traceId, Date.now());
237
+ const list = this.spans.get(span.traceId) ?? [];
238
+ list.push(span);
239
+ this.spans.set(span.traceId, list);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Backends export in batches, a moment after the requests they served: wait until the trace has had
245
+ * nothing new for `quietMs`, or `maxMs` has passed. Other traces (a scheduler polling) do not count.
246
+ */
247
+ async settle(traceId: string, quietMs = 300, maxMs = 3000): Promise<void> {
248
+ const started = Date.now();
249
+ while (Date.now() - started < maxMs) {
250
+ if (Date.now() - Math.max(this.arrived.get(traceId) ?? 0, started) >= quietMs) return;
251
+ await new Promise((r) => setTimeout(r, 50));
252
+ }
253
+ }
254
+
255
+ /**
256
+ * The spans of one trace, each under the step whose id its chain of parents reaches: `steps` maps a step's
257
+ * span id to its index and the epoch ms it began. Spans of the trace that reach no step are left out.
258
+ */
259
+ take(traceId: string, steps: Map<string, { index: number; startedMs: number }>): Map<number, BackendSpan[]> {
260
+ const spans = this.spans.get(traceId) ?? [];
261
+ this.spans.delete(traceId);
262
+ this.arrived.delete(traceId);
263
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
264
+ const out = new Map<number, BackendSpan[]>();
265
+ const place = (span: Span): { step: number; depth: number } | undefined => {
266
+ let depth = 0;
267
+ for (let at: Span | undefined = span; at; depth++) {
268
+ const step = at.parentSpanId ? steps.get(at.parentSpanId) : undefined;
269
+ if (step) return { step: step.index, depth };
270
+ at = at.parentSpanId ? byId.get(at.parentSpanId) : undefined;
271
+ }
272
+ return undefined;
273
+ };
274
+ for (const span of [...spans].sort((a, b) => (a.startNs < b.startNs ? -1 : 1))) {
275
+ const where = place(span);
276
+ if (!where) continue;
277
+ const startedMs = [...steps.values()].find((s) => s.index === where.step)!.startedMs;
278
+ const attributes = Object.fromEntries(Object.entries(span.attributes).filter(([k]) => KEEP.test(k)));
279
+ const list = out.get(where.step) ?? [];
280
+ list.push({
281
+ name: span.name,
282
+ kind: KINDS[span.kind] ?? 'internal',
283
+ at: Math.max(0, Math.round(Number(span.startNs / 1_000_000n) - startedMs)),
284
+ ms: Math.round(Number(span.endNs - span.startNs) / 1e5) / 10,
285
+ depth: where.depth,
286
+ ...(span.service ? { service: span.service } : {}),
287
+ ...(span.error ? { error: span.error } : {}),
288
+ ...(Object.keys(attributes).length ? { attributes } : {}),
289
+ });
290
+ out.set(where.step, list);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ close() {
296
+ this.server?.close();
297
+ }
298
+ }
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import readline from 'node:readline';
4
4
 
5
5
  import { AgentServer } from 'lynkeus-client';
6
+ import type { HelloEvent } from 'lynkeus-protocol';
6
7
 
7
8
  import { GLOBAL_FLAGS } from './args.js';
8
9
  import { caseCommands } from './commands/cases.js';
@@ -10,14 +11,16 @@ import { driveCommands } from './commands/drive.js';
10
11
  import { headlessCommands } from './commands/headless.js';
11
12
  import { mapCommands } from './commands/map.js';
12
13
  import { osCommands } from './commands/os.js';
14
+ import { reportCommands } from './commands/report.js';
13
15
  import { runsCommands } from './commands/runs.js';
14
- import { graphFile, loadConfig, loadGraph, portFromEnv } from './config.js';
15
- import { HEADLESS_HOST, pickAgentDevice } from './device/devices.js';
16
+ import { graphFile, loadConfig, loadGraph, portFromEnv, readToken } from './config.js';
17
+ import { agentMismatch } from './device/agentVersion.js';
18
+ import { HEADLESS_HOST, pickAgentDevice, pickAndroidDevice } from './device/devices.js';
16
19
  import { Device } from './device/driver.js';
17
20
  import { type Base, commands, find, helpFor, inSession, referenceMarkdown, register, resolve, usage } from './registry.js';
18
21
  import { execute } from './runtime.js';
19
22
 
20
- register([...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...caseCommands, ...headlessCommands]);
23
+ register([...driveCommands, ...osCommands, ...mapCommands, ...runsCommands, ...caseCommands, ...reportCommands, ...headlessCommands]);
21
24
 
22
25
  /** The global flags, read leniently from the whole argv before any command parses its own. */
23
26
  const globals = (argv: string[]) => {
@@ -37,7 +40,11 @@ const globals = (argv: string[]) => {
37
40
 
38
41
  export type Session = { ctx: Base; stop: () => Promise<void> };
39
42
 
40
- const contextFor = (argv: string[], attached = false): Session => {
43
+ // Once per process: a suite reattaches for every case.
44
+ let mismatchTold = false;
45
+
46
+ // `ownHost`: the port belongs to a host this run started for a case; whatever dials in is the device.
47
+ const contextFor = (argv: string[], attached = false, port?: number, ownHost = false): Session => {
41
48
  const g = globals(argv);
42
49
  const root = path.resolve((g.root as string | undefined) ?? process.cwd());
43
50
  const config = loadConfig(root);
@@ -49,8 +56,17 @@ const contextFor = (argv: string[], attached = false): Session => {
49
56
  const appId = (g.app as string | undefined) ?? process.env.LYNKEUS_APP_ID ?? config.app;
50
57
  const started = performance.now();
51
58
  const attachedIn = (d: Device) => err(`attached in ${Math.round(performance.now() - started)}ms (${d.info.name})`);
52
- const server = new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv(config) });
53
- const picking = pickAgentDevice((g.device as string | undefined) ?? process.env.LYNKEUS_DEVICE ?? config.device);
59
+ const server = new AgentServer({ token: readToken(root), port: port ?? portFromEnv(config) });
60
+ server.on('hello', (hello: HelloEvent) => {
61
+ const told = mismatchTold ? undefined : agentMismatch(hello);
62
+ if (!told) return;
63
+ mismatchTold = true;
64
+ err(told);
65
+ });
66
+ // A host started for the case is the device: looking for the configured simulator would only replace a
67
+ // late app's error with one about a simulator nobody asked for.
68
+ const wantedDevice = (g.device as string | undefined) ?? process.env.LYNKEUS_DEVICE ?? config.device;
69
+ const picking = ownHost ? Promise.resolve(HEADLESS_HOST) : pickAgentDevice(wantedDevice);
54
70
  if (g.launch && appId) {
55
71
  const d = new Device(await picking, { appId, server });
56
72
  await d.launchApp(appId, { fresh: true });
@@ -73,7 +89,9 @@ const contextFor = (argv: string[], attached = false): Session => {
73
89
  name: headless ? HEADLESS_HOST.name : String(hello.app?.name ?? hello.platform),
74
90
  platform: hello.platform === 'android' ? ('android' as const) : ('ios' as const),
75
91
  };
76
- const d = new Device(info, { appId: appId ?? null, server, resolving: headless ? undefined : picking });
92
+ // The app says which platform it runs on; only then is it known whether simctl or adb names its device.
93
+ const resolving = headless || ownHost ? undefined : info.platform === 'android' ? pickAndroidDevice(wantedDevice) : picking;
94
+ const d = new Device(info, { appId: appId ?? null, server, resolving });
77
95
  attachedIn(d);
78
96
  return d;
79
97
  };
@@ -92,6 +110,10 @@ const contextFor = (argv: string[], attached = false): Session => {
92
110
  device ??= connect();
93
111
  return device;
94
112
  },
113
+ forPort: (other) => {
114
+ const scoped = contextFor(argv, attached, other, true);
115
+ return { base: scoped.ctx, stop: scoped.stop };
116
+ },
95
117
  };
96
118
  return {
97
119
  ctx,