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/src/cases/runner.ts
CHANGED
|
@@ -5,22 +5,37 @@
|
|
|
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
|
|
|
11
|
-
import type { Element, Screen, Target
|
|
12
|
+
import type { Element, RequestRecord, Screen, Target } from 'lynkeus-protocol';
|
|
12
13
|
|
|
13
|
-
import { describeScreen } from '../device/driver.js';
|
|
14
|
+
import { type Device, describeScreen } from '../device/driver.js';
|
|
14
15
|
import { type AppEvent, normalizeEvents, type RunEvent } from '../knowledge/events.js';
|
|
15
16
|
import { type Base, type Command, find } from '../registry.js';
|
|
16
17
|
import { execute } from '../runtime.js';
|
|
17
18
|
import { isRoute, parseTarget, scopedTarget } from '../targets.js';
|
|
18
19
|
import type { Fixtures, FixturesConfig } from './fixtures.js';
|
|
19
|
-
import { type
|
|
20
|
+
import { type BackendSpan, newSpanId, newTraceId, type TraceSink, traceparent } from './traces.js';
|
|
21
|
+
import { type Case, dig, interpolate, matcherOf, matches, type Scope, seeded, type Step, stepOf, type StepValue } from './format.js';
|
|
22
|
+
|
|
23
|
+
export type NativeMock = {
|
|
24
|
+
result?: unknown;
|
|
25
|
+
screen?: { title?: string; subtitle?: string; image?: string; buttons?: Record<string, unknown> };
|
|
26
|
+
};
|
|
20
27
|
|
|
21
28
|
export type CasesConfig = {
|
|
22
29
|
/** The folder of the file that declared this, set when the configuration is read. */
|
|
23
30
|
base?: string;
|
|
31
|
+
/**
|
|
32
|
+
* What native SDKs a headless host cannot run answer, keyed `Module.method` (the native module's name, as
|
|
33
|
+
* `NativeModules` has it): `result` is what the call resolves with; `screen` puts up a stand-in page with a
|
|
34
|
+
* `title`, an optional `subtitle` and `image` (a URI), and `buttons`, label to result, each pressable as
|
|
35
|
+
* `#native-mock-<label>`. Sent to the app at the start of every case; a step changes it with
|
|
36
|
+
* `app.call: { command: nativeMocks, params: { set: { … } } }`, and `null` takes a key's mock away.
|
|
37
|
+
*/
|
|
38
|
+
nativeMocks?: Record<string, NativeMock | null>;
|
|
24
39
|
/** Where cases live (default `cases`). */
|
|
25
40
|
dir?: string;
|
|
26
41
|
/** Where the flows a case runs live (default `flows`). */
|
|
@@ -37,21 +52,74 @@ export type CasesConfig = {
|
|
|
37
52
|
interruptions?: { see: string; do: unknown[]; unless?: string }[];
|
|
38
53
|
};
|
|
39
54
|
|
|
40
|
-
|
|
55
|
+
/** 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> };
|
|
57
|
+
|
|
58
|
+
/** `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
|
+
export type StepReport = {
|
|
60
|
+
step: string;
|
|
61
|
+
status: 'passed' | 'failed' | 'skipped' | 'unsupported';
|
|
62
|
+
ms: number;
|
|
63
|
+
error?: string;
|
|
64
|
+
/** A frame of the screen once the step was done, an absolute path. */
|
|
65
|
+
screenshot?: string;
|
|
66
|
+
/** The second of the case's film at which the step began. */
|
|
67
|
+
videoS?: number;
|
|
68
|
+
/** What the step made the app send its analytics SDKs, where the host captures them. */
|
|
69
|
+
sent?: SentEvent[];
|
|
70
|
+
/** What the backend did for it, from the traces it exported to the run. */
|
|
71
|
+
backend?: BackendSpan[];
|
|
72
|
+
};
|
|
41
73
|
export type CaseReport = {
|
|
42
74
|
id: string;
|
|
43
75
|
title?: string;
|
|
44
76
|
file: string;
|
|
45
|
-
|
|
77
|
+
/** `unsupported`: a step could not be done on this host, so the case proved nothing here; it did not fail. */
|
|
78
|
+
result: 'passed' | 'failed' | 'unsupported';
|
|
46
79
|
ms: number;
|
|
47
80
|
steps: StepReport[];
|
|
48
81
|
/** When a step failed: the screen, the requests since the mark, and why. */
|
|
49
82
|
evidence?: { screen?: string; requests?: string[]; why?: string };
|
|
50
83
|
/** What the app told its analytics, each with the step that caused it, when the run asked for it. */
|
|
51
84
|
events?: RunEvent[];
|
|
85
|
+
/** The case's film, an absolute path, where the host films. */
|
|
86
|
+
video?: string;
|
|
87
|
+
/** Every screen the app went through while the case ran, when the run asked for them (`routes`). */
|
|
88
|
+
routes?: string[];
|
|
89
|
+
/** Why it failed, read as rules from the screen, the requests and the trace (`lynkeus why`). */
|
|
90
|
+
diagnosis?: string[];
|
|
52
91
|
};
|
|
53
92
|
|
|
54
|
-
|
|
93
|
+
/** 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; settleMs?: number; video?: { fps: number; scale?: number } };
|
|
95
|
+
|
|
96
|
+
export type RunOptions = {
|
|
97
|
+
base: Base;
|
|
98
|
+
/** Where the backend's traces arrive during the run: each app step's requests carry its id. */
|
|
99
|
+
traces?: TraceSink;
|
|
100
|
+
config: CasesConfig;
|
|
101
|
+
fixtures?: Fixtures;
|
|
102
|
+
dry?: boolean;
|
|
103
|
+
events?: boolean;
|
|
104
|
+
evidence?: Evidence;
|
|
105
|
+
log?: (line: string) => void;
|
|
106
|
+
/** The case's setup, already run by `case prepare`: the case starts from here. */
|
|
107
|
+
prepared?: Prepared;
|
|
108
|
+
/** Run only the setup and hand what it left to `keep`, for a case that will run later. */
|
|
109
|
+
setupOnly?: { keep: (prepared: Prepared) => void };
|
|
110
|
+
/** Keep running a case's steps after one fails, to see everything that is wrong in one pass. */
|
|
111
|
+
continue?: boolean;
|
|
112
|
+
/** Collect the screens the app went through, for a coverage map. `fresh`: the app was started for this case. */
|
|
113
|
+
routes?: boolean | 'fresh';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** What a case's setup leaves for its steps: the aliases and what they answered, and what ran. */
|
|
117
|
+
export type Prepared = { scope: Scope; latest: Record<string, unknown>; ran: string[]; steps: StepReport[] };
|
|
118
|
+
|
|
119
|
+
/** A setup made only of the project's fixtures can run before the case, with no app attached. */
|
|
120
|
+
export const preparable = (c: Case, config: CasesConfig): boolean =>
|
|
121
|
+
c.setup.length > 0 &&
|
|
122
|
+
c.setup.every((step) => !step.on && !step.verb.startsWith('app.') && !config.macros?.[step.verb] && step.verb !== 'exec' && step.verb !== 'assert');
|
|
55
123
|
|
|
56
124
|
/** What a buffer holds that it did not before, whichever end it grows from. */
|
|
57
125
|
export const newSince = (before: AppEvent[], now: AppEvent[]): AppEvent[] => {
|
|
@@ -105,6 +173,11 @@ export const argvFor = (command: Command, value: StepValue): string[] => {
|
|
|
105
173
|
if (typeof value !== 'object' || Array.isArray(value)) return [...argv, ...(Array.isArray(value) ? value.map(String) : [String(value)])];
|
|
106
174
|
const given = { ...(value as Record<string, unknown>) };
|
|
107
175
|
const positionals = command.positionals ?? [];
|
|
176
|
+
// A case names a control by `testId` wherever a command takes a target (`wait` calls its own `what`).
|
|
177
|
+
if ('testId' in given && !('target' in given) && !('testId' in (command.flags ?? {})) && positionals.some((p) => p.name === 'target' || p.name === 'what')) {
|
|
178
|
+
given.target = `#${String(given.testId)}`;
|
|
179
|
+
delete given.testId;
|
|
180
|
+
}
|
|
108
181
|
// A case says `target` or `request` where a command may call its first argument something else.
|
|
109
182
|
const first = positionals[0]?.name;
|
|
110
183
|
for (const alias of ['target', 'request', 'route', 'url', 'state', 'text']) {
|
|
@@ -137,8 +210,8 @@ export const argvFor = (command: Command, value: StepValue): string[] => {
|
|
|
137
210
|
const presented = (screen: Screen): Element[] => screen.elements.filter((e) => !e.hidden || e.hidden === 'inert');
|
|
138
211
|
const textOf = (e: Element) => e.text ?? e.value ?? e.accessibilityLabel ?? '';
|
|
139
212
|
|
|
140
|
-
const requestMatches = (event:
|
|
141
|
-
if (event.
|
|
213
|
+
const requestMatches = (event: RequestRecord, wanted: string, status?: number): boolean => {
|
|
214
|
+
if (event.status === undefined) return false;
|
|
142
215
|
const [method, route] = wanted.includes(' ') ? wanted.split(/\s+/, 2) : ['*', wanted];
|
|
143
216
|
let pathname = event.url;
|
|
144
217
|
try {
|
|
@@ -151,6 +224,9 @@ const requestMatches = (event: TraceEvent, wanted: string, status?: number): boo
|
|
|
151
224
|
return status === undefined ? true : event.status === status;
|
|
152
225
|
};
|
|
153
226
|
|
|
227
|
+
// Steps that look at the app rather than act on it: a request they wait for was caused by the action before them.
|
|
228
|
+
const LOOKING = new Set(['see', 'wait', 'event', 'requests', 'events']);
|
|
229
|
+
|
|
154
230
|
const BUILT_IN = ['press', 'type', 'swipe', 'see', 'requests', 'wait', 'nav', 'call', 'run', 'events', 'event'];
|
|
155
231
|
|
|
156
232
|
const unknownStep = (step: Step, config: CasesConfig): string | undefined => {
|
|
@@ -167,15 +243,36 @@ const unknownStep = (step: Step, config: CasesConfig): string | undefined => {
|
|
|
167
243
|
return undefined;
|
|
168
244
|
};
|
|
169
245
|
|
|
246
|
+
/**
|
|
247
|
+
* The fixtures a case names that the project's server did not list. A macro's
|
|
248
|
+
* own steps are the project's to keep right; these are the ones written in the case.
|
|
249
|
+
*/
|
|
250
|
+
export const missingFixtures = (c: Case, config: CasesConfig, known: string[]): string[] => {
|
|
251
|
+
const names = [...c.setup, ...c.steps].flatMap((step) => {
|
|
252
|
+
if (step.verb.startsWith('app.') || config.macros?.[step.verb] || step.verb === 'exec') return [];
|
|
253
|
+
return [step.verb === 'assert' ? (config.inspect ?? 'inspect') : step.verb];
|
|
254
|
+
});
|
|
255
|
+
return [...new Set(names)].filter((name) => !known.includes(name));
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** What the app or its host answers to a method it does not have; the name is what it lacks. */
|
|
259
|
+
export const lacking = (message: string): string | undefined => /Unknown method (\S+)/.exec(message)?.[1];
|
|
260
|
+
|
|
170
261
|
export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport> => {
|
|
171
262
|
const { base, config } = options;
|
|
172
263
|
const log = options.log ?? (() => undefined);
|
|
173
|
-
const scope: Scope = {};
|
|
174
|
-
|
|
175
|
-
const
|
|
264
|
+
const scope: Scope = structuredClone(options.prepared?.scope ?? {});
|
|
265
|
+
seeded(scope, c.id);
|
|
266
|
+
const ran = new Set<string>(options.prepared?.ran ?? []);
|
|
267
|
+
const latest: Record<string, unknown> = structuredClone(options.prepared?.latest ?? {});
|
|
176
268
|
const started = Date.now();
|
|
177
269
|
const steps: StepReport[] = [];
|
|
178
270
|
let mark: number | undefined;
|
|
271
|
+
// The app numbers its requests as they start. A step that looks for one counts only those started after the
|
|
272
|
+
// action before it began: an earlier screen's request to the same path would otherwise pass for the one the
|
|
273
|
+
// action caused. `app.requests` marks by hand, and holds until a step has looked for a request.
|
|
274
|
+
let requestsAfter: number | undefined;
|
|
275
|
+
let markedByHand = false;
|
|
179
276
|
let why: string | undefined;
|
|
180
277
|
const recorded: RunEvent[] = [];
|
|
181
278
|
let buffer: AppEvent[] = [];
|
|
@@ -191,7 +288,19 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
191
288
|
};
|
|
192
289
|
|
|
193
290
|
const device = () => base.device();
|
|
194
|
-
const
|
|
291
|
+
const lastRequest = async (d: Device): Promise<number> => Math.max(0, ...(await d.requests()).requests.map((r) => r.id));
|
|
292
|
+
const markRequests = async (d: Device, byHand = false): Promise<void> => {
|
|
293
|
+
if (markedByHand && !byHand) return;
|
|
294
|
+
requestsAfter = await lastRequest(d);
|
|
295
|
+
markedByHand = byHand;
|
|
296
|
+
};
|
|
297
|
+
const requestsSinceMark = async (): Promise<RequestRecord[]> => {
|
|
298
|
+
const d = await device();
|
|
299
|
+
const { requests } = await d.requests(requestsAfter);
|
|
300
|
+
// An app started again numbers its requests from 1.
|
|
301
|
+
if (requests.length === 0 && requestsAfter && (await lastRequest(d)) < requestsAfter) requestsAfter = 0;
|
|
302
|
+
return requests;
|
|
303
|
+
};
|
|
195
304
|
const cli = async (argv: string[]) => {
|
|
196
305
|
const outcome = await execute(argv, base);
|
|
197
306
|
if (outcome.error || outcome.code !== 0) {
|
|
@@ -226,13 +335,24 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
226
335
|
for (const field of Object.values(config.refs ?? {})) if (answer[field] !== undefined) latest[field] = answer[field];
|
|
227
336
|
};
|
|
228
337
|
|
|
338
|
+
// What the backend answered, until a step that can change it.
|
|
339
|
+
const inspected = new Map<string, Record<string, unknown>>();
|
|
340
|
+
|
|
229
341
|
const assert = async (value: StepValue): Promise<void> => {
|
|
230
342
|
const params = (value ?? {}) as Record<string, unknown>;
|
|
231
343
|
const matcher = matcherOf(params);
|
|
232
344
|
if (!matcher || typeof params.path !== 'string') throw new Error('assert needs a path and a matcher (equals, includes, gte, …)');
|
|
233
345
|
if (!options.fixtures) throw new Error('assert reads the backend through fixtures, and the project declares none');
|
|
234
346
|
const rest = Object.fromEntries(Object.entries(params).filter(([k]) => k !== 'path' && !(k in matcher)));
|
|
235
|
-
|
|
347
|
+
// `section` is the part of the answer the path reads: a server that works its answer out part by part
|
|
348
|
+
// need only do that part. Asserts in a row on the same section read one answer.
|
|
349
|
+
const ask = withRefs({ ...rest, section: params.path.split('.')[0] });
|
|
350
|
+
const key = JSON.stringify(ask);
|
|
351
|
+
let answer = inspected.get(key);
|
|
352
|
+
if (!answer) {
|
|
353
|
+
answer = await options.fixtures.call(config.inspect ?? 'inspect', ask);
|
|
354
|
+
inspected.set(key, answer);
|
|
355
|
+
}
|
|
236
356
|
const got = dig(answer, params.path);
|
|
237
357
|
if (!matches(got, matcher)) throw new Error(`${params.path} is ${JSON.stringify(got)}`);
|
|
238
358
|
};
|
|
@@ -265,7 +385,9 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
265
385
|
} else if (text !== undefined && !elements.some((e) => textOf(e).includes(text))) return `not on screen: ${JSON.stringify(text)}`;
|
|
266
386
|
if (typeof params.request === 'string') {
|
|
267
387
|
const status = typeof params.status === 'number' ? params.status : undefined;
|
|
268
|
-
if (!(await
|
|
388
|
+
if (!(await requestsSinceMark()).some((e) => requestMatches(e, params.request as string, status)))
|
|
389
|
+
return `not requested since ${markedByHand ? 'the mark' : 'the step before began'}: ${params.request}`;
|
|
390
|
+
markedByHand = false;
|
|
269
391
|
}
|
|
270
392
|
if (typeof params.event === 'string') {
|
|
271
393
|
const wanted = (params.props ?? params.properties ?? {}) as Record<string, unknown>;
|
|
@@ -276,16 +398,65 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
276
398
|
return undefined;
|
|
277
399
|
};
|
|
278
400
|
|
|
401
|
+
/**
|
|
402
|
+
* A device, and a host that draws like one, lists what is in the window: what
|
|
403
|
+
* a step looks for may be further down the page. This scrolls the window
|
|
404
|
+
* itself, most of a screenful at a time, until `there` holds or the screen
|
|
405
|
+
* stops changing; then back up in short pulls (at the top of a sheet a short
|
|
406
|
+
* pull springs back, where a long one would drag the sheet shut).
|
|
407
|
+
*/
|
|
408
|
+
const scrollTo = async (there: () => Promise<boolean>, upward = false): Promise<boolean> => {
|
|
409
|
+
const d = await device();
|
|
410
|
+
if (!d.server.hello?.native) return false;
|
|
411
|
+
const { w, h } = (await d.screen()).window;
|
|
412
|
+
const x = Math.round(w / 2);
|
|
413
|
+
const listing = async () => JSON.stringify(presented(await d.screen()).map((e) => [e.testId, textOf(e), Math.round(e.frame.y)]));
|
|
414
|
+
const pull = async (from: number, to: number, times: number): Promise<boolean> => {
|
|
415
|
+
let before: string | undefined;
|
|
416
|
+
for (let i = 0; i < times; i++) {
|
|
417
|
+
if (await there()) return true;
|
|
418
|
+
const now = await listing();
|
|
419
|
+
if (now === before) return false;
|
|
420
|
+
before = now;
|
|
421
|
+
await execute(['swipe', '--from', `${x},${from}`, '--to', `${x},${to}`], base);
|
|
422
|
+
await d.idle({ quietMs: 150, timeoutMs: 1500 }).catch(() => undefined);
|
|
423
|
+
}
|
|
424
|
+
return there();
|
|
425
|
+
};
|
|
426
|
+
if (!upward && (await pull(Math.round(h * 0.75), Math.round(h * 0.3), 12))) return true;
|
|
427
|
+
const middle = Math.round(h * 0.5);
|
|
428
|
+
return pull(middle, middle + 110, 30);
|
|
429
|
+
};
|
|
430
|
+
|
|
279
431
|
const see = async (value: StepValue): Promise<void> => {
|
|
280
432
|
const params = typeof value === 'string' ? { target: value } : ((value ?? {}) as Record<string, unknown>);
|
|
281
433
|
const absent = params.absent === true;
|
|
282
434
|
const wait = typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000;
|
|
283
435
|
const deadline = Date.now() + wait;
|
|
284
436
|
const hard = Date.now() + Math.max(wait, 12_000);
|
|
437
|
+
// Where the listing is what the window shows, something missing gets three more looks before the
|
|
438
|
+
// deadline counts: a short wait (it may be arriving), a scroll (it may be further down), and a long
|
|
439
|
+
// wait (a carousel shows it a few seconds from now, and a host with its own clock gets there fast).
|
|
440
|
+
const what = targetIn(params) ?? (typeof params.text === 'string' ? ({ text: params.text } as Target) : undefined);
|
|
441
|
+
let looks = absent || !what || !(await device()).server.hello?.native ? 3 : 0;
|
|
285
442
|
while (true) {
|
|
286
443
|
await interrupted(value);
|
|
287
444
|
const missing = await seen(params);
|
|
288
445
|
if (absent ? missing !== undefined : missing === undefined) return;
|
|
446
|
+
if (looks < 3 && what && missing?.startsWith('not on screen')) {
|
|
447
|
+
const d = await device();
|
|
448
|
+
looks += 1;
|
|
449
|
+
if (looks === 1) await d.waitFor({ target: what, timeoutMs: 1500 }).catch(() => undefined);
|
|
450
|
+
else if (looks === 2) {
|
|
451
|
+
if (await scrollTo(async () => (await seen(params)) === undefined)) return;
|
|
452
|
+
} else {
|
|
453
|
+
// A step that names its own timeout gets no more than what is left of it; one that does not may
|
|
454
|
+
// wait for a carousel to come round.
|
|
455
|
+
const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
|
|
456
|
+
await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
289
460
|
if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
|
|
290
461
|
await sleep(150);
|
|
291
462
|
continue;
|
|
@@ -301,36 +472,70 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
301
472
|
const deadline = Date.now() + timeoutMs;
|
|
302
473
|
const status = typeof params.status === 'number' ? params.status : undefined;
|
|
303
474
|
while (true) {
|
|
304
|
-
const events = await
|
|
305
|
-
const hit = events.some(
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
475
|
+
const events = await requestsSinceMark();
|
|
476
|
+
const hit = events.some((e) => requestMatches(e, String(params.request), status) && (status !== undefined || String(e.status).startsWith('2')));
|
|
477
|
+
if (hit) {
|
|
478
|
+
markedByHand = false;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
309
481
|
if (Date.now() > deadline) throw new Error(`no ${status ?? '2xx'} ${String(params.request)} within ${timeoutMs} ms`);
|
|
310
482
|
await sleep(300);
|
|
311
483
|
}
|
|
312
484
|
};
|
|
313
485
|
|
|
314
486
|
const press = async (value: StepValue): Promise<'skipped' | undefined> => {
|
|
315
|
-
const
|
|
487
|
+
const given = typeof value === 'string' ? { target: value } : ((value ?? {}) as Record<string, unknown>);
|
|
488
|
+
const { text, ...named } = given;
|
|
489
|
+
const params: Record<string, unknown> = 'target' in given || 'testId' in given || typeof text !== 'string' ? given : { ...named, target: text };
|
|
316
490
|
const target = targetIn(params);
|
|
317
491
|
if (!target) throw new Error('app.press needs a target');
|
|
318
492
|
const d = await device();
|
|
319
493
|
if (params.optional === true) {
|
|
494
|
+
// Decided once the app has gone quiet: what it presses (a one-time question, a promo) arrives a moment
|
|
495
|
+
// after the screen before it, and a fast host gets here first.
|
|
496
|
+
await d.idle({ quietMs: 150, timeoutMs: 1000 }).catch(() => undefined);
|
|
320
497
|
const there = await d.waitFor({ target, timeoutMs: 300 }).then(
|
|
321
498
|
() => true,
|
|
322
499
|
() => false,
|
|
323
500
|
);
|
|
324
501
|
if (!there) return 'skipped';
|
|
325
502
|
}
|
|
326
|
-
//
|
|
503
|
+
// Not listed at all: arriving, or further down a page that only lists its window. A short wait, then
|
|
504
|
+
// a scroll, rather than spending the whole deadline on something that is below the fold.
|
|
505
|
+
const listed = async () => !!(await d.server.call('find', target));
|
|
506
|
+
if (!(await listed())) {
|
|
507
|
+
await d.waitFor({ target, timeoutMs: 500 }).catch(() => undefined);
|
|
508
|
+
if (!(await listed())) await scrollTo(listed);
|
|
509
|
+
}
|
|
510
|
+
// Listed, but scrolled out of the window, cut by its edge or under the header or tab bar (a page left
|
|
511
|
+
// further down than the control): towards it, until it is clear.
|
|
512
|
+
const outside = async (): Promise<'above' | 'below' | undefined> => {
|
|
513
|
+
const found = (await d.server.call('find', target)) as Element | null;
|
|
514
|
+
if (!found) return undefined;
|
|
515
|
+
const { h } = (await d.screen()).window;
|
|
516
|
+
// Covered near an edge is the screen's own header or tab bar over a scrolled page, not an overlay.
|
|
517
|
+
const top = found.frame.y,
|
|
518
|
+
bottom = found.frame.y + found.frame.h;
|
|
519
|
+
if (top < 0 || (found.covered && top < h * 0.2)) return 'above';
|
|
520
|
+
if (bottom > h || (found.covered && bottom > h * 0.8)) return 'below';
|
|
521
|
+
return undefined;
|
|
522
|
+
};
|
|
523
|
+
const side = await outside();
|
|
524
|
+
if (side) await scrollTo(async () => (await outside()) === undefined, side === 'above');
|
|
525
|
+
// The control may still be disabled while what it depends on loads. The waits go through the app,
|
|
526
|
+
// so a host with its own clock moves it on instead of standing still while this one sleeps.
|
|
327
527
|
const deadline = Date.now() + (typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000);
|
|
328
528
|
const hard = Date.now() + 15_000;
|
|
529
|
+
let was: string | undefined;
|
|
329
530
|
while (Date.now() < hard && 'testId' in target) {
|
|
330
531
|
const found = (await d.server.call('find', target)) as Element | null;
|
|
331
|
-
|
|
532
|
+
// Covered or still moving is often for a moment (a screen or button sliding in, a sheet still
|
|
533
|
+
// closing), and a press then lands on whatever covers it or where the control no longer is.
|
|
534
|
+
const at = found ? JSON.stringify(found.frame) : undefined;
|
|
535
|
+
if (found?.enabled && !found.covered && at === was) break;
|
|
536
|
+
was = at;
|
|
332
537
|
if (Date.now() > deadline && !(await d.screen()).busy) break;
|
|
333
|
-
await
|
|
538
|
+
await d.idle({ quietMs: 50, timeoutMs: 200 }).catch(() => undefined);
|
|
334
539
|
}
|
|
335
540
|
const { optional: _optional, timeoutMs: _timeout, ...rest } = params;
|
|
336
541
|
await cli(argvFor(find('press')!, rest as StepValue));
|
|
@@ -356,13 +561,10 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
356
561
|
}
|
|
357
562
|
};
|
|
358
563
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const deadline = Date.now() + 5000;
|
|
364
|
-
while ((await d.screen()).busy && Date.now() < deadline) await sleep(100);
|
|
365
|
-
};
|
|
564
|
+
// A host that keeps its own clock lets an animation finish before it answers. A phone runs on the
|
|
565
|
+
// wall's and keeps moving after the last render, so only there is a pause worth its time; and a case's
|
|
566
|
+
// `on: device` steps are written for that phone.
|
|
567
|
+
const onWallClock = (d: Device) => !!d.server.hello?.native && !d.server.hello.commands?.includes('clock');
|
|
366
568
|
|
|
367
569
|
let handling = false;
|
|
368
570
|
const interrupted = async (value: StepValue): Promise<void> => {
|
|
@@ -390,28 +592,38 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
390
592
|
const params = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
391
593
|
const d = await device();
|
|
392
594
|
mark ??= (await d.trace()).last;
|
|
595
|
+
// An interruption handled inside a wait is not the action the wait is about.
|
|
596
|
+
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined) await markRequests(d);
|
|
393
597
|
switch (name) {
|
|
394
598
|
case 'press': {
|
|
395
599
|
await interrupted(value);
|
|
396
|
-
await ready();
|
|
397
600
|
const outcome = await press(value);
|
|
398
601
|
// One touch, one render: the next step reads what this one caused.
|
|
399
602
|
await d.idle({ quietMs: 50, timeoutMs: 400 }).catch(() => undefined);
|
|
400
603
|
return outcome;
|
|
401
604
|
}
|
|
402
605
|
case 'type':
|
|
403
|
-
case 'swipe':
|
|
606
|
+
case 'swipe': {
|
|
404
607
|
await interrupted(value);
|
|
405
|
-
|
|
406
|
-
|
|
608
|
+
// A field that arrives with its screen (a code input that mounts a moment after the keyboard) takes
|
|
609
|
+
// what is typed only once it is there: under load that moment is longer than the step's own wait.
|
|
610
|
+
const field = name === 'type' && params ? targetIn(params) : undefined;
|
|
611
|
+
if (field) {
|
|
612
|
+
await d.waitFor({ target: field, timeoutMs: typeof params?.timeoutMs === 'number' ? params.timeoutMs : 5000 }).catch(() => undefined);
|
|
613
|
+
await d.idle({ quietMs: 100, timeoutMs: 800 }).catch(() => undefined);
|
|
614
|
+
}
|
|
615
|
+
const { timeoutMs: _timeout, ...typed } = params ?? {};
|
|
616
|
+
await cli(argvFor(find(name)!, name === 'type' && params ? (typed as StepValue) : value));
|
|
407
617
|
// A pager on a device keeps moving after the finger lifts, and swallows a swipe sent into that momentum.
|
|
408
|
-
if (name === 'swipe' && d
|
|
618
|
+
if (name === 'swipe' && onWallClock(d)) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
409
619
|
return undefined;
|
|
620
|
+
}
|
|
410
621
|
case 'see':
|
|
411
622
|
await see(value);
|
|
412
623
|
return undefined;
|
|
413
624
|
case 'requests':
|
|
414
625
|
mark = (await d.trace()).last;
|
|
626
|
+
await markRequests(d, true);
|
|
415
627
|
return undefined;
|
|
416
628
|
case 'wait': {
|
|
417
629
|
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) await sleep(Number(value));
|
|
@@ -433,7 +645,7 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
433
645
|
} else await cli(argvFor(find('wait')!, value));
|
|
434
646
|
// 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.
|
|
435
647
|
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
436
|
-
if (d
|
|
648
|
+
if (onWallClock(d) && isRoute(what)) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
437
649
|
}
|
|
438
650
|
return undefined;
|
|
439
651
|
}
|
|
@@ -479,7 +691,22 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
479
691
|
scope[params.as] = json ? JSON.parse(json) : { out: stdout.trim() };
|
|
480
692
|
};
|
|
481
693
|
|
|
694
|
+
// A macro is one step of the case: the requests it waits for are the ones it set off, from its first
|
|
695
|
+
// action on, not only those of its last (`app.refresh` asks for a refresh, then waits for it to settle).
|
|
696
|
+
let macroDepth = 0;
|
|
482
697
|
const macro = async (steps: unknown[], value: StepValue): Promise<void> => {
|
|
698
|
+
if (macroDepth === 0 && !handling) {
|
|
699
|
+
const d = await device().catch(() => undefined);
|
|
700
|
+
if (d) await markRequests(d);
|
|
701
|
+
}
|
|
702
|
+
macroDepth++;
|
|
703
|
+
try {
|
|
704
|
+
await runMacro(steps, value);
|
|
705
|
+
} finally {
|
|
706
|
+
macroDepth--;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
const runMacro = async (steps: unknown[], value: StepValue): Promise<void> => {
|
|
483
710
|
const given = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : { value };
|
|
484
711
|
// A parameter that names an alias is that alias: `as: u` makes `{{param.as.user_id}}` the user's.
|
|
485
712
|
const param = Object.fromEntries(Object.entries(given).map(([k, v]) => [k, typeof v === 'string' && v in scope ? scope[v] : v]));
|
|
@@ -501,17 +728,26 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
501
728
|
}
|
|
502
729
|
};
|
|
503
730
|
|
|
731
|
+
/**
|
|
732
|
+
* `device`, `headless`, a platform, or `has:<command>` for a host whose app
|
|
733
|
+
* registered that command (a hosted build often has commands a store build
|
|
734
|
+
* does not); `!` in front turns any of them around. One case then carries the
|
|
735
|
+
* way each host does a thing, and runs on all of them.
|
|
736
|
+
*/
|
|
504
737
|
const runsHere = async (on: string): Promise<boolean> => {
|
|
738
|
+
if (on.startsWith('!')) return !(await runsHere(on.slice(1)));
|
|
505
739
|
const hello = (await device()).server.hello;
|
|
506
|
-
const
|
|
507
|
-
if (on === 'device') return
|
|
508
|
-
if (on === 'headless') return !
|
|
740
|
+
const phone = onWallClock(await device());
|
|
741
|
+
if (on === 'device') return phone;
|
|
742
|
+
if (on === 'headless') return !phone;
|
|
743
|
+
if (on.startsWith('has:')) return !!hello?.commands?.includes(on.slice(4));
|
|
509
744
|
return hello?.platform === on;
|
|
510
745
|
};
|
|
511
746
|
|
|
512
747
|
const perform = async (step: Step): Promise<'skipped' | undefined> => {
|
|
513
748
|
if (step.on && !(await runsHere(step.on))) return 'skipped';
|
|
514
749
|
ran.add(step.verb);
|
|
750
|
+
if (!['assert', 'app.see', 'app.wait'].includes(step.verb)) inspected.clear();
|
|
515
751
|
const value = interpolate(step.value, scope);
|
|
516
752
|
const custom = config.macros?.[step.verb];
|
|
517
753
|
if (custom) {
|
|
@@ -529,10 +765,110 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
529
765
|
if (d?.server.connected) buffer = normalizeEvents(await d.command('events').catch(() => []));
|
|
530
766
|
}
|
|
531
767
|
|
|
768
|
+
const evidence = options.dry ? undefined : options.evidence;
|
|
769
|
+
const offers = async (command: string) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
|
|
770
|
+
const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
|
|
771
|
+
// Asked only when the run keeps events: it is a call per step.
|
|
772
|
+
const capturing = !options.dry && !!options.events && (await offers('analytics'));
|
|
773
|
+
if (evidence) fs.mkdirSync(evidence.dir, { recursive: true });
|
|
774
|
+
let filming = false;
|
|
775
|
+
if (film && evidence?.video) {
|
|
776
|
+
const d = await device();
|
|
777
|
+
// A host that films at one size only ignores the scale.
|
|
778
|
+
filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(
|
|
779
|
+
() => true,
|
|
780
|
+
() => false,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
// What the app sent before the case began is not the case's.
|
|
784
|
+
if (capturing) await (await device()).command('analytics', { take: true }).catch(() => undefined);
|
|
785
|
+
const frameAt = async (): Promise<number | undefined> => {
|
|
786
|
+
if (!filming || !evidence?.video) return undefined;
|
|
787
|
+
const answer = (await (await device()).command('record', { frames: true }).catch(() => undefined)) as { frames?: number } | undefined;
|
|
788
|
+
return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
|
|
789
|
+
};
|
|
790
|
+
// What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
|
|
791
|
+
const record = async (report: StepReport, index: number, startedAt: number | undefined) => {
|
|
792
|
+
if (startedAt !== undefined) report.videoS = startedAt;
|
|
793
|
+
const d = await device().catch(() => undefined);
|
|
794
|
+
if (!d?.server.connected) return;
|
|
795
|
+
if (capturing) {
|
|
796
|
+
const raw = (await d.command('analytics', { take: true }).catch(() => [])) as { sdk?: string; name?: string; properties?: Record<string, unknown> }[];
|
|
797
|
+
const sent = (Array.isArray(raw) ? raw : []).flatMap((e) =>
|
|
798
|
+
e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}) }] : [],
|
|
799
|
+
);
|
|
800
|
+
if (sent.length > 0) report.sent = sent;
|
|
801
|
+
}
|
|
802
|
+
if (evidence?.screenshots) {
|
|
803
|
+
// A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
|
|
804
|
+
// step left the app, so two runs' frames of the same step show the same screen.
|
|
805
|
+
// A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
|
|
806
|
+
// moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
|
|
807
|
+
// arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
|
|
808
|
+
const quietMs = evidence.settleMs ?? 150;
|
|
809
|
+
const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then(
|
|
810
|
+
(r) => r.idle,
|
|
811
|
+
() => false,
|
|
812
|
+
);
|
|
813
|
+
const file = path.join(
|
|
814
|
+
evidence.dir,
|
|
815
|
+
`${String(index).padStart(2, '0')}-${report.step
|
|
816
|
+
.toLowerCase()
|
|
817
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
818
|
+
.slice(0, 48)
|
|
819
|
+
.replace(/^-|-$/g, '')}.png`,
|
|
820
|
+
);
|
|
821
|
+
// A host on another machine writes the file there; `inline` asks it for the picture as well.
|
|
822
|
+
// A host that knows what was still animating says so, as frames in points.
|
|
823
|
+
let moving: unknown;
|
|
824
|
+
const taken = (await offers('screenshot'))
|
|
825
|
+
? await d.command('screenshot', { path: file, inline: true }).then(
|
|
826
|
+
(answer) => {
|
|
827
|
+
const { png, moving: animating } = (answer ?? {}) as { png?: unknown; moving?: unknown };
|
|
828
|
+
if (typeof png === 'string' && !fs.existsSync(file)) fs.writeFileSync(file, Buffer.from(png, 'base64'));
|
|
829
|
+
if (Array.isArray(animating)) moving = animating;
|
|
830
|
+
return true;
|
|
831
|
+
},
|
|
832
|
+
() => false,
|
|
833
|
+
)
|
|
834
|
+
: (await execute(['screenshot', file], base)).code === 0;
|
|
835
|
+
if (taken && fs.existsSync(file)) {
|
|
836
|
+
report.screenshot = file;
|
|
837
|
+
// What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
|
|
838
|
+
// a balance) from how it was drawn.
|
|
839
|
+
const screen = await d.screen().catch(() => undefined);
|
|
840
|
+
if (screen) fs.writeFileSync(file.replace(/\.png$/, '.json'), JSON.stringify({ ...screen, settled, ...(moving ? { moving } : {}) }));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
|
|
532
845
|
let failed = false;
|
|
533
|
-
|
|
846
|
+
// A setup that failed leaves the steps nothing to stand on, with or without `continue`.
|
|
847
|
+
let setupFailed = false;
|
|
848
|
+
let unsupported = false;
|
|
849
|
+
let routesFrom: number | undefined;
|
|
850
|
+
// The screen a reused app was left on: a reset reports it once more before the case's own screens.
|
|
851
|
+
let routesStale: string | undefined;
|
|
852
|
+
if (options.prepared) {
|
|
853
|
+
steps.push(...options.prepared.steps);
|
|
854
|
+
log(` ⚡ setup made ahead (${options.prepared.steps.length} steps)`);
|
|
855
|
+
}
|
|
856
|
+
// What native SDKs answer in this case starts as the config says, whatever the last case on this app set.
|
|
857
|
+
if (config.nativeMocks && !options.setupOnly && !options.dry) {
|
|
858
|
+
const d = await device().catch(() => undefined);
|
|
859
|
+
await d?.command('nativeMocks', { replace: config.nativeMocks }).catch(() => undefined);
|
|
860
|
+
}
|
|
861
|
+
// Each app step gets a span id the host puts in the `traceparent` of every request the app sends while it
|
|
862
|
+
// runs; a backend that exports traces to the run files what it did under that step.
|
|
863
|
+
const tracing =
|
|
864
|
+
options.traces && !options.dry && !options.setupOnly && (await offers('traceContext'))
|
|
865
|
+
? { traceId: newTraceId(), steps: new Map<string, { index: number; startedMs: number }>() }
|
|
866
|
+
: undefined;
|
|
867
|
+
const todo = options.setupOnly ? c.setup : options.prepared ? c.steps : [...c.setup, ...c.steps];
|
|
868
|
+
const setupLength = todo === c.steps ? 0 : c.setup.length;
|
|
869
|
+
for (const [index, step] of todo.entries()) {
|
|
534
870
|
const text = label(step);
|
|
535
|
-
if (failed) {
|
|
871
|
+
if (unsupported || setupFailed || (failed && !options.continue)) {
|
|
536
872
|
steps.push({ step: text, status: 'skipped', ms: 0 });
|
|
537
873
|
continue;
|
|
538
874
|
}
|
|
@@ -543,24 +879,91 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
543
879
|
continue;
|
|
544
880
|
}
|
|
545
881
|
const t0 = Date.now();
|
|
882
|
+
// Where the app's trace stood before the case first touched it: the screens after it are the case's.
|
|
883
|
+
if (options.routes && routesFrom === undefined && (step.verb.startsWith('app.') || config.macros?.[step.verb])) {
|
|
884
|
+
const d = await device().catch(() => undefined);
|
|
885
|
+
routesFrom = await d?.trace().then(
|
|
886
|
+
(t) => t.last,
|
|
887
|
+
() => undefined,
|
|
888
|
+
);
|
|
889
|
+
// An app started for this case begins where it begins; one reused from a case before was left elsewhere.
|
|
890
|
+
if (options.routes !== 'fresh')
|
|
891
|
+
routesStale = await d?.screen().then(
|
|
892
|
+
(sc) => sc.route,
|
|
893
|
+
() => undefined,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
const at = step.verb.startsWith('app.') || config.macros?.[step.verb] ? await frameAt() : undefined;
|
|
897
|
+
if (tracing && (step.verb.startsWith('app.') || config.macros?.[step.verb])) {
|
|
898
|
+
const spanId = newSpanId();
|
|
899
|
+
tracing.steps.set(spanId, { index: steps.length, startedMs: t0 });
|
|
900
|
+
await (await device()).command('traceContext', { traceparent: traceparent(tracing.traceId, spanId) }).catch(() => undefined);
|
|
901
|
+
}
|
|
546
902
|
try {
|
|
547
903
|
const outcome = await perform(step);
|
|
548
904
|
if (step.verb.startsWith('app.') || config.macros?.[step.verb]) await collect(steps.length + 1);
|
|
549
|
-
|
|
905
|
+
const done: StepReport = { step: text, status: outcome === 'skipped' ? 'skipped' : 'passed', ms: Date.now() - t0 };
|
|
906
|
+
steps.push(done);
|
|
550
907
|
log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
|
|
908
|
+
if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
|
|
909
|
+
await record(done, steps.length, at);
|
|
551
910
|
} catch (error) {
|
|
552
|
-
failed = true;
|
|
553
911
|
const message = error instanceof Error ? error.message : String(error);
|
|
554
|
-
|
|
912
|
+
const missing = lacking(message);
|
|
913
|
+
if (missing) {
|
|
914
|
+
// Not the app's doing: the rest of the case would be standing on a step that never happened.
|
|
915
|
+
unsupported = true;
|
|
916
|
+
steps.push({ step: text, status: 'unsupported', ms: Date.now() - t0, error: `this host has no ${missing}` });
|
|
917
|
+
log(` 🚫 ${text} — this host has no ${missing}`);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
failed = true;
|
|
921
|
+
if (index < setupLength) setupFailed = true;
|
|
922
|
+
const broke: StepReport = { step: text, status: 'failed', ms: Date.now() - t0, error: message };
|
|
923
|
+
steps.push(broke);
|
|
555
924
|
log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
|
|
925
|
+
if (evidence || capturing) await record(broke, steps.length, at);
|
|
556
926
|
}
|
|
557
927
|
}
|
|
558
928
|
|
|
929
|
+
if (tracing && options.traces) {
|
|
930
|
+
await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
|
|
931
|
+
// A backend exports in batches, a moment after the requests it served.
|
|
932
|
+
await options.traces.settle(tracing.traceId);
|
|
933
|
+
for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
|
|
934
|
+
const s = steps[index];
|
|
935
|
+
// A step that set off thousands of queries keeps the first ones; a report is not a profiler.
|
|
936
|
+
if (s) s.backend = spans.slice(0, 400);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (options.setupOnly && !failed) options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
|
|
559
940
|
if (options.dry) failed = steps.some((s) => s.status === 'failed');
|
|
560
|
-
const report: CaseReport = {
|
|
941
|
+
const report: CaseReport = {
|
|
942
|
+
id: c.id,
|
|
943
|
+
title: c.title,
|
|
944
|
+
file: c.file,
|
|
945
|
+
result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
|
|
946
|
+
ms: Date.now() - started,
|
|
947
|
+
steps,
|
|
948
|
+
};
|
|
561
949
|
if (options.events) report.events = recorded;
|
|
562
|
-
if (
|
|
950
|
+
if (routesFrom !== undefined) {
|
|
951
|
+
const events = await device()
|
|
952
|
+
.then((d) => d.trace(routesFrom))
|
|
953
|
+
.then((t) => t.events)
|
|
954
|
+
.catch(() => []);
|
|
955
|
+
const walked = events.flatMap((e) => (e.kind === 'route' && e.route ? [e.route] : []));
|
|
956
|
+
while (walked.length > 0 && walked[0] === routesStale) walked.shift();
|
|
957
|
+
report.routes = [...new Set(walked)].sort();
|
|
958
|
+
}
|
|
959
|
+
if (filming) {
|
|
960
|
+
await (await device()).command('record', { stop: true }).catch(() => undefined);
|
|
961
|
+
if (film && fs.existsSync(film)) report.video = film;
|
|
962
|
+
}
|
|
963
|
+
if (failed && !options.dry && !options.setupOnly) {
|
|
563
964
|
const d = await device().catch(() => undefined);
|
|
965
|
+
// A step that failed on its own reading of the screen left no reasons; ask for them.
|
|
966
|
+
if (!why && d?.server.connected) why = (await execute(['why'], base).catch(() => undefined))?.text || undefined;
|
|
564
967
|
if (d?.server.connected) {
|
|
565
968
|
const screen = await d.screen().catch(() => undefined);
|
|
566
969
|
const events = await d.trace(mark).catch(() => undefined);
|
|
@@ -570,6 +973,11 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
570
973
|
why,
|
|
571
974
|
};
|
|
572
975
|
}
|
|
976
|
+
const reasons = (why ?? '')
|
|
977
|
+
.split('\n')
|
|
978
|
+
.map((l) => l.replace(/^\s*•\s*/, '').trim())
|
|
979
|
+
.filter((l) => l.length > 0);
|
|
980
|
+
if (reasons.length > 0) report.diagnosis = reasons;
|
|
573
981
|
}
|
|
574
982
|
return report;
|
|
575
983
|
};
|