lynkeus 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cases/ahead.d.ts +31 -0
- package/lib/cases/ahead.js +90 -0
- package/lib/cases/fixtures.d.ts +12 -0
- package/lib/cases/fixtures.js +32 -0
- package/lib/cases/flakes.d.ts +28 -0
- package/lib/cases/flakes.js +61 -0
- package/lib/cases/format.d.ts +5 -0
- package/lib/cases/format.js +30 -4
- package/lib/cases/portClaim.d.ts +10 -0
- package/lib/cases/portClaim.js +55 -0
- package/lib/cases/runfile.d.ts +24 -0
- package/lib/cases/runfile.js +108 -0
- package/lib/cases/runner.d.ts +80 -3
- package/lib/cases/runner.js +387 -40
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +14 -4
- package/lib/cases/traces.d.ts +58 -0
- package/lib/cases/traces.js +250 -0
- package/lib/cli.js +27 -6
- package/lib/commands/cases.js +360 -37
- package/lib/commands/check.d.ts +6 -0
- package/lib/commands/check.js +8 -3
- package/lib/commands/drive.js +32 -8
- package/lib/commands/headless.js +12 -2
- package/lib/commands/init.d.ts +6 -0
- package/lib/commands/init.js +78 -21
- package/lib/commands/report.d.ts +97 -0
- package/lib/commands/report.js +228 -0
- package/lib/config.d.ts +10 -1
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +12 -0
- package/lib/device/devices.d.ts +5 -0
- package/lib/device/devices.js +27 -0
- package/lib/flow/runner.js +33 -1
- package/lib/flow/types.d.ts +2 -1
- package/lib/knowledge/contrast.d.ts +45 -0
- package/lib/knowledge/contrast.js +114 -0
- package/lib/knowledge/errors.js +1 -1
- package/lib/knowledge/lint.d.ts +2 -0
- package/lib/knowledge/lint.js +13 -3
- package/lib/registry.d.ts +5 -0
- package/lib/targets.d.ts +10 -1
- package/lib/targets.js +16 -3
- package/package.json +10 -6
- package/src/cases/ahead.ts +111 -0
- package/src/cases/fixtures.ts +37 -0
- package/src/cases/flakes.ts +67 -0
- package/src/cases/format.ts +31 -6
- package/src/cases/portClaim.ts +52 -0
- package/src/cases/runfile.ts +119 -0
- package/src/cases/runner.ts +446 -46
- package/src/cases/templates.ts +14 -4
- package/src/cases/traces.ts +298 -0
- package/src/cli.ts +28 -6
- package/src/commands/cases.ts +375 -37
- package/src/commands/check.ts +11 -4
- package/src/commands/drive.ts +31 -8
- package/src/commands/headless.ts +12 -2
- package/src/commands/init.ts +84 -20
- package/src/commands/report.ts +272 -0
- package/src/config.ts +6 -2
- package/src/device/agentVersion.ts +15 -0
- package/src/device/devices.ts +28 -0
- package/src/flow/runner.ts +35 -1
- package/src/flow/types.ts +2 -2
- package/src/knowledge/contrast.ts +135 -0
- package/src/knowledge/errors.ts +1 -1
- package/src/knowledge/lint.ts +15 -4
- package/src/registry.ts +2 -0
- package/src/targets.ts +17 -3
- package/lib/__tests__/args.test.d.ts +0 -1
- package/lib/__tests__/args.test.js +0 -69
- package/lib/__tests__/check.test.d.ts +0 -1
- package/lib/__tests__/check.test.js +0 -60
- package/lib/__tests__/init.test.d.ts +0 -1
- package/lib/__tests__/init.test.js +0 -42
- package/lib/__tests__/record.test.d.ts +0 -1
- package/lib/__tests__/record.test.js +0 -44
- package/lib/__tests__/snapshot.test.d.ts +0 -1
- package/lib/__tests__/snapshot.test.js +0 -47
- package/lib/__tests__/targets.test.d.ts +0 -1
- package/lib/__tests__/targets.test.js +0 -127
- package/lib/cases/__tests__/format.test.d.ts +0 -1
- package/lib/cases/__tests__/format.test.js +0 -61
- package/lib/cases/__tests__/runner.test.d.ts +0 -1
- package/lib/cases/__tests__/runner.test.js +0 -188
- package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
- package/lib/knowledge/__tests__/edges.test.js +0 -25
- package/lib/knowledge/__tests__/events.test.d.ts +0 -1
- package/lib/knowledge/__tests__/events.test.js +0 -39
- package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
- package/lib/knowledge/__tests__/extract.test.js +0 -69
- package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
- package/lib/knowledge/__tests__/frontier.test.js +0 -57
- package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
- package/lib/knowledge/__tests__/funnels.test.js +0 -40
- package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
- package/lib/knowledge/__tests__/lint.test.js +0 -53
- package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
- package/lib/knowledge/__tests__/perf.test.js +0 -34
- package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
- package/lib/knowledge/__tests__/plan.test.js +0 -35
- package/lib/knowledge/__tests__/query.test.d.ts +0 -1
- package/lib/knowledge/__tests__/query.test.js +0 -55
- package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
- package/lib/knowledge/__tests__/screens.test.js +0 -30
- package/lib/knowledge/__tests__/why.test.d.ts +0 -1
- package/lib/knowledge/__tests__/why.test.js +0 -28
- package/src/__tests__/args.test.ts +0 -82
- package/src/__tests__/check.test.ts +0 -71
- package/src/__tests__/init.test.ts +0 -52
- package/src/__tests__/record.test.ts +0 -49
- package/src/__tests__/snapshot.test.ts +0 -51
- package/src/__tests__/targets.test.ts +0 -132
- package/src/cases/__tests__/format.test.ts +0 -76
- package/src/cases/__tests__/runner.test.ts +0 -230
- package/src/knowledge/__tests__/edges.test.ts +0 -34
- package/src/knowledge/__tests__/events.test.ts +0 -45
- package/src/knowledge/__tests__/extract.test.ts +0 -88
- package/src/knowledge/__tests__/frontier.test.ts +0 -77
- package/src/knowledge/__tests__/funnels.test.ts +0 -58
- package/src/knowledge/__tests__/lint.test.ts +0 -66
- package/src/knowledge/__tests__/perf.test.ts +0 -41
- package/src/knowledge/__tests__/plan.test.ts +0 -52
- package/src/knowledge/__tests__/query.test.ts +0 -74
- package/src/knowledge/__tests__/screens.test.ts +0 -37
- package/src/knowledge/__tests__/why.test.ts +0 -40
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Case } from './format.js';
|
|
2
|
+
import type { Prepared } from './runner.js';
|
|
3
|
+
/** Where setups made ahead wait for their cases: files for a preparer in another process, memory in the same one. */
|
|
4
|
+
export type Store = {
|
|
5
|
+
start(c: Case): void;
|
|
6
|
+
started(c: Case): boolean;
|
|
7
|
+
keep(c: Case, prepared: Prepared): void;
|
|
8
|
+
take(c: Case): Prepared | undefined;
|
|
9
|
+
waiting(): number;
|
|
10
|
+
};
|
|
11
|
+
export declare class InMemory implements Store {
|
|
12
|
+
#private;
|
|
13
|
+
start(c: Case): void;
|
|
14
|
+
started(c: Case): boolean;
|
|
15
|
+
keep(c: Case, prepared: Prepared): void;
|
|
16
|
+
take(c: Case): Prepared | undefined;
|
|
17
|
+
waiting(): number;
|
|
18
|
+
}
|
|
19
|
+
export declare class Ahead implements Store {
|
|
20
|
+
#private;
|
|
21
|
+
private readonly dir;
|
|
22
|
+
constructor(dir: string);
|
|
23
|
+
clear(): void;
|
|
24
|
+
start(c: Case): void;
|
|
25
|
+
started(c: Case): boolean;
|
|
26
|
+
keep(c: Case, prepared: Prepared): void;
|
|
27
|
+
/** Setups made and not yet taken by a case that is still to start. */
|
|
28
|
+
waiting(): number;
|
|
29
|
+
/** The case's setup if it was made, is recent and is the setup the case has now. */
|
|
30
|
+
take(c: Case): Prepared | undefined;
|
|
31
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setups made ahead of their cases. `case prepare` runs, in the order a suite
|
|
3
|
+
* will, the setup of every case that needs only the project's fixtures, and
|
|
4
|
+
* leaves what it produced here, one file a case; `case run` takes the file
|
|
5
|
+
* when its case starts and goes straight to the steps.
|
|
6
|
+
*
|
|
7
|
+
* A case that starts before its setup is ready marks itself started, runs its
|
|
8
|
+
* own setup, and the preparer skips it. Files are taken with a rename, so two
|
|
9
|
+
* processes never get the same one.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
// What a backend held a quarter of an hour ago may have moved on (a session, a rate).
|
|
14
|
+
const MAX_AGE_MS = 15 * 60_000;
|
|
15
|
+
export class InMemory {
|
|
16
|
+
#started = new Set();
|
|
17
|
+
#kept = new Map();
|
|
18
|
+
start(c) {
|
|
19
|
+
this.#started.add(c.id);
|
|
20
|
+
}
|
|
21
|
+
started(c) {
|
|
22
|
+
return this.#started.has(c.id);
|
|
23
|
+
}
|
|
24
|
+
keep(c, prepared) {
|
|
25
|
+
if (!this.started(c))
|
|
26
|
+
this.#kept.set(c.id, prepared);
|
|
27
|
+
}
|
|
28
|
+
take(c) {
|
|
29
|
+
const prepared = this.#kept.get(c.id);
|
|
30
|
+
this.#kept.delete(c.id);
|
|
31
|
+
return prepared;
|
|
32
|
+
}
|
|
33
|
+
waiting() {
|
|
34
|
+
return this.#kept.size;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class Ahead {
|
|
38
|
+
dir;
|
|
39
|
+
constructor(dir) {
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
}
|
|
42
|
+
#file(c, suffix = '.json') {
|
|
43
|
+
return path.join(this.dir, `${c.id.replace(/[^\w.-]+/g, '_')}${suffix}`);
|
|
44
|
+
}
|
|
45
|
+
clear() {
|
|
46
|
+
fs.rmSync(this.dir, { recursive: true, force: true });
|
|
47
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
start(c) {
|
|
50
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(this.#file(c, '.started'), '');
|
|
52
|
+
}
|
|
53
|
+
started(c) {
|
|
54
|
+
return fs.existsSync(this.#file(c, '.started'));
|
|
55
|
+
}
|
|
56
|
+
keep(c, prepared) {
|
|
57
|
+
// Its case started while this was being made, and ran a setup of its own.
|
|
58
|
+
if (this.started(c))
|
|
59
|
+
return;
|
|
60
|
+
const file = this.#file(c);
|
|
61
|
+
const kept = { setup: JSON.stringify(c.setup), at: Date.now(), prepared };
|
|
62
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(kept));
|
|
63
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
64
|
+
}
|
|
65
|
+
/** Setups made and not yet taken by a case that is still to start. */
|
|
66
|
+
waiting() {
|
|
67
|
+
return fs.readdirSync(this.dir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(this.dir, f.replace(/\.json$/, '.started')))).length;
|
|
68
|
+
}
|
|
69
|
+
/** The case's setup if it was made, is recent and is the setup the case has now. */
|
|
70
|
+
take(c) {
|
|
71
|
+
const file = this.#file(c);
|
|
72
|
+
const mine = `${file}.${process.pid}`;
|
|
73
|
+
try {
|
|
74
|
+
fs.renameSync(file, mine);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const kept = JSON.parse(fs.readFileSync(mine, 'utf8'));
|
|
81
|
+
return kept.setup === JSON.stringify(c.setup) && Date.now() - kept.at < MAX_AGE_MS ? kept.prepared : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
fs.rmSync(mine, { force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/cases/fixtures.d.ts
CHANGED
|
@@ -5,9 +5,21 @@ export type FixturesConfig = {
|
|
|
5
5
|
timeoutMs?: number;
|
|
6
6
|
};
|
|
7
7
|
export type Answer = Record<string, unknown>;
|
|
8
|
+
/** The one command that is lynkeus's own and not the project's. */
|
|
9
|
+
export declare const DESCRIBE = "lynkeus.describe";
|
|
10
|
+
export type Description = {
|
|
11
|
+
commands: string[];
|
|
12
|
+
down?: string;
|
|
13
|
+
};
|
|
14
|
+
/** lynkeus's second own command: this connection works ahead of the cases. */
|
|
15
|
+
export declare const AHEAD = "lynkeus.ahead";
|
|
8
16
|
export declare class Fixtures {
|
|
9
17
|
#private;
|
|
10
18
|
constructor(config: FixturesConfig, root: string);
|
|
11
19
|
call(command: string, params: Record<string, unknown>): Promise<Answer>;
|
|
20
|
+
/** What the server says it answers, or nothing when it does not say. */
|
|
21
|
+
describe(): Promise<Description | undefined>;
|
|
22
|
+
/** Says this connection works ahead of the cases being run. Whatever the server answers, it goes on. */
|
|
23
|
+
ahead(): Promise<void>;
|
|
12
24
|
close(): void;
|
|
13
25
|
}
|
package/lib/cases/fixtures.js
CHANGED
|
@@ -8,10 +8,30 @@
|
|
|
8
8
|
* — and one line out, the step's answer: any JSON object, `"ok": false` and an
|
|
9
9
|
* `error` when it failed. `params` is the step as written; `env` is the same
|
|
10
10
|
* flattened to upper-case strings, for a server that hands them to a script.
|
|
11
|
+
*
|
|
12
|
+
* Before the first case lynkeus asks what the server can do —
|
|
13
|
+
*
|
|
14
|
+
* {"command": "lynkeus.describe", "params": {}, "env": {}}
|
|
15
|
+
*
|
|
16
|
+
* — and a server that knows answers `{"commands": ["create_user", …]}`. With
|
|
17
|
+
* that, a case naming a fixture nobody answers is refused before anything
|
|
18
|
+
* runs. A server that answers `"ok": false`, as one written before this did,
|
|
19
|
+
* is taken to describe nothing, and its cases run unchecked. A server that is
|
|
20
|
+
* running but cannot serve (its database is down) adds `"down": "<why>"`, and
|
|
21
|
+
* no case starts: every one would fail at its first fixture.
|
|
22
|
+
*
|
|
23
|
+
* A connection that makes cases' setups ahead of time (`case prepare`) says so
|
|
24
|
+
* first, with `lynkeus.ahead`, so a server may give its work a lower priority
|
|
25
|
+
* than the cases running meanwhile. A server that does not know it is free to
|
|
26
|
+
* answer anything.
|
|
11
27
|
*/
|
|
12
28
|
import { spawn } from 'node:child_process';
|
|
13
29
|
import path from 'node:path';
|
|
14
30
|
import readline from 'node:readline';
|
|
31
|
+
/** The one command that is lynkeus's own and not the project's. */
|
|
32
|
+
export const DESCRIBE = 'lynkeus.describe';
|
|
33
|
+
/** lynkeus's second own command: this connection works ahead of the cases. */
|
|
34
|
+
export const AHEAD = 'lynkeus.ahead';
|
|
15
35
|
const envOf = (params) => Object.fromEntries(Object.entries(params)
|
|
16
36
|
.filter(([, v]) => v !== undefined && v !== null)
|
|
17
37
|
.map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), typeof v === 'object' ? JSON.stringify(v) : String(v)]));
|
|
@@ -83,6 +103,18 @@ export class Fixtures {
|
|
|
83
103
|
throw new Error(`${command}: ${String(answer.error ?? answer.message ?? 'ok=false')}`);
|
|
84
104
|
return answer;
|
|
85
105
|
}
|
|
106
|
+
/** What the server says it answers, or nothing when it does not say. */
|
|
107
|
+
async describe() {
|
|
108
|
+
const answer = await this.call(DESCRIBE, {}).catch(() => undefined);
|
|
109
|
+
const commands = answer?.commands;
|
|
110
|
+
if (!Array.isArray(commands) || !commands.every((c) => typeof c === 'string'))
|
|
111
|
+
return undefined;
|
|
112
|
+
return typeof answer?.down === 'string' && answer.down ? { commands, down: answer.down } : { commands };
|
|
113
|
+
}
|
|
114
|
+
/** Says this connection works ahead of the cases being run. Whatever the server answers, it goes on. */
|
|
115
|
+
async ahead() {
|
|
116
|
+
await this.call(AHEAD, {}).catch(() => undefined);
|
|
117
|
+
}
|
|
86
118
|
close() {
|
|
87
119
|
const child = this.#child;
|
|
88
120
|
this.#child = null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CaseReport } from './runner.js';
|
|
2
|
+
export type Outcome = {
|
|
3
|
+
at: string;
|
|
4
|
+
result: CaseReport['result'];
|
|
5
|
+
retried: boolean;
|
|
6
|
+
ms?: number;
|
|
7
|
+
step?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
};
|
|
10
|
+
export type Ledger = Record<string, {
|
|
11
|
+
runs: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
retried: number;
|
|
14
|
+
last: Outcome[];
|
|
15
|
+
}>;
|
|
16
|
+
export declare class Flakes {
|
|
17
|
+
private readonly file;
|
|
18
|
+
private readonly ledger;
|
|
19
|
+
constructor(file: string);
|
|
20
|
+
note(report: CaseReport & {
|
|
21
|
+
retried?: boolean;
|
|
22
|
+
}, at?: Date): void;
|
|
23
|
+
/** How long a case took when it last passed, if the ledger has seen it pass. */
|
|
24
|
+
lastMs(id: string): number | undefined;
|
|
25
|
+
/** The cases among `ids` whose recent runs disagree with each other, worded for a summary. */
|
|
26
|
+
flaky(ids: string[]): string[];
|
|
27
|
+
save(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How each case has done across runs, kept in one JSON file: the last results,
|
|
3
|
+
* how often it needed its second chance, the step it last died on. A case that
|
|
4
|
+
* passes and fails without anything changing is the one to look at first.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const KEPT = 20;
|
|
9
|
+
export class Flakes {
|
|
10
|
+
file;
|
|
11
|
+
ledger;
|
|
12
|
+
constructor(file) {
|
|
13
|
+
this.file = file;
|
|
14
|
+
let read = {};
|
|
15
|
+
try {
|
|
16
|
+
read = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// No ledger yet, or one this cannot read: start a new one.
|
|
20
|
+
}
|
|
21
|
+
this.ledger = read;
|
|
22
|
+
}
|
|
23
|
+
note(report, at = new Date()) {
|
|
24
|
+
const entry = this.ledger[report.id] ?? { runs: 0, failed: 0, retried: 0, last: [] };
|
|
25
|
+
this.ledger[report.id] = entry;
|
|
26
|
+
const broke = report.steps.find((s) => s.status === 'failed');
|
|
27
|
+
entry.runs += 1;
|
|
28
|
+
if (report.result === 'failed')
|
|
29
|
+
entry.failed += 1;
|
|
30
|
+
if (report.retried)
|
|
31
|
+
entry.retried += 1;
|
|
32
|
+
entry.last = [
|
|
33
|
+
...entry.last,
|
|
34
|
+
{
|
|
35
|
+
at: at.toISOString(),
|
|
36
|
+
result: report.result,
|
|
37
|
+
retried: !!report.retried,
|
|
38
|
+
ms: report.ms,
|
|
39
|
+
...(broke ? { step: broke.step, error: broke.error?.split('\n')[0] } : {}),
|
|
40
|
+
},
|
|
41
|
+
].slice(-KEPT);
|
|
42
|
+
}
|
|
43
|
+
/** How long a case took when it last passed, if the ledger has seen it pass. */
|
|
44
|
+
lastMs(id) {
|
|
45
|
+
return [...(this.ledger[id]?.last ?? [])].reverse().find((o) => o.result === 'passed' && o.ms !== undefined)?.ms;
|
|
46
|
+
}
|
|
47
|
+
/** The cases among `ids` whose recent runs disagree with each other, worded for a summary. */
|
|
48
|
+
flaky(ids) {
|
|
49
|
+
return ids.flatMap((id) => {
|
|
50
|
+
const last = this.ledger[id]?.last ?? [];
|
|
51
|
+
const shaky = last.filter((o) => o.result === 'failed' || o.retried).length;
|
|
52
|
+
if (shaky === 0 || shaky === last.length)
|
|
53
|
+
return [];
|
|
54
|
+
return [`⚠ ${id} is flaky: ${shaky} of its last ${last.length} runs failed or needed a second chance`];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
save() {
|
|
58
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
59
|
+
fs.writeFileSync(this.file, `${JSON.stringify(this.ledger, null, 2)}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/lib/cases/format.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export declare const readCases: (file: string) => Case[];
|
|
|
20
20
|
/** `a.b.0.c` into a value; `a.*.b` collects b from every element of the list a. */
|
|
21
21
|
export declare const dig: (json: unknown, dotted: string) => unknown;
|
|
22
22
|
export type Scope = Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* With LYNKEUS_SEED set, a case's `{{random.*}}` values are the same on every run: two runs of it then show
|
|
25
|
+
* the same names, which comparing their screens needs. Each case draws its own, so cases side by side differ.
|
|
26
|
+
*/
|
|
27
|
+
export declare const seeded: (scope: Scope, id: string) => void;
|
|
23
28
|
/** `{{alias.path}}` from what earlier steps answered, `{{random.letters}}`, `{{env.NAME}}`. */
|
|
24
29
|
export declare const interpolate: <T>(value: T, scope: Scope) => T;
|
|
25
30
|
export type Matcher = {
|
package/lib/cases/format.js
CHANGED
|
@@ -94,12 +94,38 @@ export const dig = (json, dotted) => {
|
|
|
94
94
|
}
|
|
95
95
|
return current;
|
|
96
96
|
};
|
|
97
|
+
/** Where a seeded case keeps how far its random values have gone; no alias can be named this. */
|
|
98
|
+
const SEED = '~seed';
|
|
99
|
+
/**
|
|
100
|
+
* With LYNKEUS_SEED set, a case's `{{random.*}}` values are the same on every run: two runs of it then show
|
|
101
|
+
* the same names, which comparing their screens needs. Each case draws its own, so cases side by side differ.
|
|
102
|
+
*/
|
|
103
|
+
export const seeded = (scope, id) => {
|
|
104
|
+
const seed = process.env.LYNKEUS_SEED;
|
|
105
|
+
if (!seed || scope[SEED] !== undefined)
|
|
106
|
+
return;
|
|
107
|
+
let hash = 2166136261;
|
|
108
|
+
for (const char of `${seed}\n${id}`)
|
|
109
|
+
hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
110
|
+
scope[SEED] = hash >>> 0;
|
|
111
|
+
};
|
|
97
112
|
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
98
|
-
const random = (kind) => {
|
|
113
|
+
const random = (kind, scope) => {
|
|
114
|
+
const next = () => {
|
|
115
|
+
if (typeof scope[SEED] !== 'number')
|
|
116
|
+
return Math.random();
|
|
117
|
+
// mulberry32
|
|
118
|
+
const state = (scope[SEED] + 0x6d2b79f5) >>> 0;
|
|
119
|
+
scope[SEED] = state;
|
|
120
|
+
let t = state;
|
|
121
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
122
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
123
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
124
|
+
};
|
|
99
125
|
if (kind === 'letters')
|
|
100
|
-
return Array.from({ length: 6 }, () => LETTERS[Math.floor(
|
|
126
|
+
return Array.from({ length: 6 }, () => LETTERS[Math.floor(next() * 26)]).join('');
|
|
101
127
|
if (kind === 'digits')
|
|
102
|
-
return Array.from({ length: 6 }, () => Math.floor(
|
|
128
|
+
return Array.from({ length: 6 }, () => Math.floor(next() * 10)).join('');
|
|
103
129
|
return undefined;
|
|
104
130
|
};
|
|
105
131
|
/** `{{alias.path}}` from what earlier steps answered, `{{random.letters}}`, `{{env.NAME}}`. */
|
|
@@ -108,7 +134,7 @@ export const interpolate = (value, scope) => {
|
|
|
108
134
|
const whole = /^\{\{\s*([\w-]+)\.([\w.*-]+)\s*\}\}$/.exec(value);
|
|
109
135
|
const lookup = (name, path) => {
|
|
110
136
|
if (name === 'random')
|
|
111
|
-
return random(path);
|
|
137
|
+
return random(path, scope);
|
|
112
138
|
if (name === 'env')
|
|
113
139
|
return process.env[path];
|
|
114
140
|
if (!(name in scope))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run lets go of its driver port between cases, and the app dials whichever driver is listening: two runs
|
|
3
|
+
* on one port take turns at the app, and each reports steps the other drove. A run claims its ports for as
|
|
4
|
+
* long as it lasts; what it answers is the release, or why another run holds one.
|
|
5
|
+
*/
|
|
6
|
+
export declare const claimPorts: (ports: number[], dir?: string) => {
|
|
7
|
+
release: () => void;
|
|
8
|
+
} | {
|
|
9
|
+
refused: string;
|
|
10
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const alive = (pid) => {
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
return error.code === 'EPERM';
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A run lets go of its driver port between cases, and the app dials whichever driver is listening: two runs
|
|
15
|
+
* on one port take turns at the app, and each reports steps the other drove. A run claims its ports for as
|
|
16
|
+
* long as it lasts; what it answers is the release, or why another run holds one.
|
|
17
|
+
*/
|
|
18
|
+
export const claimPorts = (ports, dir = os.tmpdir()) => {
|
|
19
|
+
const held = [];
|
|
20
|
+
const release = () => {
|
|
21
|
+
for (const file of held) {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.readFileSync(file, 'utf8').trim() === String(process.pid))
|
|
24
|
+
fs.unlinkSync(file);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Already gone.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
for (const port of ports) {
|
|
32
|
+
const file = path.join(dir, `lynkeus-port-${port}.pid`);
|
|
33
|
+
for (;;) {
|
|
34
|
+
try {
|
|
35
|
+
fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
|
|
36
|
+
held.push(file);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'EEXIST')
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const owner = Number(fs.readFileSync(file, 'utf8').trim());
|
|
44
|
+
if (Number.isInteger(owner) && owner > 0 && owner !== process.pid && alive(owner)) {
|
|
45
|
+
release();
|
|
46
|
+
return {
|
|
47
|
+
refused: `port ${port} is held by another lynkeus case run (pid ${owner}), which is probably still going: stop it, or give this run other --ports`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Left by a run that ended without letting go.
|
|
51
|
+
fs.rmSync(file, { force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { release };
|
|
55
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RunCase } from 'lynkeus-protocol';
|
|
2
|
+
import type { Case } from './format.js';
|
|
3
|
+
import type { CaseReport } from './runner.js';
|
|
4
|
+
export type Attempt = {
|
|
5
|
+
report: CaseReport;
|
|
6
|
+
attempt: number;
|
|
7
|
+
startedAt: Date;
|
|
8
|
+
};
|
|
9
|
+
export declare const toRunCase: (c: Case, { report, attempt, startedAt }: Attempt, root: string, dir?: string) => RunCase;
|
|
10
|
+
export declare class RunFile {
|
|
11
|
+
private readonly dir;
|
|
12
|
+
private readonly root;
|
|
13
|
+
private readonly manifest;
|
|
14
|
+
private readonly last;
|
|
15
|
+
constructor(dir: string, root: string, startedAt?: Date);
|
|
16
|
+
/** Where a case's frames and film go: one folder per attempt. */
|
|
17
|
+
evidenceDir(id: string, attempt: number): string;
|
|
18
|
+
add(c: Case, attempt: Attempt): void;
|
|
19
|
+
finish(app?: {
|
|
20
|
+
platform?: string;
|
|
21
|
+
native?: boolean;
|
|
22
|
+
}, finishedAt?: Date): void;
|
|
23
|
+
private save;
|
|
24
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a run of cases as the directory `lynkeus-protocol` describes: run.json
|
|
3
|
+
* and cases.jsonl. The manifest is written at the start and again at the end,
|
|
4
|
+
* so a run that was cut short still says when it began and what it ran on.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { RUN_SCHEMA } from 'lynkeus-protocol';
|
|
10
|
+
/** Paths in the run file are relative to its directory, so the directory can move. */
|
|
11
|
+
const within = (dir, file) => (file ? (dir ? path.relative(dir, file) : file) : undefined);
|
|
12
|
+
export const toRunCase = (c, { report, attempt, startedAt }, root, dir) => {
|
|
13
|
+
const steps = report.steps.map((s, index) => {
|
|
14
|
+
// The runner numbers an event's step from 1, in the order the steps ran.
|
|
15
|
+
const told = (report.events ?? [])
|
|
16
|
+
.filter((e) => e.step === index + 1)
|
|
17
|
+
.map((e) => ({ origin: 'app', name: e.name, ...(e.props ? { props: e.props } : {}), ...(e.route ? { route: e.route } : {}) }));
|
|
18
|
+
const sent = (s.sent ?? []).map((e) => ({ origin: 'app', provider: e.provider, name: e.name, ...(e.props ? { props: e.props } : {}) }));
|
|
19
|
+
const events = [...told, ...sent];
|
|
20
|
+
const screenshot = within(dir, s.screenshot);
|
|
21
|
+
return {
|
|
22
|
+
step: s.step,
|
|
23
|
+
status: s.status,
|
|
24
|
+
ms: s.ms,
|
|
25
|
+
...(s.error ? { error: s.error } : {}),
|
|
26
|
+
section: index < c.setup.length ? 'setup' : 'steps',
|
|
27
|
+
...(events.length > 0 ? { events } : {}),
|
|
28
|
+
...(screenshot ? { screenshot } : {}),
|
|
29
|
+
...(s.videoS !== undefined ? { videoS: s.videoS } : {}),
|
|
30
|
+
...(s.backend?.length ? { backend: s.backend } : {}),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
const description = typeof c.meta.description === 'string' ? c.meta.description : undefined;
|
|
34
|
+
const video = within(dir, report.video);
|
|
35
|
+
return {
|
|
36
|
+
id: report.id,
|
|
37
|
+
...(report.title ? { title: report.title } : {}),
|
|
38
|
+
...(description ? { description } : {}),
|
|
39
|
+
file: path.relative(root, path.resolve(root, report.file)),
|
|
40
|
+
attempt,
|
|
41
|
+
result: report.result,
|
|
42
|
+
startedAt: startedAt.toISOString(),
|
|
43
|
+
ms: report.ms,
|
|
44
|
+
steps,
|
|
45
|
+
...(report.diagnosis ? { diagnosis: report.diagnosis } : {}),
|
|
46
|
+
...(report.evidence ? { evidence: report.evidence } : {}),
|
|
47
|
+
...(video ? { video } : {}),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/** The branch and commit the run was made from, so a report can say which code it tested. */
|
|
51
|
+
const gitOf = (root) => {
|
|
52
|
+
const ask = (...args) => {
|
|
53
|
+
try {
|
|
54
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const commit = ask('rev-parse', '--short', 'HEAD');
|
|
61
|
+
if (!commit)
|
|
62
|
+
return undefined;
|
|
63
|
+
const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || ask('rev-parse', '--abbrev-ref', 'HEAD');
|
|
64
|
+
return { commit, ...(branch && branch !== 'HEAD' ? { branch } : {}) };
|
|
65
|
+
};
|
|
66
|
+
export class RunFile {
|
|
67
|
+
dir;
|
|
68
|
+
root;
|
|
69
|
+
manifest;
|
|
70
|
+
last = new Map();
|
|
71
|
+
constructor(dir, root, startedAt = new Date()) {
|
|
72
|
+
this.dir = dir;
|
|
73
|
+
this.root = root;
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(path.join(dir, 'cases.jsonl'), '');
|
|
76
|
+
const git = gitOf(root);
|
|
77
|
+
this.manifest = { schema: RUN_SCHEMA, startedAt: startedAt.toISOString(), ...(git ? { git } : {}) };
|
|
78
|
+
this.save();
|
|
79
|
+
}
|
|
80
|
+
/** Where a case's frames and film go: one folder per attempt. */
|
|
81
|
+
evidenceDir(id, attempt) {
|
|
82
|
+
return path.join(this.dir, `${id.replace(/[^\w.=-]+/g, '_')}${attempt > 1 ? `.${attempt}` : ''}`);
|
|
83
|
+
}
|
|
84
|
+
add(c, attempt) {
|
|
85
|
+
const line = toRunCase(c, attempt, this.root, this.dir);
|
|
86
|
+
fs.appendFileSync(path.join(this.dir, 'cases.jsonl'), `${JSON.stringify(line)}\n`);
|
|
87
|
+
this.last.set(line.id, { result: line.result, attempt: line.attempt });
|
|
88
|
+
}
|
|
89
|
+
finish(app = {}, finishedAt = new Date()) {
|
|
90
|
+
const cases = [...this.last.values()];
|
|
91
|
+
this.manifest.finishedAt = finishedAt.toISOString();
|
|
92
|
+
if (app.platform)
|
|
93
|
+
this.manifest.platform = app.platform;
|
|
94
|
+
if (app.native !== undefined)
|
|
95
|
+
this.manifest.native = app.native;
|
|
96
|
+
this.manifest.totals = {
|
|
97
|
+
cases: cases.length,
|
|
98
|
+
passed: cases.filter((c) => c.result === 'passed').length,
|
|
99
|
+
failed: cases.filter((c) => c.result === 'failed').length,
|
|
100
|
+
unsupported: cases.filter((c) => c.result === 'unsupported').length,
|
|
101
|
+
retried: cases.filter((c) => c.attempt > 1).length,
|
|
102
|
+
};
|
|
103
|
+
this.save();
|
|
104
|
+
}
|
|
105
|
+
save() {
|
|
106
|
+
fs.writeFileSync(path.join(this.dir, 'run.json'), `${JSON.stringify(this.manifest, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/lib/cases/runner.d.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { type AppEvent, type RunEvent } from '../knowledge/events.js';
|
|
2
2
|
import { type Base, type Command } from '../registry.js';
|
|
3
3
|
import type { Fixtures, FixturesConfig } from './fixtures.js';
|
|
4
|
-
import { type
|
|
4
|
+
import { type BackendSpan, type TraceSink } from './traces.js';
|
|
5
|
+
import { type Case, type Scope, type StepValue } from './format.js';
|
|
6
|
+
export type NativeMock = {
|
|
7
|
+
result?: unknown;
|
|
8
|
+
screen?: {
|
|
9
|
+
title?: string;
|
|
10
|
+
subtitle?: string;
|
|
11
|
+
image?: string;
|
|
12
|
+
buttons?: Record<string, unknown>;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
5
15
|
export type CasesConfig = {
|
|
6
16
|
/** The folder of the file that declared this, set when the configuration is read. */
|
|
7
17
|
base?: string;
|
|
18
|
+
/**
|
|
19
|
+
* What native SDKs a headless host cannot run answer, keyed `Module.method` (the native module's name, as
|
|
20
|
+
* `NativeModules` has it): `result` is what the call resolves with; `screen` puts up a stand-in page with a
|
|
21
|
+
* `title`, an optional `subtitle` and `image` (a URI), and `buttons`, label to result, each pressable as
|
|
22
|
+
* `#native-mock-<label>`. Sent to the app at the start of every case; a step changes it with
|
|
23
|
+
* `app.call: { command: nativeMocks, params: { set: { … } } }`, and `null` takes a key's mock away.
|
|
24
|
+
*/
|
|
25
|
+
nativeMocks?: Record<string, NativeMock | null>;
|
|
8
26
|
/** Where cases live (default `cases`). */
|
|
9
27
|
dir?: string;
|
|
10
28
|
/** Where the flows a case runs live (default `flows`). */
|
|
@@ -24,17 +42,33 @@ export type CasesConfig = {
|
|
|
24
42
|
unless?: string;
|
|
25
43
|
}[];
|
|
26
44
|
};
|
|
45
|
+
/** What a host's analytics capture saw a step send: the SDK it went to and the event. */
|
|
46
|
+
export type SentEvent = {
|
|
47
|
+
provider: string;
|
|
48
|
+
name: string;
|
|
49
|
+
props?: Record<string, unknown>;
|
|
50
|
+
};
|
|
51
|
+
/** `unsupported`: the host the case ran on has no such thing to do (a real device has no mocked responses), which says nothing about the app. */
|
|
27
52
|
export type StepReport = {
|
|
28
53
|
step: string;
|
|
29
|
-
status: 'passed' | 'failed' | 'skipped';
|
|
54
|
+
status: 'passed' | 'failed' | 'skipped' | 'unsupported';
|
|
30
55
|
ms: number;
|
|
31
56
|
error?: string;
|
|
57
|
+
/** A frame of the screen once the step was done, an absolute path. */
|
|
58
|
+
screenshot?: string;
|
|
59
|
+
/** The second of the case's film at which the step began. */
|
|
60
|
+
videoS?: number;
|
|
61
|
+
/** What the step made the app send its analytics SDKs, where the host captures them. */
|
|
62
|
+
sent?: SentEvent[];
|
|
63
|
+
/** What the backend did for it, from the traces it exported to the run. */
|
|
64
|
+
backend?: BackendSpan[];
|
|
32
65
|
};
|
|
33
66
|
export type CaseReport = {
|
|
34
67
|
id: string;
|
|
35
68
|
title?: string;
|
|
36
69
|
file: string;
|
|
37
|
-
|
|
70
|
+
/** `unsupported`: a step could not be done on this host, so the case proved nothing here; it did not fail. */
|
|
71
|
+
result: 'passed' | 'failed' | 'unsupported';
|
|
38
72
|
ms: number;
|
|
39
73
|
steps: StepReport[];
|
|
40
74
|
/** When a step failed: the screen, the requests since the mark, and why. */
|
|
@@ -45,17 +79,60 @@ export type CaseReport = {
|
|
|
45
79
|
};
|
|
46
80
|
/** What the app told its analytics, each with the step that caused it, when the run asked for it. */
|
|
47
81
|
events?: RunEvent[];
|
|
82
|
+
/** The case's film, an absolute path, where the host films. */
|
|
83
|
+
video?: string;
|
|
84
|
+
/** Every screen the app went through while the case ran, when the run asked for them (`routes`). */
|
|
85
|
+
routes?: string[];
|
|
86
|
+
/** Why it failed, read as rules from the screen, the requests and the trace (`lynkeus why`). */
|
|
87
|
+
diagnosis?: string[];
|
|
88
|
+
};
|
|
89
|
+
/** Where a case keeps what it can show of itself: a frame per step, a film. Each is taken only where the host can. */
|
|
90
|
+
export type Evidence = {
|
|
91
|
+
dir: string;
|
|
92
|
+
screenshots?: boolean;
|
|
93
|
+
video?: {
|
|
94
|
+
fps: number;
|
|
95
|
+
};
|
|
48
96
|
};
|
|
49
97
|
export type RunOptions = {
|
|
50
98
|
base: Base;
|
|
99
|
+
/** Where the backend's traces arrive during the run: each app step's requests carry its id. */
|
|
100
|
+
traces?: TraceSink;
|
|
51
101
|
config: CasesConfig;
|
|
52
102
|
fixtures?: Fixtures;
|
|
53
103
|
dry?: boolean;
|
|
54
104
|
events?: boolean;
|
|
105
|
+
evidence?: Evidence;
|
|
55
106
|
log?: (line: string) => void;
|
|
107
|
+
/** The case's setup, already run by `case prepare`: the case starts from here. */
|
|
108
|
+
prepared?: Prepared;
|
|
109
|
+
/** Run only the setup and hand what it left to `keep`, for a case that will run later. */
|
|
110
|
+
setupOnly?: {
|
|
111
|
+
keep: (prepared: Prepared) => void;
|
|
112
|
+
};
|
|
113
|
+
/** Keep running a case's steps after one fails, to see everything that is wrong in one pass. */
|
|
114
|
+
continue?: boolean;
|
|
115
|
+
/** Collect the screens the app went through, for a coverage map. `fresh`: the app was started for this case. */
|
|
116
|
+
routes?: boolean | 'fresh';
|
|
117
|
+
};
|
|
118
|
+
/** What a case's setup leaves for its steps: the aliases and what they answered, and what ran. */
|
|
119
|
+
export type Prepared = {
|
|
120
|
+
scope: Scope;
|
|
121
|
+
latest: Record<string, unknown>;
|
|
122
|
+
ran: string[];
|
|
123
|
+
steps: StepReport[];
|
|
56
124
|
};
|
|
125
|
+
/** A setup made only of the project's fixtures can run before the case, with no app attached. */
|
|
126
|
+
export declare const preparable: (c: Case, config: CasesConfig) => boolean;
|
|
57
127
|
/** What a buffer holds that it did not before, whichever end it grows from. */
|
|
58
128
|
export declare const newSince: (before: AppEvent[], now: AppEvent[]) => AppEvent[];
|
|
59
129
|
/** 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. */
|
|
60
130
|
export declare const argvFor: (command: Command, value: StepValue) => string[];
|
|
131
|
+
/**
|
|
132
|
+
* The fixtures a case names that the project's server did not list. A macro's
|
|
133
|
+
* own steps are the project's to keep right; these are the ones written in the case.
|
|
134
|
+
*/
|
|
135
|
+
export declare const missingFixtures: (c: Case, config: CasesConfig, known: string[]) => string[];
|
|
136
|
+
/** What the app or its host answers to a method it does not have; the name is what it lacks. */
|
|
137
|
+
export declare const lacking: (message: string) => string | undefined;
|
|
61
138
|
export declare const runCase: (c: Case, options: RunOptions) => Promise<CaseReport>;
|