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
@@ -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
- export type SentEvent = { provider: string; name: string; props?: Record<string, unknown> };
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
- export type Evidence = { dir: string; screenshots?: boolean; video?: { fps: number } };
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
- for (const e of newSince(buffer, now)) recorded.push({ ...e, step: index, route });
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
 
@@ -449,15 +556,23 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
449
556
  if (looks === 1) await d.waitFor({ target: what, timeoutMs: 1500 }).catch(() => undefined);
450
557
  else if (looks === 2) {
451
558
  if (await scrollTo(async () => (await seen(params)) === undefined)) return;
452
- } else await d.waitFor({ target: what, timeoutMs: 20_000 }).catch(() => undefined);
559
+ } else {
560
+ // A step that names its own timeout gets no more than what is left of it; one that does not may
561
+ // wait for a carousel to come round.
562
+ const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
563
+ await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
564
+ }
453
565
  continue;
454
566
  }
455
567
  if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
456
568
  await sleep(150);
457
569
  continue;
458
570
  }
459
- if (Date.now() > deadline)
460
- throw new Error(absent ? `still there: ${label({ verb: '', value: { ...params, absent: undefined } as StepValue }).trim()}` : missing!);
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
+ }
461
576
  await sleep(150);
462
577
  }
463
578
  };
@@ -586,9 +701,16 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
586
701
  const app = async (name: string, value: StepValue): Promise<'skipped' | undefined> => {
587
702
  const params = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
588
703
  const d = await device();
589
- mark ??= (await d.trace()).last;
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();
590
712
  // An interruption handled inside a wait is not the action the wait is about.
591
- 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);
592
714
  switch (name) {
593
715
  case 'press': {
594
716
  await interrupted(value);
@@ -624,8 +746,10 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
624
746
  if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) await sleep(Number(value));
625
747
  else if (params?.request) await waitForRequest(params);
626
748
  else {
749
+ const what = typeof value === 'string' ? value : String(params?.what ?? '');
750
+ const since = isRoute(what) && actedFrom !== undefined ? { since: actedFrom } : {};
627
751
  if (config.interruptions?.length) {
628
- const asked: Record<string, unknown> = typeof value === 'string' ? { what: value } : { ...(params ?? {}) };
752
+ const asked: Record<string, unknown> = { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since };
629
753
  const deadline = Date.now() + (typeof asked.timeoutMs === 'number' ? asked.timeoutMs : 10_000);
630
754
  for (;;) {
631
755
  await interrupted(value);
@@ -637,9 +761,8 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
637
761
  throw new Error(outcome.error ?? outcome.text ?? 'wait failed');
638
762
  }
639
763
  }
640
- } 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));
641
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.
642
- const what = typeof value === 'string' ? value : String(params?.what ?? '');
643
766
  if (onWallClock(d) && isRoute(what)) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
644
767
  }
645
768
  return undefined;
@@ -757,79 +880,268 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
757
880
 
758
881
  if (options.events && !options.dry) {
759
882
  const d = await base.device().catch(() => undefined);
760
- if (d?.server.connected) buffer = normalizeEvents(await d.command('events').catch(() => []));
883
+ sdkRecorded = !!d?.server.hello?.commands?.includes('analytics');
884
+ if (d?.server.connected && !sdkRecorded) buffer = normalizeEvents(await d.command('events').catch(() => []));
761
885
  }
762
886
 
763
887
  const evidence = options.dry ? undefined : options.evidence;
764
888
  const offers = async (command: string) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
765
889
  const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
766
- // Asked only when the run keeps events: it is a call per step.
767
- 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'));
768
892
  if (evidence) fs.mkdirSync(evidence.dir, { recursive: true });
769
893
  let filming = false;
770
894
  if (film && evidence?.video) {
771
895
  const d = await device();
772
- filming = await d.command('record', { path: film, fps: evidence.video.fps }).then(
896
+ // A host that films at one size only ignores the scale.
897
+ filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(
773
898
  () => true,
774
899
  () => false,
775
900
  );
776
901
  }
777
902
  // What the app sent before the case began is not the case's.
778
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
+ }
779
939
  const frameAt = async (): Promise<number | undefined> => {
780
940
  if (!filming || !evidence?.video) return undefined;
781
941
  const answer = (await (await device()).command('record', { frames: true }).catch(() => undefined)) as { frames?: number } | undefined;
782
942
  return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
783
943
  };
784
944
  // What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
785
- const record = async (report: StepReport, index: number, startedAt: number | undefined) => {
945
+ const record = async (report: StepReport, index: number, startedAt: number | undefined, begunMs: number) => {
786
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
+ }
787
967
  const d = await device().catch(() => undefined);
788
968
  if (!d?.server.connected) return;
789
969
  if (capturing) {
790
970
  const raw = (await d.command('analytics', { take: true }).catch(() => [])) as { sdk?: string; name?: string; properties?: Record<string, unknown> }[];
791
971
  const sent = (Array.isArray(raw) ? raw : []).flatMap((e) =>
792
- 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,
793
1022
  );
794
- if (sent.length > 0) report.sent = sent;
1023
+ if (shape !== undefined && shape === lastFrame?.shape && /^app\.(see|wait)\b/.test(report.step)) {
1024
+ report.screenshot = lastFrame.file;
1025
+ return;
1026
+ }
795
1027
  }
796
1028
  if (evidence?.screenshots) {
797
1029
  // A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
798
1030
  // step left the app, so two runs' frames of the same step show the same screen.
799
- // Long enough for what arrives after a short delay; a screen that never settles (an animation that
800
- // keeps going) is noted as such, and its frame is of a moment, not of a state.
801
- const settled = await d.idle({ quietMs: 700, timeoutMs: 3000 }).then(
1031
+ // A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
1032
+ // moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
1033
+ // arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
1034
+ const quietMs = evidence.settleMs ?? 150;
1035
+ const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then(
802
1036
  (r) => r.idle,
803
1037
  () => false,
804
1038
  );
805
- const file = path.join(
1039
+ const stem = path.join(
806
1040
  evidence.dir,
807
1041
  `${String(index).padStart(2, '0')}-${report.step
808
1042
  .toLowerCase()
809
1043
  .replace(/[^a-z0-9]+/g, '-')
810
1044
  .slice(0, 48)
811
- .replace(/^-|-$/g, '')}.png`,
1045
+ .replace(/^-|-$/g, '')}`,
812
1046
  );
813
1047
  // A host on another machine writes the file there; `inline` asks it for the picture as well.
814
1048
  // A host that knows what was still animating says so, as frames in points.
815
1049
  let moving: unknown;
816
- const taken = (await offers('screenshot'))
817
- ? await d.command('screenshot', { path: file, inline: true }).then(
818
- (answer) => {
819
- const { png, moving: animating } = (answer ?? {}) as { png?: unknown; moving?: unknown };
820
- if (typeof png === 'string' && !fs.existsSync(file)) fs.writeFileSync(file, Buffer.from(png, 'base64'));
821
- if (Array.isArray(animating)) moving = animating;
822
- return true;
823
- },
824
- () => false,
825
- )
826
- : (await execute(['screenshot', file], base)).code === 0;
827
- if (taken && fs.existsSync(file)) {
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))) {
828
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
+ }
829
1137
  // What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
830
1138
  // a balance) from how it was drawn.
831
1139
  const screen = await d.screen().catch(() => undefined);
832
- if (screen) fs.writeFileSync(file.replace(/\.png$/, '.json'), JSON.stringify({ ...screen, settled, ...(moving ? { moving } : {}) }));
1140
+ if (screen)
1141
+ fs.writeFileSync(
1142
+ file.replace(/\.(png|avif)$/, '.json'),
1143
+ JSON.stringify({ ...screen, settled, ...(still === undefined ? {} : { still }), ...(moving ? { moving } : {}) }),
1144
+ );
833
1145
  }
834
1146
  }
835
1147
  };
@@ -898,7 +1210,7 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
898
1210
  steps.push(done);
899
1211
  log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
900
1212
  if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
901
- await record(done, steps.length, at);
1213
+ await record(done, steps.length, at, t0);
902
1214
  } catch (error) {
903
1215
  const message = error instanceof Error ? error.message : String(error);
904
1216
  const missing = lacking(message);
@@ -914,25 +1226,43 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
914
1226
  const broke: StepReport = { step: text, status: 'failed', ms: Date.now() - t0, error: message };
915
1227
  steps.push(broke);
916
1228
  log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
917
- if (evidence || capturing) await record(broke, steps.length, at);
1229
+ if (evidence || capturing) await record(broke, steps.length, at, t0);
918
1230
  }
919
1231
  }
920
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
+ }
921
1249
  if (tracing && options.traces) {
922
1250
  await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
923
1251
  // A backend exports in batches, a moment after the requests it served.
924
1252
  await options.traces.settle(tracing.traceId);
925
1253
  for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
926
1254
  const s = steps[index];
927
- // A step that set off thousands of queries keeps the first ones; a report is not a profiler.
928
- if (s) s.backend = spans.slice(0, 400);
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);
929
1257
  }
1258
+ fileRequestsByTrace(steps, tracing.steps);
930
1259
  }
931
1260
  if (options.setupOnly && !failed) options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
932
1261
  if (options.dry) failed = steps.some((s) => s.status === 'failed');
933
1262
  const report: CaseReport = {
934
1263
  id: c.id,
935
1264
  title: c.title,
1265
+ ...aboutOf(c),
936
1266
  file: c.file,
937
1267
  result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
938
1268
  ms: Date.now() - started,
@@ -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.
@@ -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 | |