lynkeus 0.2.0 → 0.4.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.
Files changed (134) hide show
  1. package/lib/cases/ahead.d.ts +31 -0
  2. package/lib/cases/ahead.js +90 -0
  3. package/lib/cases/fixtures.d.ts +12 -0
  4. package/lib/cases/fixtures.js +32 -0
  5. package/lib/cases/flakes.d.ts +28 -0
  6. package/lib/cases/flakes.js +61 -0
  7. package/lib/cases/format.d.ts +5 -0
  8. package/lib/cases/format.js +30 -4
  9. package/lib/cases/portClaim.d.ts +10 -0
  10. package/lib/cases/portClaim.js +55 -0
  11. package/lib/cases/runfile.d.ts +24 -0
  12. package/lib/cases/runfile.js +108 -0
  13. package/lib/cases/runner.d.ts +82 -3
  14. package/lib/cases/runner.js +394 -40
  15. package/lib/cases/templates.d.ts +2 -2
  16. package/lib/cases/templates.js +14 -4
  17. package/lib/cases/traces.d.ts +58 -0
  18. package/lib/cases/traces.js +250 -0
  19. package/lib/cli.js +28 -7
  20. package/lib/commands/cases.js +411 -42
  21. package/lib/commands/check.d.ts +6 -0
  22. package/lib/commands/check.js +8 -3
  23. package/lib/commands/doctor.js +8 -2
  24. package/lib/commands/drive.js +32 -8
  25. package/lib/commands/headless.js +132 -8
  26. package/lib/commands/init.d.ts +29 -0
  27. package/lib/commands/init.js +295 -33
  28. package/lib/commands/report.d.ts +97 -0
  29. package/lib/commands/report.js +228 -0
  30. package/lib/config.d.ts +26 -1
  31. package/lib/config.js +30 -0
  32. package/lib/device/agentVersion.d.ts +7 -0
  33. package/lib/device/agentVersion.js +12 -0
  34. package/lib/device/devices.d.ts +5 -0
  35. package/lib/device/devices.js +27 -0
  36. package/lib/device/driver.js +2 -2
  37. package/lib/flow/runner.js +33 -1
  38. package/lib/flow/types.d.ts +2 -1
  39. package/lib/knowledge/contrast.d.ts +45 -0
  40. package/lib/knowledge/contrast.js +114 -0
  41. package/lib/knowledge/errors.js +1 -1
  42. package/lib/knowledge/lint.d.ts +2 -0
  43. package/lib/knowledge/lint.js +13 -3
  44. package/lib/mcp/server.js +31 -2
  45. package/lib/registry.d.ts +5 -0
  46. package/lib/targets.d.ts +10 -1
  47. package/lib/targets.js +16 -3
  48. package/package.json +10 -6
  49. package/src/cases/ahead.ts +111 -0
  50. package/src/cases/fixtures.ts +37 -0
  51. package/src/cases/flakes.ts +67 -0
  52. package/src/cases/format.ts +31 -6
  53. package/src/cases/portClaim.ts +52 -0
  54. package/src/cases/runfile.ts +119 -0
  55. package/src/cases/runner.ts +454 -46
  56. package/src/cases/templates.ts +14 -4
  57. package/src/cases/traces.ts +298 -0
  58. package/src/cli.ts +29 -7
  59. package/src/commands/cases.ts +428 -41
  60. package/src/commands/check.ts +11 -4
  61. package/src/commands/doctor.ts +9 -2
  62. package/src/commands/drive.ts +31 -8
  63. package/src/commands/headless.ts +143 -8
  64. package/src/commands/init.ts +338 -30
  65. package/src/commands/report.ts +272 -0
  66. package/src/config.ts +39 -2
  67. package/src/device/agentVersion.ts +15 -0
  68. package/src/device/devices.ts +28 -0
  69. package/src/device/driver.ts +2 -2
  70. package/src/flow/runner.ts +35 -1
  71. package/src/flow/types.ts +2 -2
  72. package/src/knowledge/contrast.ts +135 -0
  73. package/src/knowledge/errors.ts +1 -1
  74. package/src/knowledge/lint.ts +15 -4
  75. package/src/mcp/server.ts +31 -2
  76. package/src/registry.ts +2 -0
  77. package/src/targets.ts +17 -3
  78. package/lib/__tests__/args.test.d.ts +0 -1
  79. package/lib/__tests__/args.test.js +0 -69
  80. package/lib/__tests__/check.test.d.ts +0 -1
  81. package/lib/__tests__/check.test.js +0 -60
  82. package/lib/__tests__/init.test.d.ts +0 -1
  83. package/lib/__tests__/init.test.js +0 -42
  84. package/lib/__tests__/record.test.d.ts +0 -1
  85. package/lib/__tests__/record.test.js +0 -44
  86. package/lib/__tests__/snapshot.test.d.ts +0 -1
  87. package/lib/__tests__/snapshot.test.js +0 -47
  88. package/lib/__tests__/targets.test.d.ts +0 -1
  89. package/lib/__tests__/targets.test.js +0 -127
  90. package/lib/cases/__tests__/format.test.d.ts +0 -1
  91. package/lib/cases/__tests__/format.test.js +0 -61
  92. package/lib/cases/__tests__/runner.test.d.ts +0 -1
  93. package/lib/cases/__tests__/runner.test.js +0 -188
  94. package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
  95. package/lib/knowledge/__tests__/edges.test.js +0 -25
  96. package/lib/knowledge/__tests__/events.test.d.ts +0 -1
  97. package/lib/knowledge/__tests__/events.test.js +0 -39
  98. package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
  99. package/lib/knowledge/__tests__/extract.test.js +0 -69
  100. package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
  101. package/lib/knowledge/__tests__/frontier.test.js +0 -57
  102. package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
  103. package/lib/knowledge/__tests__/funnels.test.js +0 -40
  104. package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
  105. package/lib/knowledge/__tests__/lint.test.js +0 -53
  106. package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
  107. package/lib/knowledge/__tests__/perf.test.js +0 -34
  108. package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
  109. package/lib/knowledge/__tests__/plan.test.js +0 -35
  110. package/lib/knowledge/__tests__/query.test.d.ts +0 -1
  111. package/lib/knowledge/__tests__/query.test.js +0 -55
  112. package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
  113. package/lib/knowledge/__tests__/screens.test.js +0 -30
  114. package/lib/knowledge/__tests__/why.test.d.ts +0 -1
  115. package/lib/knowledge/__tests__/why.test.js +0 -28
  116. package/src/__tests__/args.test.ts +0 -82
  117. package/src/__tests__/check.test.ts +0 -71
  118. package/src/__tests__/init.test.ts +0 -52
  119. package/src/__tests__/record.test.ts +0 -49
  120. package/src/__tests__/snapshot.test.ts +0 -51
  121. package/src/__tests__/targets.test.ts +0 -132
  122. package/src/cases/__tests__/format.test.ts +0 -76
  123. package/src/cases/__tests__/runner.test.ts +0 -230
  124. package/src/knowledge/__tests__/edges.test.ts +0 -34
  125. package/src/knowledge/__tests__/events.test.ts +0 -45
  126. package/src/knowledge/__tests__/extract.test.ts +0 -88
  127. package/src/knowledge/__tests__/frontier.test.ts +0 -77
  128. package/src/knowledge/__tests__/funnels.test.ts +0 -58
  129. package/src/knowledge/__tests__/lint.test.ts +0 -66
  130. package/src/knowledge/__tests__/perf.test.ts +0 -41
  131. package/src/knowledge/__tests__/plan.test.ts +0 -52
  132. package/src/knowledge/__tests__/query.test.ts +0 -74
  133. package/src/knowledge/__tests__/screens.test.ts +0 -37
  134. 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
+ }
@@ -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
  }
@@ -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
+ }
@@ -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 = {
@@ -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(Math.random() * 26)]).join('');
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(Math.random() * 10)).join('');
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
+ }