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.
- package/lib/cases/ahead.d.ts +31 -0
- package/lib/cases/ahead.js +90 -0
- package/lib/cases/fixtures.d.ts +12 -0
- package/lib/cases/fixtures.js +32 -0
- package/lib/cases/flakes.d.ts +28 -0
- package/lib/cases/flakes.js +61 -0
- package/lib/cases/format.d.ts +5 -0
- package/lib/cases/format.js +30 -4
- package/lib/cases/portClaim.d.ts +10 -0
- package/lib/cases/portClaim.js +55 -0
- package/lib/cases/runfile.d.ts +24 -0
- package/lib/cases/runfile.js +108 -0
- package/lib/cases/runner.d.ts +82 -3
- package/lib/cases/runner.js +394 -40
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +14 -4
- package/lib/cases/traces.d.ts +58 -0
- package/lib/cases/traces.js +250 -0
- package/lib/cli.js +28 -7
- package/lib/commands/cases.js +411 -42
- package/lib/commands/check.d.ts +6 -0
- package/lib/commands/check.js +8 -3
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +32 -8
- package/lib/commands/headless.js +132 -8
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +295 -33
- package/lib/commands/report.d.ts +97 -0
- package/lib/commands/report.js +228 -0
- package/lib/config.d.ts +26 -1
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +12 -0
- package/lib/device/devices.d.ts +5 -0
- package/lib/device/devices.js +27 -0
- package/lib/device/driver.js +2 -2
- package/lib/flow/runner.js +33 -1
- package/lib/flow/types.d.ts +2 -1
- package/lib/knowledge/contrast.d.ts +45 -0
- package/lib/knowledge/contrast.js +114 -0
- package/lib/knowledge/errors.js +1 -1
- package/lib/knowledge/lint.d.ts +2 -0
- package/lib/knowledge/lint.js +13 -3
- package/lib/mcp/server.js +31 -2
- package/lib/registry.d.ts +5 -0
- package/lib/targets.d.ts +10 -1
- package/lib/targets.js +16 -3
- package/package.json +10 -6
- package/src/cases/ahead.ts +111 -0
- package/src/cases/fixtures.ts +37 -0
- package/src/cases/flakes.ts +67 -0
- package/src/cases/format.ts +31 -6
- package/src/cases/portClaim.ts +52 -0
- package/src/cases/runfile.ts +119 -0
- package/src/cases/runner.ts +454 -46
- package/src/cases/templates.ts +14 -4
- package/src/cases/traces.ts +298 -0
- package/src/cli.ts +29 -7
- package/src/commands/cases.ts +428 -41
- package/src/commands/check.ts +11 -4
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +31 -8
- package/src/commands/headless.ts +143 -8
- package/src/commands/init.ts +338 -30
- package/src/commands/report.ts +272 -0
- package/src/config.ts +39 -2
- package/src/device/agentVersion.ts +15 -0
- package/src/device/devices.ts +28 -0
- package/src/device/driver.ts +2 -2
- package/src/flow/runner.ts +35 -1
- package/src/flow/types.ts +2 -2
- package/src/knowledge/contrast.ts +135 -0
- package/src/knowledge/errors.ts +1 -1
- package/src/knowledge/lint.ts +15 -4
- package/src/mcp/server.ts +31 -2
- package/src/registry.ts +2 -0
- package/src/targets.ts +17 -3
- package/lib/__tests__/args.test.d.ts +0 -1
- package/lib/__tests__/args.test.js +0 -69
- package/lib/__tests__/check.test.d.ts +0 -1
- package/lib/__tests__/check.test.js +0 -60
- package/lib/__tests__/init.test.d.ts +0 -1
- package/lib/__tests__/init.test.js +0 -42
- package/lib/__tests__/record.test.d.ts +0 -1
- package/lib/__tests__/record.test.js +0 -44
- package/lib/__tests__/snapshot.test.d.ts +0 -1
- package/lib/__tests__/snapshot.test.js +0 -47
- package/lib/__tests__/targets.test.d.ts +0 -1
- package/lib/__tests__/targets.test.js +0 -127
- package/lib/cases/__tests__/format.test.d.ts +0 -1
- package/lib/cases/__tests__/format.test.js +0 -61
- package/lib/cases/__tests__/runner.test.d.ts +0 -1
- package/lib/cases/__tests__/runner.test.js +0 -188
- package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
- package/lib/knowledge/__tests__/edges.test.js +0 -25
- package/lib/knowledge/__tests__/events.test.d.ts +0 -1
- package/lib/knowledge/__tests__/events.test.js +0 -39
- package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
- package/lib/knowledge/__tests__/extract.test.js +0 -69
- package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
- package/lib/knowledge/__tests__/frontier.test.js +0 -57
- package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
- package/lib/knowledge/__tests__/funnels.test.js +0 -40
- package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
- package/lib/knowledge/__tests__/lint.test.js +0 -53
- package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
- package/lib/knowledge/__tests__/perf.test.js +0 -34
- package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
- package/lib/knowledge/__tests__/plan.test.js +0 -35
- package/lib/knowledge/__tests__/query.test.d.ts +0 -1
- package/lib/knowledge/__tests__/query.test.js +0 -55
- package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
- package/lib/knowledge/__tests__/screens.test.js +0 -30
- package/lib/knowledge/__tests__/why.test.d.ts +0 -1
- package/lib/knowledge/__tests__/why.test.js +0 -28
- package/src/__tests__/args.test.ts +0 -82
- package/src/__tests__/check.test.ts +0 -71
- package/src/__tests__/init.test.ts +0 -52
- package/src/__tests__/record.test.ts +0 -49
- package/src/__tests__/snapshot.test.ts +0 -51
- package/src/__tests__/targets.test.ts +0 -132
- package/src/cases/__tests__/format.test.ts +0 -76
- package/src/cases/__tests__/runner.test.ts +0 -230
- package/src/knowledge/__tests__/edges.test.ts +0 -34
- package/src/knowledge/__tests__/events.test.ts +0 -45
- package/src/knowledge/__tests__/extract.test.ts +0 -88
- package/src/knowledge/__tests__/frontier.test.ts +0 -77
- package/src/knowledge/__tests__/funnels.test.ts +0 -58
- package/src/knowledge/__tests__/lint.test.ts +0 -66
- package/src/knowledge/__tests__/perf.test.ts +0 -41
- package/src/knowledge/__tests__/plan.test.ts +0 -52
- package/src/knowledge/__tests__/query.test.ts +0 -74
- package/src/knowledge/__tests__/screens.test.ts +0 -37
- package/src/knowledge/__tests__/why.test.ts +0 -40
package/src/commands/cases.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
4
6
|
|
|
5
7
|
import { Fixtures } from '../cases/fixtures.js';
|
|
6
8
|
import { readCases } from '../cases/format.js';
|
|
7
|
-
import
|
|
9
|
+
import type { Case } from '../cases/format.js';
|
|
10
|
+
import { type CasesConfig, type CaseReport, missingFixtures, type Prepared, preparable, runCase } from '../cases/runner.js';
|
|
8
11
|
import { CONFIG, EXAMPLE_CASE, FIXTURES, FORMAT } from '../cases/templates.js';
|
|
9
|
-
import {
|
|
12
|
+
import { claimPorts } from '../cases/portClaim.js';
|
|
13
|
+
import { configFile, portFromEnv } from '../config.js';
|
|
10
14
|
import { readJson, writeJson } from '../files.js';
|
|
15
|
+
import { Ahead, InMemory, type Store } from '../cases/ahead.js';
|
|
16
|
+
import { Flakes } from '../cases/flakes.js';
|
|
17
|
+
import { RunFile } from '../cases/runfile.js';
|
|
11
18
|
import { eventsFile } from '../knowledge/events.js';
|
|
12
|
-
import { define } from '../registry.js';
|
|
19
|
+
import { type Base, define } from '../registry.js';
|
|
20
|
+
import { describeChanges, pushRun, service } from './report.js';
|
|
21
|
+
import { TraceSink } from '../cases/traces.js';
|
|
13
22
|
|
|
14
23
|
const startHost = async (root: string, err: (line: string) => void): Promise<ChildProcess> => {
|
|
15
24
|
// Its own process group: the host starts a test runner of its own, and stopping one without the other leaves an app dialing in.
|
|
@@ -21,6 +30,18 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
|
|
|
21
30
|
});
|
|
22
31
|
let output = '';
|
|
23
32
|
const tail = (n: number) => output.trim().split('\n').slice(-n).join(' · ');
|
|
33
|
+
// `headless start` ends a crash with what it usually means; without that, the error line jest printed.
|
|
34
|
+
const why = () => {
|
|
35
|
+
const plain = stripVTControlCharacters(output);
|
|
36
|
+
const explained = plain.split('lynkeus: what this looks like')[1];
|
|
37
|
+
if (explained)
|
|
38
|
+
return explained
|
|
39
|
+
.trim()
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.join(' · ');
|
|
43
|
+
return plain.match(/^\s*(\w*Error\b.*|Cannot find module.*)$/m)?.[1]?.trim() ?? tail(3);
|
|
44
|
+
};
|
|
24
45
|
await new Promise<void>((resolve, reject) => {
|
|
25
46
|
const settle = (error?: Error) => {
|
|
26
47
|
clearTimeout(timer);
|
|
@@ -31,11 +52,11 @@ const startHost = async (root: string, err: (line: string) => void): Promise<Chi
|
|
|
31
52
|
const read = (chunk: Buffer) => {
|
|
32
53
|
output = (output + chunk.toString()).slice(-4000);
|
|
33
54
|
if (/waiting for a driver/.test(output)) settle();
|
|
34
|
-
else if (/Test suite failed/.test(output)) settle(new Error(`the host failed to start: ${tail(5)}`));
|
|
35
55
|
};
|
|
36
56
|
host.stdout?.on('data', read);
|
|
37
57
|
host.stderr?.on('data', read);
|
|
38
|
-
|
|
58
|
+
// A suite that fails to load exits right after printing why, so its explanation is read in full here.
|
|
59
|
+
host.once('exit', (code) => settle(new Error(`${/Test suite failed/.test(output) ? 'the host failed to start' : `the host exited (${code})`}: ${why()}`)));
|
|
39
60
|
});
|
|
40
61
|
err('host: up');
|
|
41
62
|
return host;
|
|
@@ -47,11 +68,30 @@ const stopGroup = (pid: number) => {
|
|
|
47
68
|
} catch {}
|
|
48
69
|
};
|
|
49
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Stops a host and waits until it is gone. The next case's host dials the same port, and a host still on
|
|
73
|
+
* its way out dials it too: whichever reaches the driver first is the one the case talks to.
|
|
74
|
+
*/
|
|
75
|
+
const stopHost = async (host: ChildProcess): Promise<void> => {
|
|
76
|
+
if (!host.pid || host.exitCode !== null || host.signalCode !== null) return;
|
|
77
|
+
const gone = new Promise<void>((resolve) => host.once('exit', () => resolve()));
|
|
78
|
+
host.stdin?.end();
|
|
79
|
+
stopGroup(host.pid);
|
|
80
|
+
const late = setTimeout(() => {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(-host.pid!, 'SIGKILL');
|
|
83
|
+
} catch {}
|
|
84
|
+
}, 2000);
|
|
85
|
+
await gone;
|
|
86
|
+
clearTimeout(late);
|
|
87
|
+
};
|
|
88
|
+
|
|
50
89
|
const describe = (report: CaseReport): string => {
|
|
51
|
-
const counts = { passed: 0, failed: 0, skipped: 0 };
|
|
90
|
+
const counts = { passed: 0, failed: 0, skipped: 0, unsupported: 0 };
|
|
52
91
|
for (const s of report.steps) counts[s.status] += 1;
|
|
92
|
+
const headline = { passed: '✅ PASSED', failed: '❌ FAILED', unsupported: '🚫 NOT ON THIS HOST' }[report.result];
|
|
53
93
|
const lines = [
|
|
54
|
-
`${
|
|
94
|
+
`${headline} ✅ ${counts.passed}${counts.failed ? ` ❌ ${counts.failed}` : ''}${counts.unsupported ? ` 🚫 ${counts.unsupported}` : ''}${counts.skipped ? ` ⏭️ ${counts.skipped}` : ''} — ${(report.ms / 1000).toFixed(1)}s`,
|
|
55
95
|
];
|
|
56
96
|
if (report.evidence?.screen) lines.push('— screen at the failure:', ...report.evidence.screen.split('\n').map((l) => ` ${l}`));
|
|
57
97
|
if (report.evidence?.requests?.length) lines.push('— requests:', ...report.evidence.requests.map((l) => ` ${l}`));
|
|
@@ -59,6 +99,98 @@ const describe = (report: CaseReport): string => {
|
|
|
59
99
|
return lines.join('\n');
|
|
60
100
|
};
|
|
61
101
|
|
|
102
|
+
/** The share of the machine's CPU that sat idle over the next 300 ms. */
|
|
103
|
+
const idleShare = async (): Promise<number> => {
|
|
104
|
+
const total = () =>
|
|
105
|
+
os.cpus().reduce(
|
|
106
|
+
(sum, cpu) => {
|
|
107
|
+
const t = cpu.times;
|
|
108
|
+
return { idle: sum.idle + t.idle, all: sum.all + t.idle + t.user + t.sys + t.nice + t.irq };
|
|
109
|
+
},
|
|
110
|
+
{ idle: 0, all: 0 },
|
|
111
|
+
);
|
|
112
|
+
const before = total();
|
|
113
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
114
|
+
const after = total();
|
|
115
|
+
return after.all > before.all ? (after.idle - before.idle) / (after.all - before.all) : 1;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The case files named, or every case under the cases directory; a sentence when there are none. */
|
|
119
|
+
const caseFiles = (args: string[], home: string, casesDir: string | undefined): string[] | string => {
|
|
120
|
+
const dir = path.resolve(home, casesDir ?? 'cases');
|
|
121
|
+
const files = args.length
|
|
122
|
+
? args.map((f) => path.resolve(f))
|
|
123
|
+
: fs.existsSync(dir)
|
|
124
|
+
? fs
|
|
125
|
+
.readdirSync(dir)
|
|
126
|
+
.filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f) && !f.startsWith('.'))
|
|
127
|
+
.sort()
|
|
128
|
+
.map((f) => path.join(dir, f))
|
|
129
|
+
: [];
|
|
130
|
+
return files.length > 0 ? files : `no cases under ${path.relative(home, dir) || '.'}; \`lynkeus case init\` writes one`;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type PrepareOptions = { base: Base; config: CasesConfig; store: Store; open: () => Fixtures; limit: number; idle: number; jobs: number };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Runs the setups of `cases` ahead of them, in order, `jobs` at a time: never more than `limit` waiting
|
|
137
|
+
* to be taken, and only while the machine has `idle` of its CPU to spare. A case that started first is skipped.
|
|
138
|
+
*/
|
|
139
|
+
const prepareAhead = async (cases: Case[], o: PrepareOptions): Promise<{ made: number; skipped: number }> => {
|
|
140
|
+
let next = 0;
|
|
141
|
+
let made = 0;
|
|
142
|
+
let skipped = 0;
|
|
143
|
+
// Far enough ahead, or the machine busy: a setup made then only takes the CPU from the cases
|
|
144
|
+
// running now, and the suite gains nothing from having it early.
|
|
145
|
+
const room = async (): Promise<boolean> => {
|
|
146
|
+
if (o.store.waiting() < o.limit) return (await idleShare()) >= o.idle;
|
|
147
|
+
// A timer, not a bare check: the jobs that take the waiting setups may run in this same process,
|
|
148
|
+
// and a loop that never yields to the event loop would keep them from ever taking one.
|
|
149
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
const worker = async () => {
|
|
153
|
+
const fixtures = o.open();
|
|
154
|
+
await fixtures.ahead();
|
|
155
|
+
try {
|
|
156
|
+
for (let c = cases[next++]; c; c = cases[next++]) {
|
|
157
|
+
while (!o.store.started(c) && !(await room()));
|
|
158
|
+
if (o.store.started(c)) {
|
|
159
|
+
skipped++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const done = c;
|
|
163
|
+
await runCase(done, { base: o.base, config: o.config, fixtures, setupOnly: { keep: (p) => o.store.keep(done, p) } });
|
|
164
|
+
made++;
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
fixtures.close();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
await Promise.all(Array.from({ length: Math.max(1, o.jobs) }, worker));
|
|
171
|
+
return { made, skipped };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A host for one case, started by the project's own command: `{port}` is where it dials the driver. */
|
|
175
|
+
const spawnHost = (command: string, port: number, job: number, cwd: string): ChildProcess =>
|
|
176
|
+
spawn('/bin/sh', ['-c', command.replaceAll('{port}', String(port)).replaceAll('{job}', String(job))], {
|
|
177
|
+
cwd,
|
|
178
|
+
env: process.env,
|
|
179
|
+
// stdin stays open for the host's life: a host that serves until its stdin closes stops with the case.
|
|
180
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
181
|
+
detached: true,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/** A run that could not be uploaded still ran: the failure is said, not raised. */
|
|
185
|
+
const pushed = async (dir: string, to: Exclude<ReturnType<typeof service>, string>, project: string) => {
|
|
186
|
+
try {
|
|
187
|
+
const run = await pushRun(dir, { ...to, project });
|
|
188
|
+
return [`report: ${run.url}${run.refused.length ? ` (not kept on this plan: ${run.refused.join(', ')})` : ''}`, ...describeChanges(run.changes)].join('\n');
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return `the report was not uploaded: ${(error as Error).message}`;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
62
194
|
export const caseCommands = [
|
|
63
195
|
define({
|
|
64
196
|
name: 'case run',
|
|
@@ -70,33 +202,124 @@ export const caseCommands = [
|
|
|
70
202
|
flags: {
|
|
71
203
|
host: { type: 'boolean', help: 'start `headless start` for the run and stop it after' },
|
|
72
204
|
once: { type: 'boolean', help: 'no second chance for a case that fails' },
|
|
205
|
+
continue: { type: 'boolean', help: "keep running a case's steps after one fails (a failed setup still stops it)" },
|
|
206
|
+
seen: { type: 'string', help: 'write <dir>/<case id>.json with the screens each passing case went through, for a coverage map', value: 'dir' },
|
|
73
207
|
report: { type: 'string', help: 'append one JSON line per case', value: 'file' },
|
|
74
208
|
events: { type: 'string', help: 'keep what the app told its analytics, per case, as <dir>/<case id>/events.jsonl', value: 'dir' },
|
|
209
|
+
out: { type: 'string', help: 'write the run as a directory a report can read: run.json and cases.jsonl, a line per attempt', value: 'dir' },
|
|
210
|
+
screenshots: { type: 'boolean', help: 'with --out: a frame of the screen after every step of the app' },
|
|
211
|
+
settle: {
|
|
212
|
+
type: 'number',
|
|
213
|
+
help: 'with --screenshots: how long the screen must stay still before a frame, in ms; 700 for frames two runs can be compared by',
|
|
214
|
+
default: 150,
|
|
215
|
+
value: 'ms',
|
|
216
|
+
},
|
|
217
|
+
video: { type: 'boolean', help: 'with --out: film each case, where the host films (the step says at what second it began)' },
|
|
218
|
+
traces: {
|
|
219
|
+
type: 'boolean',
|
|
220
|
+
help: 'listen for the backend’s OpenTelemetry traces (OTLP over HTTP) and keep, under each step, what the backend did for it; point the backend’s OTEL_EXPORTER_OTLP_ENDPOINT at http://localhost:<traces-port>',
|
|
221
|
+
},
|
|
222
|
+
'traces-port': { type: 'number', help: 'where --traces listens', default: 4318, value: 'port' },
|
|
223
|
+
push: { type: 'boolean', help: 'with --out: upload the run to the reports service when it ends and print the report’s address' },
|
|
224
|
+
project: { type: 'string', help: 'with --push: the project the run is filed under (default: the app’s folder name)', value: 'name' },
|
|
225
|
+
'video-fps': { type: 'number', help: 'frames per second of the film', default: 30, value: 'n' },
|
|
226
|
+
'video-scale': { type: 'number', help: 'pixels per point of the film, where the host takes one (fastsim: 1 to 3, 2 unless told)', value: 'n' },
|
|
227
|
+
flakes: { type: 'string', help: 'the ledger of how each case did across runs (default .lynkeus/flakes.json)', value: 'file' },
|
|
228
|
+
prepared: { type: 'string', help: 'where `case prepare` leaves setups made ahead; a case found there skips its setup', value: 'dir' },
|
|
229
|
+
jobs: { type: 'number', help: 'cases at once, each on its own host (needs --ports)', value: 'n', default: 1 },
|
|
230
|
+
ports: { type: 'string', help: 'the driver port of each job, comma-separated: where its host dials in', value: 'list' },
|
|
231
|
+
'host-command': {
|
|
232
|
+
type: 'string',
|
|
233
|
+
help: 'starts a fresh host for every case and stops it after; {port} is where the host dials, {job} the job',
|
|
234
|
+
value: 'command',
|
|
235
|
+
},
|
|
236
|
+
'in-order': { type: 'boolean', help: 'with --jobs: take the cases in the order given, not the longest first' },
|
|
237
|
+
ahead: { type: 'number', help: 'make up to this many setups ahead of their cases while others run (0: none)', value: 'n', default: 0 },
|
|
238
|
+
idle: { type: 'number', help: 'with --ahead: start a setup only while this share of the CPU is idle', value: 'share', default: 0.25 },
|
|
75
239
|
'dry-run': { type: 'boolean', help: 'parse and list the steps without running them' },
|
|
76
240
|
},
|
|
77
241
|
needs: 'nothing',
|
|
78
242
|
session: false,
|
|
79
|
-
mcp:
|
|
243
|
+
mcp: 'core',
|
|
244
|
+
mcpOmit: [
|
|
245
|
+
'seen',
|
|
246
|
+
'report',
|
|
247
|
+
'events',
|
|
248
|
+
'settle',
|
|
249
|
+
'traces',
|
|
250
|
+
'traces-port',
|
|
251
|
+
'video-fps',
|
|
252
|
+
'video-scale',
|
|
253
|
+
'flakes',
|
|
254
|
+
'prepared',
|
|
255
|
+
'jobs',
|
|
256
|
+
'ports',
|
|
257
|
+
'host-command',
|
|
258
|
+
'in-order',
|
|
259
|
+
'ahead',
|
|
260
|
+
'idle',
|
|
261
|
+
'continue',
|
|
262
|
+
'video',
|
|
263
|
+
'project',
|
|
264
|
+
],
|
|
265
|
+
mcpDetails:
|
|
266
|
+
'Runs case files (default: all in cases.dir) against the attached app, or with `host` in Node. A case that fails comes back with its screen and reason. `out` + `screenshots` + `push` uploads the run and prints its address.',
|
|
80
267
|
run: async (ctx) => {
|
|
81
268
|
const config = ctx.config.cases ?? {};
|
|
82
269
|
const home = config.base ?? ctx.root;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
? ctx.args.map((f) => path.resolve(f))
|
|
86
|
-
: fs.existsSync(dir)
|
|
87
|
-
? fs
|
|
88
|
-
.readdirSync(dir)
|
|
89
|
-
.filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f) && !f.startsWith('.'))
|
|
90
|
-
.sort()
|
|
91
|
-
.map((f) => path.join(dir, f))
|
|
92
|
-
: [];
|
|
93
|
-
if (files.length === 0) return { text: `no cases under ${path.relative(ctx.root, dir) || '.'}; \`lynkeus case init\` writes one`, code: 1 };
|
|
270
|
+
const files = caseFiles(ctx.args, home, config.dir);
|
|
271
|
+
if (typeof files === 'string') return { text: files, code: 1 };
|
|
94
272
|
const cases = files.flatMap(readCases);
|
|
95
273
|
const dry = !!ctx.flags['dry-run'];
|
|
96
|
-
const
|
|
274
|
+
const seenDir = typeof ctx.flags.seen === 'string' && !dry ? path.resolve(ctx.flags.seen) : undefined;
|
|
275
|
+
const out = typeof ctx.flags.out === 'string' && !dry ? new RunFile(path.resolve(ctx.flags.out), ctx.root) : undefined;
|
|
276
|
+
if ((ctx.flags.screenshots || ctx.flags.video) && !out)
|
|
277
|
+
return { text: '--screenshots and --video keep what they take in the run directory: add --out <dir>', code: 2 };
|
|
278
|
+
// Checked before the run, so a missing key does not surface only after every case has run.
|
|
279
|
+
const reportsTo = ctx.flags.push && out ? service() : undefined;
|
|
280
|
+
if (ctx.flags.push && !out) return { text: '--push uploads the run directory: add --out <dir>', code: 2 };
|
|
281
|
+
if (typeof reportsTo === 'string') return { text: reportsTo, code: 2 };
|
|
282
|
+
const evidenceOf = (id: string, attempt: number) =>
|
|
283
|
+
out && (ctx.flags.screenshots || ctx.flags.video)
|
|
284
|
+
? {
|
|
285
|
+
dir: out.evidenceDir(id, attempt),
|
|
286
|
+
screenshots: !!ctx.flags.screenshots,
|
|
287
|
+
settleMs: Number(ctx.flags.settle ?? 150),
|
|
288
|
+
...(ctx.flags.video
|
|
289
|
+
? { video: { fps: Number(ctx.flags['video-fps'] ?? 30), ...(ctx.flags['video-scale'] ? { scale: Number(ctx.flags['video-scale']) } : {}) } }
|
|
290
|
+
: {}),
|
|
291
|
+
}
|
|
292
|
+
: undefined;
|
|
293
|
+
const ledger = dry ? undefined : new Flakes(path.resolve(ctx.root, typeof ctx.flags.flakes === 'string' ? ctx.flags.flakes : '.lynkeus/flakes.json'));
|
|
294
|
+
// A run file files each event under its step, so it needs them collected.
|
|
295
|
+
const events = typeof ctx.flags.events === 'string' || out !== undefined;
|
|
296
|
+
const claimed = dry
|
|
297
|
+
? undefined
|
|
298
|
+
: claimPorts(
|
|
299
|
+
typeof ctx.flags.ports === 'string'
|
|
300
|
+
? ctx.flags.ports
|
|
301
|
+
.split(',')
|
|
302
|
+
.map(Number)
|
|
303
|
+
.filter(Number.isInteger)
|
|
304
|
+
.slice(0, Math.max(1, Number(ctx.flags.jobs ?? 1)))
|
|
305
|
+
: [portFromEnv(ctx.config) ?? 8123],
|
|
306
|
+
);
|
|
307
|
+
if (claimed && 'refused' in claimed) return { text: claimed.refused, code: 1 };
|
|
97
308
|
const host = ctx.flags.host && !dry ? await startHost(ctx.root, ctx.err) : undefined;
|
|
98
309
|
const fixtures = config.fixtures && !dry ? new Fixtures(config.fixtures, home) : undefined;
|
|
310
|
+
const traces = ctx.flags.traces && !dry ? new TraceSink(Number(ctx.flags['traces-port'] ?? 4318)) : undefined;
|
|
311
|
+
if (traces) {
|
|
312
|
+
try {
|
|
313
|
+
await traces.start();
|
|
314
|
+
} catch (error) {
|
|
315
|
+
fixtures?.close();
|
|
316
|
+
return { text: `--traces could not listen on port ${traces.port}: ${(error as Error).message}`, code: 2 };
|
|
317
|
+
}
|
|
318
|
+
ctx.err(`backend traces: listening on http://localhost:${traces.port} (the backend's OTEL_EXPORTER_OTLP_ENDPOINT)`);
|
|
319
|
+
}
|
|
99
320
|
const stop = () => {
|
|
321
|
+
traces?.close();
|
|
322
|
+
claimed?.release();
|
|
100
323
|
fixtures?.close();
|
|
101
324
|
if (host?.pid) stopGroup(host.pid);
|
|
102
325
|
};
|
|
@@ -108,32 +331,196 @@ export const caseCommands = [
|
|
|
108
331
|
process.once('SIGTERM', interrupted);
|
|
109
332
|
const reports: (CaseReport & { retried?: boolean })[] = [];
|
|
110
333
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
report = { ...(await runCase(c, { base: ctx, config, fixtures, events, log: ctx.err })), retried: true };
|
|
118
|
-
ctx.err(describe(report));
|
|
119
|
-
}
|
|
120
|
-
if (events && report.events) {
|
|
121
|
-
const out = path.resolve(ctx.flags.events as string, c.id);
|
|
122
|
-
fs.mkdirSync(out, { recursive: true });
|
|
123
|
-
fs.writeFileSync(eventsFile(out), report.events.map((e) => `${JSON.stringify(e)}\n`).join(''));
|
|
124
|
-
}
|
|
125
|
-
reports.push(report);
|
|
126
|
-
if (ctx.flags.report) fs.appendFileSync(path.resolve(ctx.flags.report as string), `${JSON.stringify(report)}\n`);
|
|
334
|
+
// A case that names a fixture the server does not have fails halfway, after it has changed the backend. Better not to start.
|
|
335
|
+
const described = await fixtures?.describe();
|
|
336
|
+
if (described?.down) return { text: `the fixtures server cannot serve: ${described.down}`, code: 1 };
|
|
337
|
+
if (described) {
|
|
338
|
+
const missing = cases.flatMap((c) => missingFixtures(c, config, described.commands).map((name) => `${c.id}: no fixture ${name}`));
|
|
339
|
+
if (missing.length > 0) return { text: [...missing, `the fixtures command answers: ${described.commands.join(', ')}`].join('\n'), code: 1 };
|
|
127
340
|
}
|
|
341
|
+
const jobs = Math.max(1, Number(ctx.flags.jobs ?? 1));
|
|
342
|
+
const ports = typeof ctx.flags.ports === 'string' ? ctx.flags.ports.split(',').map(Number) : [];
|
|
343
|
+
const hostCommand = typeof ctx.flags['host-command'] === 'string' && !dry ? ctx.flags['host-command'] : undefined;
|
|
344
|
+
if ((jobs > 1 || hostCommand) && (ports.length < jobs || ports.some((p) => !Number.isInteger(p)) || !ctx.forPort))
|
|
345
|
+
return { text: `--jobs and --host-command need a driver port per job: --ports with ${jobs}`, code: 2 };
|
|
346
|
+
// Several jobs finish together only if the longest cases start first: one left for the end runs alone
|
|
347
|
+
// while the other jobs wait. A case the ledger has not timed goes first too, as it may be long.
|
|
348
|
+
if (jobs > 1 && ledger && !ctx.flags['in-order']) {
|
|
349
|
+
const took = (id: string) => ledger.lastMs(id) ?? Number.POSITIVE_INFINITY;
|
|
350
|
+
cases.sort((a, b) => took(b.id) - took(a.id));
|
|
351
|
+
}
|
|
352
|
+
const limit = Number(ctx.flags.ahead ?? 0);
|
|
353
|
+
const store: Store | undefined =
|
|
354
|
+
typeof ctx.flags.prepared === 'string' && !dry ? new Ahead(path.resolve(ctx.flags.prepared)) : limit > 0 && fixtures ? new InMemory() : undefined;
|
|
355
|
+
const declared = config.fixtures;
|
|
356
|
+
// The setups of the cases to come, made while these run, in the order the jobs will take them.
|
|
357
|
+
const preparing =
|
|
358
|
+
limit > 0 && declared && store && !dry
|
|
359
|
+
? prepareAhead(
|
|
360
|
+
cases.filter((c) => preparable(c, config)),
|
|
361
|
+
{
|
|
362
|
+
base: ctx,
|
|
363
|
+
config,
|
|
364
|
+
store,
|
|
365
|
+
open: () => new Fixtures(declared, home),
|
|
366
|
+
limit,
|
|
367
|
+
idle: Number(ctx.flags.idle ?? 0.25),
|
|
368
|
+
jobs: 1,
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
: undefined;
|
|
372
|
+
let next = 0;
|
|
373
|
+
const job = async (index: number) => {
|
|
374
|
+
// Each job keeps its own connection to the fixtures server, warm from one case to the next.
|
|
375
|
+
const own = index === 0 ? fixtures : declared && !dry ? new Fixtures(declared, home) : undefined;
|
|
376
|
+
const port = ports[index];
|
|
377
|
+
try {
|
|
378
|
+
for (let c = cases[next++]; c; c = cases[next++]) {
|
|
379
|
+
const lines: string[] = [];
|
|
380
|
+
// With several jobs a case's lines are held and printed together, not interleaved.
|
|
381
|
+
const log = jobs > 1 ? (line: string) => lines.push(line) : ctx.err;
|
|
382
|
+
log(`▶ ${c.id}${c.title ? ` — ${c.title}` : ''}`);
|
|
383
|
+
store?.start(c);
|
|
384
|
+
const attempt = async (n: number, prepared?: Prepared) => {
|
|
385
|
+
const host = hostCommand && port !== undefined ? spawnHost(hostCommand, port, index, ctx.root) : undefined;
|
|
386
|
+
const scoped = port !== undefined && ctx.forPort ? ctx.forPort(port) : { base: ctx as Base, stop: async () => undefined };
|
|
387
|
+
const startedAt = new Date();
|
|
388
|
+
try {
|
|
389
|
+
// A host just started is still drawing its first screens; with the setup made ahead nothing
|
|
390
|
+
// else gives it that moment before the first press.
|
|
391
|
+
if (host)
|
|
392
|
+
await scoped.base
|
|
393
|
+
.device()
|
|
394
|
+
.then((d) => d.idle({ quietMs: 300, timeoutMs: 5000 }))
|
|
395
|
+
.catch(() => undefined);
|
|
396
|
+
const report = await runCase(c, {
|
|
397
|
+
base: scoped.base,
|
|
398
|
+
config,
|
|
399
|
+
fixtures: own,
|
|
400
|
+
dry,
|
|
401
|
+
events,
|
|
402
|
+
evidence: evidenceOf(c.id, n),
|
|
403
|
+
log,
|
|
404
|
+
prepared,
|
|
405
|
+
continue: !!ctx.flags.continue,
|
|
406
|
+
routes: seenDir ? (host ? 'fresh' : true) : false,
|
|
407
|
+
traces,
|
|
408
|
+
});
|
|
409
|
+
if (seenDir && report.result === 'passed' && report.routes?.length) {
|
|
410
|
+
fs.mkdirSync(seenDir, { recursive: true });
|
|
411
|
+
fs.writeFileSync(path.join(seenDir, `${c.id}.json`), `${JSON.stringify({ id: c.id, routes: report.routes }, null, 2)}\n`);
|
|
412
|
+
}
|
|
413
|
+
return { report, startedAt, hello: (await scoped.base.device().catch(() => undefined))?.server.hello ?? undefined };
|
|
414
|
+
} finally {
|
|
415
|
+
if (host) await stopHost(host);
|
|
416
|
+
await scoped.stop();
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
let {
|
|
420
|
+
report,
|
|
421
|
+
startedAt,
|
|
422
|
+
hello,
|
|
423
|
+
}: { report: CaseReport & { retried?: boolean }; startedAt: Date; hello?: { platform?: string; native?: boolean } } = await attempt(
|
|
424
|
+
1,
|
|
425
|
+
store?.take(c),
|
|
426
|
+
);
|
|
427
|
+
seen ??= hello;
|
|
428
|
+
log(describe(report));
|
|
429
|
+
out?.add(c, { report, attempt: 1, startedAt });
|
|
430
|
+
if (report.result === 'failed' && !ctx.flags.once && !dry) {
|
|
431
|
+
log(` retrying ${c.id} once`);
|
|
432
|
+
const again = await attempt(2);
|
|
433
|
+
report = { ...again.report, retried: true };
|
|
434
|
+
startedAt = again.startedAt;
|
|
435
|
+
log(describe(report));
|
|
436
|
+
out?.add(c, { report, attempt: 2, startedAt });
|
|
437
|
+
}
|
|
438
|
+
if (jobs > 1) ctx.err(lines.join('\n'));
|
|
439
|
+
ledger?.note(report);
|
|
440
|
+
if (typeof ctx.flags.events === 'string' && report.events) {
|
|
441
|
+
const dir = path.resolve(ctx.flags.events as string, c.id);
|
|
442
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
443
|
+
fs.writeFileSync(eventsFile(dir), report.events.map((e) => `${JSON.stringify(e)}\n`).join(''));
|
|
444
|
+
}
|
|
445
|
+
reports.push(report);
|
|
446
|
+
if (ctx.flags.report) fs.appendFileSync(path.resolve(ctx.flags.report as string), `${JSON.stringify(report)}\n`);
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
if (own !== fixtures) own?.close();
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
let seen: { platform?: string; native?: boolean } | undefined;
|
|
453
|
+
await Promise.all(Array.from({ length: jobs }, (_, i) => job(i)));
|
|
454
|
+
await preparing;
|
|
455
|
+
if (out) out.finish({ platform: seen?.platform, native: seen?.native });
|
|
128
456
|
} finally {
|
|
129
457
|
stop();
|
|
130
458
|
}
|
|
131
|
-
const
|
|
459
|
+
const count = (result: CaseReport['result']) => reports.filter((r) => r.result === result).length;
|
|
460
|
+
const unsupported = count('unsupported');
|
|
132
461
|
const summary = [
|
|
133
|
-
...reports.map(
|
|
134
|
-
|
|
462
|
+
...reports.map(
|
|
463
|
+
(r) =>
|
|
464
|
+
`${{ passed: '✅', failed: '❌', unsupported: '🚫' }[r.result]} ${r.id}${r.retried && r.result === 'passed' ? ' ⚠ passed on retry' : ''}` +
|
|
465
|
+
// In a session or over MCP the caller reads this answer and nothing else: a case that did not pass brings its steps.
|
|
466
|
+
(ctx.attached && r.result !== 'passed' ? `\n${describe(r)}` : ''),
|
|
467
|
+
),
|
|
468
|
+
`${count('passed')}/${reports.length} passed${unsupported ? `, ${unsupported} cannot run on this host` : ''}`,
|
|
469
|
+
...(ledger ? ledger.flaky(reports.map((r) => r.id)) : []),
|
|
470
|
+
...(reportsTo && out
|
|
471
|
+
? [
|
|
472
|
+
await pushed(
|
|
473
|
+
path.resolve(ctx.flags.out as string),
|
|
474
|
+
reportsTo,
|
|
475
|
+
typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root),
|
|
476
|
+
),
|
|
477
|
+
]
|
|
478
|
+
: []),
|
|
135
479
|
].join('\n');
|
|
136
|
-
|
|
480
|
+
ledger?.save();
|
|
481
|
+
// A case this host cannot run is not a failure of the app; only a failed case fails the run.
|
|
482
|
+
return { text: summary, json: reports, code: count('failed') === 0 ? 0 : 1 };
|
|
483
|
+
},
|
|
484
|
+
}),
|
|
485
|
+
define({
|
|
486
|
+
name: 'case prepare',
|
|
487
|
+
group: 'runs',
|
|
488
|
+
summary: 'Run the setup of cases ahead of them, so each starts at its first step',
|
|
489
|
+
details:
|
|
490
|
+
'For a suite that starts `case run --prepared <dir>` once per case: start this first, with the same cases in the order they will run, and leave it running. It runs every setup made only of the project fixtures and leaves what each produced in <dir>; a case that starts before its setup is ready runs its own, and this skips it. It asks the fixtures server for a lower priority (`lynkeus.ahead`), so it takes what the running cases leave of the machine.',
|
|
491
|
+
positionals: [{ name: 'files', help: 'case files, in the order they will run (default: every .md under cases.dir)', rest: true }],
|
|
492
|
+
flags: {
|
|
493
|
+
prepared: { type: 'string', help: 'where to leave the setups (emptied first)', value: 'dir', default: '.lynkeus/prepared' },
|
|
494
|
+
jobs: { type: 'number', help: 'setups made at once', value: 'n', default: 1 },
|
|
495
|
+
ahead: { type: 'number', help: 'setups kept ready and not yet taken, at most', value: 'n', default: 4 },
|
|
496
|
+
idle: { type: 'number', help: 'start a setup only while at least this share of the CPU is idle (0 to 1)', value: 'share', default: 0.25 },
|
|
497
|
+
},
|
|
498
|
+
needs: 'nothing',
|
|
499
|
+
session: false,
|
|
500
|
+
mcp: false,
|
|
501
|
+
run: async (ctx) => {
|
|
502
|
+
const config = ctx.config.cases ?? {};
|
|
503
|
+
if (!config.fixtures) return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json): nothing to prepare', code: 1 };
|
|
504
|
+
const declared = config.fixtures;
|
|
505
|
+
const home = config.base ?? ctx.root;
|
|
506
|
+
const files = caseFiles(ctx.args, home, config.dir);
|
|
507
|
+
if (typeof files === 'string') return { text: files, code: 1 };
|
|
508
|
+
const cases = files.flatMap(readCases).filter((c) => preparable(c, config));
|
|
509
|
+
const probe = new Fixtures(declared, home);
|
|
510
|
+
const described = await probe.describe().finally(() => probe.close());
|
|
511
|
+
if (described?.down) return { text: `the fixtures server cannot serve: ${described.down}`, code: 1 };
|
|
512
|
+
const store = new Ahead(path.resolve(ctx.root, String(ctx.flags.prepared ?? '.lynkeus/prepared')));
|
|
513
|
+
store.clear();
|
|
514
|
+
const { made, skipped } = await prepareAhead(cases, {
|
|
515
|
+
base: ctx,
|
|
516
|
+
config,
|
|
517
|
+
store,
|
|
518
|
+
open: () => new Fixtures(declared, home),
|
|
519
|
+
limit: Math.max(1, Number(ctx.flags.ahead ?? 4)),
|
|
520
|
+
idle: Number(ctx.flags.idle ?? 0.25),
|
|
521
|
+
jobs: Number(ctx.flags.jobs ?? 1),
|
|
522
|
+
});
|
|
523
|
+
return { text: `${made} setups made ahead, ${skipped} cases had started first`, json: { made, skipped } };
|
|
137
524
|
},
|
|
138
525
|
}),
|
|
139
526
|
define({
|
|
@@ -143,7 +530,7 @@ export const caseCommands = [
|
|
|
143
530
|
flags: { force: { type: 'boolean', help: 'overwrite what is there' } },
|
|
144
531
|
needs: 'nothing',
|
|
145
532
|
session: false,
|
|
146
|
-
mcp:
|
|
533
|
+
mcp: 'core',
|
|
147
534
|
run: async (ctx) => {
|
|
148
535
|
const dir = path.resolve(ctx.root, ctx.config.cases?.dir ?? 'cases');
|
|
149
536
|
fs.mkdirSync(dir, { recursive: true });
|
package/src/commands/check.ts
CHANGED
|
@@ -55,15 +55,22 @@ export const diffGraphs = (base: ScreenGraph, head: ScreenGraph): GraphDiff => {
|
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
/** Lint findings per route, as `lynkeus lint --save` writes them. */
|
|
58
|
-
export type LintBaseline = Record<string, { rule: string; testId?: string; message: string }[]>;
|
|
58
|
+
export type LintBaseline = Record<string, { rule: string; testId?: string; subject?: string; message: string }[]>;
|
|
59
59
|
|
|
60
60
|
type LintedFinding = { finding: Finding; testId?: string };
|
|
61
61
|
|
|
62
|
-
const key = (rule: string, testId: string | undefined,
|
|
62
|
+
const key = (rule: string, testId: string | undefined, what: string) => `${rule}|${testId ?? ''}|${what}`;
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A finding is known by its rule and what it is about, not by what was measured: a target that went from
|
|
66
|
+
* 32 to 30 points is the finding the baseline already has. A baseline saved before findings named their
|
|
67
|
+
* subject has only the message to go by.
|
|
68
|
+
*/
|
|
64
69
|
export const newFindings = (baseline: LintBaseline, route: string, findings: LintedFinding[]): LintedFinding[] => {
|
|
65
|
-
const seen = new Set((baseline[route] ?? []).map((b) => key(b.rule, b.testId, b.message)));
|
|
66
|
-
return findings.filter(
|
|
70
|
+
const seen = new Set((baseline[route] ?? []).map((b) => key(b.rule, b.testId, b.subject ?? b.message)));
|
|
71
|
+
return findings.filter(
|
|
72
|
+
(f) => !seen.has(key(f.finding.rule, f.testId, f.finding.subject ?? f.finding.message)) && !seen.has(key(f.finding.rule, f.testId, f.finding.message)),
|
|
73
|
+
);
|
|
67
74
|
};
|
|
68
75
|
|
|
69
76
|
export const describeGraphDiff = (d: GraphDiff): string => {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -78,6 +78,13 @@ const PATTERNS: { re: RegExp; how: string }[] = [
|
|
|
78
78
|
{ re: /codegenNativeComponent\s*<[^>]*>\(\s*['"]([^'"]+)['"]/g, how: 'codegenNativeComponent' },
|
|
79
79
|
];
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The text with its comments blanked, lines kept where they were: a mock or a native module named in a comment
|
|
83
|
+
* (the generated entry shows `jest.mock('some-native-package', …)` as an example) is neither. Only comments that
|
|
84
|
+
* own their line are taken, so a `//` inside a string (a URL) stays.
|
|
85
|
+
*/
|
|
86
|
+
const withoutComments = (text: string): string => text.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')).replace(/^[ \t]*\/\/.*$/gm, '');
|
|
87
|
+
|
|
81
88
|
const scanNativeRefs = (root: string): NativeRef[] => {
|
|
82
89
|
const files: string[] = [];
|
|
83
90
|
for (const d of SCAN_DIRS) walk(path.join(root, d), files);
|
|
@@ -85,7 +92,7 @@ const scanNativeRefs = (root: string): NativeRef[] => {
|
|
|
85
92
|
for (const file of files) {
|
|
86
93
|
let text: string;
|
|
87
94
|
try {
|
|
88
|
-
text = fs.readFileSync(file, 'utf8');
|
|
95
|
+
text = withoutComments(fs.readFileSync(file, 'utf8'));
|
|
89
96
|
} catch {
|
|
90
97
|
continue;
|
|
91
98
|
}
|
|
@@ -107,7 +114,7 @@ const entryMocks = (root: string, entry: string): { specs: Set<string>; files: S
|
|
|
107
114
|
const entryPath = path.join(root, entry);
|
|
108
115
|
let text = '';
|
|
109
116
|
try {
|
|
110
|
-
text = fs.readFileSync(entryPath, 'utf8');
|
|
117
|
+
text = withoutComments(fs.readFileSync(entryPath, 'utf8'));
|
|
111
118
|
} catch {
|
|
112
119
|
return { specs, files };
|
|
113
120
|
}
|