lynkeus 0.2.0 → 0.3.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 (127) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +80 -3
  14. package/lib/cases/runner.js +387 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +27 -6
  20. package/lib/commands/cases.js +360 -37
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/drive.js +32 -8
  24. package/lib/commands/headless.js +12 -2
  25. package/lib/commands/init.d.ts +6 -0
  26. package/lib/commands/init.js +78 -21
  27. package/lib/commands/report.d.ts +97 -0
  28. package/lib/commands/report.js +228 -0
  29. package/lib/config.d.ts +10 -1
  30. package/lib/device/agentVersion.d.ts +7 -0
  31. package/lib/device/agentVersion.js +12 -0
  32. package/lib/device/devices.d.ts +5 -0
  33. package/lib/device/devices.js +27 -0
  34. package/lib/flow/runner.js +33 -1
  35. package/lib/flow/types.d.ts +2 -1
  36. package/lib/knowledge/contrast.d.ts +45 -0
  37. package/lib/knowledge/contrast.js +114 -0
  38. package/lib/knowledge/errors.js +1 -1
  39. package/lib/knowledge/lint.d.ts +2 -0
  40. package/lib/knowledge/lint.js +13 -3
  41. package/lib/registry.d.ts +5 -0
  42. package/lib/targets.d.ts +10 -1
  43. package/lib/targets.js +16 -3
  44. package/package.json +10 -6
  45. package/src/cases/ahead.ts +111 -0
  46. package/src/cases/fixtures.ts +37 -0
  47. package/src/cases/flakes.ts +67 -0
  48. package/src/cases/format.ts +31 -6
  49. package/src/cases/portClaim.ts +52 -0
  50. package/src/cases/runfile.ts +119 -0
  51. package/src/cases/runner.ts +446 -46
  52. package/src/cases/templates.ts +14 -4
  53. package/src/cases/traces.ts +298 -0
  54. package/src/cli.ts +28 -6
  55. package/src/commands/cases.ts +375 -37
  56. package/src/commands/check.ts +11 -4
  57. package/src/commands/drive.ts +31 -8
  58. package/src/commands/headless.ts +12 -2
  59. package/src/commands/init.ts +84 -20
  60. package/src/commands/report.ts +272 -0
  61. package/src/config.ts +6 -2
  62. package/src/device/agentVersion.ts +15 -0
  63. package/src/device/devices.ts +28 -0
  64. package/src/flow/runner.ts +35 -1
  65. package/src/flow/types.ts +2 -2
  66. package/src/knowledge/contrast.ts +135 -0
  67. package/src/knowledge/errors.ts +1 -1
  68. package/src/knowledge/lint.ts +15 -4
  69. package/src/registry.ts +2 -0
  70. package/src/targets.ts +17 -3
  71. package/lib/__tests__/args.test.d.ts +0 -1
  72. package/lib/__tests__/args.test.js +0 -69
  73. package/lib/__tests__/check.test.d.ts +0 -1
  74. package/lib/__tests__/check.test.js +0 -60
  75. package/lib/__tests__/init.test.d.ts +0 -1
  76. package/lib/__tests__/init.test.js +0 -42
  77. package/lib/__tests__/record.test.d.ts +0 -1
  78. package/lib/__tests__/record.test.js +0 -44
  79. package/lib/__tests__/snapshot.test.d.ts +0 -1
  80. package/lib/__tests__/snapshot.test.js +0 -47
  81. package/lib/__tests__/targets.test.d.ts +0 -1
  82. package/lib/__tests__/targets.test.js +0 -127
  83. package/lib/cases/__tests__/format.test.d.ts +0 -1
  84. package/lib/cases/__tests__/format.test.js +0 -61
  85. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  86. package/lib/cases/__tests__/runner.test.js +0 -188
  87. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  88. package/lib/knowledge/__tests__/edges.test.js +0 -25
  89. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  90. package/lib/knowledge/__tests__/events.test.js +0 -39
  91. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  92. package/lib/knowledge/__tests__/extract.test.js +0 -69
  93. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  94. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  95. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  96. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  97. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  98. package/lib/knowledge/__tests__/lint.test.js +0 -53
  99. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  100. package/lib/knowledge/__tests__/perf.test.js +0 -34
  101. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  102. package/lib/knowledge/__tests__/plan.test.js +0 -35
  103. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  104. package/lib/knowledge/__tests__/query.test.js +0 -55
  105. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  106. package/lib/knowledge/__tests__/screens.test.js +0 -30
  107. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  108. package/lib/knowledge/__tests__/why.test.js +0 -28
  109. package/src/__tests__/args.test.ts +0 -82
  110. package/src/__tests__/check.test.ts +0 -71
  111. package/src/__tests__/init.test.ts +0 -52
  112. package/src/__tests__/record.test.ts +0 -49
  113. package/src/__tests__/snapshot.test.ts +0 -51
  114. package/src/__tests__/targets.test.ts +0 -132
  115. package/src/cases/__tests__/format.test.ts +0 -76
  116. package/src/cases/__tests__/runner.test.ts +0 -230
  117. package/src/knowledge/__tests__/edges.test.ts +0 -34
  118. package/src/knowledge/__tests__/events.test.ts +0 -45
  119. package/src/knowledge/__tests__/extract.test.ts +0 -88
  120. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  121. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  122. package/src/knowledge/__tests__/lint.test.ts +0 -66
  123. package/src/knowledge/__tests__/perf.test.ts +0 -41
  124. package/src/knowledge/__tests__/plan.test.ts +0 -52
  125. package/src/knowledge/__tests__/query.test.ts +0 -74
  126. package/src/knowledge/__tests__/screens.test.ts +0 -37
  127. package/src/knowledge/__tests__/why.test.ts +0 -40
@@ -5,6 +5,7 @@
5
5
  * logs in or what a user is belongs to the project, as macros and fixtures.
6
6
  */
7
7
  import { exec } from 'node:child_process';
8
+ import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { promisify } from 'node:util';
10
11
  import { describeScreen } from '../device/driver.js';
@@ -12,7 +13,11 @@ import { normalizeEvents } from '../knowledge/events.js';
12
13
  import { find } from '../registry.js';
13
14
  import { execute } from '../runtime.js';
14
15
  import { isRoute, parseTarget, scopedTarget } from '../targets.js';
15
- import { dig, interpolate, matcherOf, matches, stepOf } from './format.js';
16
+ import { newSpanId, newTraceId, traceparent } from './traces.js';
17
+ import { dig, interpolate, matcherOf, matches, seeded, stepOf } from './format.js';
18
+ /** A setup made only of the project's fixtures can run before the case, with no app attached. */
19
+ export const preparable = (c, config) => c.setup.length > 0 &&
20
+ c.setup.every((step) => !step.on && !step.verb.startsWith('app.') && !config.macros?.[step.verb] && step.verb !== 'exec' && step.verb !== 'assert');
16
21
  /** What a buffer holds that it did not before, whichever end it grows from. */
17
22
  export const newSince = (before, now) => {
18
23
  if (now.length < before.length)
@@ -69,6 +74,11 @@ export const argvFor = (command, value) => {
69
74
  return [...argv, ...(Array.isArray(value) ? value.map(String) : [String(value)])];
70
75
  const given = { ...value };
71
76
  const positionals = command.positionals ?? [];
77
+ // A case names a control by `testId` wherever a command takes a target (`wait` calls its own `what`).
78
+ if ('testId' in given && !('target' in given) && !('testId' in (command.flags ?? {})) && positionals.some((p) => p.name === 'target' || p.name === 'what')) {
79
+ given.target = `#${String(given.testId)}`;
80
+ delete given.testId;
81
+ }
72
82
  // A case says `target` or `request` where a command may call its first argument something else.
73
83
  const first = positionals[0]?.name;
74
84
  for (const alias of ['target', 'request', 'route', 'url', 'state', 'text']) {
@@ -107,7 +117,7 @@ export const argvFor = (command, value) => {
107
117
  const presented = (screen) => screen.elements.filter((e) => !e.hidden || e.hidden === 'inert');
108
118
  const textOf = (e) => e.text ?? e.value ?? e.accessibilityLabel ?? '';
109
119
  const requestMatches = (event, wanted, status) => {
110
- if (event.kind !== 'request')
120
+ if (event.status === undefined)
111
121
  return false;
112
122
  const [method, route] = wanted.includes(' ') ? wanted.split(/\s+/, 2) : ['*', wanted];
113
123
  let pathname = event.url;
@@ -123,6 +133,8 @@ const requestMatches = (event, wanted, status) => {
123
133
  return false;
124
134
  return status === undefined ? true : event.status === status;
125
135
  };
136
+ // Steps that look at the app rather than act on it: a request they wait for was caused by the action before them.
137
+ const LOOKING = new Set(['see', 'wait', 'event', 'requests', 'events']);
126
138
  const BUILT_IN = ['press', 'type', 'swipe', 'see', 'requests', 'wait', 'nav', 'call', 'run', 'events', 'event'];
127
139
  const unknownStep = (step, config) => {
128
140
  if (config.macros?.[step.verb] || !step.verb.startsWith('app.'))
@@ -141,15 +153,35 @@ const unknownStep = (step, config) => {
141
153
  }
142
154
  return undefined;
143
155
  };
156
+ /**
157
+ * The fixtures a case names that the project's server did not list. A macro's
158
+ * own steps are the project's to keep right; these are the ones written in the case.
159
+ */
160
+ export const missingFixtures = (c, config, known) => {
161
+ const names = [...c.setup, ...c.steps].flatMap((step) => {
162
+ if (step.verb.startsWith('app.') || config.macros?.[step.verb] || step.verb === 'exec')
163
+ return [];
164
+ return [step.verb === 'assert' ? (config.inspect ?? 'inspect') : step.verb];
165
+ });
166
+ return [...new Set(names)].filter((name) => !known.includes(name));
167
+ };
168
+ /** What the app or its host answers to a method it does not have; the name is what it lacks. */
169
+ export const lacking = (message) => /Unknown method (\S+)/.exec(message)?.[1];
144
170
  export const runCase = async (c, options) => {
145
171
  const { base, config } = options;
146
172
  const log = options.log ?? (() => undefined);
147
- const scope = {};
148
- const ran = new Set();
149
- const latest = {};
173
+ const scope = structuredClone(options.prepared?.scope ?? {});
174
+ seeded(scope, c.id);
175
+ const ran = new Set(options.prepared?.ran ?? []);
176
+ const latest = structuredClone(options.prepared?.latest ?? {});
150
177
  const started = Date.now();
151
178
  const steps = [];
152
179
  let mark;
180
+ // The app numbers its requests as they start. A step that looks for one counts only those started after the
181
+ // action before it began: an earlier screen's request to the same path would otherwise pass for the one the
182
+ // action caused. `app.requests` marks by hand, and holds until a step has looked for a request.
183
+ let requestsAfter;
184
+ let markedByHand = false;
153
185
  let why;
154
186
  const recorded = [];
155
187
  let buffer = [];
@@ -167,7 +199,21 @@ export const runCase = async (c, options) => {
167
199
  buffer = now;
168
200
  };
169
201
  const device = () => base.device();
170
- const sinceMark = async () => (await (await device()).trace(mark)).events;
202
+ const lastRequest = async (d) => Math.max(0, ...(await d.requests()).requests.map((r) => r.id));
203
+ const markRequests = async (d, byHand = false) => {
204
+ if (markedByHand && !byHand)
205
+ return;
206
+ requestsAfter = await lastRequest(d);
207
+ markedByHand = byHand;
208
+ };
209
+ const requestsSinceMark = async () => {
210
+ const d = await device();
211
+ const { requests } = await d.requests(requestsAfter);
212
+ // An app started again numbers its requests from 1.
213
+ if (requests.length === 0 && requestsAfter && (await lastRequest(d)) < requestsAfter)
214
+ requestsAfter = 0;
215
+ return requests;
216
+ };
171
217
  const cli = async (argv) => {
172
218
  const outcome = await execute(argv, base);
173
219
  if (outcome.error || outcome.code !== 0) {
@@ -207,6 +253,8 @@ export const runCase = async (c, options) => {
207
253
  if (answer[field] !== undefined)
208
254
  latest[field] = answer[field];
209
255
  };
256
+ // What the backend answered, until a step that can change it.
257
+ const inspected = new Map();
210
258
  const assert = async (value) => {
211
259
  const params = (value ?? {});
212
260
  const matcher = matcherOf(params);
@@ -215,7 +263,15 @@ export const runCase = async (c, options) => {
215
263
  if (!options.fixtures)
216
264
  throw new Error('assert reads the backend through fixtures, and the project declares none');
217
265
  const rest = Object.fromEntries(Object.entries(params).filter(([k]) => k !== 'path' && !(k in matcher)));
218
- const answer = await options.fixtures.call(config.inspect ?? 'inspect', withRefs(rest));
266
+ // `section` is the part of the answer the path reads: a server that works its answer out part by part
267
+ // need only do that part. Asserts in a row on the same section read one answer.
268
+ const ask = withRefs({ ...rest, section: params.path.split('.')[0] });
269
+ const key = JSON.stringify(ask);
270
+ let answer = inspected.get(key);
271
+ if (!answer) {
272
+ answer = await options.fixtures.call(config.inspect ?? 'inspect', ask);
273
+ inspected.set(key, answer);
274
+ }
219
275
  const got = dig(answer, params.path);
220
276
  if (!matches(got, matcher))
221
277
  throw new Error(`${params.path} is ${JSON.stringify(got)}`);
@@ -252,8 +308,9 @@ export const runCase = async (c, options) => {
252
308
  return `not on screen: ${JSON.stringify(text)}`;
253
309
  if (typeof params.request === 'string') {
254
310
  const status = typeof params.status === 'number' ? params.status : undefined;
255
- if (!(await sinceMark()).some((e) => requestMatches(e, params.request, status)))
256
- return `not requested since the mark: ${params.request}`;
311
+ if (!(await requestsSinceMark()).some((e) => requestMatches(e, params.request, status)))
312
+ return `not requested since ${markedByHand ? 'the mark' : 'the step before began'}: ${params.request}`;
313
+ markedByHand = false;
257
314
  }
258
315
  if (typeof params.event === 'string') {
259
316
  const wanted = (params.props ?? params.properties ?? {});
@@ -264,17 +321,68 @@ export const runCase = async (c, options) => {
264
321
  }
265
322
  return undefined;
266
323
  };
324
+ /**
325
+ * A device, and a host that draws like one, lists what is in the window: what
326
+ * a step looks for may be further down the page. This scrolls the window
327
+ * itself, most of a screenful at a time, until `there` holds or the screen
328
+ * stops changing; then back up in short pulls (at the top of a sheet a short
329
+ * pull springs back, where a long one would drag the sheet shut).
330
+ */
331
+ const scrollTo = async (there, upward = false) => {
332
+ const d = await device();
333
+ if (!d.server.hello?.native)
334
+ return false;
335
+ const { w, h } = (await d.screen()).window;
336
+ const x = Math.round(w / 2);
337
+ const listing = async () => JSON.stringify(presented(await d.screen()).map((e) => [e.testId, textOf(e), Math.round(e.frame.y)]));
338
+ const pull = async (from, to, times) => {
339
+ let before;
340
+ for (let i = 0; i < times; i++) {
341
+ if (await there())
342
+ return true;
343
+ const now = await listing();
344
+ if (now === before)
345
+ return false;
346
+ before = now;
347
+ await execute(['swipe', '--from', `${x},${from}`, '--to', `${x},${to}`], base);
348
+ await d.idle({ quietMs: 150, timeoutMs: 1500 }).catch(() => undefined);
349
+ }
350
+ return there();
351
+ };
352
+ if (!upward && (await pull(Math.round(h * 0.75), Math.round(h * 0.3), 12)))
353
+ return true;
354
+ const middle = Math.round(h * 0.5);
355
+ return pull(middle, middle + 110, 30);
356
+ };
267
357
  const see = async (value) => {
268
358
  const params = typeof value === 'string' ? { target: value } : (value ?? {});
269
359
  const absent = params.absent === true;
270
360
  const wait = typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000;
271
361
  const deadline = Date.now() + wait;
272
362
  const hard = Date.now() + Math.max(wait, 12_000);
363
+ // Where the listing is what the window shows, something missing gets three more looks before the
364
+ // deadline counts: a short wait (it may be arriving), a scroll (it may be further down), and a long
365
+ // wait (a carousel shows it a few seconds from now, and a host with its own clock gets there fast).
366
+ const what = targetIn(params) ?? (typeof params.text === 'string' ? { text: params.text } : undefined);
367
+ let looks = absent || !what || !(await device()).server.hello?.native ? 3 : 0;
273
368
  while (true) {
274
369
  await interrupted(value);
275
370
  const missing = await seen(params);
276
371
  if (absent ? missing !== undefined : missing === undefined)
277
372
  return;
373
+ if (looks < 3 && what && missing?.startsWith('not on screen')) {
374
+ const d = await device();
375
+ looks += 1;
376
+ if (looks === 1)
377
+ await d.waitFor({ target: what, timeoutMs: 1500 }).catch(() => undefined);
378
+ else if (looks === 2) {
379
+ if (await scrollTo(async () => (await seen(params)) === undefined))
380
+ return;
381
+ }
382
+ else
383
+ await d.waitFor({ target: what, timeoutMs: 20_000 }).catch(() => undefined);
384
+ continue;
385
+ }
278
386
  if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
279
387
  await sleep(150);
280
388
  continue;
@@ -289,36 +397,75 @@ export const runCase = async (c, options) => {
289
397
  const deadline = Date.now() + timeoutMs;
290
398
  const status = typeof params.status === 'number' ? params.status : undefined;
291
399
  while (true) {
292
- const events = await sinceMark();
293
- const hit = events.some((e) => requestMatches(e, String(params.request), status) && (status !== undefined || (e.kind === 'request' && String(e.status).startsWith('2'))));
294
- if (hit)
400
+ const events = await requestsSinceMark();
401
+ const hit = events.some((e) => requestMatches(e, String(params.request), status) && (status !== undefined || String(e.status).startsWith('2')));
402
+ if (hit) {
403
+ markedByHand = false;
295
404
  return;
405
+ }
296
406
  if (Date.now() > deadline)
297
407
  throw new Error(`no ${status ?? '2xx'} ${String(params.request)} within ${timeoutMs} ms`);
298
408
  await sleep(300);
299
409
  }
300
410
  };
301
411
  const press = async (value) => {
302
- const params = typeof value === 'string' ? { target: value } : (value ?? {});
412
+ const given = typeof value === 'string' ? { target: value } : (value ?? {});
413
+ const { text, ...named } = given;
414
+ const params = 'target' in given || 'testId' in given || typeof text !== 'string' ? given : { ...named, target: text };
303
415
  const target = targetIn(params);
304
416
  if (!target)
305
417
  throw new Error('app.press needs a target');
306
418
  const d = await device();
307
419
  if (params.optional === true) {
420
+ // Decided once the app has gone quiet: what it presses (a one-time question, a promo) arrives a moment
421
+ // after the screen before it, and a fast host gets here first.
422
+ await d.idle({ quietMs: 150, timeoutMs: 1000 }).catch(() => undefined);
308
423
  const there = await d.waitFor({ target, timeoutMs: 300 }).then(() => true, () => false);
309
424
  if (!there)
310
425
  return 'skipped';
311
426
  }
312
- // The control may still be arriving, or disabled while what it depends on loads.
427
+ // Not listed at all: arriving, or further down a page that only lists its window. A short wait, then
428
+ // a scroll, rather than spending the whole deadline on something that is below the fold.
429
+ const listed = async () => !!(await d.server.call('find', target));
430
+ if (!(await listed())) {
431
+ await d.waitFor({ target, timeoutMs: 500 }).catch(() => undefined);
432
+ if (!(await listed()))
433
+ await scrollTo(listed);
434
+ }
435
+ // Listed, but scrolled out of the window, cut by its edge or under the header or tab bar (a page left
436
+ // further down than the control): towards it, until it is clear.
437
+ const outside = async () => {
438
+ const found = (await d.server.call('find', target));
439
+ if (!found)
440
+ return undefined;
441
+ const { h } = (await d.screen()).window;
442
+ // Covered near an edge is the screen's own header or tab bar over a scrolled page, not an overlay.
443
+ const top = found.frame.y, bottom = found.frame.y + found.frame.h;
444
+ if (top < 0 || (found.covered && top < h * 0.2))
445
+ return 'above';
446
+ if (bottom > h || (found.covered && bottom > h * 0.8))
447
+ return 'below';
448
+ return undefined;
449
+ };
450
+ const side = await outside();
451
+ if (side)
452
+ await scrollTo(async () => (await outside()) === undefined, side === 'above');
453
+ // The control may still be disabled while what it depends on loads. The waits go through the app,
454
+ // so a host with its own clock moves it on instead of standing still while this one sleeps.
313
455
  const deadline = Date.now() + (typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000);
314
456
  const hard = Date.now() + 15_000;
457
+ let was;
315
458
  while (Date.now() < hard && 'testId' in target) {
316
459
  const found = (await d.server.call('find', target));
317
- if (found?.enabled)
460
+ // Covered or still moving is often for a moment (a screen or button sliding in, a sheet still
461
+ // closing), and a press then lands on whatever covers it or where the control no longer is.
462
+ const at = found ? JSON.stringify(found.frame) : undefined;
463
+ if (found?.enabled && !found.covered && at === was)
318
464
  break;
465
+ was = at;
319
466
  if (Date.now() > deadline && !(await d.screen()).busy)
320
467
  break;
321
- await sleep(200);
468
+ await d.idle({ quietMs: 50, timeoutMs: 200 }).catch(() => undefined);
322
469
  }
323
470
  const { optional: _optional, timeoutMs: _timeout, ...rest } = params;
324
471
  await cli(argvFor(find('press'), rest));
@@ -345,14 +492,10 @@ export const runCase = async (c, options) => {
345
492
  await sleep(250);
346
493
  }
347
494
  };
348
- // Nobody acts on a screen that is still asking for what it shows: a press
349
- // that lands before the data does is a different case from the one written.
350
- const ready = async () => {
351
- const d = await device();
352
- const deadline = Date.now() + 5000;
353
- while ((await d.screen()).busy && Date.now() < deadline)
354
- await sleep(100);
355
- };
495
+ // A host that keeps its own clock lets an animation finish before it answers. A phone runs on the
496
+ // wall's and keeps moving after the last render, so only there is a pause worth its time; and a case's
497
+ // `on: device` steps are written for that phone.
498
+ const onWallClock = (d) => !!d.server.hello?.native && !d.server.hello.commands?.includes('clock');
356
499
  let handling = false;
357
500
  const interrupted = async (value) => {
358
501
  if (handling || !config.interruptions?.length)
@@ -385,29 +528,40 @@ export const runCase = async (c, options) => {
385
528
  const params = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
386
529
  const d = await device();
387
530
  mark ??= (await d.trace()).last;
531
+ // An interruption handled inside a wait is not the action the wait is about.
532
+ if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined)
533
+ await markRequests(d);
388
534
  switch (name) {
389
535
  case 'press': {
390
536
  await interrupted(value);
391
- await ready();
392
537
  const outcome = await press(value);
393
538
  // One touch, one render: the next step reads what this one caused.
394
539
  await d.idle({ quietMs: 50, timeoutMs: 400 }).catch(() => undefined);
395
540
  return outcome;
396
541
  }
397
542
  case 'type':
398
- case 'swipe':
543
+ case 'swipe': {
399
544
  await interrupted(value);
400
- await ready();
401
- await cli(argvFor(find(name), value));
545
+ // A field that arrives with its screen (a code input that mounts a moment after the keyboard) takes
546
+ // what is typed only once it is there: under load that moment is longer than the step's own wait.
547
+ const field = name === 'type' && params ? targetIn(params) : undefined;
548
+ if (field) {
549
+ await d.waitFor({ target: field, timeoutMs: typeof params?.timeoutMs === 'number' ? params.timeoutMs : 5000 }).catch(() => undefined);
550
+ await d.idle({ quietMs: 100, timeoutMs: 800 }).catch(() => undefined);
551
+ }
552
+ const { timeoutMs: _timeout, ...typed } = params ?? {};
553
+ await cli(argvFor(find(name), name === 'type' && params ? typed : value));
402
554
  // A pager on a device keeps moving after the finger lifts, and swallows a swipe sent into that momentum.
403
- if (name === 'swipe' && d.server.hello?.native)
555
+ if (name === 'swipe' && onWallClock(d))
404
556
  await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
405
557
  return undefined;
558
+ }
406
559
  case 'see':
407
560
  await see(value);
408
561
  return undefined;
409
562
  case 'requests':
410
563
  mark = (await d.trace()).last;
564
+ await markRequests(d, true);
411
565
  return undefined;
412
566
  case 'wait': {
413
567
  if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
@@ -434,7 +588,7 @@ export const runCase = async (c, options) => {
434
588
  await cli(argvFor(find('wait'), value));
435
589
  // 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.
436
590
  const what = typeof value === 'string' ? value : String(params?.what ?? '');
437
- if (d.server.hello?.native && isRoute(what))
591
+ if (onWallClock(d) && isRoute(what))
438
592
  await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
439
593
  }
440
594
  return undefined;
@@ -481,7 +635,24 @@ export const runCase = async (c, options) => {
481
635
  const json = /\{[\s\S]*\}/.exec(stdout)?.[0];
482
636
  scope[params.as] = json ? JSON.parse(json) : { out: stdout.trim() };
483
637
  };
638
+ // A macro is one step of the case: the requests it waits for are the ones it set off, from its first
639
+ // action on, not only those of its last (`app.refresh` asks for a refresh, then waits for it to settle).
640
+ let macroDepth = 0;
484
641
  const macro = async (steps, value) => {
642
+ if (macroDepth === 0 && !handling) {
643
+ const d = await device().catch(() => undefined);
644
+ if (d)
645
+ await markRequests(d);
646
+ }
647
+ macroDepth++;
648
+ try {
649
+ await runMacro(steps, value);
650
+ }
651
+ finally {
652
+ macroDepth--;
653
+ }
654
+ };
655
+ const runMacro = async (steps, value) => {
485
656
  const given = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
486
657
  // A parameter that names an alias is that alias: `as: u` makes `{{param.as.user_id}}` the user's.
487
658
  const param = Object.fromEntries(Object.entries(given).map(([k, v]) => [k, typeof v === 'string' && v in scope ? scope[v] : v]));
@@ -508,19 +679,31 @@ export const runCase = async (c, options) => {
508
679
  scope.param = outer;
509
680
  }
510
681
  };
682
+ /**
683
+ * `device`, `headless`, a platform, or `has:<command>` for a host whose app
684
+ * registered that command (a hosted build often has commands a store build
685
+ * does not); `!` in front turns any of them around. One case then carries the
686
+ * way each host does a thing, and runs on all of them.
687
+ */
511
688
  const runsHere = async (on) => {
689
+ if (on.startsWith('!'))
690
+ return !(await runsHere(on.slice(1)));
512
691
  const hello = (await device()).server.hello;
513
- const native = !!hello?.native;
692
+ const phone = onWallClock(await device());
514
693
  if (on === 'device')
515
- return native;
694
+ return phone;
516
695
  if (on === 'headless')
517
- return !native;
696
+ return !phone;
697
+ if (on.startsWith('has:'))
698
+ return !!hello?.commands?.includes(on.slice(4));
518
699
  return hello?.platform === on;
519
700
  };
520
701
  const perform = async (step) => {
521
702
  if (step.on && !(await runsHere(step.on)))
522
703
  return 'skipped';
523
704
  ran.add(step.verb);
705
+ if (!['assert', 'app.see', 'app.wait'].includes(step.verb))
706
+ inspected.clear();
524
707
  const value = interpolate(step.value, scope);
525
708
  const custom = config.macros?.[step.verb];
526
709
  if (custom) {
@@ -540,10 +723,100 @@ export const runCase = async (c, options) => {
540
723
  if (d?.server.connected)
541
724
  buffer = normalizeEvents(await d.command('events').catch(() => []));
542
725
  }
726
+ const evidence = options.dry ? undefined : options.evidence;
727
+ const offers = async (command) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
728
+ 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'));
731
+ if (evidence)
732
+ fs.mkdirSync(evidence.dir, { recursive: true });
733
+ let filming = false;
734
+ if (film && evidence?.video) {
735
+ const d = await device();
736
+ filming = await d.command('record', { path: film, fps: evidence.video.fps }).then(() => true, () => false);
737
+ }
738
+ // What the app sent before the case began is not the case's.
739
+ if (capturing)
740
+ await (await device()).command('analytics', { take: true }).catch(() => undefined);
741
+ const frameAt = async () => {
742
+ if (!filming || !evidence?.video)
743
+ return undefined;
744
+ const answer = (await (await device()).command('record', { frames: true }).catch(() => undefined));
745
+ return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
746
+ };
747
+ // 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) => {
749
+ if (startedAt !== undefined)
750
+ report.videoS = startedAt;
751
+ const d = await device().catch(() => undefined);
752
+ if (!d?.server.connected)
753
+ return;
754
+ if (capturing) {
755
+ 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 } : {}) }] : []);
757
+ if (sent.length > 0)
758
+ report.sent = sent;
759
+ }
760
+ if (evidence?.screenshots) {
761
+ // A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
762
+ // 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
767
+ .toLowerCase()
768
+ .replace(/[^a-z0-9]+/g, '-')
769
+ .slice(0, 48)
770
+ .replace(/^-|-$/g, '')}.png`);
771
+ // A host on another machine writes the file there; `inline` asks it for the picture as well.
772
+ // A host that knows what was still animating says so, as frames in points.
773
+ 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)) {
785
+ report.screenshot = file;
786
+ // What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
787
+ // a balance) from how it was drawn.
788
+ const screen = await d.screen().catch(() => undefined);
789
+ if (screen)
790
+ fs.writeFileSync(file.replace(/\.png$/, '.json'), JSON.stringify({ ...screen, settled, ...(moving ? { moving } : {}) }));
791
+ }
792
+ }
793
+ };
543
794
  let failed = false;
544
- for (const step of [...c.setup, ...c.steps]) {
795
+ // A setup that failed leaves the steps nothing to stand on, with or without `continue`.
796
+ let setupFailed = false;
797
+ let unsupported = false;
798
+ let routesFrom;
799
+ // The screen a reused app was left on: a reset reports it once more before the case's own screens.
800
+ let routesStale;
801
+ if (options.prepared) {
802
+ steps.push(...options.prepared.steps);
803
+ log(` ⚡ setup made ahead (${options.prepared.steps.length} steps)`);
804
+ }
805
+ // What native SDKs answer in this case starts as the config says, whatever the last case on this app set.
806
+ if (config.nativeMocks && !options.setupOnly && !options.dry) {
807
+ const d = await device().catch(() => undefined);
808
+ await d?.command('nativeMocks', { replace: config.nativeMocks }).catch(() => undefined);
809
+ }
810
+ // Each app step gets a span id the host puts in the `traceparent` of every request the app sends while it
811
+ // runs; a backend that exports traces to the run files what it did under that step.
812
+ const tracing = options.traces && !options.dry && !options.setupOnly && (await offers('traceContext'))
813
+ ? { traceId: newTraceId(), steps: new Map() }
814
+ : undefined;
815
+ const todo = options.setupOnly ? c.setup : options.prepared ? c.steps : [...c.setup, ...c.steps];
816
+ const setupLength = todo === c.steps ? 0 : c.setup.length;
817
+ for (const [index, step] of todo.entries()) {
545
818
  const text = label(step);
546
- if (failed) {
819
+ if (unsupported || setupFailed || (failed && !options.continue)) {
547
820
  steps.push({ step: text, status: 'skipped', ms: 0 });
548
821
  continue;
549
822
  }
@@ -555,27 +828,95 @@ export const runCase = async (c, options) => {
555
828
  continue;
556
829
  }
557
830
  const t0 = Date.now();
831
+ // Where the app's trace stood before the case first touched it: the screens after it are the case's.
832
+ if (options.routes && routesFrom === undefined && (step.verb.startsWith('app.') || config.macros?.[step.verb])) {
833
+ const d = await device().catch(() => undefined);
834
+ routesFrom = await d?.trace().then((t) => t.last, () => undefined);
835
+ // An app started for this case begins where it begins; one reused from a case before was left elsewhere.
836
+ if (options.routes !== 'fresh')
837
+ routesStale = await d?.screen().then((sc) => sc.route, () => undefined);
838
+ }
839
+ const at = step.verb.startsWith('app.') || config.macros?.[step.verb] ? await frameAt() : undefined;
840
+ if (tracing && (step.verb.startsWith('app.') || config.macros?.[step.verb])) {
841
+ const spanId = newSpanId();
842
+ tracing.steps.set(spanId, { index: steps.length, startedMs: t0 });
843
+ await (await device()).command('traceContext', { traceparent: traceparent(tracing.traceId, spanId) }).catch(() => undefined);
844
+ }
558
845
  try {
559
846
  const outcome = await perform(step);
560
847
  if (step.verb.startsWith('app.') || config.macros?.[step.verb])
561
848
  await collect(steps.length + 1);
562
- steps.push({ step: text, status: outcome === 'skipped' ? 'skipped' : 'passed', ms: Date.now() - t0 });
849
+ const done = { step: text, status: outcome === 'skipped' ? 'skipped' : 'passed', ms: Date.now() - t0 };
850
+ steps.push(done);
563
851
  log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
852
+ if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
853
+ await record(done, steps.length, at);
564
854
  }
565
855
  catch (error) {
566
- failed = true;
567
856
  const message = error instanceof Error ? error.message : String(error);
568
- steps.push({ step: text, status: 'failed', ms: Date.now() - t0, error: message });
857
+ const missing = lacking(message);
858
+ if (missing) {
859
+ // Not the app's doing: the rest of the case would be standing on a step that never happened.
860
+ unsupported = true;
861
+ steps.push({ step: text, status: 'unsupported', ms: Date.now() - t0, error: `this host has no ${missing}` });
862
+ log(` 🚫 ${text} — this host has no ${missing}`);
863
+ continue;
864
+ }
865
+ failed = true;
866
+ if (index < setupLength)
867
+ setupFailed = true;
868
+ const broke = { step: text, status: 'failed', ms: Date.now() - t0, error: message };
869
+ steps.push(broke);
569
870
  log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
871
+ if (evidence || capturing)
872
+ await record(broke, steps.length, at);
873
+ }
874
+ }
875
+ if (tracing && options.traces) {
876
+ await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
877
+ // A backend exports in batches, a moment after the requests it served.
878
+ await options.traces.settle(tracing.traceId);
879
+ for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
880
+ const s = steps[index];
881
+ // A step that set off thousands of queries keeps the first ones; a report is not a profiler.
882
+ if (s)
883
+ s.backend = spans.slice(0, 400);
570
884
  }
571
885
  }
886
+ if (options.setupOnly && !failed)
887
+ options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
572
888
  if (options.dry)
573
889
  failed = steps.some((s) => s.status === 'failed');
574
- const report = { id: c.id, title: c.title, file: c.file, result: failed ? 'failed' : 'passed', ms: Date.now() - started, steps };
890
+ const report = {
891
+ id: c.id,
892
+ title: c.title,
893
+ file: c.file,
894
+ result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
895
+ ms: Date.now() - started,
896
+ steps,
897
+ };
575
898
  if (options.events)
576
899
  report.events = recorded;
577
- if (failed && !options.dry) {
900
+ if (routesFrom !== undefined) {
901
+ const events = await device()
902
+ .then((d) => d.trace(routesFrom))
903
+ .then((t) => t.events)
904
+ .catch(() => []);
905
+ const walked = events.flatMap((e) => (e.kind === 'route' && e.route ? [e.route] : []));
906
+ while (walked.length > 0 && walked[0] === routesStale)
907
+ walked.shift();
908
+ report.routes = [...new Set(walked)].sort();
909
+ }
910
+ if (filming) {
911
+ await (await device()).command('record', { stop: true }).catch(() => undefined);
912
+ if (film && fs.existsSync(film))
913
+ report.video = film;
914
+ }
915
+ if (failed && !options.dry && !options.setupOnly) {
578
916
  const d = await device().catch(() => undefined);
917
+ // A step that failed on its own reading of the screen left no reasons; ask for them.
918
+ if (!why && d?.server.connected)
919
+ why = (await execute(['why'], base).catch(() => undefined))?.text || undefined;
579
920
  if (d?.server.connected) {
580
921
  const screen = await d.screen().catch(() => undefined);
581
922
  const events = await d.trace(mark).catch(() => undefined);
@@ -585,6 +926,12 @@ export const runCase = async (c, options) => {
585
926
  why,
586
927
  };
587
928
  }
929
+ const reasons = (why ?? '')
930
+ .split('\n')
931
+ .map((l) => l.replace(/^\s*•\s*/, '').trim())
932
+ .filter((l) => l.length > 0);
933
+ if (reasons.length > 0)
934
+ report.diagnosis = reasons;
588
935
  }
589
936
  return report;
590
937
  };