lynkeus 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cases/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +56 -1
- package/lib/cases/runner.js +309 -33
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +25 -2
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +102 -19
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +69 -3
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +154 -18
- package/lib/config.d.ts +25 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +16 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +359 -37
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +25 -2
- package/src/commands/cases.ts +111 -18
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +66 -3
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +160 -16
- package/src/config.ts +15 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +16 -4
- package/src/runtime.ts +24 -3
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Affected } from '../knowledge/query.js';
|
|
2
|
+
import type { Case } from './format.js';
|
|
3
|
+
/** The routes a case names in its steps. */
|
|
4
|
+
export declare const routesNamed: (c: Case) => string[];
|
|
5
|
+
/** What `case run --seen <dir>` kept: each passing case's screens, by case id. */
|
|
6
|
+
export declare const readSeen: (dir: string | undefined) => Record<string, string[]>;
|
|
7
|
+
export type Coverage = {
|
|
8
|
+
routes: number;
|
|
9
|
+
covered: string[];
|
|
10
|
+
uncovered: string[];
|
|
11
|
+
/** Routes a case names that the graph does not know: renamed, or a typo. */
|
|
12
|
+
unknown: string[];
|
|
13
|
+
byRoute: Record<string, string[]>;
|
|
14
|
+
files: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
/** Which cases touch which of the app's routes, by what they name and what they went through last time they passed. */
|
|
17
|
+
export declare const coverageOf: (cases: Case[], graphRoutes: string[], seen?: Record<string, string[]>) => Coverage;
|
|
18
|
+
export type Impact = {
|
|
19
|
+
/** Each case to run, with the reached routes it touches, nearest first. */
|
|
20
|
+
cases: {
|
|
21
|
+
id: string;
|
|
22
|
+
file: string;
|
|
23
|
+
routes: string[];
|
|
24
|
+
distance: number;
|
|
25
|
+
via: string;
|
|
26
|
+
}[];
|
|
27
|
+
/** Routes the change reaches that no case touches. */
|
|
28
|
+
untested: string[];
|
|
29
|
+
};
|
|
30
|
+
/** The cases a change reaches: the screens it touches or imports, crossed with what each case covers. */
|
|
31
|
+
export declare const impactOf: (affected: Affected[], coverage: Coverage) => Impact;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const ROUTE = /^[A-Z][\w.]+$/;
|
|
4
|
+
// A step names a route when it goes there, waits for it or checks it is on screen.
|
|
5
|
+
const routeOf = (step) => {
|
|
6
|
+
const v = step.value;
|
|
7
|
+
const named = (x) => (typeof x === 'string' && ROUTE.test(x) ? x : undefined);
|
|
8
|
+
if (step.verb === 'app.nav' || step.verb === 'app.wait')
|
|
9
|
+
return (named(v) ??
|
|
10
|
+
(v && typeof v === 'object' && !Array.isArray(v) ? named(v.route ?? v.what) : undefined));
|
|
11
|
+
if (step.verb === 'app.see')
|
|
12
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? named(v.route) : undefined;
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
/** The routes a case names in its steps. */
|
|
16
|
+
export const routesNamed = (c) => [...new Set([...c.setup, ...c.steps].map(routeOf).filter((r) => !!r))];
|
|
17
|
+
/** What `case run --seen <dir>` kept: each passing case's screens, by case id. */
|
|
18
|
+
export const readSeen = (dir) => {
|
|
19
|
+
if (!dir || !fs.existsSync(dir))
|
|
20
|
+
return {};
|
|
21
|
+
const seen = {};
|
|
22
|
+
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
23
|
+
try {
|
|
24
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
25
|
+
if (typeof s.id === 'string' && Array.isArray(s.routes))
|
|
26
|
+
seen[s.id] = s.routes.filter((r) => typeof r === 'string');
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
return seen;
|
|
31
|
+
};
|
|
32
|
+
/** Which cases touch which of the app's routes, by what they name and what they went through last time they passed. */
|
|
33
|
+
export const coverageOf = (cases, graphRoutes, seen = {}) => {
|
|
34
|
+
const byRoute = {};
|
|
35
|
+
const files = {};
|
|
36
|
+
const add = (route, id) => {
|
|
37
|
+
byRoute[route] ??= [];
|
|
38
|
+
if (!byRoute[route].includes(id))
|
|
39
|
+
byRoute[route].push(id);
|
|
40
|
+
};
|
|
41
|
+
for (const c of cases) {
|
|
42
|
+
files[c.id] = c.file;
|
|
43
|
+
for (const r of routesNamed(c))
|
|
44
|
+
add(r, c.id);
|
|
45
|
+
for (const r of seen[c.id] ?? [])
|
|
46
|
+
add(r, c.id);
|
|
47
|
+
}
|
|
48
|
+
const known = new Set(graphRoutes);
|
|
49
|
+
const covered = graphRoutes.filter((r) => byRoute[r]?.length);
|
|
50
|
+
return {
|
|
51
|
+
routes: graphRoutes.length,
|
|
52
|
+
covered,
|
|
53
|
+
uncovered: graphRoutes.filter((r) => !byRoute[r]?.length),
|
|
54
|
+
unknown: Object.keys(byRoute)
|
|
55
|
+
.filter((r) => !known.has(r))
|
|
56
|
+
.sort(),
|
|
57
|
+
byRoute,
|
|
58
|
+
files,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/** The cases a change reaches: the screens it touches or imports, crossed with what each case covers. */
|
|
62
|
+
export const impactOf = (affected, coverage) => {
|
|
63
|
+
const nearest = new Map();
|
|
64
|
+
for (const a of affected)
|
|
65
|
+
if (!nearest.has(a.route) || nearest.get(a.route).distance > a.distance)
|
|
66
|
+
nearest.set(a.route, a);
|
|
67
|
+
const byCase = new Map();
|
|
68
|
+
const untested = [];
|
|
69
|
+
for (const [route, hit] of nearest) {
|
|
70
|
+
const ids = coverage.byRoute[route] ?? [];
|
|
71
|
+
if (!ids.length)
|
|
72
|
+
untested.push(route);
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const c = byCase.get(id) ?? { id, file: coverage.files[id] ?? '', routes: [], distance: hit.distance, via: hit.via };
|
|
75
|
+
c.routes.push(route);
|
|
76
|
+
if (hit.distance < c.distance)
|
|
77
|
+
Object.assign(c, { distance: hit.distance, via: hit.via });
|
|
78
|
+
byCase.set(id, c);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { cases: [...byCase.values()].sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id)), untested: untested.sort() };
|
|
82
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FixtureCommand } from 'lynkeus-protocol';
|
|
2
|
+
import type { PlanStep } from '../knowledge/plan.js';
|
|
3
|
+
export type DraftInput = {
|
|
4
|
+
id: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
/** The way from `from` to `to`, as `map plan` finds it; undefined when no path is known. */
|
|
9
|
+
path: PlanStep[] | undefined;
|
|
10
|
+
/** What the project's fixtures server says it can do, offered as the setup to pick from. */
|
|
11
|
+
fixtures?: FixtureCommand[];
|
|
12
|
+
/** The project's macros (`app.login`…), offered too. */
|
|
13
|
+
macros?: string[];
|
|
14
|
+
/** The project declares a fixtures server that did not describe itself. */
|
|
15
|
+
fixturesSilent?: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A case to finish: its frontmatter with the questions a reader needs answered, the backend's setup offered (never
|
|
19
|
+
* chosen: what state the case needs is the author's call), the way to the screen, and the checks left to write.
|
|
20
|
+
*/
|
|
21
|
+
export declare const draftCase: (d: DraftInput) => string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const quote = (s) => JSON.stringify(s);
|
|
2
|
+
const paramsHint = (c) => {
|
|
3
|
+
const params = Object.entries(c.params ?? {}).map(([k, p]) => `${k}${p.values ? ` (${p.values.join('|')})` : ''}`);
|
|
4
|
+
return params.length ? ` — ${params.join(', ')}` : '';
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* A case to finish: its frontmatter with the questions a reader needs answered, the backend's setup offered (never
|
|
8
|
+
* chosen: what state the case needs is the author's call), the way to the screen, and the checks left to write.
|
|
9
|
+
*/
|
|
10
|
+
export const draftCase = (d) => {
|
|
11
|
+
const lines = [
|
|
12
|
+
'---',
|
|
13
|
+
`id: ${d.id}`,
|
|
14
|
+
`title: ${quote(d.title ?? `[What a person can do on ${d.to}]`)}`,
|
|
15
|
+
'description: >',
|
|
16
|
+
' [What this case proves, in a sentence or two.]',
|
|
17
|
+
'why: >',
|
|
18
|
+
' [What breaks for a person if it fails.]',
|
|
19
|
+
'given: >',
|
|
20
|
+
` [Who the user is and what is true when it starts, on ${d.from}.]`,
|
|
21
|
+
'---',
|
|
22
|
+
'setup:',
|
|
23
|
+
];
|
|
24
|
+
if (d.fixtures?.length) {
|
|
25
|
+
lines.push(' # Pick what the case needs; the backend answers these (lynkeus fixtures describe):');
|
|
26
|
+
for (const c of d.fixtures) {
|
|
27
|
+
const example = c.example ? ` ${JSON.stringify(c.example).replace(/"(\w+)":/g, '$1: ')}` : ' {}';
|
|
28
|
+
lines.push(` # - ${c.name}:${example}${c.summary ? ` # ${c.summary}` : ''}${paramsHint(c)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else if (d.fixturesSilent)
|
|
32
|
+
lines.push(' # The fixtures server did not answer lynkeus.describe (lynkeus fixtures check says why): the setup options are not listed.');
|
|
33
|
+
else
|
|
34
|
+
lines.push(' # The state the case needs, through the project’s fixtures (cases.fixtures).');
|
|
35
|
+
lines.push('', 'steps:');
|
|
36
|
+
if (d.macros?.length)
|
|
37
|
+
lines.push(` # The project's own steps: ${d.macros.join(', ')}`);
|
|
38
|
+
if (!d.path) {
|
|
39
|
+
lines.push(` # No known way from ${d.from} to ${d.to}: \`lynkeus crawl --to ${d.to}\` learns one.`);
|
|
40
|
+
lines.push(` - app.nav: ${d.to}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
for (const s of d.path) {
|
|
44
|
+
if (s.press)
|
|
45
|
+
lines.push(` - app.press: ${quote(`#${s.press}`)}${s.source === 'static' ? ' # from the source: confirm it on screen' : ''}`);
|
|
46
|
+
else
|
|
47
|
+
lines.push(` # ${s.from} → ${s.to}: ${s.hint ?? 'find the control that leads here'}`);
|
|
48
|
+
lines.push(` - app.wait: { what: ${s.opens ? quote(s.opens) : s.to}, timeoutMs: 10000 }`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
lines.push(` - app.see: { route: ${d.to} }`, ' # [What the person does here, and what must be on screen after.]', '', 'expected: |', ' [What must be true when it ends, on screen and in the backend.]', '');
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
};
|
package/lib/cases/fixtures.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FixtureCommand } from 'lynkeus-protocol';
|
|
1
2
|
export type FixturesConfig = {
|
|
2
3
|
command: string;
|
|
3
4
|
cwd?: string;
|
|
@@ -7,12 +8,22 @@ export type FixturesConfig = {
|
|
|
7
8
|
export type Answer = Record<string, unknown>;
|
|
8
9
|
/** The one command that is lynkeus's own and not the project's. */
|
|
9
10
|
export declare const DESCRIBE = "lynkeus.describe";
|
|
11
|
+
/** What the server answers: the names, with each command's description where it gave one. */
|
|
10
12
|
export type Description = {
|
|
11
13
|
commands: string[];
|
|
14
|
+
details: FixtureCommand[];
|
|
12
15
|
down?: string;
|
|
16
|
+
protocol?: number;
|
|
13
17
|
};
|
|
18
|
+
/** A description as the server sent it, names and descriptions mixed, made one shape; anything else is no description. */
|
|
19
|
+
export declare const readDescription: (answer: Record<string, unknown> | undefined) => Description | undefined;
|
|
14
20
|
/** lynkeus's second own command: this connection works ahead of the cases. */
|
|
15
21
|
export declare const AHEAD = "lynkeus.ahead";
|
|
22
|
+
/** What the server last said it answers, and how long ago; undefined when it never did for this command. */
|
|
23
|
+
export declare const lastDescription: (root: string, command: string) => {
|
|
24
|
+
at: number;
|
|
25
|
+
description: Description;
|
|
26
|
+
} | undefined;
|
|
16
27
|
export declare class Fixtures {
|
|
17
28
|
#private;
|
|
18
29
|
constructor(config: FixturesConfig, root: string);
|
package/lib/cases/fixtures.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* {"command": "lynkeus.describe", "params": {}, "env": {}}
|
|
15
15
|
*
|
|
16
|
-
* — and a server that knows answers `{"commands": ["create_user", …]}
|
|
16
|
+
* — and a server that knows answers `{"commands": ["create_user", …]}`, each entry a name or a description
|
|
17
|
+
* (`FixtureCommand` in lynkeus-protocol: summary, params, example). The contract is docs/FIXTURES.md. With
|
|
17
18
|
* that, a case naming a fixture nobody answers is refused before anything
|
|
18
19
|
* runs. A server that answers `"ok": false`, as one written before this did,
|
|
19
20
|
* is taken to describe nothing, and its cases run unchecked. A server that is
|
|
@@ -26,15 +27,45 @@
|
|
|
26
27
|
* answer anything.
|
|
27
28
|
*/
|
|
28
29
|
import { spawn } from 'node:child_process';
|
|
30
|
+
import fs from 'node:fs';
|
|
29
31
|
import path from 'node:path';
|
|
30
32
|
import readline from 'node:readline';
|
|
31
33
|
/** The one command that is lynkeus's own and not the project's. */
|
|
32
34
|
export const DESCRIBE = 'lynkeus.describe';
|
|
35
|
+
/** A description as the server sent it, names and descriptions mixed, made one shape; anything else is no description. */
|
|
36
|
+
export const readDescription = (answer) => {
|
|
37
|
+
const list = answer?.commands;
|
|
38
|
+
if (!Array.isArray(list))
|
|
39
|
+
return undefined;
|
|
40
|
+
const details = [];
|
|
41
|
+
for (const c of list) {
|
|
42
|
+
if (typeof c === 'string' && c)
|
|
43
|
+
details.push({ name: c });
|
|
44
|
+
else if (c && typeof c === 'object' && typeof c.name === 'string' && c.name)
|
|
45
|
+
details.push(c);
|
|
46
|
+
else
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const down = typeof answer?.down === 'string' && answer.down ? answer.down : undefined;
|
|
50
|
+
const protocol = typeof answer?.protocol === 'number' ? answer.protocol : undefined;
|
|
51
|
+
return { commands: details.map((d) => d.name), details, ...(down ? { down } : {}), ...(protocol !== undefined ? { protocol } : {}) };
|
|
52
|
+
};
|
|
33
53
|
/** lynkeus's second own command: this connection works ahead of the cases. */
|
|
34
54
|
export const AHEAD = 'lynkeus.ahead';
|
|
35
55
|
const envOf = (params) => Object.fromEntries(Object.entries(params)
|
|
36
56
|
.filter(([, v]) => v !== undefined && v !== null)
|
|
37
57
|
.map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), typeof v === 'object' ? JSON.stringify(v) : String(v)]));
|
|
58
|
+
const describedFile = (root) => path.join(root, '.lynkeus', 'fixtures-described.json');
|
|
59
|
+
/** What the server last said it answers, and how long ago; undefined when it never did for this command. */
|
|
60
|
+
export const lastDescription = (root, command) => {
|
|
61
|
+
try {
|
|
62
|
+
const kept = JSON.parse(fs.readFileSync(describedFile(root), 'utf8'));
|
|
63
|
+
return kept.command === command ? { at: kept.at, description: kept.description } : undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
38
69
|
export class Fixtures {
|
|
39
70
|
#config;
|
|
40
71
|
#root;
|
|
@@ -42,6 +73,10 @@ export class Fixtures {
|
|
|
42
73
|
#lines = [];
|
|
43
74
|
#waiting = null;
|
|
44
75
|
#stderr = '';
|
|
76
|
+
// Ends in a row without one answer. A command that cannot start (a wrong path, a backend that is not
|
|
77
|
+
// there) would otherwise be started again for every case, and each case would wait out its own start.
|
|
78
|
+
#silentEnds = 0;
|
|
79
|
+
#broken = null;
|
|
45
80
|
constructor(config, root) {
|
|
46
81
|
this.#config = config;
|
|
47
82
|
this.#root = root;
|
|
@@ -56,6 +91,8 @@ export class Fixtures {
|
|
|
56
91
|
cwd: path.resolve(this.#root, this.#config.cwd ?? '.'),
|
|
57
92
|
env: { ...process.env, ...this.#config.env },
|
|
58
93
|
});
|
|
94
|
+
// Writing to a command that already ended fails here; its close says so to whoever waits.
|
|
95
|
+
child.stdin.on('error', () => undefined);
|
|
59
96
|
child.stderr.on('data', (chunk) => {
|
|
60
97
|
this.#stderr = (this.#stderr + chunk.toString()).slice(-4000);
|
|
61
98
|
});
|
|
@@ -63,6 +100,7 @@ export class Fixtures {
|
|
|
63
100
|
reader.on('line', (line) => {
|
|
64
101
|
if (!line.trim().startsWith('{'))
|
|
65
102
|
return;
|
|
103
|
+
this.#silentEnds = 0;
|
|
66
104
|
if (this.#waiting) {
|
|
67
105
|
const resolve = this.#waiting;
|
|
68
106
|
this.#waiting = null;
|
|
@@ -72,6 +110,8 @@ export class Fixtures {
|
|
|
72
110
|
this.#lines.push(line);
|
|
73
111
|
});
|
|
74
112
|
child.on('close', () => {
|
|
113
|
+
if (this.#child === child)
|
|
114
|
+
this.#silentEnds += 1;
|
|
75
115
|
this.#child = null;
|
|
76
116
|
this.#waiting?.(null);
|
|
77
117
|
this.#waiting = null;
|
|
@@ -80,6 +120,8 @@ export class Fixtures {
|
|
|
80
120
|
return child;
|
|
81
121
|
}
|
|
82
122
|
async call(command, params) {
|
|
123
|
+
if (this.#broken)
|
|
124
|
+
throw new Error(`${command}: ${this.#broken}`);
|
|
83
125
|
const child = this.#start();
|
|
84
126
|
child.stdin.write(`${JSON.stringify({ command, params, env: envOf(params) })}\n`);
|
|
85
127
|
const timeoutMs = this.#config.timeoutMs ?? 120_000;
|
|
@@ -96,8 +138,12 @@ export class Fixtures {
|
|
|
96
138
|
resolve(answer);
|
|
97
139
|
};
|
|
98
140
|
});
|
|
99
|
-
if (line === null)
|
|
100
|
-
|
|
141
|
+
if (line === null) {
|
|
142
|
+
const ended = `the fixtures command ended${this.#stderr ? `: ${this.#stderr.trim().split('\n').slice(-3).join(' · ')}` : ''}`;
|
|
143
|
+
if (this.#silentEnds >= 2)
|
|
144
|
+
this.#broken = `${ended} (it ended twice without answering, so it is not started again in this run)`;
|
|
145
|
+
throw new Error(`${command}: ${ended}`);
|
|
146
|
+
}
|
|
101
147
|
const answer = JSON.parse(line);
|
|
102
148
|
if (answer.ok === false)
|
|
103
149
|
throw new Error(`${command}: ${String(answer.error ?? answer.message ?? 'ok=false')}`);
|
|
@@ -105,11 +151,16 @@ export class Fixtures {
|
|
|
105
151
|
}
|
|
106
152
|
/** What the server says it answers, or nothing when it does not say. */
|
|
107
153
|
async describe() {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
154
|
+
const d = readDescription(await this.call(DESCRIBE, {}).catch(() => undefined));
|
|
155
|
+
// Kept for `fixtures describe`: a server that boots a backend's framework takes longer to start than an MCP
|
|
156
|
+
// client waits, and what it answers changes only with its code.
|
|
157
|
+
if (d && !d.down)
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(path.dirname(describedFile(this.#root)), { recursive: true });
|
|
160
|
+
fs.writeFileSync(describedFile(this.#root), JSON.stringify({ at: Date.now(), command: this.#config.command, description: d }));
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
return d;
|
|
113
164
|
}
|
|
114
165
|
/** Says this connection works ahead of the cases being run. Whatever the server answers, it goes on. */
|
|
115
166
|
async ahead() {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A simulator or emulator gives its frames as PNG (`simctl io screenshot` cannot write AVIF or WebP, nor can
|
|
3
|
+
* `screencap`): a report frame is encoded again as AVIF, about 4% of the PNG's bytes in some 70 ms, and the
|
|
4
|
+
* PNG removed. Answers the frame's path, the PNG's when it could not be encoded.
|
|
5
|
+
*/
|
|
6
|
+
export declare const compactFrame: (png: string) => Promise<string>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
let encoder;
|
|
3
|
+
// sharp is optional: a CLI installed without it keeps the PNG the host took.
|
|
4
|
+
const load = () => {
|
|
5
|
+
const name = 'sharp';
|
|
6
|
+
encoder ??= import(name).then((m) => m.default).catch(() => null);
|
|
7
|
+
return encoder;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A simulator or emulator gives its frames as PNG (`simctl io screenshot` cannot write AVIF or WebP, nor can
|
|
11
|
+
* `screencap`): a report frame is encoded again as AVIF, about 4% of the PNG's bytes in some 70 ms, and the
|
|
12
|
+
* PNG removed. Answers the frame's path, the PNG's when it could not be encoded.
|
|
13
|
+
*/
|
|
14
|
+
export const compactFrame = async (png) => {
|
|
15
|
+
const sharp = await load();
|
|
16
|
+
if (!sharp || !png.endsWith('.png'))
|
|
17
|
+
return png;
|
|
18
|
+
const avif = `${png.slice(0, -'.png'.length)}.avif`;
|
|
19
|
+
try {
|
|
20
|
+
await sharp(png).avif({ quality: 60, effort: 2 }).toFile(avif);
|
|
21
|
+
fs.unlinkSync(png);
|
|
22
|
+
return avif;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
fs.rmSync(avif, { force: true });
|
|
26
|
+
return png;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DeviceInfo } from '../device/devices.js';
|
|
2
|
+
import type { LogLine } from './runner.js';
|
|
3
|
+
/** The app's native log while a run goes on: `take` hands over what came since the last take. */
|
|
4
|
+
export type NativeLogs = {
|
|
5
|
+
take: () => LogLine[];
|
|
6
|
+
stop: () => void;
|
|
7
|
+
};
|
|
8
|
+
export declare const parseOsLog: (line: string) => LogLine | undefined;
|
|
9
|
+
export declare const parseLogcat: (line: string, uid: string) => LogLine | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Streams the app's native log on a simulator or an emulator. A device lynkeus cannot read a log from, or an app it
|
|
12
|
+
* cannot find there, gives none: the run goes on without it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const startNativeLogs: (device: DeviceInfo, appId: string) => Promise<NativeLogs | undefined>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
const KEEP = 5000;
|
|
5
|
+
const LONG = 4000;
|
|
6
|
+
const cut = (text) => (text.length > LONG ? `${text.slice(0, LONG)}…` : text);
|
|
7
|
+
// `log stream --style ndjson`: one JSON object a line. React Native's own console lines (category javascript)
|
|
8
|
+
// come from the agent already, with their stacks.
|
|
9
|
+
export const parseOsLog = (line) => {
|
|
10
|
+
if (!line.startsWith('{'))
|
|
11
|
+
return undefined;
|
|
12
|
+
let e;
|
|
13
|
+
try {
|
|
14
|
+
e = JSON.parse(line);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (!e.eventMessage || !e.timestamp)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (e.subsystem === 'com.facebook.react.log' && e.category === 'javascript')
|
|
22
|
+
return undefined;
|
|
23
|
+
// "2026-10-16 14:55:01.123456-0300" → an instant.
|
|
24
|
+
const at = Date.parse(e.timestamp
|
|
25
|
+
.replace(' ', 'T')
|
|
26
|
+
.replace(/(\.\d{3})\d*/, '$1')
|
|
27
|
+
.replace(/([+-]\d\d)(\d\d)$/, '$1:$2'));
|
|
28
|
+
if (Number.isNaN(at))
|
|
29
|
+
return undefined;
|
|
30
|
+
const type = (e.messageType ?? '').toLowerCase();
|
|
31
|
+
const level = type === 'error' || type === 'fault' ? 'error' : type === 'debug' ? 'debug' : 'info';
|
|
32
|
+
return { at, level, source: 'native', text: cut(e.eventMessage) };
|
|
33
|
+
};
|
|
34
|
+
// `logcat -v epoch,uid`: " 1760637301.123 10123 4521 4533 E Tag: message". ReactNativeJS repeats the console.
|
|
35
|
+
export const parseLogcat = (line, uid) => {
|
|
36
|
+
const m = /^\s*(\d+\.\d+)\s+(\S+)\s+\d+\s+\d+\s+([VDIWEF])\s+([^:]*?)\s*:\s?(.*)$/.exec(line);
|
|
37
|
+
if (!m || m[2] !== uid)
|
|
38
|
+
return undefined;
|
|
39
|
+
const [, seconds, , priority, tag, message] = m;
|
|
40
|
+
if (tag === 'ReactNativeJS')
|
|
41
|
+
return undefined;
|
|
42
|
+
const level = priority === 'E' || priority === 'F' ? 'error' : priority === 'W' ? 'warn' : priority === 'I' ? 'info' : 'debug';
|
|
43
|
+
return { at: Math.round(Number(seconds) * 1000), level, source: 'native', text: cut(`${tag}: ${message}`) };
|
|
44
|
+
};
|
|
45
|
+
const executable = async (udid, bundleId) => {
|
|
46
|
+
const { stdout } = await run('xcrun', ['simctl', 'appinfo', udid, bundleId]);
|
|
47
|
+
return /CFBundleExecutable = "?([^";\n]+)"?;/.exec(stdout)?.[1];
|
|
48
|
+
};
|
|
49
|
+
const androidUid = async (serial, appId) => {
|
|
50
|
+
const { stdout } = await run('adb', ['-s', serial, 'shell', 'cmd', 'package', 'list', 'packages', '-U', appId]);
|
|
51
|
+
const line = stdout.split('\n').find((l) => l.startsWith(`package:${appId} `));
|
|
52
|
+
return line ? /uid:(\d+)/.exec(line)?.[1] : undefined;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Streams the app's native log on a simulator or an emulator. A device lynkeus cannot read a log from, or an app it
|
|
56
|
+
* cannot find there, gives none: the run goes on without it.
|
|
57
|
+
*/
|
|
58
|
+
export const startNativeLogs = async (device, appId) => {
|
|
59
|
+
if (device.type === 'physical' || device.id === 'headless')
|
|
60
|
+
return undefined;
|
|
61
|
+
const lines = [];
|
|
62
|
+
let child;
|
|
63
|
+
let parse;
|
|
64
|
+
try {
|
|
65
|
+
if (device.platform === 'ios') {
|
|
66
|
+
const name = await executable(device.id, appId);
|
|
67
|
+
if (!name)
|
|
68
|
+
return undefined;
|
|
69
|
+
// Inside the app's process every Apple framework logs too, thousands of lines a launch: keep its errors, and
|
|
70
|
+
// the rest only from the app's own code (NSLog and print have no subsystem) and React Native's native side.
|
|
71
|
+
const own = `subsystem == "" OR subsystem BEGINSWITH "${appId.replaceAll('"', '')}" OR subsystem BEGINSWITH "com.facebook.react"`;
|
|
72
|
+
const predicate = `process == "${name.replaceAll('"', '')}" AND (messageType == error OR messageType == fault OR ${own})`;
|
|
73
|
+
child = spawn('xcrun', ['simctl', 'spawn', device.id, 'log', 'stream', '--style', 'ndjson', '--level', 'default', '--predicate', predicate], {
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
});
|
|
76
|
+
parse = parseOsLog;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const uid = await androidUid(device.id, appId);
|
|
80
|
+
if (!uid)
|
|
81
|
+
return undefined;
|
|
82
|
+
// From now on: `-T 1` starts at the newest line instead of replaying the buffer.
|
|
83
|
+
child = spawn('adb', ['-s', device.id, 'logcat', '-v', 'epoch,uid', '-T', '1'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
84
|
+
parse = (line) => parseLogcat(line, uid);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
let rest = '';
|
|
91
|
+
child.stdout?.on('data', (chunk) => {
|
|
92
|
+
const text = rest + chunk.toString('utf8');
|
|
93
|
+
const parts = text.split('\n');
|
|
94
|
+
rest = parts.pop() ?? '';
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
const line = parse(part);
|
|
97
|
+
if (!line)
|
|
98
|
+
continue;
|
|
99
|
+
lines.push(line);
|
|
100
|
+
if (lines.length > KEEP)
|
|
101
|
+
lines.shift();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
child.on('error', () => undefined);
|
|
105
|
+
return {
|
|
106
|
+
take: () => lines.splice(0, lines.length),
|
|
107
|
+
stop: () => {
|
|
108
|
+
child.kill();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Bytes under a directory, its files and their subfolders'. */
|
|
2
|
+
export declare const sizeOf: (dir: string) => number;
|
|
3
|
+
export declare const megabytes: (bytes: number) => string;
|
|
4
|
+
/** `7d`, `12h`, `30m` as milliseconds; anything else is refused. */
|
|
5
|
+
export declare const ageOf: (text: string) => number | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* The runs `case run --out` left, at `root` itself or one level under it, that ended longer than `olderMs` ago.
|
|
8
|
+
* Only folders that hold a run are touched; `dry` lists them without removing anything.
|
|
9
|
+
*/
|
|
10
|
+
export declare const pruneRuns: (root: string, olderMs: number, dry?: boolean, now?: number) => {
|
|
11
|
+
removed: string[];
|
|
12
|
+
bytes: number;
|
|
13
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Bytes under a directory, its files and their subfolders'. */
|
|
4
|
+
export const sizeOf = (dir) => {
|
|
5
|
+
let total = 0;
|
|
6
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7
|
+
const at = path.join(dir, entry.name);
|
|
8
|
+
if (entry.isDirectory())
|
|
9
|
+
total += sizeOf(at);
|
|
10
|
+
else if (entry.isFile())
|
|
11
|
+
total += fs.statSync(at).size;
|
|
12
|
+
}
|
|
13
|
+
return total;
|
|
14
|
+
};
|
|
15
|
+
export const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
|
16
|
+
/** `7d`, `12h`, `30m` as milliseconds; anything else is refused. */
|
|
17
|
+
export const ageOf = (text) => {
|
|
18
|
+
const m = /^(\d+)([dhm])$/.exec(text.trim());
|
|
19
|
+
if (!m)
|
|
20
|
+
return undefined;
|
|
21
|
+
return Number(m[1]) * { d: 86_400_000, h: 3_600_000, m: 60_000 }[m[2]];
|
|
22
|
+
};
|
|
23
|
+
/** A directory `case run --out` wrote: it holds the run's manifest and its cases. */
|
|
24
|
+
const isRun = (dir) => fs.existsSync(path.join(dir, 'run.json')) && fs.existsSync(path.join(dir, 'cases.jsonl'));
|
|
25
|
+
/**
|
|
26
|
+
* The runs `case run --out` left, at `root` itself or one level under it, that ended longer than `olderMs` ago.
|
|
27
|
+
* Only folders that hold a run are touched; `dry` lists them without removing anything.
|
|
28
|
+
*/
|
|
29
|
+
export const pruneRuns = (root, olderMs, dry = false, now = Date.now()) => {
|
|
30
|
+
const candidates = isRun(root)
|
|
31
|
+
? [root]
|
|
32
|
+
: fs
|
|
33
|
+
.readdirSync(root, { withFileTypes: true })
|
|
34
|
+
.filter((e) => e.isDirectory())
|
|
35
|
+
.map((e) => path.join(root, e.name))
|
|
36
|
+
.filter(isRun);
|
|
37
|
+
const removed = [];
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
for (const dir of candidates) {
|
|
40
|
+
if (now - fs.statSync(path.join(dir, 'run.json')).mtimeMs < olderMs)
|
|
41
|
+
continue;
|
|
42
|
+
bytes += sizeOf(dir);
|
|
43
|
+
removed.push(dir);
|
|
44
|
+
if (!dry)
|
|
45
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
return { removed, bytes };
|
|
48
|
+
};
|
package/lib/cases/runfile.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RunCase } from 'lynkeus-protocol';
|
|
1
|
+
import { type RunCase, type RunManifest } from 'lynkeus-protocol';
|
|
2
2
|
import type { Case } from './format.js';
|
|
3
3
|
import type { CaseReport } from './runner.js';
|
|
4
4
|
export type Attempt = {
|
|
@@ -7,6 +7,10 @@ export type Attempt = {
|
|
|
7
7
|
startedAt: Date;
|
|
8
8
|
};
|
|
9
9
|
export declare const toRunCase: (c: Case, { report, attempt, startedAt }: Attempt, root: string, dir?: string) => RunCase;
|
|
10
|
+
/** The branch and commit the run was made from, so a report can say which code it tested. */
|
|
11
|
+
export declare const gitOf: (root: string) => RunManifest["git"];
|
|
12
|
+
/** A remote as a web address a report can link commits to, without any credentials it carried. */
|
|
13
|
+
export declare const webOf: (url: string | undefined) => string | undefined;
|
|
10
14
|
export declare class RunFile {
|
|
11
15
|
private readonly dir;
|
|
12
16
|
private readonly root;
|