lynkeus 0.4.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 +56 -1
- package/lib/cases/runner.js +309 -33
- 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 +25 -2
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +102 -19
- 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/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 +69 -3
- 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 +154 -18
- package/lib/config.d.ts +25 -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/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 +16 -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 +359 -37
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +25 -2
- package/src/commands/cases.ts +111 -18
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +66 -3
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +160 -16
- package/src/config.ts +15 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- 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 +16 -4
- package/src/runtime.ts +24 -3
package/lib/cases/runner.js
CHANGED
|
@@ -9,12 +9,16 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
11
|
import { describeScreen } from '../device/driver.js';
|
|
12
|
+
import { trafficEvents } from '../knowledge/analyticsTraffic.js';
|
|
12
13
|
import { normalizeEvents } from '../knowledge/events.js';
|
|
13
14
|
import { find } from '../registry.js';
|
|
14
15
|
import { execute } from '../runtime.js';
|
|
15
16
|
import { isRoute, parseTarget, scopedTarget } from '../targets.js';
|
|
17
|
+
import { compactFrame } from './frames.js';
|
|
16
18
|
import { newSpanId, newTraceId, traceparent } from './traces.js';
|
|
17
19
|
import { dig, interpolate, matcherOf, matches, seeded, stepOf } from './format.js';
|
|
20
|
+
// A host that could not write AVIF once (an older one) is asked for PNG from then on.
|
|
21
|
+
let avifRefused = false;
|
|
18
22
|
/** A setup made only of the project's fixtures can run before the case, with no app attached. */
|
|
19
23
|
export const preparable = (c, config) => c.setup.length > 0 &&
|
|
20
24
|
c.setup.every((step) => !step.on && !step.verb.startsWith('app.') && !config.macros?.[step.verb] && step.verb !== 'exec' && step.verb !== 'assert');
|
|
@@ -133,6 +137,17 @@ const requestMatches = (event, wanted, status) => {
|
|
|
133
137
|
return false;
|
|
134
138
|
return status === undefined ? true : event.status === status;
|
|
135
139
|
};
|
|
140
|
+
const aboutOf = (c) => {
|
|
141
|
+
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
142
|
+
const about = Object.fromEntries(Object.entries({
|
|
143
|
+
description: text(c.meta.description),
|
|
144
|
+
why: text(c.meta.why),
|
|
145
|
+
given: text(c.meta.given),
|
|
146
|
+
ticket: text(c.meta.ticket),
|
|
147
|
+
expected: c.expected,
|
|
148
|
+
}).filter(([, v]) => v !== undefined));
|
|
149
|
+
return Object.keys(about).length ? { about } : {};
|
|
150
|
+
};
|
|
136
151
|
// Steps that look at the app rather than act on it: a request they wait for was caused by the action before them.
|
|
137
152
|
const LOOKING = new Set(['see', 'wait', 'event', 'requests', 'events']);
|
|
138
153
|
const BUILT_IN = ['press', 'type', 'swipe', 'see', 'requests', 'wait', 'nav', 'call', 'run', 'events', 'event'];
|
|
@@ -167,6 +182,50 @@ export const missingFixtures = (c, config, known) => {
|
|
|
167
182
|
};
|
|
168
183
|
/** What the app or its host answers to a method it does not have; the name is what it lacks. */
|
|
169
184
|
export const lacking = (message) => /Unknown method (\S+)/.exec(message)?.[1];
|
|
185
|
+
// 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
|
|
186
|
+
// end of a step (a GET the app sends as an action finishes): its trace, carrying the step it left in, says where
|
|
187
|
+
// it belongs. Each request without a trace in its own step moves to the step whose trace served the same method
|
|
188
|
+
// and path at the same moment.
|
|
189
|
+
export const fileRequestsByTrace = (steps, spans) => {
|
|
190
|
+
const started = new Map([...spans.values()].map((v) => [v.index, v.startedMs]));
|
|
191
|
+
const pathOf = (u) => {
|
|
192
|
+
try {
|
|
193
|
+
return new URL(String(u)).pathname;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return String(u ?? '').split('?')[0];
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const roots = steps.flatMap((st, index) => (st.backend ?? [])
|
|
200
|
+
.filter((sp) => sp.depth === 0)
|
|
201
|
+
.map((sp) => ({
|
|
202
|
+
index,
|
|
203
|
+
method: String(sp.attributes?.['http.method'] ?? sp.attributes?.['http.request.method'] ?? '').toUpperCase(),
|
|
204
|
+
path: pathOf(sp.attributes?.['http.target'] ?? sp.attributes?.['url.path'] ?? ''),
|
|
205
|
+
at: (started.get(index) ?? 0) + sp.at,
|
|
206
|
+
})));
|
|
207
|
+
if (!roots.length)
|
|
208
|
+
return;
|
|
209
|
+
steps.forEach((st, index) => {
|
|
210
|
+
const keep = [];
|
|
211
|
+
for (const q of st.requests ?? []) {
|
|
212
|
+
const method = String(q.method).toUpperCase(), path = pathOf(q.url);
|
|
213
|
+
const same = roots.filter((r) => r.method === method && r.path === path);
|
|
214
|
+
const here = same.some((r) => r.index === index);
|
|
215
|
+
const near = same
|
|
216
|
+
.filter((r) => typeof q.startedAt === 'number' && Math.abs(r.at - q.startedAt) < 1500)
|
|
217
|
+
.sort((a, b) => Math.abs(a.at - q.startedAt) - Math.abs(b.at - q.startedAt))[0];
|
|
218
|
+
if (here || !near || near.index === index || !steps[near.index])
|
|
219
|
+
keep.push(q);
|
|
220
|
+
else
|
|
221
|
+
steps[near.index].requests = [...(steps[near.index].requests ?? []), q].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
|
222
|
+
}
|
|
223
|
+
if (st.requests)
|
|
224
|
+
st.requests = keep.length ? keep : undefined;
|
|
225
|
+
if (st.requests === undefined)
|
|
226
|
+
delete st.requests;
|
|
227
|
+
});
|
|
228
|
+
};
|
|
170
229
|
export const runCase = async (c, options) => {
|
|
171
230
|
const { base, config } = options;
|
|
172
231
|
const log = options.log ?? (() => undefined);
|
|
@@ -177,6 +236,11 @@ export const runCase = async (c, options) => {
|
|
|
177
236
|
const started = Date.now();
|
|
178
237
|
const steps = [];
|
|
179
238
|
let mark;
|
|
239
|
+
// Where the trace stood as the last action began: a screen it led to that closes itself (a success screen) can
|
|
240
|
+
// come and go while that step's frame settles or a wait for a request runs, and a wait for it still counts it.
|
|
241
|
+
// Waits and looks change nothing, so they keep the mark of the action before them.
|
|
242
|
+
let actedFrom;
|
|
243
|
+
let lastFrame;
|
|
180
244
|
// The app numbers its requests as they start. A step that looks for one counts only those started after the
|
|
181
245
|
// action before it began: an earlier screen's request to the same path would otherwise pass for the one the
|
|
182
246
|
// action caused. `app.requests` marks by hand, and holds until a step has looked for a request.
|
|
@@ -185,17 +249,22 @@ export const runCase = async (c, options) => {
|
|
|
185
249
|
let why;
|
|
186
250
|
const recorded = [];
|
|
187
251
|
let buffer = [];
|
|
252
|
+
// A host that records what each SDK was handed (fastsim, the headless host) already has every event. Asking the
|
|
253
|
+
// app for its own list as well turns on the app's tracking debug, which may draw on the screen (a counter in
|
|
254
|
+
// every frame of the film).
|
|
255
|
+
let sdkRecorded = false;
|
|
188
256
|
// Read, never emptied: a case may be asserting on the buffer.
|
|
189
257
|
const collect = async (index) => {
|
|
190
|
-
if (!options.events)
|
|
258
|
+
if (!options.events || sdkRecorded)
|
|
191
259
|
return;
|
|
192
260
|
const d = await base.device().catch(() => undefined);
|
|
193
261
|
if (!d?.server.connected)
|
|
194
262
|
return;
|
|
195
263
|
const now = normalizeEvents(await d.command('events').catch(() => []));
|
|
196
264
|
const route = (await d.screen().catch(() => undefined))?.route;
|
|
265
|
+
const provider = config.appEvents?.provider;
|
|
197
266
|
for (const e of newSince(buffer, now))
|
|
198
|
-
recorded.push({ ...e, step: index, route });
|
|
267
|
+
recorded.push({ ...e, step: index, route, ...(provider ? { provider } : {}) });
|
|
199
268
|
buffer = now;
|
|
200
269
|
};
|
|
201
270
|
const device = () => base.device();
|
|
@@ -391,8 +460,12 @@ export const runCase = async (c, options) => {
|
|
|
391
460
|
await sleep(150);
|
|
392
461
|
continue;
|
|
393
462
|
}
|
|
394
|
-
if (Date.now() > deadline)
|
|
395
|
-
|
|
463
|
+
if (Date.now() > deadline) {
|
|
464
|
+
if (!absent)
|
|
465
|
+
throw new Error(missing);
|
|
466
|
+
const { absent: _, ...what } = params;
|
|
467
|
+
throw new Error(`still on screen: ${label({ verb: '', value: what }).trim()}`);
|
|
468
|
+
}
|
|
396
469
|
await sleep(150);
|
|
397
470
|
}
|
|
398
471
|
};
|
|
@@ -531,10 +604,14 @@ export const runCase = async (c, options) => {
|
|
|
531
604
|
const app = async (name, value) => {
|
|
532
605
|
const params = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
533
606
|
const d = await device();
|
|
534
|
-
|
|
607
|
+
// An app in the background has no connection to answer with: the step that brings it back must still run.
|
|
608
|
+
const lastSeq = () => d.trace().then((t) => t.last, () => undefined);
|
|
609
|
+
mark ??= await lastSeq();
|
|
610
|
+
if (!LOOKING.has(name))
|
|
611
|
+
actedFrom = await lastSeq();
|
|
535
612
|
// An interruption handled inside a wait is not the action the wait is about.
|
|
536
613
|
if ((!LOOKING.has(name) && !handling && macroDepth === 0) || requestsAfter === undefined)
|
|
537
|
-
await markRequests(d);
|
|
614
|
+
await markRequests(d).catch(() => undefined);
|
|
538
615
|
switch (name) {
|
|
539
616
|
case 'press': {
|
|
540
617
|
await interrupted(value);
|
|
@@ -573,8 +650,10 @@ export const runCase = async (c, options) => {
|
|
|
573
650
|
else if (params?.request)
|
|
574
651
|
await waitForRequest(params);
|
|
575
652
|
else {
|
|
653
|
+
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
654
|
+
const since = isRoute(what) && actedFrom !== undefined ? { since: actedFrom } : {};
|
|
576
655
|
if (config.interruptions?.length) {
|
|
577
|
-
const asked = typeof value === 'string' ? { what: value } :
|
|
656
|
+
const asked = { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since };
|
|
578
657
|
const deadline = Date.now() + (typeof asked.timeoutMs === 'number' ? asked.timeoutMs : 10_000);
|
|
579
658
|
for (;;) {
|
|
580
659
|
await interrupted(value);
|
|
@@ -589,9 +668,8 @@ export const runCase = async (c, options) => {
|
|
|
589
668
|
}
|
|
590
669
|
}
|
|
591
670
|
else
|
|
592
|
-
await cli(argvFor(find('wait'), value));
|
|
671
|
+
await cli(argvFor(find('wait'), 'since' in since ? { ...(typeof value === 'string' ? { what: value } : (params ?? {})), ...since } : value));
|
|
593
672
|
// 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.
|
|
594
|
-
const what = typeof value === 'string' ? value : String(params?.what ?? '');
|
|
595
673
|
if (onWallClock(d) && isRoute(what))
|
|
596
674
|
await d.idle({ quietMs: 700, timeoutMs: 8000 }).catch(() => undefined);
|
|
597
675
|
}
|
|
@@ -724,14 +802,15 @@ export const runCase = async (c, options) => {
|
|
|
724
802
|
};
|
|
725
803
|
if (options.events && !options.dry) {
|
|
726
804
|
const d = await base.device().catch(() => undefined);
|
|
727
|
-
|
|
805
|
+
sdkRecorded = !!d?.server.hello?.commands?.includes('analytics');
|
|
806
|
+
if (d?.server.connected && !sdkRecorded)
|
|
728
807
|
buffer = normalizeEvents(await d.command('events').catch(() => []));
|
|
729
808
|
}
|
|
730
809
|
const evidence = options.dry ? undefined : options.evidence;
|
|
731
810
|
const offers = async (command) => !!(await base.device().catch(() => undefined))?.server.hello?.commands?.includes(command);
|
|
732
811
|
const film = evidence?.video && (await offers('record')) ? path.join(evidence.dir, 'video.mp4') : undefined;
|
|
733
|
-
// Asked
|
|
734
|
-
const capturing = !options.dry && !!options.events && (await offers('analytics'));
|
|
812
|
+
// Asked when the run keeps events or a report: it is a call per step.
|
|
813
|
+
const capturing = !options.dry && (!!options.events || !!options.evidence) && (await offers('analytics'));
|
|
735
814
|
if (evidence)
|
|
736
815
|
fs.mkdirSync(evidence.dir, { recursive: true });
|
|
737
816
|
let filming = false;
|
|
@@ -743,6 +822,37 @@ export const runCase = async (c, options) => {
|
|
|
743
822
|
// What the app sent before the case began is not the case's.
|
|
744
823
|
if (capturing)
|
|
745
824
|
await (await device()).command('analytics', { take: true }).catch(() => undefined);
|
|
825
|
+
// The same for what it printed.
|
|
826
|
+
const logging = !!evidence && (await offers('logs'));
|
|
827
|
+
if (logging)
|
|
828
|
+
await (await device()).command('logs', { take: true }).catch(() => undefined);
|
|
829
|
+
options.nativeLogs?.take();
|
|
830
|
+
// A report shows what each request sent and got: the app keeps headers and bodies only while this case runs.
|
|
831
|
+
let requestsSeen;
|
|
832
|
+
// Requests filed while still waiting for an answer: read again later and completed where they were filed.
|
|
833
|
+
const inFlight = new Map();
|
|
834
|
+
const answered = (r) => r.status !== undefined || !!r.error;
|
|
835
|
+
const completeInFlight = async (d) => {
|
|
836
|
+
if (!inFlight.size)
|
|
837
|
+
return;
|
|
838
|
+
const again = await d.requests(Math.min(...inFlight.keys()) - 1).then((r) => r.requests, () => []);
|
|
839
|
+
for (const r of again) {
|
|
840
|
+
const kept = inFlight.get(r.id);
|
|
841
|
+
if (kept && answered(r)) {
|
|
842
|
+
Object.assign(kept, r);
|
|
843
|
+
inFlight.delete(r.id);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
// Frames the host is still encoding: the case waits for them before it says it is done.
|
|
848
|
+
const encoding = [];
|
|
849
|
+
// Frames a simulator or emulator gave as PNG, being encoded again as AVIF while the case goes on.
|
|
850
|
+
const compacting = [];
|
|
851
|
+
if (evidence && (await offers('networkBodies'))) {
|
|
852
|
+
const d = await device();
|
|
853
|
+
await d.command('networkBodies', { on: true }).catch(() => undefined);
|
|
854
|
+
requestsSeen = await d.requests().then((r) => r.requests.at(-1)?.id ?? 0, () => undefined);
|
|
855
|
+
}
|
|
746
856
|
const frameAt = async () => {
|
|
747
857
|
if (!filming || !evidence?.video)
|
|
748
858
|
return undefined;
|
|
@@ -750,17 +860,87 @@ export const runCase = async (c, options) => {
|
|
|
750
860
|
return typeof answer?.frames === 'number' ? Math.round((answer.frames / evidence.video.fps) * 100) / 100 : undefined;
|
|
751
861
|
};
|
|
752
862
|
// What a step leaves for a report: the frame after it and what it sent. Nothing here may fail the case.
|
|
753
|
-
const record = async (report, index, startedAt) => {
|
|
863
|
+
const record = async (report, index, startedAt, begunMs) => {
|
|
754
864
|
if (startedAt !== undefined)
|
|
755
865
|
report.videoS = startedAt;
|
|
866
|
+
if (config.serverEvents && options.fixtures) {
|
|
867
|
+
const { fixture: name, provider } = config.serverEvents;
|
|
868
|
+
const answer = await options.fixtures.call(name, { since: new Date(begunMs).toISOString() }).catch(() => undefined);
|
|
869
|
+
const list = Array.isArray(answer?.events) ? answer.events : [];
|
|
870
|
+
const server = list.flatMap((e) => {
|
|
871
|
+
const named = typeof e?.name === 'string' ? e.name : typeof e?.event === 'string' ? e.event : undefined;
|
|
872
|
+
const props = (e?.props ?? e?.payload);
|
|
873
|
+
return named
|
|
874
|
+
? [
|
|
875
|
+
{
|
|
876
|
+
provider: String(e.provider ?? provider ?? 'backend'),
|
|
877
|
+
name: named,
|
|
878
|
+
...(props && typeof props === 'object' ? { props } : {}),
|
|
879
|
+
via: 'server',
|
|
880
|
+
},
|
|
881
|
+
]
|
|
882
|
+
: [];
|
|
883
|
+
});
|
|
884
|
+
if (server.length > 0)
|
|
885
|
+
report.sent = [...(report.sent ?? []), ...server];
|
|
886
|
+
}
|
|
756
887
|
const d = await device().catch(() => undefined);
|
|
757
888
|
if (!d?.server.connected)
|
|
758
889
|
return;
|
|
759
890
|
if (capturing) {
|
|
760
891
|
const raw = (await d.command('analytics', { take: true }).catch(() => []));
|
|
761
|
-
const sent = (Array.isArray(raw) ? raw : []).flatMap((e) => e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}) }] : []);
|
|
892
|
+
const sent = (Array.isArray(raw) ? raw : []).flatMap((e) => e?.name ? [{ provider: String(e.sdk ?? 'unknown'), name: String(e.name), ...(e.properties ? { props: e.properties } : {}), via: 'sdk' }] : []);
|
|
893
|
+
// Added to what the backend told its analytics, read above for the same step, not in its place.
|
|
762
894
|
if (sent.length > 0)
|
|
763
|
-
report.sent = sent;
|
|
895
|
+
report.sent = [...(report.sent ?? []), ...sent];
|
|
896
|
+
}
|
|
897
|
+
if (logging) {
|
|
898
|
+
const raw = (await d.command('logs', { take: true }).catch(() => []));
|
|
899
|
+
const logs = (Array.isArray(raw) ? raw : []).flatMap((l) => typeof l?.text === 'string' && typeof l.at === 'number'
|
|
900
|
+
? [
|
|
901
|
+
{
|
|
902
|
+
at: l.at,
|
|
903
|
+
level: l.level === 'debug' || l.level === 'warn' || l.level === 'error' ? l.level : 'info',
|
|
904
|
+
source: typeof l.source === 'string' ? l.source : 'js',
|
|
905
|
+
text: l.text,
|
|
906
|
+
...(typeof l.stack === 'string' ? { stack: l.stack } : {}),
|
|
907
|
+
},
|
|
908
|
+
]
|
|
909
|
+
: []);
|
|
910
|
+
if (logs.length > 0)
|
|
911
|
+
report.logs = logs;
|
|
912
|
+
}
|
|
913
|
+
const native = options.nativeLogs?.take() ?? [];
|
|
914
|
+
// In the order they were printed, whichever side printed them.
|
|
915
|
+
if (native.length > 0)
|
|
916
|
+
report.logs = [...(report.logs ?? []), ...native].sort((x, y) => x.at - y.at);
|
|
917
|
+
if (requestsSeen !== undefined) {
|
|
918
|
+
await completeInFlight(d);
|
|
919
|
+
const made = await d.requests(requestsSeen).then((r) => r.requests, () => []);
|
|
920
|
+
// A reload (app.refresh) turns the app's keeping of bodies off with everything else it held: ask again.
|
|
921
|
+
await d.command('networkBodies', { on: true }).catch(() => undefined);
|
|
922
|
+
if (made.length > 0) {
|
|
923
|
+
report.requests = made;
|
|
924
|
+
requestsSeen = made.at(-1).id;
|
|
925
|
+
for (const r of made)
|
|
926
|
+
if (!answered(r))
|
|
927
|
+
inFlight.set(r.id, r);
|
|
928
|
+
// A JS SDK (Segment, PostHog…) never reaches a native module: what it sent is in its requests.
|
|
929
|
+
const traffic = trafficEvents(made).map((e) => ({ ...e, via: 'network' }));
|
|
930
|
+
if (traffic.length > 0)
|
|
931
|
+
report.sent = [...(report.sent ?? []), ...traffic];
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// A step that only looks (app.see, app.wait) on a screen no different from the last frame's shares that frame:
|
|
935
|
+
// its wait and capture are what make a frame per step slow on a device. The shape is the route and each
|
|
936
|
+
// element's id, text and place, read from the app, so a change in any of them still takes a frame.
|
|
937
|
+
let shape;
|
|
938
|
+
if (evidence?.screenshots && evidence.onChange) {
|
|
939
|
+
shape = await d.screen().then((sc) => `${sc.route}|${sc.elements.map((e) => `${e.testId ?? ''}:${e.text ?? ''}:${Math.round(e.frame.x)},${Math.round(e.frame.y)}`).join(';')}`, () => undefined);
|
|
940
|
+
if (shape !== undefined && shape === lastFrame?.shape && /^app\.(see|wait)\b/.test(report.step)) {
|
|
941
|
+
report.screenshot = lastFrame.file;
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
764
944
|
}
|
|
765
945
|
if (evidence?.screenshots) {
|
|
766
946
|
// A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
|
|
@@ -770,31 +950,106 @@ export const runCase = async (c, options) => {
|
|
|
770
950
|
// arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
|
|
771
951
|
const quietMs = evidence.settleMs ?? 150;
|
|
772
952
|
const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then((r) => r.idle, () => false);
|
|
773
|
-
const
|
|
953
|
+
const stem = path.join(evidence.dir, `${String(index).padStart(2, '0')}-${report.step
|
|
774
954
|
.toLowerCase()
|
|
775
955
|
.replace(/[^a-z0-9]+/g, '-')
|
|
776
956
|
.slice(0, 48)
|
|
777
|
-
.replace(/^-|-$/g, '')}
|
|
957
|
+
.replace(/^-|-$/g, '')}`);
|
|
778
958
|
// A host on another machine writes the file there; `inline` asks it for the picture as well.
|
|
779
959
|
// A host that knows what was still animating says so, as frames in points.
|
|
780
960
|
let moving;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
961
|
+
// A host that waits for a still screen on its own clock signs each picture, so it can say whether the screen
|
|
962
|
+
// it settled on is still the one in the picture.
|
|
963
|
+
const hostStill = !!evidence.stillMs && (await offers('still'));
|
|
964
|
+
let signature;
|
|
965
|
+
const hostShot = async (file) => d.command('screenshot', { path: file, inline: true, ...(hostStill ? { sign: true } : {}) }).then((answer) => {
|
|
966
|
+
const { png, moving: animating, signature: signed } = (answer ?? {});
|
|
967
|
+
if (typeof png === 'string' && !fs.existsSync(file))
|
|
968
|
+
fs.writeFileSync(file, Buffer.from(png, 'base64'));
|
|
969
|
+
if (Array.isArray(animating))
|
|
970
|
+
moving = animating;
|
|
971
|
+
if (typeof signed === 'string')
|
|
972
|
+
signature = signed;
|
|
973
|
+
return fs.existsSync(file);
|
|
974
|
+
}, () => false);
|
|
975
|
+
let still;
|
|
976
|
+
let file = `${stem}.png`;
|
|
977
|
+
let taken;
|
|
978
|
+
if (await offers('screenshot')) {
|
|
979
|
+
const avif = evidence.frames !== 'png' && !avifRefused;
|
|
980
|
+
file = `${stem}.${avif ? 'avif' : 'png'}`;
|
|
981
|
+
// A host on this machine encodes an AVIF frame after it answers, so the step does not wait for it;
|
|
982
|
+
// the case waits for all its frames once, at its end.
|
|
983
|
+
const later = avif
|
|
984
|
+
? await d.command('screenshot', { path: file, background: true }).then((answer) => {
|
|
985
|
+
const { background, moving: animating } = (answer ?? {});
|
|
986
|
+
if (Array.isArray(animating))
|
|
987
|
+
moving = animating;
|
|
988
|
+
return background === true;
|
|
989
|
+
}, () => false)
|
|
990
|
+
: false;
|
|
991
|
+
if (later)
|
|
992
|
+
encoding.push(file);
|
|
993
|
+
taken = later || (await hostShot(file));
|
|
994
|
+
if (!taken && avif) {
|
|
995
|
+
avifRefused = true;
|
|
996
|
+
file = `${stem}.png`;
|
|
997
|
+
taken = await hostShot(file);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
else
|
|
1001
|
+
taken = (await execute(['screenshot', file], base)).code === 0;
|
|
1002
|
+
// An animation the agent cannot see (a device's native transition, a host's printer running on its own clock)
|
|
1003
|
+
// leaves a quiet app on a moving screen: the picture itself says when it stopped. Two frames alike, taken
|
|
1004
|
+
// stillMs apart, are a still screen. Frames still being encoded (AVIF on a host) are left as they are.
|
|
1005
|
+
if (taken && hostStill && signature !== undefined && !encoding.includes(file)) {
|
|
1006
|
+
// No real time passes and no picture is made while the host waits; the picture is taken again only when
|
|
1007
|
+
// the screen it settled on is another one.
|
|
1008
|
+
const stillMs = evidence.stillMs;
|
|
1009
|
+
const answer = (await d.command('still', { stepMs: stillMs, timeoutMs: Math.max(2000, stillMs * 8), since: signature }).catch(() => undefined));
|
|
1010
|
+
if (answer) {
|
|
1011
|
+
still = answer.still === true;
|
|
1012
|
+
if (answer.changed === true) {
|
|
1013
|
+
fs.rmSync(file, { force: true });
|
|
1014
|
+
taken = await hostShot(file);
|
|
1015
|
+
}
|
|
1016
|
+
// A screen that never stood still: what moved during the wait is of a moment too.
|
|
1017
|
+
if (Array.isArray(answer.moving))
|
|
1018
|
+
moving = [...(Array.isArray(moving) ? moving : []), ...answer.moving];
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
else if (taken && evidence.stillMs && !encoding.includes(file)) {
|
|
1022
|
+
const stillMs = evidence.stillMs;
|
|
1023
|
+
const take = async (to) => ((await offers('screenshot')) ? hostShot(to) : (await execute(['screenshot', to], base)).code === 0);
|
|
1024
|
+
still = false;
|
|
1025
|
+
const until = Date.now() + Math.max(2000, stillMs * 8);
|
|
1026
|
+
let last = fs.readFileSync(file);
|
|
1027
|
+
while (!still && Date.now() < until) {
|
|
1028
|
+
await sleep(stillMs);
|
|
1029
|
+
const again = `${file}.again${path.extname(file)}`;
|
|
1030
|
+
if (!(await take(again)))
|
|
1031
|
+
break;
|
|
1032
|
+
const now = fs.readFileSync(again);
|
|
1033
|
+
fs.renameSync(again, file);
|
|
1034
|
+
still = now.equals(last);
|
|
1035
|
+
last = now;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (taken && (encoding.includes(file) || fs.existsSync(file))) {
|
|
792
1039
|
report.screenshot = file;
|
|
1040
|
+
lastFrame = { file, shape };
|
|
1041
|
+
if (file.endsWith('.png') && evidence.frames !== 'png') {
|
|
1042
|
+
const taken = file;
|
|
1043
|
+
compacting.push(compactFrame(taken).then((compact) => {
|
|
1044
|
+
if (report.screenshot === taken)
|
|
1045
|
+
report.screenshot = compact;
|
|
1046
|
+
}));
|
|
1047
|
+
}
|
|
793
1048
|
// What each pixel shows: comparing two runs' frames needs to tell the data they were given (a name,
|
|
794
1049
|
// a balance) from how it was drawn.
|
|
795
1050
|
const screen = await d.screen().catch(() => undefined);
|
|
796
1051
|
if (screen)
|
|
797
|
-
fs.writeFileSync(file.replace(/\.png$/, '.json'), JSON.stringify({ ...screen, settled, ...(moving ? { moving } : {}) }));
|
|
1052
|
+
fs.writeFileSync(file.replace(/\.(png|avif)$/, '.json'), JSON.stringify({ ...screen, settled, ...(still === undefined ? {} : { still }), ...(moving ? { moving } : {}) }));
|
|
798
1053
|
}
|
|
799
1054
|
}
|
|
800
1055
|
};
|
|
@@ -857,7 +1112,7 @@ export const runCase = async (c, options) => {
|
|
|
857
1112
|
steps.push(done);
|
|
858
1113
|
log(` ${outcome === 'skipped' ? '⏭️' : '✅'} ${text} (${Date.now() - t0}ms)`);
|
|
859
1114
|
if ((evidence || capturing) && outcome !== 'skipped' && (step.verb.startsWith('app.') || config.macros?.[step.verb]))
|
|
860
|
-
await record(done, steps.length, at);
|
|
1115
|
+
await record(done, steps.length, at, t0);
|
|
861
1116
|
}
|
|
862
1117
|
catch (error) {
|
|
863
1118
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -876,19 +1131,39 @@ export const runCase = async (c, options) => {
|
|
|
876
1131
|
steps.push(broke);
|
|
877
1132
|
log(` ❌ ${text} — ${message.split('\n')[0]} (${Date.now() - t0}ms)`);
|
|
878
1133
|
if (evidence || capturing)
|
|
879
|
-
await record(broke, steps.length, at);
|
|
1134
|
+
await record(broke, steps.length, at, t0);
|
|
880
1135
|
}
|
|
881
1136
|
}
|
|
1137
|
+
if (requestsSeen !== undefined) {
|
|
1138
|
+
const d = await device().catch(() => undefined);
|
|
1139
|
+
// The last answers of the case, given a moment to arrive.
|
|
1140
|
+
for (let i = 0; d && inFlight.size && i < 5; i++) {
|
|
1141
|
+
await sleep(200);
|
|
1142
|
+
await completeInFlight(d);
|
|
1143
|
+
}
|
|
1144
|
+
await d?.command('networkBodies', { on: false }).catch(() => undefined);
|
|
1145
|
+
}
|
|
1146
|
+
await Promise.all(compacting);
|
|
1147
|
+
if (encoding.length > 0) {
|
|
1148
|
+
const until = Date.now() + 15_000;
|
|
1149
|
+
while (encoding.some((f) => !fs.existsSync(f)) && Date.now() < until)
|
|
1150
|
+
await sleep(25);
|
|
1151
|
+
// A frame that never arrived is not offered to a report.
|
|
1152
|
+
for (const s of steps)
|
|
1153
|
+
if (s.screenshot && encoding.includes(s.screenshot) && !fs.existsSync(s.screenshot))
|
|
1154
|
+
delete s.screenshot;
|
|
1155
|
+
}
|
|
882
1156
|
if (tracing && options.traces) {
|
|
883
1157
|
await (await device().catch(() => undefined))?.command('traceContext', { traceparent: null }).catch(() => undefined);
|
|
884
1158
|
// A backend exports in batches, a moment after the requests it served.
|
|
885
1159
|
await options.traces.settle(tracing.traceId);
|
|
886
1160
|
for (const [index, spans] of options.traces.take(tracing.traceId, tracing.steps)) {
|
|
887
1161
|
const s = steps[index];
|
|
888
|
-
// A step that set off thousands of
|
|
1162
|
+
// A step that set off thousands of spans keeps the first ones; a report is not a profiler.
|
|
889
1163
|
if (s)
|
|
890
|
-
s.backend = spans.slice(0,
|
|
1164
|
+
s.backend = spans.slice(0, 1500);
|
|
891
1165
|
}
|
|
1166
|
+
fileRequestsByTrace(steps, tracing.steps);
|
|
892
1167
|
}
|
|
893
1168
|
if (options.setupOnly && !failed)
|
|
894
1169
|
options.setupOnly.keep({ scope, latest, ran: [...ran], steps });
|
|
@@ -897,6 +1172,7 @@ export const runCase = async (c, options) => {
|
|
|
897
1172
|
const report = {
|
|
898
1173
|
id: c.id,
|
|
899
1174
|
title: c.title,
|
|
1175
|
+
...aboutOf(c),
|
|
900
1176
|
file: c.file,
|
|
901
1177
|
result: failed ? 'failed' : unsupported ? 'unsupported' : 'passed',
|
|
902
1178
|
ms: Date.now() - started,
|
package/lib/cases/templates.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* format next to it, and a fixtures server with two empty fixtures, so a
|
|
4
4
|
* project starts from something that runs instead of from a blank folder.
|
|
5
5
|
*/
|
|
6
|
-
export declare const EXAMPLE_CASE = "---\nid: example\ntitle: \"A user who was given points sees them on the first screen\"\ndescription: >\n What this proves
|
|
7
|
-
export declare const FORMAT = "# Cases\n\nA case is a markdown file: frontmatter that names it, then `setup`, `steps`\nand `expected`. `lynkeus case run cases/example.md` runs one; without files\nit runs every case in this folder. A step that fails stops the case, and the\nreport carries the screen, the requests and the reason.\n\n## Steps that drive the app\n\n| Step | Value | |\n|---|---|---|\n| `app.press` | target, or `{ target, within?, nth?, optional?, hold?, timeoutMs? }` | Waits for the control to be there and enabled. `optional` presses only if it is on screen. |\n| `app.type` | `{ text, target?, submit?, clear?, timeoutMs? }` | `target` is tapped first, once it is there (`timeoutMs` is how long that may take); `submit` ends with the return key. |\n| `app.swipe` | `{ direction, target?, distance? }` or `{ from, to }` | |\n| `app.wait` | route, target, ms, `{ target, scroll? }` or `{ request, status? }` | `scroll` swipes until the target is in the window. A `request` is `METHOD /path`. |\n| `app.see` | target, or `{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }` | An assertion on what the app shows or did. Everything named must hold. |\n| `app.nav` | route, or `{ route, params }` | A teleport, for getting to where the case starts. |\n| `app.back` | | |\n| `app.requests` | `mark` | A `request` counts only what the app asked for after the step before it began; this marks further back, until a step has looked for a request. |\n| `app.call` | `{ command, params?, expect?, as? }` | A command the app registered with `qa.register`. |\n| `app.run` | `{ flow, vars? }` | A lynkeus flow, from the flows folder. |\n| `app.events` / `app.event` | `clear` / `{ name, props?, absent? }` | What the app told its analytics. |\n| `app.<command>` | its arguments by name | Any other lynkeus command: `app.mock`, `app.network`, `app.appstate`, `app.deeplink`\u2026 |\n\nA target is `#testId`, a text, an index or `x,y`. Where a step takes `target` it also takes `testId`. A text inside quotes of its own (`'\"Next\"'`) is always a text, where `app.wait: Next` would wait for a route called `Next`.\n\nA case that runs over several inputs declares `matrix: { locale: [en, fr] }`\nin its frontmatter and runs once per combination; `{{matrix.locale}}` is that\nrun's value.\n\nOne case runs on a host and on a device. What only one of them needs \u2014 closing a\nnative screen the host never shows \u2014 is kept to it:\n\n```yaml\n - on: device # or headless, ios, android\n do:\n - app.press: \"364,84\"\n```\n\n## Steps that prepare and read the backend\n\nAny other step name is a **fixture**: lynkeus sends it to the command in\n`cases.fixtures` (lynkeus.config.json) as one JSON line and reads one back.\n\n```\n\u2192 {\"command\": \"create_user\", \"params\": {\"name\": \"Ana\"}, \"env\": {\"NAME\": \"Ana\"}}\n\u2190 {\"ok\": true, \"user_id\": \"u_1\", \"name\": \"Ana\"}\n```\n\nBefore the first case lynkeus sends `{\"command\": \"lynkeus.describe\"}`. A\nserver that answers `{\"commands\": [\"create_user\", \u2026]}` gets its cases checked\nfirst: one that names a fixture missing from the list is refused before\nanything runs. A server that answers `\"ok\": false` is not checked.\n\n`as: u` keeps the answer; `{{u.user_id}}` reads it later. `assert` calls\nthe `inspect` fixture and checks one path of its answer with `equals`,\n`includes`, `gte`, `lte`, `near`, `matches`, `present` or `absent`.\n`exec: { run: \"\u2026\", as? }` runs a shell command and keeps its JSON output.\n\n## Steps of your own\n\n`cases.macros` names a step and the steps it stands for. Inside,\n`{{param.x}}` is what the case passed, and a parameter that names an alias\nis that alias.\n\n```json\n\"macros\": {\n \"app.login\": [\n { \"app.call\": { \"command\": \"reset\" } },\n { \"app.deeplink\": \"myapp://dev-login?user={{param.as.user_id}}\" },\n { \"when\": \"wait\", \"do\": { \"app.wait\": \"{{param.wait}}\" } }\n ]\n}\n```\n\n`when` runs a step only if the case passed that parameter (`\"when\": \"wait\"`)\nor passed that value (`\"when\": \"method=phone\"`); `unless` is the opposite.\n\n`cases.interruptions` names what the app may put in the way of any step \u2014 a\nlock screen, a consent sheet \u2014 and the steps that get past it:\n`{ \"see\": \"#unlock-keypad\", \"do\": [\"app.unlock\"], \"unless\": \"keypad\" }`. It\nruns before a press, a type, a swipe or a see finds it there; a step whose own\nwords match `unless` is about it, and is left alone.\n\nCases and fixtures often live in a repository of their own: `LYNKEUS_CONFIG`\nnames a second configuration file, read over the app's, and the paths under\nits `cases` are relative to it.\n";
|
|
6
|
+
export declare const EXAMPLE_CASE = "---\nid: example\ntitle: \"A user who was given points sees them on the first screen\"\ndescription: >\n What this proves, in a sentence or two. A case reads top to bottom: what has\n to exist, what the person does, what must be true after.\nwhy: >\n Optional: what breaks for people if this fails. The report shows it above the steps.\ngiven: >\n Optional: what is true when it starts (who the user is, what is installed).\nticket: \"[TICKET-ID]\"\n---\nsetup:\n # Anything that is not an app step is a fixture: fixtures.mjs answers it.\n - create_user: { as: u, name: \"Ana\" }\n\nsteps:\n - app.see: { text: \"Hello\" }\n # `user: u` points at what `create_user` answered (cases.refs in lynkeus.config.json).\n - give_points: { user: u, points: 25 }\n - app.see: { text: \"25 points\", timeoutMs: 10000 }\n - assert: { user: u, path: points, equals: 25 }\n\nexpected: |\n The points on screen and the points the backend holds are both 25.\n";
|
|
7
|
+
export declare const FORMAT = "# Cases\n\nA case is a markdown file: frontmatter that names it, then `setup`, `steps`\nand `expected`. `lynkeus case run cases/example.md` runs one; without files\nit runs every case in this folder. A step that fails stops the case, and the\nreport carries the screen, the requests and the reason.\n\n## Frontmatter\n\n| Key | | |\n|---|---|---|\n| `id`, `title` | required, a line | how runs and reports name the case |\n| `description` | a paragraph | what the case proves |\n| `why` | optional | what breaks for people if it fails |\n| `given` | optional | what is true when it starts |\n| `ticket` | optional | the ticket it covers |\n\nA report shows `description`, `why`, `given`, `ticket` and `expected` above the steps, so a reader\nwho never opened the file knows what they are looking at.\n\n## Steps that drive the app\n\n| Step | Value | |\n|---|---|---|\n| `app.press` | target, or `{ target, within?, nth?, optional?, hold?, timeoutMs? }` | Waits for the control to be there and enabled. `optional` presses only if it is on screen. |\n| `app.type` | `{ text, target?, submit?, clear?, timeoutMs? }` | `target` is tapped first, once it is there (`timeoutMs` is how long that may take); `submit` ends with the return key. |\n| `app.swipe` | `{ direction, target?, distance? }` or `{ from, to }` | |\n| `app.wait` | route, target, ms, `{ target, scroll? }` or `{ request, status? }` | `scroll` swipes until the target is in the window. A `request` is `METHOD /path`. |\n| `app.see` | target, or `{ testId?, text?, route?, request?, event?, layer?, absent?, timeoutMs? }` | An assertion on what the app shows or did. Everything named must hold. |\n| `app.nav` | route, or `{ route, params }` | A teleport, for getting to where the case starts. |\n| `app.back` | | |\n| `app.requests` | `mark` | A `request` counts only what the app asked for after the step before it began; this marks further back, until a step has looked for a request. |\n| `app.call` | `{ command, params?, expect?, as? }` | A command the app registered with `qa.register`. |\n| `app.run` | `{ flow, vars? }` | A lynkeus flow, from the flows folder. |\n| `app.events` / `app.event` | `clear` / `{ name, props?, absent? }` | What the app told its analytics. |\n| `app.<command>` | its arguments by name | Any other lynkeus command: `app.mock`, `app.network`, `app.appstate`, `app.deeplink`\u2026 |\n\nA target is `#testId`, a text, an index or `x,y`. Where a step takes `target` it also takes `testId`. A text inside quotes of its own (`'\"Next\"'`) is always a text, where `app.wait: Next` would wait for a route called `Next`.\n\nA case that runs over several inputs declares `matrix: { locale: [en, fr] }`\nin its frontmatter and runs once per combination; `{{matrix.locale}}` is that\nrun's value.\n\nOne case runs on a host and on a device. What only one of them needs \u2014 closing a\nnative screen the host never shows \u2014 is kept to it:\n\n```yaml\n - on: device # or headless, ios, android\n do:\n - app.press: \"364,84\"\n```\n\n## Steps that prepare and read the backend\n\nAny other step name is a **fixture**: lynkeus sends it to the command in\n`cases.fixtures` (lynkeus.config.json) as one JSON line and reads one back.\n\n```\n\u2192 {\"command\": \"create_user\", \"params\": {\"name\": \"Ana\"}, \"env\": {\"NAME\": \"Ana\"}}\n\u2190 {\"ok\": true, \"user_id\": \"u_1\", \"name\": \"Ana\"}\n```\n\nBefore the first case lynkeus sends `{\"command\": \"lynkeus.describe\"}`. A\nserver that answers `{\"commands\": [\"create_user\", \u2026]}` gets its cases checked\nfirst: one that names a fixture missing from the list is refused before\nanything runs. A server that answers `\"ok\": false` is not checked.\n\n`as: u` keeps the answer; `{{u.user_id}}` reads it later. `assert` calls\nthe `inspect` fixture and checks one path of its answer with `equals`,\n`includes`, `gte`, `lte`, `near`, `matches`, `present` or `absent`.\n`exec: { run: \"\u2026\", as? }` runs a shell command and keeps its JSON output.\n\n## Steps of your own\n\n`cases.macros` names a step and the steps it stands for. Inside,\n`{{param.x}}` is what the case passed, and a parameter that names an alias\nis that alias.\n\n```json\n\"macros\": {\n \"app.login\": [\n { \"app.call\": { \"command\": \"reset\" } },\n { \"app.deeplink\": \"myapp://dev-login?user={{param.as.user_id}}\" },\n { \"when\": \"wait\", \"do\": { \"app.wait\": \"{{param.wait}}\" } }\n ]\n}\n```\n\n`when` runs a step only if the case passed that parameter (`\"when\": \"wait\"`)\nor passed that value (`\"when\": \"method=phone\"`); `unless` is the opposite.\n\n`cases.interruptions` names what the app may put in the way of any step \u2014 a\nlock screen, a consent sheet \u2014 and the steps that get past it:\n`{ \"see\": \"#unlock-keypad\", \"do\": [\"app.unlock\"], \"unless\": \"keypad\" }`. It\nruns before a press, a type, a swipe or a see finds it there; a step whose own\nwords match `unless` is about it, and is left alone.\n\nCases and fixtures often live in a repository of their own: `LYNKEUS_CONFIG`\nnames a second configuration file, read over the app's, and the paths under\nits `cases` are relative to it.\n";
|
|
8
8
|
export declare const FIXTURES = "#!/usr/bin/env node\n// The fixtures of this project: how a case prepares the backend and reads it.\n// lynkeus starts this once per run and talks JSON lines to it: one line in per\n// step, one line out. Replace the bodies with calls to your backend \u2014 its\n// admin API, a script, a database seed.\nimport readline from 'node:readline';\n\nconst users = new Map();\n\nconst fixtures = {\n async create_user({ name = 'Test user' }) {\n const user = { user_id: `u_${users.size + 1}`, name, points: 0 };\n users.set(user.user_id, user);\n return user;\n },\n async give_points({ user_id, points }) {\n const user = users.get(user_id);\n if (!user) throw new Error(`no user ${user_id}`);\n user.points += Number(points);\n return { user_id, points: user.points };\n },\n // What `assert` reads.\n async inspect({ user_id }) {\n return users.get(user_id) ?? {};\n },\n};\n\nfor await (const line of readline.createInterface({ input: process.stdin })) {\n if (!line.trim()) continue;\n const { command, params } = JSON.parse(line);\n // lynkeus asks once what this server answers, and refuses a case that names anything else.\n if (command === 'lynkeus.describe') {\n console.log(JSON.stringify({ commands: Object.keys(fixtures) }));\n continue;\n }\n try {\n const fixture = fixtures[command];\n if (!fixture) throw new Error(`no fixture ${command}`);\n console.log(JSON.stringify({ ok: true, ...(await fixture(params ?? {})) }));\n } catch (error) {\n console.log(JSON.stringify({ ok: false, error: error.message }));\n }\n}\n";
|
|
9
9
|
export declare const CONFIG: {
|
|
10
10
|
dir: string;
|
package/lib/cases/templates.js
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.
|
|
@@ -31,6 +36,19 @@ and \`expected\`. \`lynkeus case run cases/example.md\` runs one; without files
|
|
|
31
36
|
it runs every case in this folder. A step that fails stops the case, and the
|
|
32
37
|
report carries the screen, the requests and the reason.
|
|
33
38
|
|
|
39
|
+
## Frontmatter
|
|
40
|
+
|
|
41
|
+
| Key | | |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| \`id\`, \`title\` | required, a line | how runs and reports name the case |
|
|
44
|
+
| \`description\` | a paragraph | what the case proves |
|
|
45
|
+
| \`why\` | optional | what breaks for people if it fails |
|
|
46
|
+
| \`given\` | optional | what is true when it starts |
|
|
47
|
+
| \`ticket\` | optional | the ticket it covers |
|
|
48
|
+
|
|
49
|
+
A report shows \`description\`, \`why\`, \`given\`, \`ticket\` and \`expected\` above the steps, so a reader
|
|
50
|
+
who never opened the file knows what they are looking at.
|
|
51
|
+
|
|
34
52
|
## Steps that drive the app
|
|
35
53
|
|
|
36
54
|
| Step | Value | |
|
package/lib/cases/traces.js
CHANGED
|
@@ -145,7 +145,7 @@ export const decodeJson = (body) => {
|
|
|
145
145
|
return spans;
|
|
146
146
|
};
|
|
147
147
|
// What a report keeps of a span's attributes: what it did, not every label an instrumentation adds.
|
|
148
|
-
const KEEP = /^(http\.(request\.)?method|http\.(response\.)?status_code|http\.route|http\.target|url\.(path|full)|server\.address|net\.peer\.name|
|
|
148
|
+
const KEEP = /^(http\.(request\.)?method|http\.(response\.)?status_code|http\.route|http\.target|url\.(path|full)|server\.address|net\.peer\.name|messaging\.|code\.(namespace|function)|rpc\.|exception\.message|enduser\.id)/;
|
|
149
149
|
/**
|
|
150
150
|
* Listens for OTLP traces while a run goes on: a backend with OTEL_EXPORTER_OTLP_ENDPOINT pointed here sends
|
|
151
151
|
* what each request did, and the steps that sent those requests (their ids travel in `traceparent`) get it.
|
|
@@ -224,6 +224,9 @@ export class TraceSink {
|
|
|
224
224
|
return undefined;
|
|
225
225
|
};
|
|
226
226
|
for (const span of [...spans].sort((a, b) => (a.startNs < b.startNs ? -1 : 1))) {
|
|
227
|
+
// A database query never leaves the machine: its statement names the backend's tables and columns.
|
|
228
|
+
if (span.attributes['db.system'] !== undefined)
|
|
229
|
+
continue;
|
|
227
230
|
const where = place(span);
|
|
228
231
|
if (!where)
|
|
229
232
|
continue;
|