@jskit-ai/jskit-catalog 0.2.31 → 0.2.33

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.
@@ -0,0 +1,150 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { toPosixPath } from "./files.mjs";
5
+ import { STOPPED_PREFIX } from "./test-file-stop.mjs";
6
+
7
+ // How long `jskit test` took, file by file. The node --test reporter below runs beside a run's own reporters and writes
8
+ // the run's file and top-level test durations to the file `jskit test` names. After its last run, `jskit test` lists the
9
+ // ten slowest files and tests, records each file's time in node_modules/.cache/jskit-test/file-times.json, and fails
10
+ // when a file or the whole command went past a coarse bound that only a clearly broken suite reaches: a file waiting
11
+ // out its timeouts, a suite that would run for hours. An application whose measured suite needs more writes its own
12
+ // bounds in test-time-bounds.json, `{ "runMs": ..., "fileMs": ..., "files": { "<path>": ... } }`, with the measurement.
13
+ // A file that has not finished by a generous multiple of its time is stopped while it runs (test-file-stop.mjs), so
14
+ // one file that never ends fails by name instead of holding up the whole run until a runner's own ceiling ends it.
15
+ const SHOWN = 10;
16
+ // Stops a file at twice its bound (at least two seconds, well past any file's start-up), or at ten times its recorded
17
+ // time (at least two minutes, more than a cold compile cache or a loaded machine needs), whichever comes first. A file
18
+ // with its own bound in test-time-bounds.json is stopped at twice that bound.
19
+ const STOP_BOUND_TIMES = 2;
20
+ const STOP_BOUND_MIN_MS = 2000;
21
+ const STOP_RECORDED_TIMES = 10;
22
+ const STOP_RECORDED_MIN_MS = 2 * 60_000;
23
+ const BOUNDS_FILE = "test-time-bounds.json";
24
+ const DEFAULT_BOUNDS = Object.freeze({ runMs: 45 * 60_000, fileMs: 10 * 60_000, files: Object.freeze({}) });
25
+ const FILE_TIMES = path.join("node_modules", ".cache", "jskit-test", "file-times.json");
26
+
27
+ const seconds = (ms) => (ms >= 60_000 ? `${(ms / 60_000).toFixed(1)} min` : `${(ms / 1000).toFixed(1)} s`);
28
+
29
+ /** @param {AsyncIterable<{ type: string, data: any }>} source node --test's events */
30
+ export default async function* collectTestTimes(source) {
31
+ const relative = (file) => toPosixPath(path.relative(process.cwd(), file));
32
+ const files = {};
33
+ const tests = [];
34
+ // Files that skipped a test: their time says little about how long they take when every test runs.
35
+ const skipped = new Set();
36
+ for await (const event of source) {
37
+ if (event.type === "test:summary" && event.data.file) {
38
+ files[relative(event.data.file)] = event.data.duration_ms;
39
+ } else if ((event.type === "test:pass" || event.type === "test:fail") && event.data.file) {
40
+ if (event.data.skip != null && event.data.skip !== false) skipped.add(relative(event.data.file));
41
+ if (event.data.nesting === 0) {
42
+ tests.push({ file: relative(event.data.file), name: event.data.name, ms: event.data.details?.duration_ms ?? 0 });
43
+ }
44
+ }
45
+ }
46
+ yield `${JSON.stringify({ files, tests, skipped: [...skipped] })}\n`;
47
+ }
48
+
49
+ /** The application's bounds over JSKIT's defaults; each must be a positive number of milliseconds. */
50
+ function readTestTimeBounds(cwd) {
51
+ const file = path.join(cwd, BOUNDS_FILE);
52
+ if (!existsSync(file)) return DEFAULT_BOUNDS;
53
+ const own = JSON.parse(readFileSync(file, "utf8"));
54
+ const bounds = { ...DEFAULT_BOUNDS, ...own, files: { ...own.files } };
55
+ for (const [name, ms] of [["runMs", bounds.runMs], ["fileMs", bounds.fileMs], ...Object.entries(bounds.files)]) {
56
+ if (!(Number.isFinite(ms) && ms > 0)) throw new Error(`${BOUNDS_FILE}: ${name} must be a positive number of milliseconds.`);
57
+ }
58
+ return bounds;
59
+ }
60
+
61
+ /**
62
+ * When a file still running is stopped, and why: twice its bound, or ten times its recorded time when that comes first.
63
+ * @param {{ bound: number, recorded?: number }} times milliseconds; `recorded` is the file's last time, if any
64
+ * @returns {{ ms: number, reason: string }}
65
+ */
66
+ function fileStop({ bound, recorded }) {
67
+ const byBound = Math.max(STOP_BOUND_MIN_MS, STOP_BOUND_TIMES * bound);
68
+ const byRecorded = recorded > 0 ? Math.max(STOP_RECORDED_MIN_MS, STOP_RECORDED_TIMES * recorded) : Infinity;
69
+ return byRecorded < byBound
70
+ ? { ms: byRecorded, reason: `past ten times its last time of ${seconds(recorded)}` }
71
+ : { ms: byBound, reason: `past twice its ${seconds(bound)} bound` };
72
+ }
73
+
74
+ /**
75
+ * Writes when each of the run's files is stopped, keyed by its absolute path, for test-file-stop.mjs; returns the file.
76
+ * @param {{ cwd: string, files: string[], directory: string }} options `files`: the run's test files, relative to cwd
77
+ */
78
+ function writeFileStops({ cwd, files, directory }) {
79
+ const bounds = readTestTimeBounds(cwd);
80
+ const record = path.join(cwd, FILE_TIMES);
81
+ const recorded = existsSync(record) ? JSON.parse(readFileSync(record, "utf8")) : {};
82
+ const stops = Object.fromEntries(files.map((file) => [
83
+ path.resolve(cwd, file),
84
+ // A file's own bound is the application's measured statement of its time, so it alone sets the stop.
85
+ { file, ...fileStop(file in bounds.files ? { bound: bounds.files[file] } : { bound: bounds.fileMs, recorded: recorded[file] }) }
86
+ ]));
87
+ const stopsFile = path.join(directory, "file-stops.json");
88
+ writeFileSync(stopsFile, JSON.stringify(stops));
89
+ return stopsFile;
90
+ }
91
+
92
+ /**
93
+ * The slowest files and tests of a `jskit test` command against its bounds, and the bounds it went past.
94
+ * @param {{ bounds: { runMs: number, fileMs: number, files: Record<string, number> }, files: Record<string, number>,
95
+ * tests: Array<{ file: string, name: string, ms: number }>, wallMs: number,
96
+ * stopped?: Array<{ file: string, ms: number, reason: string }> }} times `stopped`: the files stopped while running
97
+ */
98
+ function checkTestTimes({ bounds, files, tests, wallMs, stopped = [] }) {
99
+ const rows = Object.entries(files).map(([name, ms]) => ({ name, ms, bound: bounds.files[name] ?? bounds.fileMs }));
100
+ const overruns = [
101
+ ...stopped.map((stop) => `${stop.file} was stopped after ${seconds(stop.ms)}, ${stop.reason}: it did not finish.`),
102
+ ...rows.filter((row) => row.ms > row.bound && !stopped.some((stop) => stop.file === row.name))
103
+ .map((row) => `${row.name} took ${seconds(row.ms)}, past its ${seconds(row.bound)} bound.`)
104
+ ];
105
+ if (wallMs > bounds.runMs) overruns.push(`The run took ${seconds(wallMs)}, past its ${seconds(bounds.runMs)} bound.`);
106
+ const lines = [
107
+ `jskit test: ${rows.length} test file(s) in ${seconds(wallMs)} (bound ${seconds(bounds.runMs)}); the slowest, time / bound:`,
108
+ ...rows.sort((a, b) => b.ms - a.ms).slice(0, SHOWN).map((row) => ` ${seconds(row.ms)} / ${seconds(row.bound)} ${row.name}`),
109
+ "jskit test: the slowest tests:",
110
+ ...[...tests].sort((a, b) => b.ms - a.ms).slice(0, SHOWN)
111
+ .map((entry) => ` ${seconds(entry.ms)} ${entry.file}: ${entry.name.slice(0, 120)}`)
112
+ ];
113
+ if (overruns.length > 0) {
114
+ lines.push(
115
+ "jskit test: past a time bound, so something is clearly broken:",
116
+ ...overruns.map((overrun) => ` ${overrun}`),
117
+ `Find what got slow and fix it; raise a bound in ${BOUNDS_FILE} only when the work itself grew, with the measurement.`
118
+ );
119
+ }
120
+ return { overruns, report: `${lines.join("\n")}\n` };
121
+ }
122
+
123
+ /**
124
+ * Reads what the runs' reporters wrote and which files test-file-stop.mjs stopped, records each finished file's time
125
+ * for the next command (a stopped file, or one that skipped a test, keeps the time it last finished all its tests in,
126
+ * since the next stop is reckoned from it), and checks the times. A run whose
127
+ * reporters were replaced (inside another node --test run) wrote nothing, and has no report unless a file was stopped.
128
+ * @param {{ cwd: string, collected: string[], directory: string, wallMs: number }} options `collected`: the reporters'
129
+ * destination files; `directory`: where the stopped files are written
130
+ * @returns {{ overruns: string[], report: string } | null}
131
+ */
132
+ function reportTestTimes({ cwd, collected, directory, wallMs }) {
133
+ const runs = collected.filter((file) => existsSync(file) && readFileSync(file, "utf8").trim())
134
+ .map((file) => JSON.parse(readFileSync(file, "utf8")));
135
+ const stopped = readdirSync(directory).filter((name) => name.startsWith(STOPPED_PREFIX)).sort()
136
+ .map((name) => JSON.parse(readFileSync(path.join(directory, name), "utf8")));
137
+ const files = Object.assign({}, ...runs.map((run) => run.files));
138
+ if (Object.keys(files).length === 0 && stopped.length === 0) return null;
139
+ const unrepresentative = new Set([...stopped.map((stop) => stop.file), ...runs.flatMap((run) => run.skipped ?? [])]);
140
+ const finished = Object.fromEntries(Object.entries(files).filter(([name]) => !unrepresentative.has(name)));
141
+ const record = path.join(cwd, FILE_TIMES);
142
+ const recorded = existsSync(record) ? JSON.parse(readFileSync(record, "utf8")) : {};
143
+ mkdirSync(path.dirname(record), { recursive: true });
144
+ // Written whole, then renamed, so a command beside this one never reads half a file.
145
+ writeFileSync(`${record}.${process.pid}`, `${JSON.stringify({ ...recorded, ...finished }, null, 2)}\n`);
146
+ renameSync(`${record}.${process.pid}`, record);
147
+ return checkTestTimes({ bounds: readTestTimeBounds(cwd), files, tests: runs.flatMap((run) => run.tests), wallMs, stopped });
148
+ }
149
+
150
+ export { BOUNDS_FILE, FILE_TIMES, checkTestTimes, fileStop, reportTestTimes, writeFileStops };
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { globSync } from "node:fs";
2
3
  import { readdir } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -8,6 +9,8 @@ import {
8
9
  discoverPackagePatterns
9
10
  } from "./pattern-assets.mjs";
10
11
  import { collectSingletonPackageIds, discoverFrameworkPackages } from "./framework-packages.mjs";
12
+ import { discoverProjectManifests, readJsonRecord } from "./project-manifests.mjs";
13
+ import { checkTestRuns, formatTestRunsReport } from "./test-runs.mjs";
11
14
 
12
15
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
13
16
  const REPO_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "../../..");
@@ -352,6 +355,23 @@ function validateSingletonPeerDependencies(packages) {
352
355
  }
353
356
  }
354
357
 
358
+ // JSKIT's own package scripts and every pattern example an application copies run tests within the test budget.
359
+ async function verifyTestRuns() {
360
+ const examples = globSync("packages/*/patterns/*/example/**/package.json", {
361
+ cwd: REPO_ROOT,
362
+ exclude: (name) => path.basename(name) === "node_modules"
363
+ });
364
+ const manifests = [
365
+ ...await discoverProjectManifests(REPO_ROOT),
366
+ ...await Promise.all(examples.sort().map((file) => readJsonRecord(path.join(REPO_ROOT, file))))
367
+ ];
368
+ const result = await checkTestRuns({ projectRoot: REPO_ROOT, manifests });
369
+ if (!result.ok) {
370
+ throw new Error(formatTestRunsReport(result));
371
+ }
372
+ return result.manifests;
373
+ }
374
+
355
375
  async function main() {
356
376
  const packages = await discoverFrameworkPackages(PACKAGES_ROOT);
357
377
  const localVersions = new Map(packages.map(({ packageJson }) => [packageJson.name, packageJson.version]));
@@ -366,9 +386,11 @@ async function main() {
366
386
  serverProviderCount += packageResult.serverProviderCount;
367
387
  }
368
388
  assertUniquePatternIds(patterns);
389
+ const testManifests = await verifyTestRuns();
369
390
  process.stdout.write(
370
391
  `Verified ${packages.length} JSKIT runtime packages, ${serverProviderCount} loadable server providers, ` +
371
- `${patterns.length} patterns, and ${migrationOwners.size} loadable package migrations.\n`
392
+ `${patterns.length} patterns, ${migrationOwners.size} loadable package migrations, and the test runs of ` +
393
+ `${testManifests} manifests.\n`
372
394
  );
373
395
  }
374
396