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
@@ -9,12 +9,16 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { promisify } from 'node:util';
11
11
  import { describeScreen } from '../device/driver.js';
12
+ import { trafficEvents } from '../knowledge/analyticsTraffic.js';
12
13
  import { normalizeEvents } from '../knowledge/events.js';
13
14
  import { find } from '../registry.js';
14
15
  import { execute } from '../runtime.js';
15
16
  import { isRoute, parseTarget, scopedTarget } from '../targets.js';
17
+ import { compactFrame } from './frames.js';
16
18
  import { newSpanId, newTraceId, traceparent } from './traces.js';
17
19
  import { dig, interpolate, matcherOf, matches, seeded, stepOf } from './format.js';
20
+ // A host that could not write AVIF once (an older one) is asked for PNG from then on.
21
+ let avifRefused = false;
18
22
  /** A setup made only of the project's fixtures can run before the case, with no app attached. */
19
23
  export const preparable = (c, config) => c.setup.length > 0 &&
20
24
  c.setup.every((step) => !step.on && !step.verb.startsWith('app.') && !config.macros?.[step.verb] && step.verb !== 'exec' && step.verb !== 'assert');
@@ -133,6 +137,17 @@ const requestMatches = (event, wanted, status) => {
133
137
  return false;
134
138
  return status === undefined ? true : event.status === status;
135
139
  };
140
+ const aboutOf = (c) => {
141
+ const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
142
+ const about = Object.fromEntries(Object.entries({
143
+ description: text(c.meta.description),
144
+ why: text(c.meta.why),
145
+ given: text(c.meta.given),
146
+ ticket: text(c.meta.ticket),
147
+ expected: c.expected,
148
+ }).filter(([, v]) => v !== undefined));
149
+ return Object.keys(about).length ? { about } : {};
150
+ };
136
151
  // Steps that look at the app rather than act on it: a request they wait for was caused by the action before them.
137
152
  const LOOKING = new Set(['see', 'wait', 'event', 'requests', 'events']);
138
153
  const BUILT_IN = ['press', 'type', 'swipe', 'see', 'requests', 'wait', 'nav', 'call', 'run', 'events', 'event'];
@@ -167,6 +182,50 @@ export const missingFixtures = (c, config, known) => {
167
182
  };
168
183
  /** What the app or its host answers to a method it does not have; the name is what it lacks. */
169
184
  export const lacking = (message) => /Unknown method (\S+)/.exec(message)?.[1];
185
+ // 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
186
+ // end of a step (a GET the app sends as an action finishes): its trace, carrying the step it left in, says where
187
+ // it belongs. Each request without a trace in its own step moves to the step whose trace served the same method
188
+ // and path at the same moment.
189
+ export const fileRequestsByTrace = (steps, spans) => {
190
+ const started = new Map([...spans.values()].map((v) => [v.index, v.startedMs]));
191
+ const pathOf = (u) => {
192
+ try {
193
+ return new URL(String(u)).pathname;
194
+ }
195
+ catch {
196
+ return String(u ?? '').split('?')[0];
197
+ }
198
+ };
199
+ const roots = steps.flatMap((st, index) => (st.backend ?? [])
200
+ .filter((sp) => sp.depth === 0)
201
+ .map((sp) => ({
202
+ index,
203
+ method: String(sp.attributes?.['http.method'] ?? sp.attributes?.['http.request.method'] ?? '').toUpperCase(),
204
+ path: pathOf(sp.attributes?.['http.target'] ?? sp.attributes?.['url.path'] ?? ''),
205
+ at: (started.get(index) ?? 0) + sp.at,
206
+ })));
207
+ if (!roots.length)
208
+ return;
209
+ steps.forEach((st, index) => {
210
+ const keep = [];
211
+ for (const q of st.requests ?? []) {
212
+ const method = String(q.method).toUpperCase(), path = pathOf(q.url);
213
+ const same = roots.filter((r) => r.method === method && r.path === path);
214
+ const here = same.some((r) => r.index === index);
215
+ const near = same
216
+ .filter((r) => typeof q.startedAt === 'number' && Math.abs(r.at - q.startedAt) < 1500)
217
+ .sort((a, b) => Math.abs(a.at - q.startedAt) - Math.abs(b.at - q.startedAt))[0];
218
+ if (here || !near || near.index === index || !steps[near.index])
219
+ keep.push(q);
220
+ else
221
+ steps[near.index].requests = [...(steps[near.index].requests ?? []), q].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
222
+ }
223
+ if (st.requests)
224
+ st.requests = keep.length ? keep : undefined;
225
+ if (st.requests === undefined)
226
+ delete st.requests;
227
+ });
228
+ };
170
229
  export const runCase = async (c, options) => {
171
230
  const { base, config } = options;
172
231
  const log = options.log ?? (() => undefined);
@@ -177,6 +236,11 @@ export const runCase = async (c, options) => {
177
236
  const started = Date.now();
178
237
  const steps = [];
179
238
  let mark;
239
+ // Where the trace stood as the last action began: a screen it led to that closes itself (a success screen) can
240
+ // come and go while that step's frame settles or a wait for a request runs, and a wait for it still counts it.
241
+ // Waits and looks change nothing, so they keep the mark of the action before them.
242
+ let actedFrom;
243
+ let lastFrame;
180
244
  // The app numbers its requests as they start. A step that looks for one counts only those started after the
181
245
  // action before it began: an earlier screen's request to the same path would otherwise pass for the one the
182
246
  // action caused. `app.requests` marks by hand, and holds until a step has looked for a request.
@@ -185,17 +249,22 @@ export const runCase = async (c, options) => {
185
249
  let why;
186
250
  const recorded = [];
187
251
  let buffer = [];
252
+ // A host that records what each SDK was handed (fastsim, the headless host) already has every event. Asking the
253
+ // app for its own list as well turns on the app's tracking debug, which may draw on the screen (a counter in
254
+ // every frame of the film).
255
+ let sdkRecorded = false;
188
256
  // Read, never emptied: a case may be asserting on the buffer.
189
257
  const collect = async (index) => {
190
- if (!options.events)
258
+ if (!options.events || sdkRecorded)
191
259
  return;
192
260
  const d = await base.device().catch(() => undefined);
193
261
  if (!d?.server.connected)
194
262
  return;
195
263
  const now = normalizeEvents(await d.command('events').catch(() => []));
196
264
  const route = (await d.screen().catch(() => undefined))?.route;
265
+ const provider = config.appEvents?.provider;
197
266
  for (const e of newSince(buffer, now))
198
- recorded.push({ ...e, step: index, route });
267
+ recorded.push({ ...e, step: index, route, ...(provider ? { provider } : {}) });
199
268
  buffer = now;
200
269
  };
201
270
  const device = () => base.device();
@@ -379,16 +448,24 @@ export const runCase = async (c, options) => {
379
448
  if (await scrollTo(async () => (await seen(params)) === undefined))
380
449
  return;
381
450
  }
382
- else
383
- await d.waitFor({ target: what, timeoutMs: 20_000 }).catch(() => undefined);
451
+ else {
452
+ // A step that names its own timeout gets no more than what is left of it; one that does not may
453
+ // wait for a carousel to come round.
454
+ const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
455
+ await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
456
+ }
384
457
  continue;
385
458
  }
386
459
  if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
387
460
  await sleep(150);
388
461
  continue;
389
462
  }
390
- if (Date.now() > deadline)
391
- throw new Error(absent ? `still there: ${label({ verb: '', value: { ...params, absent: undefined } }).trim()}` : missing);
463
+ if (Date.now() > deadline) {
464
+ if (!absent)
465
+ throw new Error(missing);
466
+ const { absent: _, ...what } = params;
467
+ throw new Error(`still on screen: ${label({ verb: '', value: what }).trim()}`);
468
+ }
392
469
  await sleep(150);
393
470
  }
394
471
  };
@@ -527,10 +604,14 @@ export const runCase = async (c, options) => {
527
604
  const app = async (name, value) => {
528
605
  const params = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
529
606
  const d = await device();
530
- mark ??= (await d.trace()).last;
607
+ // An app in the background has no connection to answer with: the step that brings it back must still run.
608
+ const lastSeq = () => d.trace().then((t) => t.last, () => undefined);
609
+ mark ??= await lastSeq();
610
+ if (!LOOKING.has(name))
611
+ actedFrom = await lastSeq();
531
612
  // An interruption handled inside a wait is not the action the wait is about.
532
613
  if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined)
533
- await markRequests(d);
614
+ await markRequests(d).catch(() => undefined);
534
615
  switch (name) {
535
616
  case 'press': {
536
617
  await interrupted(value);
@@ -569,8 +650,10 @@ export const runCase = async (c, options) => {
569
650
  else if (params?.request)
570
651
  await waitForRequest(params);
571
652
  else {
653
+ const what = typeof value === 'string' ? value : String(params?.what ?? '');
654
+ const since = isRoute(what) && actedFrom !== undefined ? { since: actedFrom } : {};
572
655
  if (config.interruptions?.length) {
573
- const asked = typeof value === 'string' ? { what: value } : { ...(params ?? {}) };
656
+ const asked = { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since };
574
657
  const deadline = Date.now() + (typeof asked.timeoutMs === 'number' ? asked.timeoutMs : 10_000);
575
658
  for (;;) {
576
659
  await interrupted(value);
@@ -585,9 +668,8 @@ export const runCase = async (c, options) => {
585
668
  }
586
669
  }
587
670
  else
588
- await cli(argvFor(find('wait'), value));
671
+ await cli(argvFor(find('wait'), 'since' in since ? { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since } : value));
589
672
  // 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.
590
- const what = typeof value === 'string' ? value : String(params?.what ?? '');
591
673
  if (onWallClock(d) && isRoute(what))
592
674
  await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
593
675
  }
@@ -720,24 +802,57 @@ export const runCase = async (c, options) => {
720
802
  };
721
803
  if (options.events && !options.dry) {
722
804
  const d = await base.device().catch(() => undefined);
723
- if (d?.server.connected)
805
+ sdkRecorded = !!d?.server.hello?.commands?.includes('analytics');
806
+ if (d?.server.connected && !sdkRecorded)
724
807
  buffer = normalizeEvents(await d.command('events').catch(() => []));
725
808
  }
726
809
  const evidence = options.dry ? undefined : options.evidence;
727
810
  const offers = async (command) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
728
811
  const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
729
- // Asked only when the run keeps events: it is a call per step.
730
- const capturing = !options.dry && !!options.events && (await offers('analytics'));
812
+ // Asked when the run keeps events or a report: it is a call per step.
813
+ const capturing = !options.dry && (!!options.events || !!options.evidence) && (await offers('analytics'));
731
814
  if (evidence)
732
815
  fs.mkdirSync(evidence.dir, { recursive: true });
733
816
  let filming = false;
734
817
  if (film && evidence?.video) {
735
818
  const d = await device();
736
- filming = await d.command('record', { path: film, fps: evidence.video.fps }).then(() => true, () => false);
819
+ // A host that films at one size only ignores the scale.
820
+ filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(() => true, () => false);
737
821
  }
738
822
  // What the app sent before the case began is not the case's.
739
823
  if (capturing)
740
824
  await (await device()).command('analytics', { take: true }).catch(() => undefined);
825
+ // The same for what it printed.
826
+ const logging = !!evidence && (await offers('logs'));
827
+ if (logging)
828
+ await (await device()).command('logs', { take: true }).catch(() => undefined);
829
+ options.nativeLogs?.take();
830
+ // A report shows what each request sent and got: the app keeps headers and bodies only while this case runs.
831
+ let requestsSeen;
832
+ // Requests filed while still waiting for an answer: read again later and completed where they were filed.
833
+ const inFlight = new Map();
834
+ const answered = (r) => r.status !== undefined || !!r.error;
835
+ const completeInFlight = async (d) => {
836
+ if (!inFlight.size)
837
+ return;
838
+ const again = await d.requests(Math.min(...inFlight.keys()) - 1).then((r) => r.requests, () => []);
839
+ for (const r of again) {
840
+ const kept = inFlight.get(r.id);
841
+ if (kept && answered(r)) {
842
+ Object.assign(kept, r);
843
+ inFlight.delete(r.id);
844
+ }
845
+ }
846
+ };
847
+ // Frames the host is still encoding: the case waits for them before it says it is done.
848
+ const encoding = [];
849
+ // Frames a simulator or emulator gave as PNG, being encoded again as AVIF while the case goes on.
850
+ const compacting = [];
851
+ if (evidence && (await offers('networkBodies'))) {
852
+ const d = await device();
853
+ await d.command('networkBodies', { on: true }).catch(() => undefined);
854
+ requestsSeen = await d.requests().then((r) => r.requests.at(-1)?.id ?? 0, () => undefined);
855
+ }
741
856
  const frameAt = async () => {
742
857
  if (!filming || !evidence?.video)
743
858
  return undefined;
@@ -745,49 +860,196 @@ export const runCase = async (c, options) => {
745
860
  return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
746
861
  };
747
862
  // What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
748
- const record = async (report, index, startedAt) => {
863
+ const record = async (report, index, startedAt, begunMs) => {
749
864
  if (startedAt !== undefined)
750
865
  report.videoS = startedAt;
866
+ if (config.serverEvents && options.fixtures) {
867
+ const { fixture: name, provider } = config.serverEvents;
868
+ const answer = await options.fixtures.call(name, { since: new Date(begunMs).toISOString() }).catch(() => undefined);
869
+ const list = Array.isArray(answer?.events) ? answer.events : [];
870
+ const server = list.flatMap((e) => {
871
+ const named = typeof e?.name === 'string' ? e.name : typeof e?.event === 'string' ? e.event : undefined;
872
+ const props = (e?.props ?? e?.payload);
873
+ return named
874
+ ? [
875
+ {
876
+ provider: String(e.provider ?? provider ?? 'backend'),
877
+ name: named,
878
+ ...(props && typeof props === 'object' ? { props } : {}),
879
+ via: 'server',
880
+ },
881
+ ]
882
+ : [];
883
+ });
884
+ if (server.length > 0)
885
+ report.sent = [...(report.sent ?? []), ...server];
886
+ }
751
887
  const d = await device().catch(() => undefined);
752
888
  if (!d?.server.connected)
753
889
  return;
754
890
  if (capturing) {
755
891
  const raw = (await d.command('analytics', { take: true }).catch(() => []));
756
- const sent = (Array.isArray(raw) ? raw : []).flatMap((e) => e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}) }] : []);
892
+ const sent = (Array.isArray(raw) ? raw : []).flatMap((e) => e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}), via: 'sdk' }] : []);
893
+ // Added to what the backend told its analytics, read above for the same step, not in its place.
757
894
  if (sent.length > 0)
758
- report.sent = sent;
895
+ report.sent = [...(report.sent ?? []), ...sent];
896
+ }
897
+ if (logging) {
898
+ const raw = (await d.command('logs', { take: true }).catch(() => []));
899
+ const logs = (Array.isArray(raw) ? raw : []).flatMap((l) => typeof l?.text === 'string' && typeof l.at === 'number'
900
+ ? [
901
+ {
902
+ at: l.at,
903
+ level: l.level === 'debug' || l.level === 'warn' || l.level === 'error' ? l.level : 'info',
904
+ source: typeof l.source === 'string' ? l.source : 'js',
905
+ text: l.text,
906
+ ...(typeof l.stack === 'string' ? { stack: l.stack } : {}),
907
+ },
908
+ ]
909
+ : []);
910
+ if (logs.length > 0)
911
+ report.logs = logs;
912
+ }
913
+ const native = options.nativeLogs?.take() ?? [];
914
+ // In the order they were printed, whichever side printed them.
915
+ if (native.length > 0)
916
+ report.logs = [...(report.logs ?? []), ...native].sort((x, y) => x.at - y.at);
917
+ if (requestsSeen !== undefined) {
918
+ await completeInFlight(d);
919
+ const made = await d.requests(requestsSeen).then((r) => r.requests, () => []);
920
+ // A reload (app.refresh) turns the app's keeping of bodies off with everything else it held: ask again.
921
+ await d.command('networkBodies', { on: true }).catch(() => undefined);
922
+ if (made.length > 0) {
923
+ report.requests = made;
924
+ requestsSeen = made.at(-1).id;
925
+ for (const r of made)
926
+ if (!answered(r))
927
+ inFlight.set(r.id, r);
928
+ // A JS SDK (Segment, PostHog…) never reaches a native module: what it sent is in its requests.
929
+ const traffic = trafficEvents(made).map((e) => ({ ...e, via: 'network' }));
930
+ if (traffic.length > 0)
931
+ report.sent = [...(report.sent ?? []), ...traffic];
932
+ }
933
+ }
934
+ // A step that only looks (app.see, app.wait) on a screen no different from the last frame's shares that frame:
935
+ // its wait and capture are what make a frame per step slow on a device. The shape is the route and each
936
+ // element's id, text and place, read from the app, so a change in any of them still takes a frame.
937
+ let shape;
938
+ if (evidence?.screenshots && evidence.onChange) {
939
+ shape = await d.screen().then((sc) => `${sc.route}|${sc.elements.map((e) => `${e.testId ?? ''}:${e.text ?? ''}:${Math.round(e.frame.x)},${Math.round(e.frame.y)}`).join(';')}`, () => undefined);
940
+ if (shape !== undefined && shape === lastFrame?.shape && /^app\.(see|wait)\b/.test(report.step)) {
941
+ report.screenshot = lastFrame.file;
942
+ return;
943
+ }
759
944
  }
760
945
  if (evidence?.screenshots) {
761
946
  // A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
762
947
  // step left the app, so two runs' frames of the same step show the same screen.
763
- // Long enough for what arrives after a short delay; a screen that never settles (an animation that
764
- // keeps going) is noted as such, and its frame is of a moment, not of a state.
765
- const settled = await d.idle({ quietMs: 700, timeoutMs: 3000 }).then((r) => r.idle, () => false);
766
- const file = path.join(evidence.dir, `${String(index).padStart(2, '0')}-${report.step
948
+ // A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
949
+ // moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
950
+ // arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
951
+ const quietMs = evidence.settleMs ?? 150;
952
+ const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then((r) => r.idle, () => false);
953
+ const stem = path.join(evidence.dir, `${String(index).padStart(2, '0')}-${report.step
767
954
  .toLowerCase()
768
955
  .replace(/[^a-z0-9]+/g, '-')
769
956
  .slice(0, 48)
770
- .replace(/^-|-$/g, '')}.png`);
957
+ .replace(/^-|-$/g, '')}`);
771
958
  // A host on another machine writes the file there; `inline` asks it for the picture as well.
772
959
  // A host that knows what was still animating says so, as frames in points.
773
960
  let moving;
774
- const taken = (await offers('screenshot'))
775
- ? await d.command('screenshot', { path: file, inline: true }).then((answer) => {
776
- const { png, moving: animating } = (answer ?? {});
777
- if (typeof png === 'string' && !fs.existsSync(file))
778
- fs.writeFileSync(file, Buffer.from(png, 'base64'));
779
- if (Array.isArray(animating))
780
- moving = animating;
781
- return true;
782
- }, () => false)
783
- : (await execute(['screenshot', file], base)).code === 0;
784
- if (taken && fs.existsSync(file)) {
961
+ // A host that waits for a still screen on its own clock signs each picture, so it can say whether the screen
962
+ // it settled on is still the one in the picture.
963
+ const hostStill = !!evidence.stillMs && (await offers('still'));
964
+ let signature;
965
+ const hostShot = async (file) => d.command('screenshot', { path: file, inline: true, ...(hostStill ? { sign: true } : {}) }).then((answer) => {
966
+ const { png, moving: animating, signature: signed } = (answer ?? {});
967
+ if (typeof png === 'string' && !fs.existsSync(file))
968
+ fs.writeFileSync(file, Buffer.from(png, 'base64'));
969
+ if (Array.isArray(animating))
970
+ moving = animating;
971
+ if (typeof signed === 'string')
972
+ signature = signed;
973
+ return fs.existsSync(file);
974
+ }, () => false);
975
+ let still;
976
+ let file = `${stem}.png`;
977
+ let taken;
978
+ if (await offers('screenshot')) {
979
+ const avif = evidence.frames !== 'png' && !avifRefused;
980
+ file = `${stem}.${avif ? 'avif' : 'png'}`;
981
+ // A host on this machine encodes an AVIF frame after it answers, so the step does not wait for it;
982
+ // the case waits for all its frames once, at its end.
983
+ const later = avif
984
+ ? await d.command('screenshot', { path: file, background: true }).then((answer) => {
985
+ const { background, moving: animating } = (answer ?? {});
986
+ if (Array.isArray(animating))
987
+ moving = animating;
988
+ return background === true;
989
+ }, () => false)
990
+ : false;
991
+ if (later)
992
+ encoding.push(file);
993
+ taken = later || (await hostShot(file));
994
+ if (!taken && avif) {
995
+ avifRefused = true;
996
+ file = `${stem}.png`;
997
+ taken = await hostShot(file);
998
+ }
999
+ }
1000
+ else
1001
+ taken = (await execute(['screenshot', file], base)).code === 0;
1002
+ // An animation the agent cannot see (a device's native transition, a host's printer running on its own clock)
1003
+ // leaves a quiet app on a moving screen: the picture itself says when it stopped. Two frames alike, taken
1004
+ // stillMs apart, are a still screen. Frames still being encoded (AVIF on a host) are left as they are.
1005
+ if (taken && hostStill && signature !== undefined && !encoding.includes(file)) {
1006
+ // No real time passes and no picture is made while the host waits; the picture is taken again only when
1007
+ // the screen it settled on is another one.
1008
+ const stillMs = evidence.stillMs;
1009
+ const answer = (await d.command('still', { stepMs: stillMs, timeoutMs: Math.max(2000, stillMs * 8), since: signature }).catch(() => undefined));
1010
+ if (answer) {
1011
+ still = answer.still === true;
1012
+ if (answer.changed === true) {
1013
+ fs.rmSync(file, { force: true });
1014
+ taken = await hostShot(file);
1015
+ }
1016
+ // A screen that never stood still: what moved during the wait is of a moment too.
1017
+ if (Array.isArray(answer.moving))
1018
+ moving = [...(Array.isArray(moving) ? moving : []), ...answer.moving];
1019
+ }
1020
+ }
1021
+ else if (taken && evidence.stillMs && !encoding.includes(file)) {
1022
+ const stillMs = evidence.stillMs;
1023
+ const take = async (to) => ((await offers('screenshot')) ? hostShot(to) : (await execute(['screenshot', to], base)).code === 0);
1024
+ still = false;
1025
+ const until = Date.now() + Math.max(2000, stillMs * 8);
1026
+ let last = fs.readFileSync(file);
1027
+ while (!still && Date.now() < until) {
1028
+ await sleep(stillMs);
1029
+ const again = `${file}.again${path.extname(file)}`;
1030
+ if (!(await take(again)))
1031
+ break;
1032
+ const now = fs.readFileSync(again);
1033
+ fs.renameSync(again, file);
1034
+ still = now.equals(last);
1035
+ last = now;
1036
+ }
1037
+ }
1038
+ if (taken && (encoding.includes(file) || fs.existsSync(file))) {
785
1039
  report.screenshot = file;
1040
+ lastFrame = { file, shape };
1041
+ if (file.endsWith('.png') && evidence.frames !== 'png') {
1042
+ const taken = file;
1043
+ compacting.push(compactFrame(taken).then((compact) => {
1044
+ if (report.screenshot === taken)
1045
+ report.screenshot = compact;
1046
+ }));
1047
+ }
786
1048
  // What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
787
1049
  // a balance) from how it was drawn.
788
1050
  const screen = await d.screen().catch(() => undefined);
789
1051
  if (screen)
790
- fs.writeFileSync(file.replace(/\.png$/, '.json'), JSON.stringify({ ...screen, settled, ...(moving ? { moving } : {}) }));
1052
+ fs.writeFileSync(file.replace(/\.(png|avif)$/, '.json'), JSON.stringify({ ...screen, settled, ...(still === undefined ? {} : { still }), ...(moving ? { moving } : {}) }));
791
1053
  }
792
1054
  }
793
1055
  };
@@ -850,7 +1112,7 @@ export const runCase = async (c, options) => {
850
1112
  steps.push(done);
851
1113
  log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
852
1114
  if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
853
- await record(done, steps.length, at);
1115
+ await record(done, steps.length, at, t0);
854
1116
  }
855
1117
  catch (error) {
856
1118
  const message = error instanceof Error ? error.message : String(error);
@@ -869,19 +1131,39 @@ export const runCase = async (c, options) => {
869
1131
  steps.push(broke);
870
1132
  log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
871
1133
  if (evidence || capturing)
872
- await record(broke, steps.length, at);
1134
+ await record(broke, steps.length, at, t0);
873
1135
  }
874
1136
  }
1137
+ if (requestsSeen !== undefined) {
1138
+ const d = await device().catch(() => undefined);
1139
+ // The last answers of the case, given a moment to arrive.
1140
+ for (let i = 0; d && inFlight.size && i < 5; i++) {
1141
+ await sleep(200);
1142
+ await completeInFlight(d);
1143
+ }
1144
+ await d?.command('networkBodies', { on: false }).catch(() => undefined);
1145
+ }
1146
+ await Promise.all(compacting);
1147
+ if (encoding.length > 0) {
1148
+ const until = Date.now() + 15_000;
1149
+ while (encoding.some((f) => !fs.existsSync(f)) && Date.now() < until)
1150
+ await sleep(25);
1151
+ // A frame that never arrived is not offered to a report.
1152
+ for (const s of steps)
1153
+ if (s.screenshot && encoding.includes(s.screenshot) && !fs.existsSync(s.screenshot))
1154
+ delete s.screenshot;
1155
+ }
875
1156
  if (tracing && options.traces) {
876
1157
  await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
877
1158
  // A backend exports in batches, a moment after the requests it served.
878
1159
  await options.traces.settle(tracing.traceId);
879
1160
  for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
880
1161
  const s = steps[index];
881
- // A step that set off thousands of queries keeps the first ones; a report is not a profiler.
1162
+ // A step that set off thousands of spans keeps the first ones; a report is not a profiler.
882
1163
  if (s)
883
- s.backend = spans.slice(0, 400);
1164
+ s.backend = spans.slice(0, 1500);
884
1165
  }
1166
+ fileRequestsByTrace(steps, tracing.steps);
885
1167
  }
886
1168
  if (options.setupOnly && !failed)
887
1169
  options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
@@ -890,6 +1172,7 @@ export const runCase = async (c, options) => {
890
1172
  const report = {
891
1173
  id: c.id,
892
1174
  title: c.title,
1175
+ ...aboutOf(c),
893
1176
  file: c.file,
894
1177
  result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
895
1178
  ms: Date.now() - started,
@@ -3,8 +3,8 @@
3
3
  * format next to it, and a fixtures server with two empty fixtures, so a
4
4
  * project starts from something that runs instead of from a blank folder.
5
5
  */
6
- export declare const EXAMPLE_CASE = "---\nid: example\ntitle: \"A user who was given points sees them on the first screen\"\ndescription: >\n What this proves and why it matters, in a sentence or two. A case reads top\n to bottom: what has to exist, what the person does, what must be true after.\n---\nsetup:\n # Anything that is not an app step is a fixture: fixtures.mjs answers it.\n - create_user: { as: u, name: \"Ana\" }\n\nsteps:\n - app.see: { text: \"Hello\" }\n # `user: u` points at what `create_user` answered (cases.refs in lynkeus.config.json).\n - give_points: { user: u, points: 25 }\n - app.see: { text: \"25 points\", timeoutMs: 10000 }\n - assert: { user: u, path: points, equals: 25 }\n\nexpected: |\n The points on screen and the points the backend holds are both 25.\n";
7
- export declare const FORMAT = "# Cases\n\nA case is a markdown file: frontmatter that names it, then `setup`, `steps`\nand `expected`. `lynkeus case run cases/example.md` runs one; without files\nit runs every case in this folder. A step that fails stops the case, and the\nreport carries the screen, the requests and the reason.\n\n## Steps that drive the app\n\n| Step | Value | |\n|---|---|---|\n| `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. |\n| `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. |\n| `app.swipe` | `{ direction, target?, distance? }` or `{ from, to }` | |\n| `app.wait` | route, target, ms, `{ target, scroll? }` or `{ request, status? }` | `scroll` swipes until the target is in the window. A `request` is `METHOD /path`. |\n| `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. |\n| `app.nav` | route, or `{ route, params }` | A teleport, for getting to where the case starts. |\n| `app.back` | | |\n| `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. |\n| `app.call` | `{ command, params?, expect?, as? }` | A command the app registered with `qa.register`. |\n| `app.run` | `{ flow, vars? }` | A lynkeus flow, from the flows folder. |\n| `app.events` / `app.event` | `clear` / `{ name, props?, absent? }` | What the app told its analytics. |\n| `app.<command>` | its arguments by name | Any other lynkeus command: `app.mock`, `app.network`, `app.appstate`, `app.deeplink`\u2026 |\n\nA 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`.\n\nA case that runs over several inputs declares `matrix: { locale: [en, fr] }`\nin its frontmatter and runs once per combination; `{{matrix.locale}}` is that\nrun's value.\n\nOne case runs on a host and on a device. What only one of them needs \u2014 closing a\nnative screen the host never shows \u2014 is kept to it:\n\n```yaml\n - on: device # or headless, ios, android\n do:\n - app.press: \"364,84\"\n```\n\n## Steps that prepare and read the backend\n\nAny other step name is a **fixture**: lynkeus sends it to the command in\n`cases.fixtures` (lynkeus.config.json) as one JSON line and reads one back.\n\n```\n\u2192 {\"command\": \"create_user\", \"params\": {\"name\": \"Ana\"}, \"env\": {\"NAME\": \"Ana\"}}\n\u2190 {\"ok\": true, \"user_id\": \"u_1\", \"name\": \"Ana\"}\n```\n\nBefore the first case lynkeus sends `{\"command\": \"lynkeus.describe\"}`. A\nserver that answers `{\"commands\": [\"create_user\", \u2026]}` gets its cases checked\nfirst: one that names a fixture missing from the list is refused before\nanything runs. A server that answers `\"ok\": false` is not checked.\n\n`as: u` keeps the answer; `{{u.user_id}}` reads it later. `assert` calls\nthe `inspect` fixture and checks one path of its answer with `equals`,\n`includes`, `gte`, `lte`, `near`, `matches`, `present` or `absent`.\n`exec: { run: \"\u2026\", as? }` runs a shell command and keeps its JSON output.\n\n## Steps of your own\n\n`cases.macros` names a step and the steps it stands for. Inside,\n`{{param.x}}` is what the case passed, and a parameter that names an alias\nis that alias.\n\n```json\n\"macros\": {\n \"app.login\": [\n { \"app.call\": { \"command\": \"reset\" } },\n { \"app.deeplink\": \"myapp://dev-login?user={{param.as.user_id}}\" },\n { \"when\": \"wait\", \"do\": { \"app.wait\": \"{{param.wait}}\" } }\n ]\n}\n```\n\n`when` runs a step only if the case passed that parameter (`\"when\": \"wait\"`)\nor passed that value (`\"when\": \"method=phone\"`); `unless` is the opposite.\n\n`cases.interruptions` names what the app may put in the way of any step \u2014 a\nlock screen, a consent sheet \u2014 and the steps that get past it:\n`{ \"see\": \"#unlock-keypad\", \"do\": [\"app.unlock\"], \"unless\": \"keypad\" }`. It\nruns before a press, a type, a swipe or a see finds it there; a step whose own\nwords match `unless` is about it, and is left alone.\n\nCases and fixtures often live in a repository of their own: `LYNKEUS_CONFIG`\nnames a second configuration file, read over the app's, and the paths under\nits `cases` are relative to it.\n";
6
+ export declare const EXAMPLE_CASE = "---\nid: example\ntitle: \"A user who was given points sees them on the first screen\"\ndescription: >\n What this proves, in a sentence or two. A case reads top to bottom: what has\n to exist, what the person does, what must be true after.\nwhy: >\n Optional: what breaks for people if this fails. The report shows it above the steps.\ngiven: >\n Optional: what is true when it starts (who the user is, what is installed).\nticket: \"[TICKET-ID]\"\n---\nsetup:\n # Anything that is not an app step is a fixture: fixtures.mjs answers it.\n - create_user: { as: u, name: \"Ana\" }\n\nsteps:\n - app.see: { text: \"Hello\" }\n # `user: u` points at what `create_user` answered (cases.refs in lynkeus.config.json).\n - give_points: { user: u, points: 25 }\n - app.see: { text: \"25 points\", timeoutMs: 10000 }\n - assert: { user: u, path: points, equals: 25 }\n\nexpected: |\n The points on screen and the points the backend holds are both 25.\n";
7
+ export declare const FORMAT = "# Cases\n\nA case is a markdown file: frontmatter that names it, then `setup`, `steps`\nand `expected`. `lynkeus case run cases/example.md` runs one; without files\nit runs every case in this folder. A step that fails stops the case, and the\nreport carries the screen, the requests and the reason.\n\n## Frontmatter\n\n| Key | | |\n|---|---|---|\n| `id`, `title` | required, a line | how runs and reports name the case |\n| `description` | a paragraph | what the case proves |\n| `why` | optional | what breaks for people if it fails |\n| `given` | optional | what is true when it starts |\n| `ticket` | optional | the ticket it covers |\n\nA report shows `description`, `why`, `given`, `ticket` and `expected` above the steps, so a reader\nwho never opened the file knows what they are looking at.\n\n## Steps that drive the app\n\n| Step | Value | |\n|---|---|---|\n| `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. |\n| `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. |\n| `app.swipe` | `{ direction, target?, distance? }` or `{ from, to }` | |\n| `app.wait` | route, target, ms, `{ target, scroll? }` or `{ request, status? }` | `scroll` swipes until the target is in the window. A `request` is `METHOD /path`. |\n| `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. |\n| `app.nav` | route, or `{ route, params }` | A teleport, for getting to where the case starts. |\n| `app.back` | | |\n| `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. |\n| `app.call` | `{ command, params?, expect?, as? }` | A command the app registered with `qa.register`. |\n| `app.run` | `{ flow, vars? }` | A lynkeus flow, from the flows folder. |\n| `app.events` / `app.event` | `clear` / `{ name, props?, absent? }` | What the app told its analytics. |\n| `app.<command>` | its arguments by name | Any other lynkeus command: `app.mock`, `app.network`, `app.appstate`, `app.deeplink`\u2026 |\n\nA 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`.\n\nA case that runs over several inputs declares `matrix: { locale: [en, fr] }`\nin its frontmatter and runs once per combination; `{{matrix.locale}}` is that\nrun's value.\n\nOne case runs on a host and on a device. What only one of them needs \u2014 closing a\nnative screen the host never shows \u2014 is kept to it:\n\n```yaml\n - on: device # or headless, ios, android\n do:\n - app.press: \"364,84\"\n```\n\n## Steps that prepare and read the backend\n\nAny other step name is a **fixture**: lynkeus sends it to the command in\n`cases.fixtures` (lynkeus.config.json) as one JSON line and reads one back.\n\n```\n\u2192 {\"command\": \"create_user\", \"params\": {\"name\": \"Ana\"}, \"env\": {\"NAME\": \"Ana\"}}\n\u2190 {\"ok\": true, \"user_id\": \"u_1\", \"name\": \"Ana\"}\n```\n\nBefore the first case lynkeus sends `{\"command\": \"lynkeus.describe\"}`. A\nserver that answers `{\"commands\": [\"create_user\", \u2026]}` gets its cases checked\nfirst: one that names a fixture missing from the list is refused before\nanything runs. A server that answers `\"ok\": false` is not checked.\n\n`as: u` keeps the answer; `{{u.user_id}}` reads it later. `assert` calls\nthe `inspect` fixture and checks one path of its answer with `equals`,\n`includes`, `gte`, `lte`, `near`, `matches`, `present` or `absent`.\n`exec: { run: \"\u2026\", as? }` runs a shell command and keeps its JSON output.\n\n## Steps of your own\n\n`cases.macros` names a step and the steps it stands for. Inside,\n`{{param.x}}` is what the case passed, and a parameter that names an alias\nis that alias.\n\n```json\n\"macros\": {\n \"app.login\": [\n { \"app.call\": { \"command\": \"reset\" } },\n { \"app.deeplink\": \"myapp://dev-login?user={{param.as.user_id}}\" },\n { \"when\": \"wait\", \"do\": { \"app.wait\": \"{{param.wait}}\" } }\n ]\n}\n```\n\n`when` runs a step only if the case passed that parameter (`\"when\": \"wait\"`)\nor passed that value (`\"when\": \"method=phone\"`); `unless` is the opposite.\n\n`cases.interruptions` names what the app may put in the way of any step \u2014 a\nlock screen, a consent sheet \u2014 and the steps that get past it:\n`{ \"see\": \"#unlock-keypad\", \"do\": [\"app.unlock\"], \"unless\": \"keypad\" }`. It\nruns before a press, a type, a swipe or a see finds it there; a step whose own\nwords match `unless` is about it, and is left alone.\n\nCases and fixtures often live in a repository of their own: `LYNKEUS_CONFIG`\nnames a second configuration file, read over the app's, and the paths under\nits `cases` are relative to it.\n";
8
8
  export declare const FIXTURES = "#!/usr/bin/env node\n// The fixtures of this project: how a case prepares the backend and reads it.\n// lynkeus starts this once per run and talks JSON lines to it: one line in per\n// step, one line out. Replace the bodies with calls to your backend \u2014 its\n// admin API, a script, a database seed.\nimport readline from 'node:readline';\n\nconst users = new Map();\n\nconst fixtures = {\n async create_user({ name = 'Test user' }) {\n const user = { user_id: `u_${users.size + 1}`, name, points: 0 };\n users.set(user.user_id, user);\n return user;\n },\n async give_points({ user_id, points }) {\n const user = users.get(user_id);\n if (!user) throw new Error(`no user ${user_id}`);\n user.points += Number(points);\n return { user_id, points: user.points };\n },\n // What `assert` reads.\n async inspect({ user_id }) {\n return users.get(user_id) ?? {};\n },\n};\n\nfor await (const line of readline.createInterface({ input: process.stdin })) {\n if (!line.trim()) continue;\n const { command, params } = JSON.parse(line);\n // lynkeus asks once what this server answers, and refuses a case that names anything else.\n if (command === 'lynkeus.describe') {\n console.log(JSON.stringify({ commands: Object.keys(fixtures) }));\n continue;\n }\n try {\n const fixture = fixtures[command];\n if (!fixture) throw new Error(`no fixture ${command}`);\n console.log(JSON.stringify({ ok: true, ...(await fixture(params ?? {})) }));\n } catch (error) {\n console.log(JSON.stringify({ ok: false, error: error.message }));\n }\n}\n";
9
9
  export declare const CONFIG: {
10
10
  dir: string;
@@ -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 and why it matters, in a sentence or two. A case reads top
11
- to bottom: what has to exist, what the person does, what must be true after.
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.
@@ -31,6 +36,19 @@ and \`expected\`. \`lynkeus case run cases/example.md\` runs one; without files
31
36
  it runs every case in this folder. A step that fails stops the case, and the
32
37
  report carries the screen, the requests and the reason.
33
38
 
39
+ ## Frontmatter
40
+
41
+ | Key | | |
42
+ |---|---|---|
43
+ | \`id\`, \`title\` | required, a line | how runs and reports name the case |
44
+ | \`description\` | a paragraph | what the case proves |
45
+ | \`why\` | optional | what breaks for people if it fails |
46
+ | \`given\` | optional | what is true when it starts |
47
+ | \`ticket\` | optional | the ticket it covers |
48
+
49
+ A report shows \`description\`, \`why\`, \`given\`, \`ticket\` and \`expected\` above the steps, so a reader
50
+ who never opened the file knows what they are looking at.
51
+
34
52
  ## Steps that drive the app
35
53
 
36
54
  | Step | Value | |
@@ -145,7 +145,7 @@ export const decodeJson = (body) => {
145
145
  return spans;
146
146
  };
147
147
  // What a report keeps of a span's attributes: what it did, not every label an instrumentation adds.
148
- const KEEP = /^(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)/;
148
+ const KEEP = /^(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)/;
149
149
  /**
150
150
  * Listens for OTLP traces while a run goes on: a backend with OTEL_EXPORTER_OTLP_ENDPOINT pointed here sends
151
151
  * what each request did, and the steps that sent those requests (their ids travel in `traceparent`) get it.
@@ -224,6 +224,9 @@ export class TraceSink {
224
224
  return undefined;
225
225
  };
226
226
  for (const span of [...spans].sort((a, b) => (a.startNs < b.startNs ? -1 : 1))) {
227
+ // A database query never leaves the machine: its statement names the backend's tables and columns.
228
+ if (span.attributes['db.system'] !== undefined)
229
+ continue;
227
230
  const where = place(span);
228
231
  if (!where)
229
232
  continue;