lynkeus 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cases/ahead.d.ts +31 -0
- package/lib/cases/ahead.js +90 -0
- package/lib/cases/fixtures.d.ts +12 -0
- package/lib/cases/fixtures.js +32 -0
- package/lib/cases/flakes.d.ts +28 -0
- package/lib/cases/flakes.js +61 -0
- package/lib/cases/format.d.ts +5 -0
- package/lib/cases/format.js +30 -4
- package/lib/cases/portClaim.d.ts +10 -0
- package/lib/cases/portClaim.js +55 -0
- package/lib/cases/runfile.d.ts +24 -0
- package/lib/cases/runfile.js +108 -0
- package/lib/cases/runner.d.ts +82 -3
- package/lib/cases/runner.js +394 -40
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +14 -4
- package/lib/cases/traces.d.ts +58 -0
- package/lib/cases/traces.js +250 -0
- package/lib/cli.js +28 -7
- package/lib/commands/cases.js +411 -42
- package/lib/commands/check.d.ts +6 -0
- package/lib/commands/check.js +8 -3
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +32 -8
- package/lib/commands/headless.js +132 -8
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +295 -33
- package/lib/commands/report.d.ts +97 -0
- package/lib/commands/report.js +228 -0
- package/lib/config.d.ts +26 -1
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +12 -0
- package/lib/device/devices.d.ts +5 -0
- package/lib/device/devices.js +27 -0
- package/lib/device/driver.js +2 -2
- package/lib/flow/runner.js +33 -1
- package/lib/flow/types.d.ts +2 -1
- package/lib/knowledge/contrast.d.ts +45 -0
- package/lib/knowledge/contrast.js +114 -0
- package/lib/knowledge/errors.js +1 -1
- package/lib/knowledge/lint.d.ts +2 -0
- package/lib/knowledge/lint.js +13 -3
- package/lib/mcp/server.js +31 -2
- package/lib/registry.d.ts +5 -0
- package/lib/targets.d.ts +10 -1
- package/lib/targets.js +16 -3
- package/package.json +10 -6
- package/src/cases/ahead.ts +111 -0
- package/src/cases/fixtures.ts +37 -0
- package/src/cases/flakes.ts +67 -0
- package/src/cases/format.ts +31 -6
- package/src/cases/portClaim.ts +52 -0
- package/src/cases/runfile.ts +119 -0
- package/src/cases/runner.ts +454 -46
- package/src/cases/templates.ts +14 -4
- package/src/cases/traces.ts +298 -0
- package/src/cli.ts +29 -7
- package/src/commands/cases.ts +428 -41
- package/src/commands/check.ts +11 -4
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +31 -8
- package/src/commands/headless.ts +143 -8
- package/src/commands/init.ts +338 -30
- package/src/commands/report.ts +272 -0
- package/src/config.ts +39 -2
- package/src/device/agentVersion.ts +15 -0
- package/src/device/devices.ts +28 -0
- package/src/device/driver.ts +2 -2
- package/src/flow/runner.ts +35 -1
- package/src/flow/types.ts +2 -2
- package/src/knowledge/contrast.ts +135 -0
- package/src/knowledge/errors.ts +1 -1
- package/src/knowledge/lint.ts +15 -4
- package/src/mcp/server.ts +31 -2
- package/src/registry.ts +2 -0
- package/src/targets.ts +17 -3
- package/lib/__tests__/args.test.d.ts +0 -1
- package/lib/__tests__/args.test.js +0 -69
- package/lib/__tests__/check.test.d.ts +0 -1
- package/lib/__tests__/check.test.js +0 -60
- package/lib/__tests__/init.test.d.ts +0 -1
- package/lib/__tests__/init.test.js +0 -42
- package/lib/__tests__/record.test.d.ts +0 -1
- package/lib/__tests__/record.test.js +0 -44
- package/lib/__tests__/snapshot.test.d.ts +0 -1
- package/lib/__tests__/snapshot.test.js +0 -47
- package/lib/__tests__/targets.test.d.ts +0 -1
- package/lib/__tests__/targets.test.js +0 -127
- package/lib/cases/__tests__/format.test.d.ts +0 -1
- package/lib/cases/__tests__/format.test.js +0 -61
- package/lib/cases/__tests__/runner.test.d.ts +0 -1
- package/lib/cases/__tests__/runner.test.js +0 -188
- package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
- package/lib/knowledge/__tests__/edges.test.js +0 -25
- package/lib/knowledge/__tests__/events.test.d.ts +0 -1
- package/lib/knowledge/__tests__/events.test.js +0 -39
- package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
- package/lib/knowledge/__tests__/extract.test.js +0 -69
- package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
- package/lib/knowledge/__tests__/frontier.test.js +0 -57
- package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
- package/lib/knowledge/__tests__/funnels.test.js +0 -40
- package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
- package/lib/knowledge/__tests__/lint.test.js +0 -53
- package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
- package/lib/knowledge/__tests__/perf.test.js +0 -34
- package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
- package/lib/knowledge/__tests__/plan.test.js +0 -35
- package/lib/knowledge/__tests__/query.test.d.ts +0 -1
- package/lib/knowledge/__tests__/query.test.js +0 -55
- package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
- package/lib/knowledge/__tests__/screens.test.js +0 -30
- package/lib/knowledge/__tests__/why.test.d.ts +0 -1
- package/lib/knowledge/__tests__/why.test.js +0 -28
- package/src/__tests__/args.test.ts +0 -82
- package/src/__tests__/check.test.ts +0 -71
- package/src/__tests__/init.test.ts +0 -52
- package/src/__tests__/record.test.ts +0 -49
- package/src/__tests__/snapshot.test.ts +0 -51
- package/src/__tests__/targets.test.ts +0 -132
- package/src/cases/__tests__/format.test.ts +0 -76
- package/src/cases/__tests__/runner.test.ts +0 -230
- package/src/knowledge/__tests__/edges.test.ts +0 -34
- package/src/knowledge/__tests__/events.test.ts +0 -45
- package/src/knowledge/__tests__/extract.test.ts +0 -88
- package/src/knowledge/__tests__/frontier.test.ts +0 -77
- package/src/knowledge/__tests__/funnels.test.ts +0 -58
- package/src/knowledge/__tests__/lint.test.ts +0 -66
- package/src/knowledge/__tests__/perf.test.ts +0 -41
- package/src/knowledge/__tests__/plan.test.ts +0 -52
- package/src/knowledge/__tests__/query.test.ts +0 -74
- package/src/knowledge/__tests__/screens.test.ts +0 -37
- package/src/knowledge/__tests__/why.test.ts +0 -40
package/lib/cases/runner.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
149
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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,72 @@ 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
|
+
// A step that names its own timeout gets no more than what is left of it; one that does not may
|
|
384
|
+
// wait for a carousel to come round.
|
|
385
|
+
const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
|
|
386
|
+
await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
|
|
387
|
+
}
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
278
390
|
if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
|
|
279
391
|
await sleep(150);
|
|
280
392
|
continue;
|
|
@@ -289,36 +401,75 @@ export const runCase = async (c, options) => {
|
|
|
289
401
|
const deadline = Date.now() + timeoutMs;
|
|
290
402
|
const status = typeof params.status === 'number' ? params.status : undefined;
|
|
291
403
|
while (true) {
|
|
292
|
-
const events = await
|
|
293
|
-
const hit = events.some((e) => requestMatches(e, String(params.request), status) && (status !== undefined ||
|
|
294
|
-
if (hit)
|
|
404
|
+
const events = await requestsSinceMark();
|
|
405
|
+
const hit = events.some((e) => requestMatches(e, String(params.request), status) && (status !== undefined || String(e.status).startsWith('2')));
|
|
406
|
+
if (hit) {
|
|
407
|
+
markedByHand = false;
|
|
295
408
|
return;
|
|
409
|
+
}
|
|
296
410
|
if (Date.now() > deadline)
|
|
297
411
|
throw new Error(`no ${status ?? '2xx'} ${String(params.request)} within ${timeoutMs} ms`);
|
|
298
412
|
await sleep(300);
|
|
299
413
|
}
|
|
300
414
|
};
|
|
301
415
|
const press = async (value) => {
|
|
302
|
-
const
|
|
416
|
+
const given = typeof value === 'string' ? { target: value } : (value ?? {});
|
|
417
|
+
const { text, ...named } = given;
|
|
418
|
+
const params = 'target' in given || 'testId' in given || typeof text !== 'string' ? given : { ...named, target: text };
|
|
303
419
|
const target = targetIn(params);
|
|
304
420
|
if (!target)
|
|
305
421
|
throw new Error('app.press needs a target');
|
|
306
422
|
const d = await device();
|
|
307
423
|
if (params.optional === true) {
|
|
424
|
+
// Decided once the app has gone quiet: what it presses (a one-time question, a promo) arrives a moment
|
|
425
|
+
// after the screen before it, and a fast host gets here first.
|
|
426
|
+
await d.idle({ quietMs: 150, timeoutMs: 1000 }).catch(() => undefined);
|
|
308
427
|
const there = await d.waitFor({ target, timeoutMs: 300 }).then(() => true, () => false);
|
|
309
428
|
if (!there)
|
|
310
429
|
return 'skipped';
|
|
311
430
|
}
|
|
312
|
-
//
|
|
431
|
+
// Not listed at all: arriving, or further down a page that only lists its window. A short wait, then
|
|
432
|
+
// a scroll, rather than spending the whole deadline on something that is below the fold.
|
|
433
|
+
const listed = async () => !!(await d.server.call('find', target));
|
|
434
|
+
if (!(await listed())) {
|
|
435
|
+
await d.waitFor({ target, timeoutMs: 500 }).catch(() => undefined);
|
|
436
|
+
if (!(await listed()))
|
|
437
|
+
await scrollTo(listed);
|
|
438
|
+
}
|
|
439
|
+
// Listed, but scrolled out of the window, cut by its edge or under the header or tab bar (a page left
|
|
440
|
+
// further down than the control): towards it, until it is clear.
|
|
441
|
+
const outside = async () => {
|
|
442
|
+
const found = (await d.server.call('find', target));
|
|
443
|
+
if (!found)
|
|
444
|
+
return undefined;
|
|
445
|
+
const { h } = (await d.screen()).window;
|
|
446
|
+
// Covered near an edge is the screen's own header or tab bar over a scrolled page, not an overlay.
|
|
447
|
+
const top = found.frame.y, bottom = found.frame.y + found.frame.h;
|
|
448
|
+
if (top < 0 || (found.covered && top < h * 0.2))
|
|
449
|
+
return 'above';
|
|
450
|
+
if (bottom > h || (found.covered && bottom > h * 0.8))
|
|
451
|
+
return 'below';
|
|
452
|
+
return undefined;
|
|
453
|
+
};
|
|
454
|
+
const side = await outside();
|
|
455
|
+
if (side)
|
|
456
|
+
await scrollTo(async () => (await outside()) === undefined, side === 'above');
|
|
457
|
+
// The control may still be disabled while what it depends on loads. The waits go through the app,
|
|
458
|
+
// so a host with its own clock moves it on instead of standing still while this one sleeps.
|
|
313
459
|
const deadline = Date.now() + (typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000);
|
|
314
460
|
const hard = Date.now() + 15_000;
|
|
461
|
+
let was;
|
|
315
462
|
while (Date.now() < hard && 'testId' in target) {
|
|
316
463
|
const found = (await d.server.call('find', target));
|
|
317
|
-
|
|
464
|
+
// Covered or still moving is often for a moment (a screen or button sliding in, a sheet still
|
|
465
|
+
// closing), and a press then lands on whatever covers it or where the control no longer is.
|
|
466
|
+
const at = found ? JSON.stringify(found.frame) : undefined;
|
|
467
|
+
if (found?.enabled && !found.covered && at === was)
|
|
318
468
|
break;
|
|
469
|
+
was = at;
|
|
319
470
|
if (Date.now() > deadline && !(await d.screen()).busy)
|
|
320
471
|
break;
|
|
321
|
-
await
|
|
472
|
+
await d.idle({ quietMs: 50, timeoutMs: 200 }).catch(() => undefined);
|
|
322
473
|
}
|
|
323
474
|
const { optional: _optional, timeoutMs: _timeout, ...rest } = params;
|
|
324
475
|
await cli(argvFor(find('press'), rest));
|
|
@@ -345,14 +496,10 @@ export const runCase = async (c, options) => {
|
|
|
345
496
|
await sleep(250);
|
|
346
497
|
}
|
|
347
498
|
};
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const deadline = Date.now() + 5000;
|
|
353
|
-
while ((await d.screen()).busy && Date.now() < deadline)
|
|
354
|
-
await sleep(100);
|
|
355
|
-
};
|
|
499
|
+
// A host that keeps its own clock lets an animation finish before it answers. A phone runs on the
|
|
500
|
+
// wall's and keeps moving after the last render, so only there is a pause worth its time; and a case's
|
|
501
|
+
// `on: device` steps are written for that phone.
|
|
502
|
+
const onWallClock = (d) => !!d.server.hello?.native && !d.server.hello.commands?.includes('clock');
|
|
356
503
|
let handling = false;
|
|
357
504
|
const interrupted = async (value) => {
|
|
358
505
|
if (handling || !config.interruptions?.length)
|
|
@@ -385,29 +532,40 @@ export const runCase = async (c, options) => {
|
|
|
385
532
|
const params = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
386
533
|
const d = await device();
|
|
387
534
|
mark ??= (await d.trace()).last;
|
|
535
|
+
// An interruption handled inside a wait is not the action the wait is about.
|
|
536
|
+
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined)
|
|
537
|
+
await markRequests(d);
|
|
388
538
|
switch (name) {
|
|
389
539
|
case 'press': {
|
|
390
540
|
await interrupted(value);
|
|
391
|
-
await ready();
|
|
392
541
|
const outcome = await press(value);
|
|
393
542
|
// One touch, one render: the next step reads what this one caused.
|
|
394
543
|
await d.idle({ quietMs: 50, timeoutMs: 400 }).catch(() => undefined);
|
|
395
544
|
return outcome;
|
|
396
545
|
}
|
|
397
546
|
case 'type':
|
|
398
|
-
case 'swipe':
|
|
547
|
+
case 'swipe': {
|
|
399
548
|
await interrupted(value);
|
|
400
|
-
|
|
401
|
-
|
|
549
|
+
// A field that arrives with its screen (a code input that mounts a moment after the keyboard) takes
|
|
550
|
+
// what is typed only once it is there: under load that moment is longer than the step's own wait.
|
|
551
|
+
const field = name === 'type' && params ? targetIn(params) : undefined;
|
|
552
|
+
if (field) {
|
|
553
|
+
await d.waitFor({ target: field, timeoutMs: typeof params?.timeoutMs === 'number' ? params.timeoutMs : 5000 }).catch(() => undefined);
|
|
554
|
+
await d.idle({ quietMs: 100, timeoutMs: 800 }).catch(() => undefined);
|
|
555
|
+
}
|
|
556
|
+
const { timeoutMs: _timeout, ...typed } = params ?? {};
|
|
557
|
+
await cli(argvFor(find(name), name === 'type' && params ? typed : value));
|
|
402
558
|
// A pager on a device keeps moving after the finger lifts, and swallows a swipe sent into that momentum.
|
|
403
|
-
if (name === 'swipe' && d
|
|
559
|
+
if (name === 'swipe' && onWallClock(d))
|
|
404
560
|
await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
405
561
|
return undefined;
|
|
562
|
+
}
|
|
406
563
|
case 'see':
|
|
407
564
|
await see(value);
|
|
408
565
|
return undefined;
|
|
409
566
|
case 'requests':
|
|
410
567
|
mark = (await d.trace()).last;
|
|
568
|
+
await markRequests(d, true);
|
|
411
569
|
return undefined;
|
|
412
570
|
case 'wait': {
|
|
413
571
|
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
|
@@ -434,7 +592,7 @@ export const runCase = async (c, options) => {
|
|
|
434
592
|
await cli(argvFor(find('wait'), value));
|
|
435
593
|
// 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
594
|
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
437
|
-
if (d
|
|
595
|
+
if (onWallClock(d) && isRoute(what))
|
|
438
596
|
await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
439
597
|
}
|
|
440
598
|
return undefined;
|
|
@@ -481,7 +639,24 @@ export const runCase = async (c, options) => {
|
|
|
481
639
|
const json = /\{[\s\S]*\}/.exec(stdout)?.[0];
|
|
482
640
|
scope[params.as] = json ? JSON.parse(json) : { out: stdout.trim() };
|
|
483
641
|
};
|
|
642
|
+
// A macro is one step of the case: the requests it waits for are the ones it set off, from its first
|
|
643
|
+
// action on, not only those of its last (`app.refresh` asks for a refresh, then waits for it to settle).
|
|
644
|
+
let macroDepth = 0;
|
|
484
645
|
const macro = async (steps, value) => {
|
|
646
|
+
if (macroDepth === 0 && !handling) {
|
|
647
|
+
const d = await device().catch(() => undefined);
|
|
648
|
+
if (d)
|
|
649
|
+
await markRequests(d);
|
|
650
|
+
}
|
|
651
|
+
macroDepth++;
|
|
652
|
+
try {
|
|
653
|
+
await runMacro(steps, value);
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
macroDepth--;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
const runMacro = async (steps, value) => {
|
|
485
660
|
const given = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
|
|
486
661
|
// A parameter that names an alias is that alias: `as: u` makes `{{param.as.user_id}}` the user's.
|
|
487
662
|
const param = Object.fromEntries(Object.entries(given).map(([k, v]) => [k, typeof v === 'string' && v in scope ? scope[v] : v]));
|
|
@@ -508,19 +683,31 @@ export const runCase = async (c, options) => {
|
|
|
508
683
|
scope.param = outer;
|
|
509
684
|
}
|
|
510
685
|
};
|
|
686
|
+
/**
|
|
687
|
+
* `device`, `headless`, a platform, or `has:<command>` for a host whose app
|
|
688
|
+
* registered that command (a hosted build often has commands a store build
|
|
689
|
+
* does not); `!` in front turns any of them around. One case then carries the
|
|
690
|
+
* way each host does a thing, and runs on all of them.
|
|
691
|
+
*/
|
|
511
692
|
const runsHere = async (on) => {
|
|
693
|
+
if (on.startsWith('!'))
|
|
694
|
+
return !(await runsHere(on.slice(1)));
|
|
512
695
|
const hello = (await device()).server.hello;
|
|
513
|
-
const
|
|
696
|
+
const phone = onWallClock(await device());
|
|
514
697
|
if (on === 'device')
|
|
515
|
-
return
|
|
698
|
+
return phone;
|
|
516
699
|
if (on === 'headless')
|
|
517
|
-
return !
|
|
700
|
+
return !phone;
|
|
701
|
+
if (on.startsWith('has:'))
|
|
702
|
+
return !!hello?.commands?.includes(on.slice(4));
|
|
518
703
|
return hello?.platform === on;
|
|
519
704
|
};
|
|
520
705
|
const perform = async (step) => {
|
|
521
706
|
if (step.on && !(await runsHere(step.on)))
|
|
522
707
|
return 'skipped';
|
|
523
708
|
ran.add(step.verb);
|
|
709
|
+
if (!['assert', 'app.see', 'app.wait'].includes(step.verb))
|
|
710
|
+
inspected.clear();
|
|
524
711
|
const value = interpolate(step.value, scope);
|
|
525
712
|
const custom = config.macros?.[step.verb];
|
|
526
713
|
if (custom) {
|
|
@@ -540,10 +727,103 @@ export const runCase = async (c, options) => {
|
|
|
540
727
|
if (d?.server.connected)
|
|
541
728
|
buffer = normalizeEvents(await d.command('events').catch(() => []));
|
|
542
729
|
}
|
|
730
|
+
const evidence = options.dry ? undefined : options.evidence;
|
|
731
|
+
const offers = async (command) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
|
|
732
|
+
const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
|
|
733
|
+
// Asked only when the run keeps events: it is a call per step.
|
|
734
|
+
const capturing = !options.dry && !!options.events && (await offers('analytics'));
|
|
735
|
+
if (evidence)
|
|
736
|
+
fs.mkdirSync(evidence.dir, { recursive: true });
|
|
737
|
+
let filming = false;
|
|
738
|
+
if (film && evidence?.video) {
|
|
739
|
+
const d = await device();
|
|
740
|
+
// A host that films at one size only ignores the scale.
|
|
741
|
+
filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(() => true, () => false);
|
|
742
|
+
}
|
|
743
|
+
// What the app sent before the case began is not the case's.
|
|
744
|
+
if (capturing)
|
|
745
|
+
await (await device()).command('analytics', { take: true }).catch(() => undefined);
|
|
746
|
+
const frameAt = async () => {
|
|
747
|
+
if (!filming || !evidence?.video)
|
|
748
|
+
return undefined;
|
|
749
|
+
const answer = (await (await device()).command('record', { frames: true }).catch(() => undefined));
|
|
750
|
+
return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
|
|
751
|
+
};
|
|
752
|
+
// What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
|
|
753
|
+
const record = async (report, index, startedAt) => {
|
|
754
|
+
if (startedAt !== undefined)
|
|
755
|
+
report.videoS = startedAt;
|
|
756
|
+
const d = await device().catch(() => undefined);
|
|
757
|
+
if (!d?.server.connected)
|
|
758
|
+
return;
|
|
759
|
+
if (capturing) {
|
|
760
|
+
const raw = (await d.command('analytics', { take: true }).catch(() => []));
|
|
761
|
+
const sent = (Array.isArray(raw) ? raw : []).flatMap((e) => e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}) }] : []);
|
|
762
|
+
if (sent.length > 0)
|
|
763
|
+
report.sent = sent;
|
|
764
|
+
}
|
|
765
|
+
if (evidence?.screenshots) {
|
|
766
|
+
// A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
|
|
767
|
+
// step left the app, so two runs' frames of the same step show the same screen.
|
|
768
|
+
// A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
|
|
769
|
+
// moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
|
|
770
|
+
// arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
|
|
771
|
+
const quietMs = evidence.settleMs ?? 150;
|
|
772
|
+
const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then((r) => r.idle, () => false);
|
|
773
|
+
const file = path.join(evidence.dir, `${String(index).padStart(2, '0')}-${report.step
|
|
774
|
+
.toLowerCase()
|
|
775
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
776
|
+
.slice(0, 48)
|
|
777
|
+
.replace(/^-|-$/g, '')}.png`);
|
|
778
|
+
// A host on another machine writes the file there; `inline` asks it for the picture as well.
|
|
779
|
+
// A host that knows what was still animating says so, as frames in points.
|
|
780
|
+
let moving;
|
|
781
|
+
const taken = (await offers('screenshot'))
|
|
782
|
+
? await d.command('screenshot', { path: file, inline: true }).then((answer) => {
|
|
783
|
+
const { png, moving: animating } = (answer ?? {});
|
|
784
|
+
if (typeof png === 'string' && !fs.existsSync(file))
|
|
785
|
+
fs.writeFileSync(file, Buffer.from(png, 'base64'));
|
|
786
|
+
if (Array.isArray(animating))
|
|
787
|
+
moving = animating;
|
|
788
|
+
return true;
|
|
789
|
+
}, () => false)
|
|
790
|
+
: (await execute(['screenshot', file], base)).code === 0;
|
|
791
|
+
if (taken && fs.existsSync(file)) {
|
|
792
|
+
report.screenshot = file;
|
|
793
|
+
// What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
|
|
794
|
+
// a balance) from how it was drawn.
|
|
795
|
+
const screen = await d.screen().catch(() => undefined);
|
|
796
|
+
if (screen)
|
|
797
|
+
fs.writeFileSync(file.replace(/\.png$/, '.json'), JSON.stringify({ ...screen, settled, ...(moving ? { moving } : {}) }));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
};
|
|
543
801
|
let failed = false;
|
|
544
|
-
|
|
802
|
+
// A setup that failed leaves the steps nothing to stand on, with or without `continue`.
|
|
803
|
+
let setupFailed = false;
|
|
804
|
+
let unsupported = false;
|
|
805
|
+
let routesFrom;
|
|
806
|
+
// The screen a reused app was left on: a reset reports it once more before the case's own screens.
|
|
807
|
+
let routesStale;
|
|
808
|
+
if (options.prepared) {
|
|
809
|
+
steps.push(...options.prepared.steps);
|
|
810
|
+
log(` ⚡ setup made ahead (${options.prepared.steps.length} steps)`);
|
|
811
|
+
}
|
|
812
|
+
// What native SDKs answer in this case starts as the config says, whatever the last case on this app set.
|
|
813
|
+
if (config.nativeMocks && !options.setupOnly && !options.dry) {
|
|
814
|
+
const d = await device().catch(() => undefined);
|
|
815
|
+
await d?.command('nativeMocks', { replace: config.nativeMocks }).catch(() => undefined);
|
|
816
|
+
}
|
|
817
|
+
// Each app step gets a span id the host puts in the `traceparent` of every request the app sends while it
|
|
818
|
+
// runs; a backend that exports traces to the run files what it did under that step.
|
|
819
|
+
const tracing = options.traces && !options.dry && !options.setupOnly && (await offers('traceContext'))
|
|
820
|
+
? { traceId: newTraceId(), steps: new Map() }
|
|
821
|
+
: undefined;
|
|
822
|
+
const todo = options.setupOnly ? c.setup : options.prepared ? c.steps : [...c.setup, ...c.steps];
|
|
823
|
+
const setupLength = todo === c.steps ? 0 : c.setup.length;
|
|
824
|
+
for (const [index, step] of todo.entries()) {
|
|
545
825
|
const text = label(step);
|
|
546
|
-
if (failed) {
|
|
826
|
+
if (unsupported || setupFailed || (failed && !options.continue)) {
|
|
547
827
|
steps.push({ step: text, status: 'skipped', ms: 0 });
|
|
548
828
|
continue;
|
|
549
829
|
}
|
|
@@ -555,27 +835,95 @@ export const runCase = async (c, options) => {
|
|
|
555
835
|
continue;
|
|
556
836
|
}
|
|
557
837
|
const t0 = Date.now();
|
|
838
|
+
// Where the app's trace stood before the case first touched it: the screens after it are the case's.
|
|
839
|
+
if (options.routes && routesFrom === undefined && (step.verb.startsWith('app.') || config.macros?.[step.verb])) {
|
|
840
|
+
const d = await device().catch(() => undefined);
|
|
841
|
+
routesFrom = await d?.trace().then((t) => t.last, () => undefined);
|
|
842
|
+
// An app started for this case begins where it begins; one reused from a case before was left elsewhere.
|
|
843
|
+
if (options.routes !== 'fresh')
|
|
844
|
+
routesStale = await d?.screen().then((sc) => sc.route, () => undefined);
|
|
845
|
+
}
|
|
846
|
+
const at = step.verb.startsWith('app.') || config.macros?.[step.verb] ? await frameAt() : undefined;
|
|
847
|
+
if (tracing && (step.verb.startsWith('app.') || config.macros?.[step.verb])) {
|
|
848
|
+
const spanId = newSpanId();
|
|
849
|
+
tracing.steps.set(spanId, { index: steps.length, startedMs: t0 });
|
|
850
|
+
await (await device()).command('traceContext', { traceparent: traceparent(tracing.traceId, spanId) }).catch(() => undefined);
|
|
851
|
+
}
|
|
558
852
|
try {
|
|
559
853
|
const outcome = await perform(step);
|
|
560
854
|
if (step.verb.startsWith('app.') || config.macros?.[step.verb])
|
|
561
855
|
await collect(steps.length + 1);
|
|
562
|
-
|
|
856
|
+
const done = { step: text, status: outcome === 'skipped' ? 'skipped' : 'passed', ms: Date.now() - t0 };
|
|
857
|
+
steps.push(done);
|
|
563
858
|
log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
|
|
859
|
+
if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
|
|
860
|
+
await record(done, steps.length, at);
|
|
564
861
|
}
|
|
565
862
|
catch (error) {
|
|
566
|
-
failed = true;
|
|
567
863
|
const message = error instanceof Error ? error.message : String(error);
|
|
568
|
-
|
|
864
|
+
const missing = lacking(message);
|
|
865
|
+
if (missing) {
|
|
866
|
+
// Not the app's doing: the rest of the case would be standing on a step that never happened.
|
|
867
|
+
unsupported = true;
|
|
868
|
+
steps.push({ step: text, status: 'unsupported', ms: Date.now() - t0, error: `this host has no ${missing}` });
|
|
869
|
+
log(` 🚫 ${text} — this host has no ${missing}`);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
failed = true;
|
|
873
|
+
if (index < setupLength)
|
|
874
|
+
setupFailed = true;
|
|
875
|
+
const broke = { step: text, status: 'failed', ms: Date.now() - t0, error: message };
|
|
876
|
+
steps.push(broke);
|
|
569
877
|
log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
|
|
878
|
+
if (evidence || capturing)
|
|
879
|
+
await record(broke, steps.length, at);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (tracing && options.traces) {
|
|
883
|
+
await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
|
|
884
|
+
// A backend exports in batches, a moment after the requests it served.
|
|
885
|
+
await options.traces.settle(tracing.traceId);
|
|
886
|
+
for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
|
|
887
|
+
const s = steps[index];
|
|
888
|
+
// A step that set off thousands of queries keeps the first ones; a report is not a profiler.
|
|
889
|
+
if (s)
|
|
890
|
+
s.backend = spans.slice(0, 400);
|
|
570
891
|
}
|
|
571
892
|
}
|
|
893
|
+
if (options.setupOnly && !failed)
|
|
894
|
+
options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
|
|
572
895
|
if (options.dry)
|
|
573
896
|
failed = steps.some((s) => s.status === 'failed');
|
|
574
|
-
const report = {
|
|
897
|
+
const report = {
|
|
898
|
+
id: c.id,
|
|
899
|
+
title: c.title,
|
|
900
|
+
file: c.file,
|
|
901
|
+
result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
|
|
902
|
+
ms: Date.now() - started,
|
|
903
|
+
steps,
|
|
904
|
+
};
|
|
575
905
|
if (options.events)
|
|
576
906
|
report.events = recorded;
|
|
577
|
-
if (
|
|
907
|
+
if (routesFrom !== undefined) {
|
|
908
|
+
const events = await device()
|
|
909
|
+
.then((d) => d.trace(routesFrom))
|
|
910
|
+
.then((t) => t.events)
|
|
911
|
+
.catch(() => []);
|
|
912
|
+
const walked = events.flatMap((e) => (e.kind === 'route' && e.route ? [e.route] : []));
|
|
913
|
+
while (walked.length > 0 && walked[0] === routesStale)
|
|
914
|
+
walked.shift();
|
|
915
|
+
report.routes = [...new Set(walked)].sort();
|
|
916
|
+
}
|
|
917
|
+
if (filming) {
|
|
918
|
+
await (await device()).command('record', { stop: true }).catch(() => undefined);
|
|
919
|
+
if (film && fs.existsSync(film))
|
|
920
|
+
report.video = film;
|
|
921
|
+
}
|
|
922
|
+
if (failed && !options.dry && !options.setupOnly) {
|
|
578
923
|
const d = await device().catch(() => undefined);
|
|
924
|
+
// A step that failed on its own reading of the screen left no reasons; ask for them.
|
|
925
|
+
if (!why && d?.server.connected)
|
|
926
|
+
why = (await execute(['why'], base).catch(() => undefined))?.text || undefined;
|
|
579
927
|
if (d?.server.connected) {
|
|
580
928
|
const screen = await d.screen().catch(() => undefined);
|
|
581
929
|
const events = await d.trace(mark).catch(() => undefined);
|
|
@@ -585,6 +933,12 @@ export const runCase = async (c, options) => {
|
|
|
585
933
|
why,
|
|
586
934
|
};
|
|
587
935
|
}
|
|
936
|
+
const reasons = (why ?? '')
|
|
937
|
+
.split('\n')
|
|
938
|
+
.map((l) => l.replace(/^\s*•\s*/, '').trim())
|
|
939
|
+
.filter((l) => l.length > 0);
|
|
940
|
+
if (reasons.length > 0)
|
|
941
|
+
report.diagnosis = reasons;
|
|
588
942
|
}
|
|
589
943
|
return report;
|
|
590
944
|
};
|