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