lynkeus 0.2.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/__tests__/args.test.d.ts +1 -0
- package/lib/__tests__/args.test.js +69 -0
- package/lib/__tests__/check.test.d.ts +1 -0
- package/lib/__tests__/check.test.js +60 -0
- package/lib/__tests__/init.test.d.ts +1 -0
- package/lib/__tests__/init.test.js +42 -0
- package/lib/__tests__/record.test.d.ts +1 -0
- package/lib/__tests__/record.test.js +44 -0
- package/lib/__tests__/snapshot.test.d.ts +1 -0
- package/lib/__tests__/snapshot.test.js +47 -0
- package/lib/__tests__/targets.test.d.ts +1 -0
- package/lib/__tests__/targets.test.js +127 -0
- package/lib/args.d.ts +30 -0
- package/lib/args.js +86 -0
- package/lib/cases/__tests__/format.test.d.ts +1 -0
- package/lib/cases/__tests__/format.test.js +61 -0
- package/lib/cases/__tests__/runner.test.d.ts +1 -0
- package/lib/cases/__tests__/runner.test.js +188 -0
- package/lib/cases/fixtures.d.ts +13 -0
- package/lib/cases/fixtures.js +100 -0
- package/lib/cases/format.d.ts +37 -0
- package/lib/cases/format.js +169 -0
- package/lib/cases/runner.d.ts +61 -0
- package/lib/cases/runner.js +590 -0
- package/lib/cases/templates.d.ts +20 -0
- package/lib/cases/templates.js +156 -0
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +173 -0
- package/lib/commands/cases.d.ts +1 -0
- package/lib/commands/cases.js +179 -0
- package/lib/commands/check.d.ts +36 -0
- package/lib/commands/check.js +67 -0
- package/lib/commands/crawl/return.d.ts +20 -0
- package/lib/commands/crawl/return.js +125 -0
- package/lib/commands/crawl/state.d.ts +30 -0
- package/lib/commands/crawl/state.js +56 -0
- package/lib/commands/crawl.d.ts +67 -0
- package/lib/commands/crawl.js +213 -0
- package/lib/commands/device.d.ts +10 -0
- package/lib/commands/device.js +122 -0
- package/lib/commands/doctor.d.ts +3 -0
- package/lib/commands/doctor.js +240 -0
- package/lib/commands/drive.d.ts +1 -0
- package/lib/commands/drive.js +410 -0
- package/lib/commands/flake.d.ts +14 -0
- package/lib/commands/flake.js +42 -0
- package/lib/commands/headless.d.ts +1 -0
- package/lib/commands/headless.js +127 -0
- package/lib/commands/init.d.ts +13 -0
- package/lib/commands/init.js +85 -0
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +297 -0
- package/lib/commands/os.d.ts +1 -0
- package/lib/commands/os.js +193 -0
- package/lib/commands/overlay.d.ts +9 -0
- package/lib/commands/overlay.js +12 -0
- package/lib/commands/record.d.ts +5 -0
- package/lib/commands/record.js +55 -0
- package/lib/commands/runs.d.ts +1 -0
- package/lib/commands/runs.js +452 -0
- package/lib/commands/smoke.d.ts +28 -0
- package/lib/commands/smoke.js +59 -0
- package/lib/commands/snapshot.d.ts +27 -0
- package/lib/commands/snapshot.js +69 -0
- package/lib/config.d.ts +72 -0
- package/lib/config.js +44 -0
- package/lib/device/devices.d.ts +11 -0
- package/lib/device/devices.js +33 -0
- package/lib/device/driver.d.ts +53 -0
- package/lib/device/driver.js +169 -0
- package/lib/device/platform.d.ts +7 -0
- package/lib/device/platform.js +59 -0
- package/lib/files.d.ts +7 -0
- package/lib/files.js +29 -0
- package/lib/flow/artifacts.d.ts +19 -0
- package/lib/flow/artifacts.js +49 -0
- package/lib/flow/runner.d.ts +13 -0
- package/lib/flow/runner.js +169 -0
- package/lib/flow/types.d.ts +111 -0
- package/lib/flow/types.js +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +11 -0
- package/lib/knowledge/__tests__/edges.test.d.ts +1 -0
- package/lib/knowledge/__tests__/edges.test.js +25 -0
- package/lib/knowledge/__tests__/events.test.d.ts +1 -0
- package/lib/knowledge/__tests__/events.test.js +39 -0
- package/lib/knowledge/__tests__/extract.test.d.ts +1 -0
- package/lib/knowledge/__tests__/extract.test.js +69 -0
- package/lib/knowledge/__tests__/frontier.test.d.ts +1 -0
- package/lib/knowledge/__tests__/frontier.test.js +57 -0
- package/lib/knowledge/__tests__/funnels.test.d.ts +1 -0
- package/lib/knowledge/__tests__/funnels.test.js +40 -0
- package/lib/knowledge/__tests__/lint.test.d.ts +1 -0
- package/lib/knowledge/__tests__/lint.test.js +53 -0
- package/lib/knowledge/__tests__/perf.test.d.ts +1 -0
- package/lib/knowledge/__tests__/perf.test.js +34 -0
- package/lib/knowledge/__tests__/plan.test.d.ts +1 -0
- package/lib/knowledge/__tests__/plan.test.js +35 -0
- package/lib/knowledge/__tests__/query.test.d.ts +1 -0
- package/lib/knowledge/__tests__/query.test.js +55 -0
- package/lib/knowledge/__tests__/screens.test.d.ts +1 -0
- package/lib/knowledge/__tests__/screens.test.js +30 -0
- package/lib/knowledge/__tests__/why.test.d.ts +1 -0
- package/lib/knowledge/__tests__/why.test.js +28 -0
- package/lib/knowledge/edges.d.ts +30 -0
- package/lib/knowledge/edges.js +68 -0
- package/lib/knowledge/errors.d.ts +8 -0
- package/lib/knowledge/errors.js +62 -0
- package/lib/knowledge/events.d.ts +23 -0
- package/lib/knowledge/events.js +51 -0
- package/lib/knowledge/extract.d.ts +26 -0
- package/lib/knowledge/extract.js +365 -0
- package/lib/knowledge/frontier.d.ts +26 -0
- package/lib/knowledge/frontier.js +60 -0
- package/lib/knowledge/funnels.d.ts +46 -0
- package/lib/knowledge/funnels.js +103 -0
- package/lib/knowledge/lint.d.ts +16 -0
- package/lib/knowledge/lint.js +89 -0
- package/lib/knowledge/perf.d.ts +32 -0
- package/lib/knowledge/perf.js +91 -0
- package/lib/knowledge/plan.d.ts +22 -0
- package/lib/knowledge/plan.js +81 -0
- package/lib/knowledge/problems.d.ts +5 -0
- package/lib/knowledge/problems.js +8 -0
- package/lib/knowledge/query.d.ts +36 -0
- package/lib/knowledge/query.js +127 -0
- package/lib/knowledge/route-params.d.ts +6 -0
- package/lib/knowledge/route-params.js +42 -0
- package/lib/knowledge/screens.d.ts +30 -0
- package/lib/knowledge/screens.js +125 -0
- package/lib/knowledge/tree.d.ts +15 -0
- package/lib/knowledge/tree.js +63 -0
- package/lib/knowledge/types.d.ts +62 -0
- package/lib/knowledge/types.js +6 -0
- package/lib/knowledge/why.d.ts +24 -0
- package/lib/knowledge/why.js +153 -0
- package/lib/mcp/server.d.ts +4 -0
- package/lib/mcp/server.js +111 -0
- package/lib/registry.d.ts +78 -0
- package/lib/registry.js +113 -0
- package/lib/runtime.d.ts +12 -0
- package/lib/runtime.js +56 -0
- package/lib/targets.d.ts +17 -0
- package/lib/targets.js +43 -0
- package/package.json +47 -0
- package/src/__tests__/args.test.ts +82 -0
- package/src/__tests__/check.test.ts +71 -0
- package/src/__tests__/init.test.ts +52 -0
- package/src/__tests__/record.test.ts +49 -0
- package/src/__tests__/snapshot.test.ts +51 -0
- package/src/__tests__/targets.test.ts +132 -0
- package/src/args.ts +111 -0
- package/src/cases/__tests__/format.test.ts +76 -0
- package/src/cases/__tests__/runner.test.ts +230 -0
- package/src/cases/fixtures.ts +104 -0
- package/src/cases/format.ts +183 -0
- package/src/cases/runner.ts +575 -0
- package/src/cases/templates.ts +159 -0
- package/src/cli.ts +176 -0
- package/src/commands/cases.ts +170 -0
- package/src/commands/check.ts +83 -0
- package/src/commands/crawl/return.ts +130 -0
- package/src/commands/crawl/state.ts +70 -0
- package/src/commands/crawl.ts +262 -0
- package/src/commands/device.ts +119 -0
- package/src/commands/doctor.ts +237 -0
- package/src/commands/drive.ts +413 -0
- package/src/commands/flake.ts +48 -0
- package/src/commands/headless.ts +131 -0
- package/src/commands/init.ts +98 -0
- package/src/commands/map.ts +298 -0
- package/src/commands/os.ts +195 -0
- package/src/commands/overlay.ts +20 -0
- package/src/commands/record.ts +46 -0
- package/src/commands/runs.ts +475 -0
- package/src/commands/smoke.ts +86 -0
- package/src/commands/snapshot.ts +87 -0
- package/src/config.ts +103 -0
- package/src/device/devices.ts +44 -0
- package/src/device/driver.ts +197 -0
- package/src/device/platform.ts +74 -0
- package/src/files.ts +35 -0
- package/src/flow/artifacts.ts +61 -0
- package/src/flow/runner.ts +182 -0
- package/src/flow/types.ts +55 -0
- package/src/index.ts +18 -0
- package/src/knowledge/__tests__/edges.test.ts +34 -0
- package/src/knowledge/__tests__/events.test.ts +45 -0
- package/src/knowledge/__tests__/extract.test.ts +88 -0
- package/src/knowledge/__tests__/frontier.test.ts +77 -0
- package/src/knowledge/__tests__/funnels.test.ts +58 -0
- package/src/knowledge/__tests__/lint.test.ts +66 -0
- package/src/knowledge/__tests__/perf.test.ts +41 -0
- package/src/knowledge/__tests__/plan.test.ts +52 -0
- package/src/knowledge/__tests__/query.test.ts +74 -0
- package/src/knowledge/__tests__/screens.test.ts +37 -0
- package/src/knowledge/__tests__/why.test.ts +40 -0
- package/src/knowledge/edges.ts +91 -0
- package/src/knowledge/errors.ts +73 -0
- package/src/knowledge/events.ts +60 -0
- package/src/knowledge/extract.ts +379 -0
- package/src/knowledge/frontier.ts +82 -0
- package/src/knowledge/funnels.ts +126 -0
- package/src/knowledge/lint.ts +99 -0
- package/src/knowledge/perf.ts +114 -0
- package/src/knowledge/plan.ts +95 -0
- package/src/knowledge/problems.ts +12 -0
- package/src/knowledge/query.ts +146 -0
- package/src/knowledge/route-params.ts +39 -0
- package/src/knowledge/screens.ts +139 -0
- package/src/knowledge/tree.ts +66 -0
- package/src/knowledge/types.ts +60 -0
- package/src/knowledge/why.ts +189 -0
- package/src/mcp/server.ts +109 -0
- package/src/registry.ts +191 -0
- package/src/runtime.ts +54 -0
- package/src/targets.ts +44 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a case's setup, then its steps, stopping at the first that fails. The
|
|
3
|
+
* app is driven through the terminal's own commands on one connection; the
|
|
4
|
+
* backend is prepared and read through the project's fixtures. How this app
|
|
5
|
+
* logs in or what a user is belongs to the project, as macros and fixtures.
|
|
6
|
+
*/
|
|
7
|
+
import { exec } from 'node:child_process';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
|
|
11
|
+
import type { Element, Screen, Target, TraceEvent } from 'lynkeus-protocol';
|
|
12
|
+
|
|
13
|
+
import { describeScreen } from '../device/driver.js';
|
|
14
|
+
import { type AppEvent, normalizeEvents, type RunEvent } from '../knowledge/events.js';
|
|
15
|
+
import { type Base, type Command, find } from '../registry.js';
|
|
16
|
+
import { execute } from '../runtime.js';
|
|
17
|
+
import { isRoute, parseTarget, scopedTarget } from '../targets.js';
|
|
18
|
+
import type { Fixtures, FixturesConfig } from './fixtures.js';
|
|
19
|
+
import { type Case, dig, interpolate, matcherOf, matches, type Scope, type Step, stepOf, type StepValue } from './format.js';
|
|
20
|
+
|
|
21
|
+
export type CasesConfig = {
|
|
22
|
+
/** The folder of the file that declared this, set when the configuration is read. */
|
|
23
|
+
base?: string;
|
|
24
|
+
/** Where cases live (default `cases`). */
|
|
25
|
+
dir?: string;
|
|
26
|
+
/** Where the flows a case runs live (default `flows`). */
|
|
27
|
+
flows?: string;
|
|
28
|
+
/** The long-lived command that answers fixture steps, as JSON lines. */
|
|
29
|
+
fixtures?: FixturesConfig;
|
|
30
|
+
/** The fixture `assert` reads the backend through (default `inspect`). */
|
|
31
|
+
inspect?: string;
|
|
32
|
+
/** `{ "user": "user_id" }` turns `user: u` into `user_id: <u.user_id>`, and gives a step that names no user the latest one. */
|
|
33
|
+
refs?: Record<string, string>;
|
|
34
|
+
/** Steps this project adds: `{ "app.login": [...] }`. Inside, `{{param.x}}` is what the case passed. */
|
|
35
|
+
macros?: Record<string, unknown[]>;
|
|
36
|
+
/** What the app may put in the way of a step and the steps that get past it; a step whose own words match `unless` is about it, and is left alone. */
|
|
37
|
+
interruptions?: { see: string; do: unknown[]; unless?: string }[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type StepReport = { step: string; status: 'passed' | 'failed' | 'skipped'; ms: number; error?: string };
|
|
41
|
+
export type CaseReport = {
|
|
42
|
+
id: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
file: string;
|
|
45
|
+
result: 'passed' | 'failed';
|
|
46
|
+
ms: number;
|
|
47
|
+
steps: StepReport[];
|
|
48
|
+
/** When a step failed: the screen, the requests since the mark, and why. */
|
|
49
|
+
evidence?: { screen?: string; requests?: string[]; why?: string };
|
|
50
|
+
/** What the app told its analytics, each with the step that caused it, when the run asked for it. */
|
|
51
|
+
events?: RunEvent[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type RunOptions = { base: Base; config: CasesConfig; fixtures?: Fixtures; dry?: boolean; events?: boolean; log?: (line: string) => void };
|
|
55
|
+
|
|
56
|
+
/** What a buffer holds that it did not before, whichever end it grows from. */
|
|
57
|
+
export const newSince = (before: AppEvent[], now: AppEvent[]): AppEvent[] => {
|
|
58
|
+
if (now.length < before.length) return now;
|
|
59
|
+
if (now.length === before.length) return [];
|
|
60
|
+
const same = (a: AppEvent[], b: AppEvent[]) => JSON.stringify(a) === JSON.stringify(b);
|
|
61
|
+
const extra = now.length - before.length;
|
|
62
|
+
if (same(now.slice(0, before.length), before)) return now.slice(before.length);
|
|
63
|
+
if (same(now.slice(extra), before)) return now.slice(0, extra).reverse();
|
|
64
|
+
return now;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
68
|
+
const run = promisify(exec);
|
|
69
|
+
|
|
70
|
+
const label = (step: Step): string => {
|
|
71
|
+
const value = step.value;
|
|
72
|
+
if (value === null || value === undefined) return step.verb;
|
|
73
|
+
const text =
|
|
74
|
+
typeof value === 'object'
|
|
75
|
+
? Object.entries(value)
|
|
76
|
+
.map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
|
|
77
|
+
.join(', ')
|
|
78
|
+
: String(value);
|
|
79
|
+
return `${step.verb} ${text}`.slice(0, 140);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const FLAG_NAMES: Record<string, string> = {
|
|
83
|
+
within: 'in',
|
|
84
|
+
advanceMs: 'advance',
|
|
85
|
+
timeoutMs: 'timeout',
|
|
86
|
+
delayMs: 'delay',
|
|
87
|
+
durationMs: 'duration',
|
|
88
|
+
holdMs: 'hold',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** `wait` holds when the case passed it; `method=phone` when it passed that value. */
|
|
92
|
+
const holds = (condition: string, param: Record<string, unknown>, ran: Set<string> = new Set()): boolean => {
|
|
93
|
+
if (condition.startsWith('ran:')) return ran.has(condition.slice(4));
|
|
94
|
+
const [name, value] = condition.split('=', 2);
|
|
95
|
+
const given = param[name!];
|
|
96
|
+
if (value !== undefined) return String(given) === value;
|
|
97
|
+
return given !== undefined && given !== null && given !== false && given !== 'none';
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A step's value as the argv of the command it names: what matches a positional by name goes in place, the rest are flags. */
|
|
101
|
+
export const argvFor = (command: Command, value: StepValue): string[] => {
|
|
102
|
+
const argv = command.name.split(' ');
|
|
103
|
+
if (value === null || value === undefined) return argv;
|
|
104
|
+
if (typeof value === 'string' && command.flags?.[value]?.type === 'boolean') return [...argv, `--${value}`];
|
|
105
|
+
if (typeof value !== 'object' || Array.isArray(value)) return [...argv, ...(Array.isArray(value) ? value.map(String) : [String(value)])];
|
|
106
|
+
const given = { ...(value as Record<string, unknown>) };
|
|
107
|
+
const positionals = command.positionals ?? [];
|
|
108
|
+
// A case says `target` or `request` where a command may call its first argument something else.
|
|
109
|
+
const first = positionals[0]?.name;
|
|
110
|
+
for (const alias of ['target', 'request', 'route', 'url', 'state', 'text']) {
|
|
111
|
+
if (first && !(first in given) && alias in given && !positionals.some((p) => p.name === alias) && !(alias in (command.flags ?? {}))) {
|
|
112
|
+
given[first] = given[alias];
|
|
113
|
+
delete given[alias];
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const p of positionals) {
|
|
118
|
+
if (!(p.name in given)) {
|
|
119
|
+
if (positionals.slice(positionals.indexOf(p) + 1).some((later) => later.name in given)) throw new Error(`${command.name} needs ${p.name}`);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
const v = given[p.name];
|
|
123
|
+
argv.push(typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
124
|
+
delete given[p.name];
|
|
125
|
+
}
|
|
126
|
+
for (const [key, v] of Object.entries(given)) {
|
|
127
|
+
const flag = FLAG_NAMES[key] ?? key;
|
|
128
|
+
if (!(flag in (command.flags ?? {}))) throw new Error(`${command.name} has no ${key}`);
|
|
129
|
+
if (v === false || v === undefined || v === null) continue;
|
|
130
|
+
if (v === true) argv.push(`--${flag}`);
|
|
131
|
+
else if (Array.isArray(v)) for (const item of v) argv.push(`--${flag}`, String(item));
|
|
132
|
+
else argv.push(`--${flag}`, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
133
|
+
}
|
|
134
|
+
return argv;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const presented = (screen: Screen): Element[] => screen.elements.filter((e) => !e.hidden || e.hidden === 'inert');
|
|
138
|
+
const textOf = (e: Element) => e.text ?? e.value ?? e.accessibilityLabel ?? '';
|
|
139
|
+
|
|
140
|
+
const requestMatches = (event: TraceEvent, wanted: string, status?: number): boolean => {
|
|
141
|
+
if (event.kind !== 'request') return false;
|
|
142
|
+
const [method, route] = wanted.includes(' ') ? wanted.split(/\s+/, 2) : ['*', wanted];
|
|
143
|
+
let pathname = event.url;
|
|
144
|
+
try {
|
|
145
|
+
pathname = new URL(event.url).pathname;
|
|
146
|
+
} catch {
|
|
147
|
+
pathname = event.url.split('?')[0]!;
|
|
148
|
+
}
|
|
149
|
+
if (method !== '*' && event.method.toUpperCase() !== method!.toUpperCase()) return false;
|
|
150
|
+
if (!pathname.endsWith(route!)) return false;
|
|
151
|
+
return status === undefined ? true : event.status === status;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const BUILT_IN = ['press', 'type', 'swipe', 'see', 'requests', 'wait', 'nav', 'call', 'run', 'events', 'event'];
|
|
155
|
+
|
|
156
|
+
const unknownStep = (step: Step, config: CasesConfig): string | undefined => {
|
|
157
|
+
if (config.macros?.[step.verb] || !step.verb.startsWith('app.')) return undefined;
|
|
158
|
+
const name = step.verb.slice(4);
|
|
159
|
+
if (BUILT_IN.includes(name)) return undefined;
|
|
160
|
+
const command = find(name) ?? find(`os ${name}`);
|
|
161
|
+
if (!command) return `${step.verb} is not a step: lynkeus has no command ${name}, and the project declares no macro for it`;
|
|
162
|
+
try {
|
|
163
|
+
argvFor(command, step.value);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return (error as Error).message;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport> => {
|
|
171
|
+
const { base, config } = options;
|
|
172
|
+
const log = options.log ?? (() => undefined);
|
|
173
|
+
const scope: Scope = {};
|
|
174
|
+
const ran = new Set<string>();
|
|
175
|
+
const latest: Record<string, unknown> = {};
|
|
176
|
+
const started = Date.now();
|
|
177
|
+
const steps: StepReport[] = [];
|
|
178
|
+
let mark: number | undefined;
|
|
179
|
+
let why: string | undefined;
|
|
180
|
+
const recorded: RunEvent[] = [];
|
|
181
|
+
let buffer: AppEvent[] = [];
|
|
182
|
+
// Read, never emptied: a case may be asserting on the buffer.
|
|
183
|
+
const collect = async (index: number) => {
|
|
184
|
+
if (!options.events) return;
|
|
185
|
+
const d = await base.device().catch(() => undefined);
|
|
186
|
+
if (!d?.server.connected) return;
|
|
187
|
+
const now = normalizeEvents(await d.command('events').catch(() => []));
|
|
188
|
+
const route = (await d.screen().catch(() => undefined))?.route;
|
|
189
|
+
for (const e of newSince(buffer, now)) recorded.push({ ...e, step: index, route });
|
|
190
|
+
buffer = now;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const device = () => base.device();
|
|
194
|
+
const sinceMark = async (): Promise<TraceEvent[]> => (await (await device()).trace(mark)).events;
|
|
195
|
+
const cli = async (argv: string[]) => {
|
|
196
|
+
const outcome = await execute(argv, base);
|
|
197
|
+
if (outcome.error || outcome.code !== 0) {
|
|
198
|
+
why = outcome.why;
|
|
199
|
+
throw new Error(outcome.error || outcome.text || `${argv.join(' ')} failed`);
|
|
200
|
+
}
|
|
201
|
+
return outcome;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const withRefs = (params: Record<string, unknown>): Record<string, unknown> => {
|
|
205
|
+
const out = { ...params };
|
|
206
|
+
for (const [param, field] of Object.entries(config.refs ?? {})) {
|
|
207
|
+
const alias = out[param];
|
|
208
|
+
delete out[param];
|
|
209
|
+
if (typeof alias === 'string') {
|
|
210
|
+
if (!(alias in scope)) throw new Error(`no alias '${alias}'`);
|
|
211
|
+
const found = dig(scope[alias], field);
|
|
212
|
+
if (found === undefined) throw new Error(`alias '${alias}' has no ${field}`);
|
|
213
|
+
out[field] = found;
|
|
214
|
+
} else if (!(field in out) && latest[field] !== undefined) out[field] = latest[field];
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const fixture = async (name: string, value: StepValue): Promise<void> => {
|
|
220
|
+
if (!options.fixtures) throw new Error(`${name} is not a step lynkeus has, and the project declares no fixtures (cases.fixtures in lynkeus.config.json)`);
|
|
221
|
+
const given = value && typeof value === 'object' && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : {};
|
|
222
|
+
const as = typeof given.as === 'string' ? given.as : undefined;
|
|
223
|
+
delete given.as;
|
|
224
|
+
const answer = await options.fixtures.call(name, withRefs(given));
|
|
225
|
+
if (as) scope[as] = answer;
|
|
226
|
+
for (const field of Object.values(config.refs ?? {})) if (answer[field] !== undefined) latest[field] = answer[field];
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const assert = async (value: StepValue): Promise<void> => {
|
|
230
|
+
const params = (value ?? {}) as Record<string, unknown>;
|
|
231
|
+
const matcher = matcherOf(params);
|
|
232
|
+
if (!matcher || typeof params.path !== 'string') throw new Error('assert needs a path and a matcher (equals, includes, gte, …)');
|
|
233
|
+
if (!options.fixtures) throw new Error('assert reads the backend through fixtures, and the project declares none');
|
|
234
|
+
const rest = Object.fromEntries(Object.entries(params).filter(([k]) => k !== 'path' && !(k in matcher)));
|
|
235
|
+
const answer = await options.fixtures.call(config.inspect ?? 'inspect', withRefs(rest));
|
|
236
|
+
const got = dig(answer, params.path);
|
|
237
|
+
if (!matches(got, matcher)) throw new Error(`${params.path} is ${JSON.stringify(got)}`);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const targetIn = (params: Record<string, unknown>): Target | undefined => {
|
|
241
|
+
const raw = typeof params.target === 'string' ? params.target : params.testId ? `#${String(params.testId)}` : undefined;
|
|
242
|
+
return raw === undefined ? undefined : scopedTarget(raw, { in: params.within, nth: params.nth });
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Every condition a `see` names, read off one screen and the trace since the mark; what is missing, or nothing. */
|
|
246
|
+
const seen = async (params: Record<string, unknown>): Promise<string | undefined> => {
|
|
247
|
+
const d = await device();
|
|
248
|
+
const screen = await d.screen();
|
|
249
|
+
const elements = presented(screen);
|
|
250
|
+
if (typeof params.route === 'string' && screen.route !== params.route) return `on ${screen.route ?? 'no route'}, not ${params.route}`;
|
|
251
|
+
if ('layer' in params) {
|
|
252
|
+
const open = screen.presenting;
|
|
253
|
+
const wanted = params.layer;
|
|
254
|
+
const ok = wanted === true ? !!open : wanted === false ? !open : !!open && `${open.testId ?? ''}`.includes(String(wanted));
|
|
255
|
+
if (!ok) return wanted === false ? 'a layer is open' : `no layer${wanted === true ? '' : ` ${String(wanted)}`} is open`;
|
|
256
|
+
}
|
|
257
|
+
const target = targetIn(params);
|
|
258
|
+
const text = typeof params.text === 'string' ? params.text : undefined;
|
|
259
|
+
if (target) {
|
|
260
|
+
const found = (await d.server.call('find', target)) as Element | null;
|
|
261
|
+
if (!found || (found.hidden && found.hidden !== 'inert')) return `not on screen: ${JSON.stringify(target)}`;
|
|
262
|
+
if (text !== undefined && !textOf(found).includes(text) && !elements.some((e) => e.parent === found.i && textOf(e).includes(text))) {
|
|
263
|
+
return `${JSON.stringify(target)} says ${JSON.stringify(textOf(found))}, not ${JSON.stringify(text)}`;
|
|
264
|
+
}
|
|
265
|
+
} else if (text !== undefined && !elements.some((e) => textOf(e).includes(text))) return `not on screen: ${JSON.stringify(text)}`;
|
|
266
|
+
if (typeof params.request === 'string') {
|
|
267
|
+
const status = typeof params.status === 'number' ? params.status : undefined;
|
|
268
|
+
if (!(await sinceMark()).some((e) => requestMatches(e, params.request as string, status))) return `not requested since the mark: ${params.request}`;
|
|
269
|
+
}
|
|
270
|
+
if (typeof params.event === 'string') {
|
|
271
|
+
const wanted = (params.props ?? params.properties ?? {}) as Record<string, unknown>;
|
|
272
|
+
const events = normalizeEvents(await d.command('events'));
|
|
273
|
+
const hit = events.some((e) => e.name === params.event && Object.entries(wanted).every(([k, v]) => String(e.props?.[k]) === String(v)));
|
|
274
|
+
if (!hit) return `no ${params.event} event${Object.keys(wanted).length ? ` with ${JSON.stringify(wanted)}` : ''}`;
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const see = async (value: StepValue): Promise<void> => {
|
|
280
|
+
const params = typeof value === 'string' ? { target: value } : ((value ?? {}) as Record<string, unknown>);
|
|
281
|
+
const absent = params.absent === true;
|
|
282
|
+
const wait = typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000;
|
|
283
|
+
const deadline = Date.now() + wait;
|
|
284
|
+
const hard = Date.now() + Math.max(wait, 12_000);
|
|
285
|
+
while (true) {
|
|
286
|
+
await interrupted(value);
|
|
287
|
+
const missing = await seen(params);
|
|
288
|
+
if (absent ? missing !== undefined : missing === undefined) return;
|
|
289
|
+
if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
|
|
290
|
+
await sleep(150);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (Date.now() > deadline)
|
|
294
|
+
throw new Error(absent ? `still there: ${label({ verb: '', value: { ...params, absent: undefined } as StepValue }).trim()}` : missing!);
|
|
295
|
+
await sleep(150);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const waitForRequest = async (params: Record<string, unknown>): Promise<void> => {
|
|
300
|
+
const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs : 10_000;
|
|
301
|
+
const deadline = Date.now() + timeoutMs;
|
|
302
|
+
const status = typeof params.status === 'number' ? params.status : undefined;
|
|
303
|
+
while (true) {
|
|
304
|
+
const events = await sinceMark();
|
|
305
|
+
const hit = events.some(
|
|
306
|
+
(e) => requestMatches(e, String(params.request), status) && (status !== undefined || (e.kind === 'request' && String(e.status).startsWith('2'))),
|
|
307
|
+
);
|
|
308
|
+
if (hit) return;
|
|
309
|
+
if (Date.now() > deadline) throw new Error(`no ${status ?? '2xx'} ${String(params.request)} within ${timeoutMs} ms`);
|
|
310
|
+
await sleep(300);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const press = async (value: StepValue): Promise<'skipped' | undefined> => {
|
|
315
|
+
const params = typeof value === 'string' ? { target: value } : ((value ?? {}) as Record<string, unknown>);
|
|
316
|
+
const target = targetIn(params);
|
|
317
|
+
if (!target) throw new Error('app.press needs a target');
|
|
318
|
+
const d = await device();
|
|
319
|
+
if (params.optional === true) {
|
|
320
|
+
const there = await d.waitFor({ target, timeoutMs: 300 }).then(
|
|
321
|
+
() => true,
|
|
322
|
+
() => false,
|
|
323
|
+
);
|
|
324
|
+
if (!there) return 'skipped';
|
|
325
|
+
}
|
|
326
|
+
// The control may still be arriving, or disabled while what it depends on loads.
|
|
327
|
+
const deadline = Date.now() + (typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000);
|
|
328
|
+
const hard = Date.now() + 15_000;
|
|
329
|
+
while (Date.now() < hard && 'testId' in target) {
|
|
330
|
+
const found = (await d.server.call('find', target)) as Element | null;
|
|
331
|
+
if (found?.enabled) break;
|
|
332
|
+
if (Date.now() > deadline && !(await d.screen()).busy) break;
|
|
333
|
+
await sleep(200);
|
|
334
|
+
}
|
|
335
|
+
const { optional: _optional, timeoutMs: _timeout, ...rest } = params;
|
|
336
|
+
await cli(argvFor(find('press')!, rest as StepValue));
|
|
337
|
+
return undefined;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const call = async (params: Record<string, unknown> | undefined): Promise<void> => {
|
|
341
|
+
if (!params?.command) throw new Error('app.call needs a command');
|
|
342
|
+
const d = await device();
|
|
343
|
+
const deadline = Date.now() + (typeof params.timeoutMs === 'number' ? params.timeoutMs : 3000);
|
|
344
|
+
const expect = (params.expect ?? {}) as Record<string, unknown>;
|
|
345
|
+
for (;;) {
|
|
346
|
+
const answer = await d.command(String(params.command), params.params);
|
|
347
|
+
if (typeof params.as === 'string') scope[params.as] = answer;
|
|
348
|
+
const miss = Object.entries(expect).find(([p, wanted]) => {
|
|
349
|
+
const got = dig(answer, p);
|
|
350
|
+
return wanted === null ? got !== undefined && got !== null : String(got) !== String(wanted);
|
|
351
|
+
});
|
|
352
|
+
if (!miss) return;
|
|
353
|
+
if (Date.now() > deadline)
|
|
354
|
+
throw new Error(`${String(params.command)}: ${miss[0]} is ${JSON.stringify(dig(answer, miss[0]))}, expected ${JSON.stringify(miss[1])}`);
|
|
355
|
+
await sleep(250);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Nobody acts on a screen that is still asking for what it shows: a press
|
|
360
|
+
// that lands before the data does is a different case from the one written.
|
|
361
|
+
const ready = async () => {
|
|
362
|
+
const d = await device();
|
|
363
|
+
const deadline = Date.now() + 5000;
|
|
364
|
+
while ((await d.screen()).busy && Date.now() < deadline) await sleep(100);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
let handling = false;
|
|
368
|
+
const interrupted = async (value: StepValue): Promise<void> => {
|
|
369
|
+
if (handling || !config.interruptions?.length) return;
|
|
370
|
+
const d = await device();
|
|
371
|
+
const about = JSON.stringify(value ?? '');
|
|
372
|
+
for (const one of config.interruptions) {
|
|
373
|
+
if (one.unless && new RegExp(one.unless, 'i').test(about)) continue;
|
|
374
|
+
const target = parseTarget(one.see);
|
|
375
|
+
const found = (await d.server.call('find', target).catch(() => null)) as Element | null;
|
|
376
|
+
if (!found || found.hidden) continue;
|
|
377
|
+
handling = true;
|
|
378
|
+
try {
|
|
379
|
+
for (const raw of one.do) await perform(stepOf(raw));
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const still = (await d.server.call('find', target).catch(() => null)) as Element | null;
|
|
382
|
+
if (still && !still.hidden) throw error;
|
|
383
|
+
} finally {
|
|
384
|
+
handling = false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const app = async (name: string, value: StepValue): Promise<'skipped' | undefined> => {
|
|
390
|
+
const params = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
391
|
+
const d = await device();
|
|
392
|
+
mark ??= (await d.trace()).last;
|
|
393
|
+
switch (name) {
|
|
394
|
+
case 'press': {
|
|
395
|
+
await interrupted(value);
|
|
396
|
+
await ready();
|
|
397
|
+
const outcome = await press(value);
|
|
398
|
+
// One touch, one render: the next step reads what this one caused.
|
|
399
|
+
await d.idle({ quietMs: 50, timeoutMs: 400 }).catch(() => undefined);
|
|
400
|
+
return outcome;
|
|
401
|
+
}
|
|
402
|
+
case 'type':
|
|
403
|
+
case 'swipe':
|
|
404
|
+
await interrupted(value);
|
|
405
|
+
await ready();
|
|
406
|
+
await cli(argvFor(find(name)!, value));
|
|
407
|
+
// A pager on a device keeps moving after the finger lifts, and swallows a swipe sent into that momentum.
|
|
408
|
+
if (name === 'swipe' && d.server.hello?.native) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
409
|
+
return undefined;
|
|
410
|
+
case 'see':
|
|
411
|
+
await see(value);
|
|
412
|
+
return undefined;
|
|
413
|
+
case 'requests':
|
|
414
|
+
mark = (await d.trace()).last;
|
|
415
|
+
return undefined;
|
|
416
|
+
case 'wait': {
|
|
417
|
+
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) await sleep(Number(value));
|
|
418
|
+
else if (params?.request) await waitForRequest(params);
|
|
419
|
+
else {
|
|
420
|
+
if (config.interruptions?.length) {
|
|
421
|
+
const asked: Record<string, unknown> = typeof value === 'string' ? { what: value } : { ...(params ?? {}) };
|
|
422
|
+
const deadline = Date.now() + (typeof asked.timeoutMs === 'number' ? asked.timeoutMs : 10_000);
|
|
423
|
+
for (;;) {
|
|
424
|
+
await interrupted(value);
|
|
425
|
+
const left = deadline - Date.now();
|
|
426
|
+
const outcome = await execute(argvFor(find('wait')!, { ...asked, timeoutMs: Math.max(Math.min(left, 1000), 100) } as StepValue), base);
|
|
427
|
+
if (!outcome.error && outcome.code === 0) break;
|
|
428
|
+
if (Date.now() >= deadline) {
|
|
429
|
+
why = outcome.why;
|
|
430
|
+
throw new Error(outcome.error ?? outcome.text ?? 'wait failed');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} else await cli(argvFor(find('wait')!, value));
|
|
434
|
+
// 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
|
+
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
436
|
+
if (d.server.hello?.native && isRoute(what)) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
437
|
+
}
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
case 'nav': {
|
|
441
|
+
const route = typeof value === 'string' ? value : String(params?.route);
|
|
442
|
+
await cli(argvFor(find('nav')!, value));
|
|
443
|
+
await d.waitFor({ route, timeoutMs: 8000 });
|
|
444
|
+
await d.idle({ quietMs: 300, timeoutMs: 8000 });
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
case 'call':
|
|
448
|
+
await call(params);
|
|
449
|
+
return undefined;
|
|
450
|
+
case 'run': {
|
|
451
|
+
const flow = typeof value === 'string' ? value : String(params?.flow);
|
|
452
|
+
const file = path.isAbsolute(flow) ? flow : path.resolve(config.base ?? base.root, config.flows ?? 'flows', flow);
|
|
453
|
+
const vars = Object.entries((params?.vars ?? {}) as Record<string, unknown>).flatMap(([k, v]) => ['--var', `${k}=${String(v)}`]);
|
|
454
|
+
await cli(['run', file, ...vars]);
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
case 'events':
|
|
458
|
+
await collect(steps.length + 1);
|
|
459
|
+
await d.command('events', { clear: true });
|
|
460
|
+
buffer = [];
|
|
461
|
+
return undefined;
|
|
462
|
+
case 'event':
|
|
463
|
+
await see({ ...(params ?? {}), event: typeof value === 'string' ? value : params?.name, name: undefined } as StepValue);
|
|
464
|
+
return undefined;
|
|
465
|
+
default: {
|
|
466
|
+
const command = find(name) ?? find(`os ${name}`);
|
|
467
|
+
if (!command) throw new Error(`app.${name} is not a step: lynkeus has no command ${name}, and the project declares no macro for it`);
|
|
468
|
+
await cli(argvFor(command, value));
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const shell = async (value: StepValue): Promise<void> => {
|
|
475
|
+
const params = typeof value === 'string' ? { run: value } : ((value ?? {}) as Record<string, unknown>);
|
|
476
|
+
const { stdout } = await run(String(params.run), { cwd: config.base ?? base.root, env: process.env, timeout: 120_000 });
|
|
477
|
+
if (typeof params.as !== 'string') return;
|
|
478
|
+
const json = /\{[\s\S]*\}/.exec(stdout)?.[0];
|
|
479
|
+
scope[params.as] = json ? JSON.parse(json) : { out: stdout.trim() };
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const macro = async (steps: unknown[], value: StepValue): Promise<void> => {
|
|
483
|
+
const given = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : { value };
|
|
484
|
+
// A parameter that names an alias is that alias: `as: u` makes `{{param.as.user_id}}` the user's.
|
|
485
|
+
const param = Object.fromEntries(Object.entries(given).map(([k, v]) => [k, typeof v === 'string' && v in scope ? scope[v] : v]));
|
|
486
|
+
const outer = scope.param;
|
|
487
|
+
scope.param = param;
|
|
488
|
+
try {
|
|
489
|
+
for (const raw of steps) {
|
|
490
|
+
const entry = raw as Record<string, unknown>;
|
|
491
|
+
if ('do' in entry) {
|
|
492
|
+
const all = (v: unknown) => (Array.isArray(v) ? v : [v]).map(String);
|
|
493
|
+
if ('when' in entry && !all(entry.when).every((c) => holds(c, param, ran))) continue;
|
|
494
|
+
if ('unless' in entry && all(entry.unless).some((c) => holds(c, param, ran))) continue;
|
|
495
|
+
if ('on' in entry && !(await runsHere(String(entry.on)))) continue;
|
|
496
|
+
await perform(stepOf(entry.do));
|
|
497
|
+
} else await perform(stepOf(entry));
|
|
498
|
+
}
|
|
499
|
+
} finally {
|
|
500
|
+
scope.param = outer;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const runsHere = async (on: string): Promise<boolean> => {
|
|
505
|
+
const hello = (await device()).server.hello;
|
|
506
|
+
const native = !!hello?.native;
|
|
507
|
+
if (on === 'device') return native;
|
|
508
|
+
if (on === 'headless') return !native;
|
|
509
|
+
return hello?.platform === on;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const perform = async (step: Step): Promise<'skipped' | undefined> => {
|
|
513
|
+
if (step.on && !(await runsHere(step.on))) return 'skipped';
|
|
514
|
+
ran.add(step.verb);
|
|
515
|
+
const value = interpolate(step.value, scope);
|
|
516
|
+
const custom = config.macros?.[step.verb];
|
|
517
|
+
if (custom) {
|
|
518
|
+
await macro(custom, value);
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
if (step.verb.startsWith('app.')) return app(step.verb.slice(4), value);
|
|
522
|
+
if (step.verb === 'assert') return void (await assert(value));
|
|
523
|
+
if (step.verb === 'exec') return void (await shell(value));
|
|
524
|
+
return void (await fixture(step.verb, value));
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
if (options.events && !options.dry) {
|
|
528
|
+
const d = await base.device().catch(() => undefined);
|
|
529
|
+
if (d?.server.connected) buffer = normalizeEvents(await d.command('events').catch(() => []));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let failed = false;
|
|
533
|
+
for (const step of [...c.setup, ...c.steps]) {
|
|
534
|
+
const text = label(step);
|
|
535
|
+
if (failed) {
|
|
536
|
+
steps.push({ step: text, status: 'skipped', ms: 0 });
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (options.dry) {
|
|
540
|
+
const unknown = unknownStep(step, config);
|
|
541
|
+
steps.push({ step: text, status: unknown ? 'failed' : 'passed', ms: 0, ...(unknown ? { error: unknown } : {}) });
|
|
542
|
+
if (unknown) log(` ❌ ${text} — ${unknown}`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const t0 = Date.now();
|
|
546
|
+
try {
|
|
547
|
+
const outcome = await perform(step);
|
|
548
|
+
if (step.verb.startsWith('app.') || config.macros?.[step.verb]) await collect(steps.length + 1);
|
|
549
|
+
steps.push({ step: text, status: outcome === 'skipped' ? 'skipped' : 'passed', ms: Date.now() - t0 });
|
|
550
|
+
log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
failed = true;
|
|
553
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
554
|
+
steps.push({ step: text, status: 'failed', ms: Date.now() - t0, error: message });
|
|
555
|
+
log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (options.dry) failed = steps.some((s) => s.status === 'failed');
|
|
560
|
+
const report: CaseReport = { id: c.id, title: c.title, file: c.file, result: failed ? 'failed' : 'passed', ms: Date.now() - started, steps };
|
|
561
|
+
if (options.events) report.events = recorded;
|
|
562
|
+
if (failed && !options.dry) {
|
|
563
|
+
const d = await device().catch(() => undefined);
|
|
564
|
+
if (d?.server.connected) {
|
|
565
|
+
const screen = await d.screen().catch(() => undefined);
|
|
566
|
+
const events = await d.trace(mark).catch(() => undefined);
|
|
567
|
+
report.evidence = {
|
|
568
|
+
screen: screen ? describeScreen(screen, { limit: 40 }) : undefined,
|
|
569
|
+
requests: events?.events.flatMap((e) => (e.kind === 'request' ? [`${e.status ?? '…'} ${e.method} ${e.url}`] : [])).slice(-8),
|
|
570
|
+
why,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return report;
|
|
575
|
+
};
|