lynkeus 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cases/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +58 -1
- package/lib/cases/runner.js +322 -39
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +27 -4
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +154 -25
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +189 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +156 -20
- package/lib/config.d.ts +41 -0
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/device/driver.js +2 -2
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +45 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +372 -42
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +27 -4
- package/src/commands/cases.ts +165 -23
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +197 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +162 -18
- package/src/config.ts +48 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/device/driver.ts +2 -2
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +45 -4
- package/src/runtime.ts +24 -3
package/src/cases/runner.ts
CHANGED
|
@@ -12,11 +12,13 @@ import { promisify } from 'node:util';
|
|
|
12
12
|
import type { Element, RequestRecord, Screen, Target } from 'lynkeus-protocol';
|
|
13
13
|
|
|
14
14
|
import { type Device, describeScreen } from '../device/driver.js';
|
|
15
|
+
import { trafficEvents } from '../knowledge/analyticsTraffic.js';
|
|
15
16
|
import { type AppEvent, normalizeEvents, type RunEvent } from '../knowledge/events.js';
|
|
16
17
|
import { type Base, type Command, find } from '../registry.js';
|
|
17
18
|
import { execute } from '../runtime.js';
|
|
18
19
|
import { isRoute, parseTarget, scopedTarget } from '../targets.js';
|
|
19
20
|
import type { Fixtures, FixturesConfig } from './fixtures.js';
|
|
21
|
+
import { compactFrame } from './frames.js';
|
|
20
22
|
import { type BackendSpan, newSpanId, newTraceId, type TraceSink, traceparent } from './traces.js';
|
|
21
23
|
import { type Case, dig, interpolate, matcherOf, matches, type Scope, seeded, type Step, stepOf, type StepValue } from './format.js';
|
|
22
24
|
|
|
@@ -47,13 +49,24 @@ export type CasesConfig = {
|
|
|
47
49
|
/** `{ "user": "user_id" }` turns `user: u` into `user_id: <u.user_id>`, and gives a step that names no user the latest one. */
|
|
48
50
|
refs?: Record<string, string>;
|
|
49
51
|
/** Steps this project adds: `{ "app.login": [...] }`. Inside, `{{param.x}}` is what the case passed. */
|
|
52
|
+
/** The screen a case begins on, for drafts (`case new`): the first the app shows a signed-in person, say. */
|
|
53
|
+
start?: string;
|
|
50
54
|
macros?: Record<string, unknown[]>;
|
|
51
55
|
/** What the app may put in the way of a step and the steps that get past it; a step whose own words match `unless` is about it, and is left alone. */
|
|
52
56
|
interruptions?: { see: string; do: unknown[]; unless?: string }[];
|
|
57
|
+
/**
|
|
58
|
+
* What the backend told its own analytics while a step ran, for a report: after each app step the fixture is
|
|
59
|
+
* asked with `{ since }` (the step's start, ISO) and answers `{ events: [{ name | event, props | payload,
|
|
60
|
+
* provider? }] }`. `provider` names the service when the fixture does not (an Olympus that sends to Mixpanel).
|
|
61
|
+
*/
|
|
62
|
+
serverEvents?: { fixture: string; provider?: string };
|
|
63
|
+
/** The service the app's own event list feeds (its `events` command), so a report names it instead of "App". */
|
|
64
|
+
appEvents?: { provider: string };
|
|
53
65
|
};
|
|
54
66
|
|
|
55
67
|
/** What a host's analytics capture saw a step send: the SDK it went to and the event. */
|
|
56
|
-
|
|
68
|
+
/** `via`: the SDK's native module (a host that stands in for it), the request it sent, or the backend's own analytics. */
|
|
69
|
+
export type SentEvent = { provider: string; name: string; props?: Record<string, unknown>; via?: 'sdk' | 'network' | 'server' };
|
|
57
70
|
|
|
58
71
|
/** `unsupported`: the host the case ran on has no such thing to do (a real device has no mocked responses), which says nothing about the app. */
|
|
59
72
|
export type StepReport = {
|
|
@@ -69,7 +82,14 @@ export type StepReport = {
|
|
|
69
82
|
sent?: SentEvent[];
|
|
70
83
|
/** What the backend did for it, from the traces it exported to the run. */
|
|
71
84
|
backend?: BackendSpan[];
|
|
85
|
+
/** The requests the app sent while it ran, with headers and bodies (secrets hidden), when the run keeps evidence. */
|
|
86
|
+
requests?: RequestRecord[];
|
|
87
|
+
/** What the app printed while it ran: its console and the errors it did not catch, when the run keeps evidence. */
|
|
88
|
+
logs?: LogLine[];
|
|
72
89
|
};
|
|
90
|
+
|
|
91
|
+
/** A line the app printed: `at` in ms since the epoch, `source` where it came from (`js`, `native`, `host`). */
|
|
92
|
+
export type LogLine = { at: number; level: 'debug' | 'info' | 'warn' | 'error'; source: string; text: string; stack?: string };
|
|
73
93
|
export type CaseReport = {
|
|
74
94
|
id: string;
|
|
75
95
|
title?: string;
|
|
@@ -78,6 +98,11 @@ export type CaseReport = {
|
|
|
78
98
|
result: 'passed' | 'failed' | 'unsupported';
|
|
79
99
|
ms: number;
|
|
80
100
|
steps: StepReport[];
|
|
101
|
+
/**
|
|
102
|
+
* What a reader needs before the steps: what the case proves (`description`), why it matters (`why`), what
|
|
103
|
+
* has to be true when it starts (`given`), the ticket it covers and the outcome it expects. From its frontmatter.
|
|
104
|
+
*/
|
|
105
|
+
about?: { description?: string; why?: string; given?: string; ticket?: string; expected?: string };
|
|
81
106
|
/** When a step failed: the screen, the requests since the mark, and why. */
|
|
82
107
|
evidence?: { screen?: string; requests?: string[]; why?: string };
|
|
83
108
|
/** What the app told its analytics, each with the step that caused it, when the run asked for it. */
|
|
@@ -91,7 +116,22 @@ export type CaseReport = {
|
|
|
91
116
|
};
|
|
92
117
|
|
|
93
118
|
/** Where a case keeps what it can show of itself: a frame per step, a film. Each is taken only where the host can. */
|
|
94
|
-
|
|
119
|
+
/** `frames`: AVIF where the host writes it (a fifth of a PNG's bytes, lossy), or PNG, exact, for frames two runs are compared by. */
|
|
120
|
+
export type Evidence = {
|
|
121
|
+
dir: string;
|
|
122
|
+
screenshots?: boolean;
|
|
123
|
+
settleMs?: number;
|
|
124
|
+
/** Wait for the screen to stand still: two frames alike this many ms apart, on a host's own clock where it offers
|
|
125
|
+
* that (`still`), or taken again on a device. */
|
|
126
|
+
stillMs?: number;
|
|
127
|
+
/** A looking step on an unchanged screen shares the last frame instead of taking one. */
|
|
128
|
+
onChange?: boolean;
|
|
129
|
+
frames?: 'avif' | 'png';
|
|
130
|
+
video?: { fps: number; scale?: number };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// A host that could not write AVIF once (an older one) is asked for PNG from then on.
|
|
134
|
+
let avifRefused = false;
|
|
95
135
|
|
|
96
136
|
export type RunOptions = {
|
|
97
137
|
base: Base;
|
|
@@ -110,7 +150,8 @@ export type RunOptions = {
|
|
|
110
150
|
/** Keep running a case's steps after one fails, to see everything that is wrong in one pass. */
|
|
111
151
|
continue?: boolean;
|
|
112
152
|
/** Collect the screens the app went through, for a coverage map. `fresh`: the app was started for this case. */
|
|
113
|
-
routes?: boolean | 'fresh'
|
|
153
|
+
routes?: boolean | 'fresh' /** The app's native log on a simulator or an emulator, read step by step into each step's logs. */;
|
|
154
|
+
nativeLogs?: { take: () => LogLine[] };
|
|
114
155
|
};
|
|
115
156
|
|
|
116
157
|
/** What a case's setup leaves for its steps: the aliases and what they answered, and what ran. */
|
|
@@ -224,6 +265,20 @@ const requestMatches = (event: RequestRecord, wanted: string, status?: number):
|
|
|
224
265
|
return status === undefined ? true : event.status === status;
|
|
225
266
|
};
|
|
226
267
|
|
|
268
|
+
const aboutOf = (c: Case): { about?: CaseReport['about'] } => {
|
|
269
|
+
const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
270
|
+
const about = Object.fromEntries(
|
|
271
|
+
Object.entries({
|
|
272
|
+
description: text(c.meta.description),
|
|
273
|
+
why: text(c.meta.why),
|
|
274
|
+
given: text(c.meta.given),
|
|
275
|
+
ticket: text(c.meta.ticket),
|
|
276
|
+
expected: c.expected,
|
|
277
|
+
}).filter(([, v]) => v !== undefined),
|
|
278
|
+
);
|
|
279
|
+
return Object.keys(about).length ? { about } : {};
|
|
280
|
+
};
|
|
281
|
+
|
|
227
282
|
// Steps that look at the app rather than act on it: a request they wait for was caused by the action before them.
|
|
228
283
|
const LOOKING = new Set(['see', 'wait', 'event', 'requests', 'events']);
|
|
229
284
|
|
|
@@ -258,6 +313,48 @@ export const missingFixtures = (c: Case, config: CasesConfig, known: string[]):
|
|
|
258
313
|
/** What the app or its host answers to a method it does not have; the name is what it lacks. */
|
|
259
314
|
export const lacking = (message: string): string | undefined => /Unknown method (\S+)/.exec(message)?.[1];
|
|
260
315
|
|
|
316
|
+
// A request is read from the app at the end of the step after the one that sent it when it leaves at the very
|
|
317
|
+
// end of a step (a GET the app sends as an action finishes): its trace, carrying the step it left in, says where
|
|
318
|
+
// it belongs. Each request without a trace in its own step moves to the step whose trace served the same method
|
|
319
|
+
// and path at the same moment.
|
|
320
|
+
export const fileRequestsByTrace = (steps: StepReport[], spans: Map<string, { index: number; startedMs: number }>) => {
|
|
321
|
+
const started = new Map([...spans.values()].map((v) => [v.index, v.startedMs]));
|
|
322
|
+
const pathOf = (u: unknown) => {
|
|
323
|
+
try {
|
|
324
|
+
return new URL(String(u)).pathname;
|
|
325
|
+
} catch {
|
|
326
|
+
return String(u ?? '').split('?')[0]!;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const roots = steps.flatMap((st, index) =>
|
|
330
|
+
(st.backend ?? [])
|
|
331
|
+
.filter((sp) => sp.depth === 0)
|
|
332
|
+
.map((sp) => ({
|
|
333
|
+
index,
|
|
334
|
+
method: String(sp.attributes?.['http.method'] ?? sp.attributes?.['http.request.method'] ?? '').toUpperCase(),
|
|
335
|
+
path: pathOf(sp.attributes?.['http.target'] ?? sp.attributes?.['url.path'] ?? ''),
|
|
336
|
+
at: (started.get(index) ?? 0) + sp.at,
|
|
337
|
+
})),
|
|
338
|
+
);
|
|
339
|
+
if (!roots.length) return;
|
|
340
|
+
steps.forEach((st, index) => {
|
|
341
|
+
const keep: RequestRecord[] = [];
|
|
342
|
+
for (const q of st.requests ?? []) {
|
|
343
|
+
const method = String(q.method).toUpperCase(),
|
|
344
|
+
path = pathOf(q.url);
|
|
345
|
+
const same = roots.filter((r) => r.method === method && r.path === path);
|
|
346
|
+
const here = same.some((r) => r.index === index);
|
|
347
|
+
const near = same
|
|
348
|
+
.filter((r) => typeof q.startedAt === 'number' && Math.abs(r.at - q.startedAt) < 1500)
|
|
349
|
+
.sort((a, b) => Math.abs(a.at - q.startedAt!) - Math.abs(b.at - q.startedAt!))[0];
|
|
350
|
+
if (here || !near || near.index === index || !steps[near.index]) keep.push(q);
|
|
351
|
+
else steps[near.index]!.requests = [...(steps[near.index]!.requests ?? []), q].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
|
352
|
+
}
|
|
353
|
+
if (st.requests) st.requests = keep.length ? keep : undefined;
|
|
354
|
+
if (st.requests === undefined) delete st.requests;
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
261
358
|
export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport> => {
|
|
262
359
|
const { base, config } = options;
|
|
263
360
|
const log = options.log ?? (() => undefined);
|
|
@@ -268,6 +365,11 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
268
365
|
const started = Date.now();
|
|
269
366
|
const steps: StepReport[] = [];
|
|
270
367
|
let mark: number | undefined;
|
|
368
|
+
// Where the trace stood as the last action began: a screen it led to that closes itself (a success screen) can
|
|
369
|
+
// come and go while that step's frame settles or a wait for a request runs, and a wait for it still counts it.
|
|
370
|
+
// Waits and looks change nothing, so they keep the mark of the action before them.
|
|
371
|
+
let actedFrom: number | undefined;
|
|
372
|
+
let lastFrame: { file: string; shape?: string } | undefined;
|
|
271
373
|
// The app numbers its requests as they start. A step that looks for one counts only those started after the
|
|
272
374
|
// action before it began: an earlier screen's request to the same path would otherwise pass for the one the
|
|
273
375
|
// action caused. `app.requests` marks by hand, and holds until a step has looked for a request.
|
|
@@ -276,14 +378,19 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
276
378
|
let why: string | undefined;
|
|
277
379
|
const recorded: RunEvent[] = [];
|
|
278
380
|
let buffer: AppEvent[] = [];
|
|
381
|
+
// A host that records what each SDK was handed (fastsim, the headless host) already has every event. Asking the
|
|
382
|
+
// app for its own list as well turns on the app's tracking debug, which may draw on the screen (a counter in
|
|
383
|
+
// every frame of the film).
|
|
384
|
+
let sdkRecorded = false;
|
|
279
385
|
// Read, never emptied: a case may be asserting on the buffer.
|
|
280
386
|
const collect = async (index: number) => {
|
|
281
|
-
if (!options.events) return;
|
|
387
|
+
if (!options.events || sdkRecorded) return;
|
|
282
388
|
const d = await base.device().catch(() => undefined);
|
|
283
389
|
if (!d?.server.connected) return;
|
|
284
390
|
const now = normalizeEvents(await d.command('events').catch(() => []));
|
|
285
391
|
const route = (await d.screen().catch(() => undefined))?.route;
|
|
286
|
-
|
|
392
|
+
const provider = config.appEvents?.provider;
|
|
393
|
+
for (const e of newSince(buffer, now)) recorded.push({ ...e, step: index, route, ...(provider ? { provider } : {}) });
|
|
287
394
|
buffer = now;
|
|
288
395
|
};
|
|
289
396
|
|
|
@@ -449,15 +556,23 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
449
556
|
if (looks === 1) await d.waitFor({ target: what, timeoutMs: 1500 }).catch(() => undefined);
|
|
450
557
|
else if (looks === 2) {
|
|
451
558
|
if (await scrollTo(async () => (await seen(params)) === undefined)) return;
|
|
452
|
-
} else
|
|
559
|
+
} else {
|
|
560
|
+
// A step that names its own timeout gets no more than what is left of it; one that does not may
|
|
561
|
+
// wait for a carousel to come round.
|
|
562
|
+
const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
|
|
563
|
+
await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
|
|
564
|
+
}
|
|
453
565
|
continue;
|
|
454
566
|
}
|
|
455
567
|
if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
|
|
456
568
|
await sleep(150);
|
|
457
569
|
continue;
|
|
458
570
|
}
|
|
459
|
-
if (Date.now() > deadline)
|
|
460
|
-
|
|
571
|
+
if (Date.now() > deadline) {
|
|
572
|
+
if (!absent) throw new Error(missing!);
|
|
573
|
+
const { absent: _, ...what } = params;
|
|
574
|
+
throw new Error(`still on screen: ${label({ verb: '', value: what as StepValue }).trim()}`);
|
|
575
|
+
}
|
|
461
576
|
await sleep(150);
|
|
462
577
|
}
|
|
463
578
|
};
|
|
@@ -586,9 +701,16 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
586
701
|
const app = async (name: string, value: StepValue): Promise<'skipped' | undefined> => {
|
|
587
702
|
const params = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
588
703
|
const d = await device();
|
|
589
|
-
|
|
704
|
+
// An app in the background has no connection to answer with: the step that brings it back must still run.
|
|
705
|
+
const lastSeq = () =>
|
|
706
|
+
d.trace().then(
|
|
707
|
+
(t) => t.last,
|
|
708
|
+
() => undefined,
|
|
709
|
+
);
|
|
710
|
+
mark ??= await lastSeq();
|
|
711
|
+
if (!LOOKING.has(name)) actedFrom = await lastSeq();
|
|
590
712
|
// An interruption handled inside a wait is not the action the wait is about.
|
|
591
|
-
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined) await markRequests(d);
|
|
713
|
+
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined) await markRequests(d).catch(() => undefined);
|
|
592
714
|
switch (name) {
|
|
593
715
|
case 'press': {
|
|
594
716
|
await interrupted(value);
|
|
@@ -624,8 +746,10 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
624
746
|
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) await sleep(Number(value));
|
|
625
747
|
else if (params?.request) await waitForRequest(params);
|
|
626
748
|
else {
|
|
749
|
+
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
750
|
+
const since = isRoute(what) && actedFrom !== undefined ? { since: actedFrom } : {};
|
|
627
751
|
if (config.interruptions?.length) {
|
|
628
|
-
const asked: Record<string, unknown> = typeof value === 'string' ? { what: value } :
|
|
752
|
+
const asked: Record<string, unknown> = { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since };
|
|
629
753
|
const deadline = Date.now() + (typeof asked.timeoutMs === 'number' ? asked.timeoutMs : 10_000);
|
|
630
754
|
for (;;) {
|
|
631
755
|
await interrupted(value);
|
|
@@ -637,9 +761,8 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
637
761
|
throw new Error(outcome.error ?? outcome.text ?? 'wait failed');
|
|
638
762
|
}
|
|
639
763
|
}
|
|
640
|
-
} else await cli(argvFor(find('wait')!, value));
|
|
764
|
+
} else await cli(argvFor(find('wait')!, 'since' in since ? { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since } : value));
|
|
641
765
|
// A screen that just arrived on a device is still sliding in, which no render shows: a press then lands on the one it left.
|
|
642
|
-
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
643
766
|
if (onWallClock(d) && isRoute(what)) await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
644
767
|
}
|
|
645
768
|
return undefined;
|
|
@@ -757,79 +880,268 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
757
880
|
|
|
758
881
|
if (options.events && !options.dry) {
|
|
759
882
|
const d = await base.device().catch(() => undefined);
|
|
760
|
-
|
|
883
|
+
sdkRecorded = !!d?.server.hello?.commands?.includes('analytics');
|
|
884
|
+
if (d?.server.connected && !sdkRecorded) buffer = normalizeEvents(await d.command('events').catch(() => []));
|
|
761
885
|
}
|
|
762
886
|
|
|
763
887
|
const evidence = options.dry ? undefined : options.evidence;
|
|
764
888
|
const offers = async (command: string) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
|
|
765
889
|
const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
|
|
766
|
-
// Asked
|
|
767
|
-
const capturing = !options.dry && !!options.events && (await offers('analytics'));
|
|
890
|
+
// Asked when the run keeps events or a report: it is a call per step.
|
|
891
|
+
const capturing = !options.dry && (!!options.events || !!options.evidence) && (await offers('analytics'));
|
|
768
892
|
if (evidence) fs.mkdirSync(evidence.dir, { recursive: true });
|
|
769
893
|
let filming = false;
|
|
770
894
|
if (film && evidence?.video) {
|
|
771
895
|
const d = await device();
|
|
772
|
-
|
|
896
|
+
// A host that films at one size only ignores the scale.
|
|
897
|
+
filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(
|
|
773
898
|
() => true,
|
|
774
899
|
() => false,
|
|
775
900
|
);
|
|
776
901
|
}
|
|
777
902
|
// What the app sent before the case began is not the case's.
|
|
778
903
|
if (capturing) await (await device()).command('analytics', { take: true }).catch(() => undefined);
|
|
904
|
+
// The same for what it printed.
|
|
905
|
+
const logging = !!evidence && (await offers('logs'));
|
|
906
|
+
if (logging) await (await device()).command('logs', { take: true }).catch(() => undefined);
|
|
907
|
+
options.nativeLogs?.take();
|
|
908
|
+
// A report shows what each request sent and got: the app keeps headers and bodies only while this case runs.
|
|
909
|
+
let requestsSeen: number | undefined;
|
|
910
|
+
// Requests filed while still waiting for an answer: read again later and completed where they were filed.
|
|
911
|
+
const inFlight = new Map<number, RequestRecord>();
|
|
912
|
+
const answered = (r: RequestRecord) => r.status !== undefined || !!r.error;
|
|
913
|
+
const completeInFlight = async (d: Device) => {
|
|
914
|
+
if (!inFlight.size) return;
|
|
915
|
+
const again = await d.requests(Math.min(...inFlight.keys()) - 1).then(
|
|
916
|
+
(r) => r.requests,
|
|
917
|
+
() => [],
|
|
918
|
+
);
|
|
919
|
+
for (const r of again) {
|
|
920
|
+
const kept = inFlight.get(r.id);
|
|
921
|
+
if (kept && answered(r)) {
|
|
922
|
+
Object.assign(kept, r);
|
|
923
|
+
inFlight.delete(r.id);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
// Frames the host is still encoding: the case waits for them before it says it is done.
|
|
928
|
+
const encoding: string[] = [];
|
|
929
|
+
// Frames a simulator or emulator gave as PNG, being encoded again as AVIF while the case goes on.
|
|
930
|
+
const compacting: Promise<void>[] = [];
|
|
931
|
+
if (evidence && (await offers('networkBodies'))) {
|
|
932
|
+
const d = await device();
|
|
933
|
+
await d.command('networkBodies', { on: true }).catch(() => undefined);
|
|
934
|
+
requestsSeen = await d.requests().then(
|
|
935
|
+
(r) => r.requests.at(-1)?.id ?? 0,
|
|
936
|
+
() => undefined,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
779
939
|
const frameAt = async (): Promise<number | undefined> => {
|
|
780
940
|
if (!filming || !evidence?.video) return undefined;
|
|
781
941
|
const answer = (await (await device()).command('record', { frames: true }).catch(() => undefined)) as { frames?: number } | undefined;
|
|
782
942
|
return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
|
|
783
943
|
};
|
|
784
944
|
// What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
|
|
785
|
-
const record = async (report: StepReport, index: number, startedAt: number | undefined) => {
|
|
945
|
+
const record = async (report: StepReport, index: number, startedAt: number | undefined, begunMs: number) => {
|
|
786
946
|
if (startedAt !== undefined) report.videoS = startedAt;
|
|
947
|
+
if (config.serverEvents && options.fixtures) {
|
|
948
|
+
const { fixture: name, provider } = config.serverEvents;
|
|
949
|
+
const answer = await options.fixtures.call(name, { since: new Date(begunMs).toISOString() }).catch(() => undefined);
|
|
950
|
+
const list = Array.isArray(answer?.events) ? (answer.events as Record<string, unknown>[]) : [];
|
|
951
|
+
const server = list.flatMap((e) => {
|
|
952
|
+
const named = typeof e?.name === 'string' ? e.name : typeof e?.event === 'string' ? e.event : undefined;
|
|
953
|
+
const props = (e?.props ?? e?.payload) as Record<string, unknown> | undefined;
|
|
954
|
+
return named
|
|
955
|
+
? [
|
|
956
|
+
{
|
|
957
|
+
provider: String(e.provider ?? provider ?? 'backend'),
|
|
958
|
+
name: named,
|
|
959
|
+
...(props && typeof props === 'object' ? { props } : {}),
|
|
960
|
+
via: 'server' as const,
|
|
961
|
+
},
|
|
962
|
+
]
|
|
963
|
+
: [];
|
|
964
|
+
});
|
|
965
|
+
if (server.length > 0) report.sent = [...(report.sent ?? []), ...server];
|
|
966
|
+
}
|
|
787
967
|
const d = await device().catch(() => undefined);
|
|
788
968
|
if (!d?.server.connected) return;
|
|
789
969
|
if (capturing) {
|
|
790
970
|
const raw = (await d.command('analytics', { take: true }).catch(() => [])) as { sdk?: string; name?: string; properties?: Record<string, unknown> }[];
|
|
791
971
|
const sent = (Array.isArray(raw) ? raw : []).flatMap((e) =>
|
|
792
|
-
e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}) }] : [],
|
|
972
|
+
e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}), via: 'sdk' as const }] : [],
|
|
973
|
+
);
|
|
974
|
+
// Added to what the backend told its analytics, read above for the same step, not in its place.
|
|
975
|
+
if (sent.length > 0) report.sent = [...(report.sent ?? []), ...sent];
|
|
976
|
+
}
|
|
977
|
+
if (logging) {
|
|
978
|
+
const raw = (await d.command('logs', { take: true }).catch(() => [])) as Partial<LogLine>[];
|
|
979
|
+
const logs = (Array.isArray(raw) ? raw : []).flatMap((l): LogLine[] =>
|
|
980
|
+
typeof l?.text === 'string' && typeof l.at === 'number'
|
|
981
|
+
? [
|
|
982
|
+
{
|
|
983
|
+
at: l.at,
|
|
984
|
+
level: l.level === 'debug' || l.level === 'warn' || l.level === 'error' ? l.level : 'info',
|
|
985
|
+
source: typeof l.source === 'string' ? l.source : 'js',
|
|
986
|
+
text: l.text,
|
|
987
|
+
...(typeof l.stack === 'string' ? { stack: l.stack } : {}),
|
|
988
|
+
},
|
|
989
|
+
]
|
|
990
|
+
: [],
|
|
991
|
+
);
|
|
992
|
+
if (logs.length > 0) report.logs = logs;
|
|
993
|
+
}
|
|
994
|
+
const native = options.nativeLogs?.take() ?? [];
|
|
995
|
+
// In the order they were printed, whichever side printed them.
|
|
996
|
+
if (native.length > 0) report.logs = [...(report.logs ?? []), ...native].sort((x, y) => x.at - y.at);
|
|
997
|
+
if (requestsSeen !== undefined) {
|
|
998
|
+
await completeInFlight(d);
|
|
999
|
+
const made = await d.requests(requestsSeen).then(
|
|
1000
|
+
(r) => r.requests,
|
|
1001
|
+
() => [],
|
|
1002
|
+
);
|
|
1003
|
+
// A reload (app.refresh) turns the app's keeping of bodies off with everything else it held: ask again.
|
|
1004
|
+
await d.command('networkBodies', { on: true }).catch(() => undefined);
|
|
1005
|
+
if (made.length > 0) {
|
|
1006
|
+
report.requests = made;
|
|
1007
|
+
requestsSeen = made.at(-1)!.id;
|
|
1008
|
+
for (const r of made) if (!answered(r)) inFlight.set(r.id, r);
|
|
1009
|
+
// A JS SDK (Segment, PostHog…) never reaches a native module: what it sent is in its requests.
|
|
1010
|
+
const traffic = trafficEvents(made).map((e) => ({ ...e, via: 'network' as const }));
|
|
1011
|
+
if (traffic.length > 0) report.sent = [...(report.sent ?? []), ...traffic];
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// A step that only looks (app.see, app.wait) on a screen no different from the last frame's shares that frame:
|
|
1015
|
+
// its wait and capture are what make a frame per step slow on a device. The shape is the route and each
|
|
1016
|
+
// element's id, text and place, read from the app, so a change in any of them still takes a frame.
|
|
1017
|
+
let shape: string | undefined;
|
|
1018
|
+
if (evidence?.screenshots && evidence.onChange) {
|
|
1019
|
+
shape = await d.screen().then(
|
|
1020
|
+
(sc) => `${sc.route}|${sc.elements.map((e) => `${e.testId ?? ''}:${e.text ?? ''}:${Math.round(e.frame.x)},${Math.round(e.frame.y)}`).join(';')}`,
|
|
1021
|
+
() => undefined,
|
|
793
1022
|
);
|
|
794
|
-
if (
|
|
1023
|
+
if (shape !== undefined && shape === lastFrame?.shape && /^app\.(see|wait)\b/.test(report.step)) {
|
|
1024
|
+
report.screenshot = lastFrame.file;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
795
1027
|
}
|
|
796
1028
|
if (evidence?.screenshots) {
|
|
797
1029
|
// A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
|
|
798
1030
|
// step left the app, so two runs' frames of the same step show the same screen.
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
1031
|
+
// A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
|
|
1032
|
+
// moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
|
|
1033
|
+
// arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
|
|
1034
|
+
const quietMs = evidence.settleMs ?? 150;
|
|
1035
|
+
const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then(
|
|
802
1036
|
(r) => r.idle,
|
|
803
1037
|
() => false,
|
|
804
1038
|
);
|
|
805
|
-
const
|
|
1039
|
+
const stem = path.join(
|
|
806
1040
|
evidence.dir,
|
|
807
1041
|
`${String(index).padStart(2, '0')}-${report.step
|
|
808
1042
|
.toLowerCase()
|
|
809
1043
|
.replace(/[^a-z0-9]+/g, '-')
|
|
810
1044
|
.slice(0, 48)
|
|
811
|
-
.replace(/^-|-$/g, '')}
|
|
1045
|
+
.replace(/^-|-$/g, '')}`,
|
|
812
1046
|
);
|
|
813
1047
|
// A host on another machine writes the file there; `inline` asks it for the picture as well.
|
|
814
1048
|
// A host that knows what was still animating says so, as frames in points.
|
|
815
1049
|
let moving: unknown;
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}
|
|
824
|
-
()
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1050
|
+
// A host that waits for a still screen on its own clock signs each picture, so it can say whether the screen
|
|
1051
|
+
// it settled on is still the one in the picture.
|
|
1052
|
+
const hostStill = !!evidence.stillMs && (await offers('still'));
|
|
1053
|
+
let signature: string | undefined;
|
|
1054
|
+
const hostShot = async (file: string) =>
|
|
1055
|
+
d.command('screenshot', { path: file, inline: true, ...(hostStill ? { sign: true } : {}) }).then(
|
|
1056
|
+
(answer) => {
|
|
1057
|
+
const { png, moving: animating, signature: signed } = (answer ?? {}) as { png?: unknown; moving?: unknown; signature?: unknown };
|
|
1058
|
+
if (typeof png === 'string' && !fs.existsSync(file)) fs.writeFileSync(file, Buffer.from(png, 'base64'));
|
|
1059
|
+
if (Array.isArray(animating)) moving = animating;
|
|
1060
|
+
if (typeof signed === 'string') signature = signed;
|
|
1061
|
+
return fs.existsSync(file);
|
|
1062
|
+
},
|
|
1063
|
+
() => false,
|
|
1064
|
+
);
|
|
1065
|
+
let still: boolean | undefined;
|
|
1066
|
+
let file = `${stem}.png`;
|
|
1067
|
+
let taken: boolean;
|
|
1068
|
+
if (await offers('screenshot')) {
|
|
1069
|
+
const avif = evidence.frames !== 'png' && !avifRefused;
|
|
1070
|
+
file = `${stem}.${avif ? 'avif' : 'png'}`;
|
|
1071
|
+
// A host on this machine encodes an AVIF frame after it answers, so the step does not wait for it;
|
|
1072
|
+
// the case waits for all its frames once, at its end.
|
|
1073
|
+
const later = avif
|
|
1074
|
+
? await d.command('screenshot', { path: file, background: true }).then(
|
|
1075
|
+
(answer) => {
|
|
1076
|
+
const { background, moving: animating } = (answer ?? {}) as { background?: unknown; moving?: unknown };
|
|
1077
|
+
if (Array.isArray(animating)) moving = animating;
|
|
1078
|
+
return background === true;
|
|
1079
|
+
},
|
|
1080
|
+
() => false,
|
|
1081
|
+
)
|
|
1082
|
+
: false;
|
|
1083
|
+
if (later) encoding.push(file);
|
|
1084
|
+
taken = later || (await hostShot(file));
|
|
1085
|
+
if (!taken && avif) {
|
|
1086
|
+
avifRefused = true;
|
|
1087
|
+
file = `${stem}.png`;
|
|
1088
|
+
taken = await hostShot(file);
|
|
1089
|
+
}
|
|
1090
|
+
} else taken = (await execute(['screenshot', file], base)).code === 0;
|
|
1091
|
+
// An animation the agent cannot see (a device's native transition, a host's printer running on its own clock)
|
|
1092
|
+
// leaves a quiet app on a moving screen: the picture itself says when it stopped. Two frames alike, taken
|
|
1093
|
+
// stillMs apart, are a still screen. Frames still being encoded (AVIF on a host) are left as they are.
|
|
1094
|
+
if (taken && hostStill && signature !== undefined && !encoding.includes(file)) {
|
|
1095
|
+
// No real time passes and no picture is made while the host waits; the picture is taken again only when
|
|
1096
|
+
// the screen it settled on is another one.
|
|
1097
|
+
const stillMs = evidence.stillMs!;
|
|
1098
|
+
const answer = (await d.command('still', { stepMs: stillMs, timeoutMs: Math.max(2000, stillMs * 8), since: signature }).catch(() => undefined)) as
|
|
1099
|
+
| { still?: unknown; changed?: unknown; moving?: unknown }
|
|
1100
|
+
| undefined;
|
|
1101
|
+
if (answer) {
|
|
1102
|
+
still = answer.still === true;
|
|
1103
|
+
if (answer.changed === true) {
|
|
1104
|
+
fs.rmSync(file, { force: true });
|
|
1105
|
+
taken = await hostShot(file);
|
|
1106
|
+
}
|
|
1107
|
+
// A screen that never stood still: what moved during the wait is of a moment too.
|
|
1108
|
+
if (Array.isArray(answer.moving)) moving = [...(Array.isArray(moving) ? moving : []), ...answer.moving];
|
|
1109
|
+
}
|
|
1110
|
+
} else if (taken && evidence.stillMs && !encoding.includes(file)) {
|
|
1111
|
+
const stillMs = evidence.stillMs;
|
|
1112
|
+
const take = async (to: string) => ((await offers('screenshot')) ? hostShot(to) : (await execute(['screenshot', to], base)).code === 0);
|
|
1113
|
+
still = false;
|
|
1114
|
+
const until = Date.now() + Math.max(2000, stillMs * 8);
|
|
1115
|
+
let last = fs.readFileSync(file);
|
|
1116
|
+
while (!still && Date.now() < until) {
|
|
1117
|
+
await sleep(stillMs);
|
|
1118
|
+
const again = `${file}.again${path.extname(file)}`;
|
|
1119
|
+
if (!(await take(again))) break;
|
|
1120
|
+
const now = fs.readFileSync(again);
|
|
1121
|
+
fs.renameSync(again, file);
|
|
1122
|
+
still = now.equals(last);
|
|
1123
|
+
last = now;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (taken && (encoding.includes(file) || fs.existsSync(file))) {
|
|
828
1127
|
report.screenshot = file;
|
|
1128
|
+
lastFrame = { file, shape };
|
|
1129
|
+
if (file.endsWith('.png') && evidence.frames !== 'png') {
|
|
1130
|
+
const taken = file;
|
|
1131
|
+
compacting.push(
|
|
1132
|
+
compactFrame(taken).then((compact) => {
|
|
1133
|
+
if (report.screenshot === taken) report.screenshot = compact;
|
|
1134
|
+
}),
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
829
1137
|
// What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
|
|
830
1138
|
// a balance) from how it was drawn.
|
|
831
1139
|
const screen = await d.screen().catch(() => undefined);
|
|
832
|
-
if (screen)
|
|
1140
|
+
if (screen)
|
|
1141
|
+
fs.writeFileSync(
|
|
1142
|
+
file.replace(/\.(png|avif)$/, '.json'),
|
|
1143
|
+
JSON.stringify({ ...screen, settled, ...(still === undefined ? {} : { still }), ...(moving ? { moving } : {}) }),
|
|
1144
|
+
);
|
|
833
1145
|
}
|
|
834
1146
|
}
|
|
835
1147
|
};
|
|
@@ -898,7 +1210,7 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
898
1210
|
steps.push(done);
|
|
899
1211
|
log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
|
|
900
1212
|
if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
|
|
901
|
-
await record(done, steps.length, at);
|
|
1213
|
+
await record(done, steps.length, at, t0);
|
|
902
1214
|
} catch (error) {
|
|
903
1215
|
const message = error instanceof Error ? error.message : String(error);
|
|
904
1216
|
const missing = lacking(message);
|
|
@@ -914,25 +1226,43 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
|
|
|
914
1226
|
const broke: StepReport = { step: text, status: 'failed', ms: Date.now() - t0, error: message };
|
|
915
1227
|
steps.push(broke);
|
|
916
1228
|
log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
|
|
917
|
-
if (evidence || capturing) await record(broke, steps.length, at);
|
|
1229
|
+
if (evidence || capturing) await record(broke, steps.length, at, t0);
|
|
918
1230
|
}
|
|
919
1231
|
}
|
|
920
1232
|
|
|
1233
|
+
if (requestsSeen !== undefined) {
|
|
1234
|
+
const d = await device().catch(() => undefined);
|
|
1235
|
+
// The last answers of the case, given a moment to arrive.
|
|
1236
|
+
for (let i = 0; d && inFlight.size && i < 5; i++) {
|
|
1237
|
+
await sleep(200);
|
|
1238
|
+
await completeInFlight(d);
|
|
1239
|
+
}
|
|
1240
|
+
await d?.command('networkBodies', { on: false }).catch(() => undefined);
|
|
1241
|
+
}
|
|
1242
|
+
await Promise.all(compacting);
|
|
1243
|
+
if (encoding.length > 0) {
|
|
1244
|
+
const until = Date.now() + 15_000;
|
|
1245
|
+
while (encoding.some((f) => !fs.existsSync(f)) && Date.now() < until) await sleep(25);
|
|
1246
|
+
// A frame that never arrived is not offered to a report.
|
|
1247
|
+
for (const s of steps) if (s.screenshot && encoding.includes(s.screenshot) && !fs.existsSync(s.screenshot)) delete s.screenshot;
|
|
1248
|
+
}
|
|
921
1249
|
if (tracing && options.traces) {
|
|
922
1250
|
await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
|
|
923
1251
|
// A backend exports in batches, a moment after the requests it served.
|
|
924
1252
|
await options.traces.settle(tracing.traceId);
|
|
925
1253
|
for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
|
|
926
1254
|
const s = steps[index];
|
|
927
|
-
// A step that set off thousands of
|
|
928
|
-
if (s) s.backend = spans.slice(0,
|
|
1255
|
+
// A step that set off thousands of spans keeps the first ones; a report is not a profiler.
|
|
1256
|
+
if (s) s.backend = spans.slice(0, 1500);
|
|
929
1257
|
}
|
|
1258
|
+
fileRequestsByTrace(steps, tracing.steps);
|
|
930
1259
|
}
|
|
931
1260
|
if (options.setupOnly && !failed) options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
|
|
932
1261
|
if (options.dry) failed = steps.some((s) => s.status === 'failed');
|
|
933
1262
|
const report: CaseReport = {
|
|
934
1263
|
id: c.id,
|
|
935
1264
|
title: c.title,
|
|
1265
|
+
...aboutOf(c),
|
|
936
1266
|
file: c.file,
|
|
937
1267
|
result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
|
|
938
1268
|
ms: Date.now() - started,
|
package/src/cases/templates.ts
CHANGED
|
@@ -7,8 +7,13 @@ export const EXAMPLE_CASE = `---
|
|
|
7
7
|
id: example
|
|
8
8
|
title: "A user who was given points sees them on the first screen"
|
|
9
9
|
description: >
|
|
10
|
-
What this proves
|
|
11
|
-
to
|
|
10
|
+
What this proves, in a sentence or two. A case reads top to bottom: what has
|
|
11
|
+
to exist, what the person does, what must be true after.
|
|
12
|
+
why: >
|
|
13
|
+
Optional: what breaks for people if this fails. The report shows it above the steps.
|
|
14
|
+
given: >
|
|
15
|
+
Optional: what is true when it starts (who the user is, what is installed).
|
|
16
|
+
ticket: "[TICKET-ID]"
|
|
12
17
|
---
|
|
13
18
|
setup:
|
|
14
19
|
# Anything that is not an app step is a fixture: fixtures.mjs answers it.
|
|
@@ -32,6 +37,19 @@ and \`expected\`. \`lynkeus case run cases/example.md\` runs one; without files
|
|
|
32
37
|
it runs every case in this folder. A step that fails stops the case, and the
|
|
33
38
|
report carries the screen, the requests and the reason.
|
|
34
39
|
|
|
40
|
+
## Frontmatter
|
|
41
|
+
|
|
42
|
+
| Key | | |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| \`id\`, \`title\` | required, a line | how runs and reports name the case |
|
|
45
|
+
| \`description\` | a paragraph | what the case proves |
|
|
46
|
+
| \`why\` | optional | what breaks for people if it fails |
|
|
47
|
+
| \`given\` | optional | what is true when it starts |
|
|
48
|
+
| \`ticket\` | optional | the ticket it covers |
|
|
49
|
+
|
|
50
|
+
A report shows \`description\`, \`why\`, \`given\`, \`ticket\` and \`expected\` above the steps, so a reader
|
|
51
|
+
who never opened the file knows what they are looking at.
|
|
52
|
+
|
|
35
53
|
## Steps that drive the app
|
|
36
54
|
|
|
37
55
|
| Step | Value | |
|