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