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.
- package/lib/cases/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +56 -1
- package/lib/cases/runner.js +309 -33
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +25 -2
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +102 -19
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +69 -3
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +154 -18
- package/lib/config.d.ts +25 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +16 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +359 -37
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +25 -2
- package/src/commands/cases.ts +111 -18
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +66 -3
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +160 -16
- package/src/config.ts +15 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +16 -4
- package/src/runtime.ts +24 -3
package/src/cases/runner.ts
CHANGED
|
@@ -12,11 +12,13 @@ import { promisify } from 'node:util';
|
|
|
12
12
|
import type { Element, RequestRecord, Screen, Target } from 'lynkeus-protocol';
|
|
13
13
|
|
|
14
14
|
import { type Device, describeScreen } from '../device/driver.js';
|
|
15
|
+
import { trafficEvents } from '../knowledge/analyticsTraffic.js';
|
|
15
16
|
import { type AppEvent, normalizeEvents, type RunEvent } from '../knowledge/events.js';
|
|
16
17
|
import { type Base, type Command, find } from '../registry.js';
|
|
17
18
|
import { execute } from '../runtime.js';
|
|
18
19
|
import { isRoute, parseTarget, scopedTarget } from '../targets.js';
|
|
19
20
|
import type { Fixtures, FixturesConfig } from './fixtures.js';
|
|
21
|
+
import { compactFrame } from './frames.js';
|
|
20
22
|
import { type BackendSpan, newSpanId, newTraceId, type TraceSink, traceparent } from './traces.js';
|
|
21
23
|
import { type Case, dig, interpolate, matcherOf, matches, type Scope, seeded, type Step, stepOf, type StepValue } from './format.js';
|
|
22
24
|
|
|
@@ -47,13 +49,24 @@ export type CasesConfig = {
|
|
|
47
49
|
/** `{ "user": "user_id" }` turns `user: u` into `user_id: <u.user_id>`, and gives a step that names no user the latest one. */
|
|
48
50
|
refs?: Record<string, string>;
|
|
49
51
|
/** Steps this project adds: `{ "app.login": [...] }`. Inside, `{{param.x}}` is what the case passed. */
|
|
52
|
+
/** The screen a case begins on, for drafts (`case new`): the first the app shows a signed-in person, say. */
|
|
53
|
+
start?: string;
|
|
50
54
|
macros?: Record<string, unknown[]>;
|
|
51
55
|
/** What the app may put in the way of a step and the steps that get past it; a step whose own words match `unless` is about it, and is left alone. */
|
|
52
56
|
interruptions?: { see: string; do: unknown[]; unless?: string }[];
|
|
57
|
+
/**
|
|
58
|
+
* What the backend told its own analytics while a step ran, for a report: after each app step the fixture is
|
|
59
|
+
* asked with `{ since }` (the step's start, ISO) and answers `{ events: [{ name | event, props | payload,
|
|
60
|
+
* provider? }] }`. `provider` names the service when the fixture does not (an Olympus that sends to Mixpanel).
|
|
61
|
+
*/
|
|
62
|
+
serverEvents?: { fixture: string; provider?: string };
|
|
63
|
+
/** The service the app's own event list feeds (its `events` command), so a report names it instead of "App". */
|
|
64
|
+
appEvents?: { provider: string };
|
|
53
65
|
};
|
|
54
66
|
|
|
55
67
|
/** What a host's analytics capture saw a step send: the SDK it went to and the event. */
|
|
56
|
-
|
|
68
|
+
/** `via`: the SDK's native module (a host that stands in for it), the request it sent, or the backend's own analytics. */
|
|
69
|
+
export type SentEvent = { provider: string; name: string; props?: Record<string, unknown>; via?: 'sdk' | 'network' | 'server' };
|
|
57
70
|
|
|
58
71
|
/** `unsupported`: the host the case ran on has no such thing to do (a real device has no mocked responses), which says nothing about the app. */
|
|
59
72
|
export type StepReport = {
|
|
@@ -69,7 +82,14 @@ export type StepReport = {
|
|
|
69
82
|
sent?: SentEvent[];
|
|
70
83
|
/** What the backend did for it, from the traces it exported to the run. */
|
|
71
84
|
backend?: BackendSpan[];
|
|
85
|
+
/** The requests the app sent while it ran, with headers and bodies (secrets hidden), when the run keeps evidence. */
|
|
86
|
+
requests?: RequestRecord[];
|
|
87
|
+
/** What the app printed while it ran: its console and the errors it did not catch, when the run keeps evidence. */
|
|
88
|
+
logs?: LogLine[];
|
|
72
89
|
};
|
|
90
|
+
|
|
91
|
+
/** A line the app printed: `at` in ms since the epoch, `source` where it came from (`js`, `native`, `host`). */
|
|
92
|
+
export type LogLine = { at: number; level: 'debug' | 'info' | 'warn' | 'error'; source: string; text: string; stack?: string };
|
|
73
93
|
export type CaseReport = {
|
|
74
94
|
id: string;
|
|
75
95
|
title?: string;
|
|
@@ -78,6 +98,11 @@ export type CaseReport = {
|
|
|
78
98
|
result: 'passed' | 'failed' | 'unsupported';
|
|
79
99
|
ms: number;
|
|
80
100
|
steps: StepReport[];
|
|
101
|
+
/**
|
|
102
|
+
* What a reader needs before the steps: what the case proves (`description`), why it matters (`why`), what
|
|
103
|
+
* has to be true when it starts (`given`), the ticket it covers and the outcome it expects. From its frontmatter.
|
|
104
|
+
*/
|
|
105
|
+
about?: { description?: string; why?: string; given?: string; ticket?: string; expected?: string };
|
|
81
106
|
/** When a step failed: the screen, the requests since the mark, and why. */
|
|
82
107
|
evidence?: { screen?: string; requests?: string[]; why?: string };
|
|
83
108
|
/** What the app told its analytics, each with the step that caused it, when the run asked for it. */
|
|
@@ -91,7 +116,22 @@ export type CaseReport = {
|
|
|
91
116
|
};
|
|
92
117
|
|
|
93
118
|
/** Where a case keeps what it can show of itself: a frame per step, a film. Each is taken only where the host can. */
|
|
94
|
-
|
|
119
|
+
/** `frames`: AVIF where the host writes it (a fifth of a PNG's bytes, lossy), or PNG, exact, for frames two runs are compared by. */
|
|
120
|
+
export type Evidence = {
|
|
121
|
+
dir: string;
|
|
122
|
+
screenshots?: boolean;
|
|
123
|
+
settleMs?: number;
|
|
124
|
+
/** Wait for the screen to stand still: two frames alike this many ms apart, on a host's own clock where it offers
|
|
125
|
+
* that (`still`), or taken again on a device. */
|
|
126
|
+
stillMs?: number;
|
|
127
|
+
/** A looking step on an unchanged screen shares the last frame instead of taking one. */
|
|
128
|
+
onChange?: boolean;
|
|
129
|
+
frames?: 'avif' | 'png';
|
|
130
|
+
video?: { fps: number; scale?: number };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// A host that could not write AVIF once (an older one) is asked for PNG from then on.
|
|
134
|
+
let avifRefused = false;
|
|
95
135
|
|
|
96
136
|
export type RunOptions = {
|
|
97
137
|
base: Base;
|
|
@@ -110,7 +150,8 @@ export type RunOptions = {
|
|
|
110
150
|
/** Keep running a case's steps after one fails, to see everything that is wrong in one pass. */
|
|
111
151
|
continue?: boolean;
|
|
112
152
|
/** Collect the screens the app went through, for a coverage map. `fresh`: the app was started for this case. */
|
|
113
|
-
routes?: boolean | 'fresh'
|
|
153
|
+
routes?: boolean | 'fresh' /** The app's native log on a simulator or an emulator, read step by step into each step's logs. */;
|
|
154
|
+
nativeLogs?: { take: () => LogLine[] };
|
|
114
155
|
};
|
|
115
156
|
|
|
116
157
|
/** What a case's setup leaves for its steps: the aliases and what they answered, and what ran. */
|
|
@@ -224,6 +265,20 @@ const requestMatches = (event: RequestRecord, wanted: string, status?: number):
|
|
|
224
265
|
return status === undefined ? true : event.status === status;
|
|
225
266
|
};
|
|
226
267
|
|
|
268
|
+
const aboutOf = (c: Case): { about?: CaseReport['about'] } => {
|
|
269
|
+
const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
270
|
+
const about = Object.fromEntries(
|
|
271
|
+
Object.entries({
|
|
272
|
+
description: text(c.meta.description),
|
|
273
|
+
why: text(c.meta.why),
|
|
274
|
+
given: text(c.meta.given),
|
|
275
|
+
ticket: text(c.meta.ticket),
|
|
276
|
+
expected: c.expected,
|
|
277
|
+
}).filter(([, v]) => v !== undefined),
|
|
278
|
+
);
|
|
279
|
+
return Object.keys(about).length ? { about } : {};
|
|
280
|
+
};
|
|
281
|
+
|
|
227
282
|
// Steps that look at the app rather than act on it: a request they wait for was caused by the action before them.
|
|
228
283
|
const LOOKING = new Set(['see', 'wait', 'event', 'requests', 'events']);
|
|
229
284
|
|
|
@@ -258,6 +313,48 @@ export const missingFixtures = (c: Case, config: CasesConfig, known: string[]):
|
|
|
258
313
|
/** What the app or its host answers to a method it does not have; the name is what it lacks. */
|
|
259
314
|
export const lacking = (message: string): string | undefined => /Unknown method (\S+)/.exec(message)?.[1];
|
|
260
315
|
|
|
316
|
+
// A request is read from the app at the end of the step after the one that sent it when it leaves at the very
|
|
317
|
+
// end of a step (a GET the app sends as an action finishes): its trace, carrying the step it left in, says where
|
|
318
|
+
// it belongs. Each request without a trace in its own step moves to the step whose trace served the same method
|
|
319
|
+
// and path at the same moment.
|
|
320
|
+
export const fileRequestsByTrace = (steps: StepReport[], spans: Map<string, { index: number; startedMs: number }>) => {
|
|
321
|
+
const started = new Map([...spans.values()].map((v) => [v.index, v.startedMs]));
|
|
322
|
+
const pathOf = (u: unknown) => {
|
|
323
|
+
try {
|
|
324
|
+
return new URL(String(u)).pathname;
|
|
325
|
+
} catch {
|
|
326
|
+
return String(u ?? '').split('?')[0]!;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const roots = steps.flatMap((st, index) =>
|
|
330
|
+
(st.backend ?? [])
|
|
331
|
+
.filter((sp) => sp.depth === 0)
|
|
332
|
+
.map((sp) => ({
|
|
333
|
+
index,
|
|
334
|
+
method: String(sp.attributes?.['http.method'] ?? sp.attributes?.['http.request.method'] ?? '').toUpperCase(),
|
|
335
|
+
path: pathOf(sp.attributes?.['http.target'] ?? sp.attributes?.['url.path'] ?? ''),
|
|
336
|
+
at: (started.get(index) ?? 0) + sp.at,
|
|
337
|
+
})),
|
|
338
|
+
);
|
|
339
|
+
if (!roots.length) return;
|
|
340
|
+
steps.forEach((st, index) => {
|
|
341
|
+
const keep: RequestRecord[] = [];
|
|
342
|
+
for (const q of st.requests ?? []) {
|
|
343
|
+
const method = String(q.method).toUpperCase(),
|
|
344
|
+
path = pathOf(q.url);
|
|
345
|
+
const same = roots.filter((r) => r.method === method && r.path === path);
|
|
346
|
+
const here = same.some((r) => r.index === index);
|
|
347
|
+
const near = same
|
|
348
|
+
.filter((r) => typeof q.startedAt === 'number' && Math.abs(r.at - q.startedAt) < 1500)
|
|
349
|
+
.sort((a, b) => Math.abs(a.at - q.startedAt!) - Math.abs(b.at - q.startedAt!))[0];
|
|
350
|
+
if (here || !near || near.index === index || !steps[near.index]) keep.push(q);
|
|
351
|
+
else steps[near.index]!.requests = [...(steps[near.index]!.requests ?? []), q].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
|
352
|
+
}
|
|
353
|
+
if (st.requests) st.requests = keep.length ? keep : undefined;
|
|
354
|
+
if (st.requests === undefined) delete st.requests;
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
261
358
|
export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport> => {
|
|
262
359
|
const { base, config } = options;
|
|
263
360
|
const log = options.log ?? (() => undefined);
|
|
@@ -268,6 +365,11 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
268
365
|
const started = Date.now();
|
|
269
366
|
const steps: StepReport[] = [];
|
|
270
367
|
let mark: number | undefined;
|
|
368
|
+
// Where the trace stood as the last action began: a screen it led to that closes itself (a success screen) can
|
|
369
|
+
// come and go while that step's frame settles or a wait for a request runs, and a wait for it still counts it.
|
|
370
|
+
// Waits and looks change nothing, so they keep the mark of the action before them.
|
|
371
|
+
let actedFrom: number | undefined;
|
|
372
|
+
let lastFrame: { file: string; shape?: string } | undefined;
|
|
271
373
|
// The app numbers its requests as they start. A step that looks for one counts only those started after the
|
|
272
374
|
// action before it began: an earlier screen's request to the same path would otherwise pass for the one the
|
|
273
375
|
// action caused. `app.requests` marks by hand, and holds until a step has looked for a request.
|
|
@@ -276,14 +378,19 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
276
378
|
let why: string | undefined;
|
|
277
379
|
const recorded: RunEvent[] = [];
|
|
278
380
|
let buffer: AppEvent[] = [];
|
|
381
|
+
// A host that records what each SDK was handed (fastsim, the headless host) already has every event. Asking the
|
|
382
|
+
// app for its own list as well turns on the app's tracking debug, which may draw on the screen (a counter in
|
|
383
|
+
// every frame of the film).
|
|
384
|
+
let sdkRecorded = false;
|
|
279
385
|
// Read, never emptied: a case may be asserting on the buffer.
|
|
280
386
|
const collect = async (index: number) => {
|
|
281
|
-
if (!options.events) return;
|
|
387
|
+
if (!options.events || sdkRecorded) return;
|
|
282
388
|
const d = await base.device().catch(() => undefined);
|
|
283
389
|
if (!d?.server.connected) return;
|
|
284
390
|
const now = normalizeEvents(await d.command('events').catch(() => []));
|
|
285
391
|
const route = (await d.screen().catch(() => undefined))?.route;
|
|
286
|
-
|
|
392
|
+
const provider = config.appEvents?.provider;
|
|
393
|
+
for (const e of newSince(buffer, now)) recorded.push({ ...e, step: index, route, ...(provider ? { provider } : {}) });
|
|
287
394
|
buffer = now;
|
|
288
395
|
};
|
|
289
396
|
|
|
@@ -461,8 +568,11 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
461
568
|
await sleep(150);
|
|
462
569
|
continue;
|
|
463
570
|
}
|
|
464
|
-
if (Date.now() > deadline)
|
|
465
|
-
|
|
571
|
+
if (Date.now() > deadline) {
|
|
572
|
+
if (!absent) throw new Error(missing!);
|
|
573
|
+
const { absent: _, ...what } = params;
|
|
574
|
+
throw new Error(`still on screen: ${label({ verb: '', value: what as StepValue }).trim()}`);
|
|
575
|
+
}
|
|
466
576
|
await sleep(150);
|
|
467
577
|
}
|
|
468
578
|
};
|
|
@@ -591,9 +701,16 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
591
701
|
const app = async (name: string, value: StepValue): Promise<'skipped' | undefined> => {
|
|
592
702
|
const params = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
593
703
|
const d = await device();
|
|
594
|
-
|
|
704
|
+
// An app in the background has no connection to answer with: the step that brings it back must still run.
|
|
705
|
+
const lastSeq = () =>
|
|
706
|
+
d.trace().then(
|
|
707
|
+
(t) => t.last,
|
|
708
|
+
() => undefined,
|
|
709
|
+
);
|
|
710
|
+
mark ??= await lastSeq();
|
|
711
|
+
if (!LOOKING.has(name)) actedFrom = await lastSeq();
|
|
595
712
|
// An interruption handled inside a wait is not the action the wait is about.
|
|
596
|
-
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined) await markRequests(d);
|
|
713
|
+
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined) await markRequests(d).catch(() => undefined);
|
|
597
714
|
switch (name) {
|
|
598
715
|
case 'press': {
|
|
599
716
|
await interrupted(value);
|
|
@@ -629,8 +746,10 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
629
746
|
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) await sleep(Number(value));
|
|
630
747
|
else if (params?.request) await waitForRequest(params);
|
|
631
748
|
else {
|
|
749
|
+
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
750
|
+
const since = isRoute(what) && actedFrom !== undefined ? { since: actedFrom } : {};
|
|
632
751
|
if (config.interruptions?.length) {
|
|
633
|
-
const asked: Record<string, unknown> = typeof value === 'string' ? { what: value } :
|
|
752
|
+
const asked: Record<string, unknown> = { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since };
|
|
634
753
|
const deadline = Date.now() + (typeof asked.timeoutMs === 'number' ? asked.timeoutMs : 10_000);
|
|
635
754
|
for (;;) {
|
|
636
755
|
await interrupted(value);
|
|
@@ -642,9 +761,8 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
642
761
|
throw new Error(outcome.error ?? outcome.text ?? 'wait failed');
|
|
643
762
|
}
|
|
644
763
|
}
|
|
645
|
-
} else await cli(argvFor(find('wait')!, value));
|
|
764
|
+
} else await cli(argvFor(find('wait')!, 'since' in since ? { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since } : value));
|
|
646
765
|
// A screen that just arrived on a device is still sliding in, which no render shows: a press then lands on the one it left.
|
|
647
|
-
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
648
766
|
if (onWallClock(d) && isRoute(what)) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
649
767
|
}
|
|
650
768
|
return undefined;
|
|
@@ -762,14 +880,15 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
762
880
|
|
|
763
881
|
if (options.events && !options.dry) {
|
|
764
882
|
const d = await base.device().catch(() => undefined);
|
|
765
|
-
|
|
883
|
+
sdkRecorded = !!d?.server.hello?.commands?.includes('analytics');
|
|
884
|
+
if (d?.server.connected && !sdkRecorded) buffer = normalizeEvents(await d.command('events').catch(() => []));
|
|
766
885
|
}
|
|
767
886
|
|
|
768
887
|
const evidence = options.dry ? undefined : options.evidence;
|
|
769
888
|
const offers = async (command: string) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
|
|
770
889
|
const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
|
|
771
|
-
// Asked
|
|
772
|
-
const capturing = !options.dry && !!options.events && (await offers('analytics'));
|
|
890
|
+
// Asked when the run keeps events or a report: it is a call per step.
|
|
891
|
+
const capturing = !options.dry && (!!options.events || !!options.evidence) && (await offers('analytics'));
|
|
773
892
|
if (evidence) fs.mkdirSync(evidence.dir, { recursive: true });
|
|
774
893
|
let filming = false;
|
|
775
894
|
if (film && evidence?.video) {
|
|
@@ -782,22 +901,129 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
782
901
|
}
|
|
783
902
|
// What the app sent before the case began is not the case's.
|
|
784
903
|
if (capturing) await (await device()).command('analytics', { take: true }).catch(() => undefined);
|
|
904
|
+
// The same for what it printed.
|
|
905
|
+
const logging = !!evidence && (await offers('logs'));
|
|
906
|
+
if (logging) await (await device()).command('logs', { take: true }).catch(() => undefined);
|
|
907
|
+
options.nativeLogs?.take();
|
|
908
|
+
// A report shows what each request sent and got: the app keeps headers and bodies only while this case runs.
|
|
909
|
+
let requestsSeen: number | undefined;
|
|
910
|
+
// Requests filed while still waiting for an answer: read again later and completed where they were filed.
|
|
911
|
+
const inFlight = new Map<number, RequestRecord>();
|
|
912
|
+
const answered = (r: RequestRecord) => r.status !== undefined || !!r.error;
|
|
913
|
+
const completeInFlight = async (d: Device) => {
|
|
914
|
+
if (!inFlight.size) return;
|
|
915
|
+
const again = await d.requests(Math.min(...inFlight.keys()) - 1).then(
|
|
916
|
+
(r) => r.requests,
|
|
917
|
+
() => [],
|
|
918
|
+
);
|
|
919
|
+
for (const r of again) {
|
|
920
|
+
const kept = inFlight.get(r.id);
|
|
921
|
+
if (kept && answered(r)) {
|
|
922
|
+
Object.assign(kept, r);
|
|
923
|
+
inFlight.delete(r.id);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
// Frames the host is still encoding: the case waits for them before it says it is done.
|
|
928
|
+
const encoding: string[] = [];
|
|
929
|
+
// Frames a simulator or emulator gave as PNG, being encoded again as AVIF while the case goes on.
|
|
930
|
+
const compacting: Promise<void>[] = [];
|
|
931
|
+
if (evidence && (await offers('networkBodies'))) {
|
|
932
|
+
const d = await device();
|
|
933
|
+
await d.command('networkBodies', { on: true }).catch(() => undefined);
|
|
934
|
+
requestsSeen = await d.requests().then(
|
|
935
|
+
(r) => r.requests.at(-1)?.id ?? 0,
|
|
936
|
+
() => undefined,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
785
939
|
const frameAt = async (): Promise<number | undefined> => {
|
|
786
940
|
if (!filming || !evidence?.video) return undefined;
|
|
787
941
|
const answer = (await (await device()).command('record', { frames: true }).catch(() => undefined)) as { frames?: number } | undefined;
|
|
788
942
|
return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
|
|
789
943
|
};
|
|
790
944
|
// What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
|
|
791
|
-
const record = async (report: StepReport, index: number, startedAt: number | undefined) => {
|
|
945
|
+
const record = async (report: StepReport, index: number, startedAt: number | undefined, begunMs: number) => {
|
|
792
946
|
if (startedAt !== undefined) report.videoS = startedAt;
|
|
947
|
+
if (config.serverEvents && options.fixtures) {
|
|
948
|
+
const { fixture: name, provider } = config.serverEvents;
|
|
949
|
+
const answer = await options.fixtures.call(name, { since: new Date(begunMs).toISOString() }).catch(() => undefined);
|
|
950
|
+
const list = Array.isArray(answer?.events) ? (answer.events as Record<string, unknown>[]) : [];
|
|
951
|
+
const server = list.flatMap((e) => {
|
|
952
|
+
const named = typeof e?.name === 'string' ? e.name : typeof e?.event === 'string' ? e.event : undefined;
|
|
953
|
+
const props = (e?.props ?? e?.payload) as Record<string, unknown> | undefined;
|
|
954
|
+
return named
|
|
955
|
+
? [
|
|
956
|
+
{
|
|
957
|
+
provider: String(e.provider ?? provider ?? 'backend'),
|
|
958
|
+
name: named,
|
|
959
|
+
...(props && typeof props === 'object' ? { props } : {}),
|
|
960
|
+
via: 'server' as const,
|
|
961
|
+
},
|
|
962
|
+
]
|
|
963
|
+
: [];
|
|
964
|
+
});
|
|
965
|
+
if (server.length > 0) report.sent = [...(report.sent ?? []), ...server];
|
|
966
|
+
}
|
|
793
967
|
const d = await device().catch(() => undefined);
|
|
794
968
|
if (!d?.server.connected) return;
|
|
795
969
|
if (capturing) {
|
|
796
970
|
const raw = (await d.command('analytics', { take: true }).catch(() => [])) as { sdk?: string; name?: string; properties?: Record<string, unknown> }[];
|
|
797
971
|
const sent = (Array.isArray(raw) ? raw : []).flatMap((e) =>
|
|
798
|
-
e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}) }] : [],
|
|
972
|
+
e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}), via: 'sdk' as const }] : [],
|
|
973
|
+
);
|
|
974
|
+
// Added to what the backend told its analytics, read above for the same step, not in its place.
|
|
975
|
+
if (sent.length > 0) report.sent = [...(report.sent ?? []), ...sent];
|
|
976
|
+
}
|
|
977
|
+
if (logging) {
|
|
978
|
+
const raw = (await d.command('logs', { take: true }).catch(() => [])) as Partial<LogLine>[];
|
|
979
|
+
const logs = (Array.isArray(raw) ? raw : []).flatMap((l): LogLine[] =>
|
|
980
|
+
typeof l?.text === 'string' && typeof l.at === 'number'
|
|
981
|
+
? [
|
|
982
|
+
{
|
|
983
|
+
at: l.at,
|
|
984
|
+
level: l.level === 'debug' || l.level === 'warn' || l.level === 'error' ? l.level : 'info',
|
|
985
|
+
source: typeof l.source === 'string' ? l.source : 'js',
|
|
986
|
+
text: l.text,
|
|
987
|
+
...(typeof l.stack === 'string' ? { stack: l.stack } : {}),
|
|
988
|
+
},
|
|
989
|
+
]
|
|
990
|
+
: [],
|
|
991
|
+
);
|
|
992
|
+
if (logs.length > 0) report.logs = logs;
|
|
993
|
+
}
|
|
994
|
+
const native = options.nativeLogs?.take() ?? [];
|
|
995
|
+
// In the order they were printed, whichever side printed them.
|
|
996
|
+
if (native.length > 0) report.logs = [...(report.logs ?? []), ...native].sort((x, y) => x.at - y.at);
|
|
997
|
+
if (requestsSeen !== undefined) {
|
|
998
|
+
await completeInFlight(d);
|
|
999
|
+
const made = await d.requests(requestsSeen).then(
|
|
1000
|
+
(r) => r.requests,
|
|
1001
|
+
() => [],
|
|
1002
|
+
);
|
|
1003
|
+
// A reload (app.refresh) turns the app's keeping of bodies off with everything else it held: ask again.
|
|
1004
|
+
await d.command('networkBodies', { on: true }).catch(() => undefined);
|
|
1005
|
+
if (made.length > 0) {
|
|
1006
|
+
report.requests = made;
|
|
1007
|
+
requestsSeen = made.at(-1)!.id;
|
|
1008
|
+
for (const r of made) if (!answered(r)) inFlight.set(r.id, r);
|
|
1009
|
+
// A JS SDK (Segment, PostHog…) never reaches a native module: what it sent is in its requests.
|
|
1010
|
+
const traffic = trafficEvents(made).map((e) => ({ ...e, via: 'network' as const }));
|
|
1011
|
+
if (traffic.length > 0) report.sent = [...(report.sent ?? []), ...traffic];
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// A step that only looks (app.see, app.wait) on a screen no different from the last frame's shares that frame:
|
|
1015
|
+
// its wait and capture are what make a frame per step slow on a device. The shape is the route and each
|
|
1016
|
+
// element's id, text and place, read from the app, so a change in any of them still takes a frame.
|
|
1017
|
+
let shape: string | undefined;
|
|
1018
|
+
if (evidence?.screenshots && evidence.onChange) {
|
|
1019
|
+
shape = await d.screen().then(
|
|
1020
|
+
(sc) => `${sc.route}|${sc.elements.map((e) => `${e.testId ?? ''}:${e.text ?? ''}:${Math.round(e.frame.x)},${Math.round(e.frame.y)}`).join(';')}`,
|
|
1021
|
+
() => undefined,
|
|
799
1022
|
);
|
|
800
|
-
if (
|
|
1023
|
+
if (shape !== undefined && shape === lastFrame?.shape && /^app\.(see|wait)\b/.test(report.step)) {
|
|
1024
|
+
report.screenshot = lastFrame.file;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
801
1027
|
}
|
|
802
1028
|
if (evidence?.screenshots) {
|
|
803
1029
|
// A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
|
|
@@ -810,34 +1036,112 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
810
1036
|
(r) => r.idle,
|
|
811
1037
|
() => false,
|
|
812
1038
|
);
|
|
813
|
-
const
|
|
1039
|
+
const stem = path.join(
|
|
814
1040
|
evidence.dir,
|
|
815
1041
|
`${String(index).padStart(2, '0')}-${report.step
|
|
816
1042
|
.toLowerCase()
|
|
817
1043
|
.replace(/[^a-z0-9]+/g, '-')
|
|
818
1044
|
.slice(0, 48)
|
|
819
|
-
.replace(/^-|-$/g, '')}
|
|
1045
|
+
.replace(/^-|-$/g, '')}`,
|
|
820
1046
|
);
|
|
821
1047
|
// A host on another machine writes the file there; `inline` asks it for the picture as well.
|
|
822
1048
|
// A host that knows what was still animating says so, as frames in points.
|
|
823
1049
|
let moving: unknown;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
()
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1050
|
+
// A host that waits for a still screen on its own clock signs each picture, so it can say whether the screen
|
|
1051
|
+
// it settled on is still the one in the picture.
|
|
1052
|
+
const hostStill = !!evidence.stillMs && (await offers('still'));
|
|
1053
|
+
let signature: string | undefined;
|
|
1054
|
+
const hostShot = async (file: string) =>
|
|
1055
|
+
d.command('screenshot', { path: file, inline: true, ...(hostStill ? { sign: true } : {}) }).then(
|
|
1056
|
+
(answer) => {
|
|
1057
|
+
const { png, moving: animating, signature: signed } = (answer ?? {}) as { png?: unknown; moving?: unknown; signature?: unknown };
|
|
1058
|
+
if (typeof png === 'string' && !fs.existsSync(file)) fs.writeFileSync(file, Buffer.from(png, 'base64'));
|
|
1059
|
+
if (Array.isArray(animating)) moving = animating;
|
|
1060
|
+
if (typeof signed === 'string') signature = signed;
|
|
1061
|
+
return fs.existsSync(file);
|
|
1062
|
+
},
|
|
1063
|
+
() => false,
|
|
1064
|
+
);
|
|
1065
|
+
let still: boolean | undefined;
|
|
1066
|
+
let file = `${stem}.png`;
|
|
1067
|
+
let taken: boolean;
|
|
1068
|
+
if (await offers('screenshot')) {
|
|
1069
|
+
const avif = evidence.frames !== 'png' && !avifRefused;
|
|
1070
|
+
file = `${stem}.${avif ? 'avif' : 'png'}`;
|
|
1071
|
+
// A host on this machine encodes an AVIF frame after it answers, so the step does not wait for it;
|
|
1072
|
+
// the case waits for all its frames once, at its end.
|
|
1073
|
+
const later = avif
|
|
1074
|
+
? await d.command('screenshot', { path: file, background: true }).then(
|
|
1075
|
+
(answer) => {
|
|
1076
|
+
const { background, moving: animating } = (answer ?? {}) as { background?: unknown; moving?: unknown };
|
|
1077
|
+
if (Array.isArray(animating)) moving = animating;
|
|
1078
|
+
return background === true;
|
|
1079
|
+
},
|
|
1080
|
+
() => false,
|
|
1081
|
+
)
|
|
1082
|
+
: false;
|
|
1083
|
+
if (later) encoding.push(file);
|
|
1084
|
+
taken = later || (await hostShot(file));
|
|
1085
|
+
if (!taken && avif) {
|
|
1086
|
+
avifRefused = true;
|
|
1087
|
+
file = `${stem}.png`;
|
|
1088
|
+
taken = await hostShot(file);
|
|
1089
|
+
}
|
|
1090
|
+
} else taken = (await execute(['screenshot', file], base)).code === 0;
|
|
1091
|
+
// An animation the agent cannot see (a device's native transition, a host's printer running on its own clock)
|
|
1092
|
+
// leaves a quiet app on a moving screen: the picture itself says when it stopped. Two frames alike, taken
|
|
1093
|
+
// stillMs apart, are a still screen. Frames still being encoded (AVIF on a host) are left as they are.
|
|
1094
|
+
if (taken && hostStill && signature !== undefined && !encoding.includes(file)) {
|
|
1095
|
+
// No real time passes and no picture is made while the host waits; the picture is taken again only when
|
|
1096
|
+
// the screen it settled on is another one.
|
|
1097
|
+
const stillMs = evidence.stillMs!;
|
|
1098
|
+
const answer = (await d.command('still', { stepMs: stillMs, timeoutMs: Math.max(2000, stillMs * 8), since: signature }).catch(() => undefined)) as
|
|
1099
|
+
| { still?: unknown; changed?: unknown; moving?: unknown }
|
|
1100
|
+
| undefined;
|
|
1101
|
+
if (answer) {
|
|
1102
|
+
still = answer.still === true;
|
|
1103
|
+
if (answer.changed === true) {
|
|
1104
|
+
fs.rmSync(file, { force: true });
|
|
1105
|
+
taken = await hostShot(file);
|
|
1106
|
+
}
|
|
1107
|
+
// A screen that never stood still: what moved during the wait is of a moment too.
|
|
1108
|
+
if (Array.isArray(answer.moving)) moving = [...(Array.isArray(moving) ? moving : []), ...answer.moving];
|
|
1109
|
+
}
|
|
1110
|
+
} else if (taken && evidence.stillMs && !encoding.includes(file)) {
|
|
1111
|
+
const stillMs = evidence.stillMs;
|
|
1112
|
+
const take = async (to: string) => ((await offers('screenshot')) ? hostShot(to) : (await execute(['screenshot', to], base)).code === 0);
|
|
1113
|
+
still = false;
|
|
1114
|
+
const until = Date.now() + Math.max(2000, stillMs * 8);
|
|
1115
|
+
let last = fs.readFileSync(file);
|
|
1116
|
+
while (!still && Date.now() < until) {
|
|
1117
|
+
await sleep(stillMs);
|
|
1118
|
+
const again = `${file}.again${path.extname(file)}`;
|
|
1119
|
+
if (!(await take(again))) break;
|
|
1120
|
+
const now = fs.readFileSync(again);
|
|
1121
|
+
fs.renameSync(again, file);
|
|
1122
|
+
still = now.equals(last);
|
|
1123
|
+
last = now;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (taken && (encoding.includes(file) || fs.existsSync(file))) {
|
|
836
1127
|
report.screenshot = file;
|
|
1128
|
+
lastFrame = { file, shape };
|
|
1129
|
+
if (file.endsWith('.png') && evidence.frames !== 'png') {
|
|
1130
|
+
const taken = file;
|
|
1131
|
+
compacting.push(
|
|
1132
|
+
compactFrame(taken).then((compact) => {
|
|
1133
|
+
if (report.screenshot === taken) report.screenshot = compact;
|
|
1134
|
+
}),
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
837
1137
|
// What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
|
|
838
1138
|
// a balance) from how it was drawn.
|
|
839
1139
|
const screen = await d.screen().catch(() => undefined);
|
|
840
|
-
if (screen)
|
|
1140
|
+
if (screen)
|
|
1141
|
+
fs.writeFileSync(
|
|
1142
|
+
file.replace(/\.(png|avif)$/, '.json'),
|
|
1143
|
+
JSON.stringify({ ...screen, settled, ...(still === undefined ? {} : { still }), ...(moving ? { moving } : {}) }),
|
|
1144
|
+
);
|
|
841
1145
|
}
|
|
842
1146
|
}
|
|
843
1147
|
};
|
|
@@ -906,7 +1210,7 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
906
1210
|
steps.push(done);
|
|
907
1211
|
log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
|
|
908
1212
|
if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
|
|
909
|
-
await record(done, steps.length, at);
|
|
1213
|
+
await record(done, steps.length, at, t0);
|
|
910
1214
|
} catch (error) {
|
|
911
1215
|
const message = error instanceof Error ? error.message : String(error);
|
|
912
1216
|
const missing = lacking(message);
|
|
@@ -922,25 +1226,43 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
922
1226
|
const broke: StepReport = { step: text, status: 'failed', ms: Date.now() - t0, error: message };
|
|
923
1227
|
steps.push(broke);
|
|
924
1228
|
log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
|
|
925
|
-
if (evidence || capturing) await record(broke, steps.length, at);
|
|
1229
|
+
if (evidence || capturing) await record(broke, steps.length, at, t0);
|
|
926
1230
|
}
|
|
927
1231
|
}
|
|
928
1232
|
|
|
1233
|
+
if (requestsSeen !== undefined) {
|
|
1234
|
+
const d = await device().catch(() => undefined);
|
|
1235
|
+
// The last answers of the case, given a moment to arrive.
|
|
1236
|
+
for (let i = 0; d && inFlight.size && i < 5; i++) {
|
|
1237
|
+
await sleep(200);
|
|
1238
|
+
await completeInFlight(d);
|
|
1239
|
+
}
|
|
1240
|
+
await d?.command('networkBodies', { on: false }).catch(() => undefined);
|
|
1241
|
+
}
|
|
1242
|
+
await Promise.all(compacting);
|
|
1243
|
+
if (encoding.length > 0) {
|
|
1244
|
+
const until = Date.now() + 15_000;
|
|
1245
|
+
while (encoding.some((f) => !fs.existsSync(f)) && Date.now() < until) await sleep(25);
|
|
1246
|
+
// A frame that never arrived is not offered to a report.
|
|
1247
|
+
for (const s of steps) if (s.screenshot && encoding.includes(s.screenshot) && !fs.existsSync(s.screenshot)) delete s.screenshot;
|
|
1248
|
+
}
|
|
929
1249
|
if (tracing && options.traces) {
|
|
930
1250
|
await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
|
|
931
1251
|
// A backend exports in batches, a moment after the requests it served.
|
|
932
1252
|
await options.traces.settle(tracing.traceId);
|
|
933
1253
|
for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
|
|
934
1254
|
const s = steps[index];
|
|
935
|
-
// A step that set off thousands of
|
|
936
|
-
if (s) s.backend = spans.slice(0,
|
|
1255
|
+
// A step that set off thousands of spans keeps the first ones; a report is not a profiler.
|
|
1256
|
+
if (s) s.backend = spans.slice(0, 1500);
|
|
937
1257
|
}
|
|
1258
|
+
fileRequestsByTrace(steps, tracing.steps);
|
|
938
1259
|
}
|
|
939
1260
|
if (options.setupOnly && !failed) options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
|
|
940
1261
|
if (options.dry) failed = steps.some((s) => s.status === 'failed');
|
|
941
1262
|
const report: CaseReport = {
|
|
942
1263
|
id: c.id,
|
|
943
1264
|
title: c.title,
|
|
1265
|
+
...aboutOf(c),
|
|
944
1266
|
file: c.file,
|
|
945
1267
|
result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
|
|
946
1268
|
ms: Date.now() - started,
|
package/src/cases/templates.ts
CHANGED
|
@@ -7,8 +7,13 @@ export const EXAMPLE_CASE = `---
|
|
|
7
7
|
id: example
|
|
8
8
|
title: "A user who was given points sees them on the first screen"
|
|
9
9
|
description: >
|
|
10
|
-
What this proves
|
|
11
|
-
to
|
|
10
|
+
What this proves, in a sentence or two. A case reads top to bottom: what has
|
|
11
|
+
to exist, what the person does, what must be true after.
|
|
12
|
+
why: >
|
|
13
|
+
Optional: what breaks for people if this fails. The report shows it above the steps.
|
|
14
|
+
given: >
|
|
15
|
+
Optional: what is true when it starts (who the user is, what is installed).
|
|
16
|
+
ticket: "[TICKET-ID]"
|
|
12
17
|
---
|
|
13
18
|
setup:
|
|
14
19
|
# Anything that is not an app step is a fixture: fixtures.mjs answers it.
|
|
@@ -32,6 +37,19 @@ and \`expected\`. \`lynkeus case run cases/example.md\` runs one; without files
|
|
|
32
37
|
it runs every case in this folder. A step that fails stops the case, and the
|
|
33
38
|
report carries the screen, the requests and the reason.
|
|
34
39
|
|
|
40
|
+
## Frontmatter
|
|
41
|
+
|
|
42
|
+
| Key | | |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| \`id\`, \`title\` | required, a line | how runs and reports name the case |
|
|
45
|
+
| \`description\` | a paragraph | what the case proves |
|
|
46
|
+
| \`why\` | optional | what breaks for people if it fails |
|
|
47
|
+
| \`given\` | optional | what is true when it starts |
|
|
48
|
+
| \`ticket\` | optional | the ticket it covers |
|
|
49
|
+
|
|
50
|
+
A report shows \`description\`, \`why\`, \`given\`, \`ticket\` and \`expected\` above the steps, so a reader
|
|
51
|
+
who never opened the file knows what they are looking at.
|
|
52
|
+
|
|
35
53
|
## Steps that drive the app
|
|
36
54
|
|
|
37
55
|
| Step | Value | |
|
package/src/cases/traces.ts
CHANGED
|
@@ -193,7 +193,7 @@ export const decodeJson = (body: unknown): Span[] => {
|
|
|
193
193
|
|
|
194
194
|
// What a report keeps of a span's attributes: what it did, not every label an instrumentation adds.
|
|
195
195
|
const KEEP =
|
|
196
|
-
/^(http\.(request\.)?method|http\.(response\.)?status_code|http\.route|http\.target|url\.(path|full)|server\.address|net\.peer\.name|
|
|
196
|
+
/^(http\.(request\.)?method|http\.(response\.)?status_code|http\.route|http\.target|url\.(path|full)|server\.address|net\.peer\.name|messaging\.|code\.(namespace|function)|rpc\.|exception\.message|enduser\.id)/;
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
199
|
* Listens for OTLP traces while a run goes on: a backend with OTEL_EXPORTER_OTLP_ENDPOINT pointed here sends
|
|
@@ -272,6 +272,8 @@ export class TraceSink {
|
|
|
272
272
|
return undefined;
|
|
273
273
|
};
|
|
274
274
|
for (const span of [...spans].sort((a, b) => (a.startNs < b.startNs ? -1 : 1))) {
|
|
275
|
+
// A database query never leaves the machine: its statement names the backend's tables and columns.
|
|
276
|
+
if (span.attributes['db.system'] !== undefined) continue;
|
|
275
277
|
const where = place(span);
|
|
276
278
|
if (!where) continue;
|
|
277
279
|
const startedMs = [...steps.values()].find((s) => s.index === where.step)!.startedMs;
|