@jskit-ai/jskit-catalog 0.2.32 → 0.2.34

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.
@@ -1,19 +1,36 @@
1
1
  import { spawn } from "node:child_process";
2
- import { globSync, readFileSync } from "node:fs";
2
+ import { globSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
+ import { pathToFileURL } from "node:url";
6
7
  import { toPosixPath } from "./files.mjs";
7
8
  import { BUDGET_VARIABLE, testConcurrency } from "./test-budget.mjs";
9
+ import { DATABASES_OPTION, testDatabaseRunFor } from "./test-databases.mjs";
10
+ import { STOPS_VARIABLE } from "./test-file-stop.mjs";
11
+ import { reportTestTimes, writeFileStops } from "./test-time-report.mjs";
8
12
 
9
13
  // `jskit test [node options] [files or globs]`: node --test, running test files in parallel within the machine's test
10
14
  // budget (test-budget.mjs). Its arguments go to node --test unchanged. A file that truly must run alone says why on its
11
- // first line, `// jskit-test: alone - <reason>`, and runs after the others, one such file at a time.
15
+ // first line, `// jskit-test: alone - <reason>`, and runs after the others, one such file at a time. That second run
16
+ // writes a reporter's file destination beside the first run's (`<name>.alone<ext>`) instead of over it, and with
17
+ // --test-shard=<index>/<total> it runs only its shard's alone files, picked the way node --test picks a shard's files.
18
+ // Every run also reports to test-time-report.mjs, which times the command's files against coarse bounds, and preloads
19
+ // test-file-stop.mjs, which stops a file still running at a generous multiple of its time.
12
20
 
13
21
  // node --test's own default (Node 26) when it is given no files.
14
22
  const DEFAULT_PATTERN = "**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs,ts,mts,cts}";
15
23
  const MARKER = /^\s*\/\/\s*jskit-test:(.*)$/u;
16
24
  const ALONE = /^\s*alone\s+-\s+(\S.*)$/u;
25
+ const SHARD = "--test-shard";
26
+ const REPORTER = "--test-reporter";
27
+ const DESTINATION = "--test-reporter-destination";
28
+ const TIME_REPORTER = new URL("./test-time-report.mjs", import.meta.url).href;
29
+ const FILE_STOP = new URL("./test-file-stop.mjs", import.meta.url).href;
30
+ // Every test file's process compiles the same modules; Node's compile cache keeps them for the next process and run.
31
+ // A cache the environment names, or NODE_DISABLE_COMPILE_CACHE=1, wins.
32
+ const COMPILE_CACHE = path.join("node_modules", ".cache", "jskit-node-compile-cache");
33
+ const STREAM_DESTINATIONS = new Set(["stdout", "stderr"]);
17
34
 
18
35
  function findTestFiles(patterns, cwd) {
19
36
  const entries = globSync(patterns, { cwd, withFileTypes: true, exclude: (entry) => entry.name === "node_modules" });
@@ -46,6 +63,41 @@ function readAloneFiles(files, cwd) {
46
63
  return { alone, problems };
47
64
  }
48
65
 
66
+ /** A reporter's file destination for the alone run, beside the parallel run's: `<name>.alone<ext>`; streams stay. */
67
+ function aloneDestination(destination) {
68
+ if (STREAM_DESTINATIONS.has(destination)) return destination;
69
+ const { dir, name, ext } = path.parse(destination);
70
+ return path.join(dir, `${name}.alone${ext}`);
71
+ }
72
+
73
+ /**
74
+ * The alone run's options: the parallel run's, with each file destination moved beside it and without the shard, which
75
+ * `aloneShard` has already applied to the alone files.
76
+ * @param {string[]} options
77
+ */
78
+ function aloneRunOptions(options) {
79
+ const result = [];
80
+ for (let index = 0; index < options.length; index += 1) {
81
+ const option = options[index];
82
+ if (option === SHARD) index += 1;
83
+ else if (option.startsWith(`${SHARD}=`)) continue;
84
+ else if (option === DESTINATION && index + 1 < options.length) {
85
+ result.push(option, aloneDestination(options[index + 1]));
86
+ index += 1;
87
+ } else if (option.startsWith(`${DESTINATION}=`)) result.push(`${DESTINATION}=${aloneDestination(option.slice(DESTINATION.length + 1))}`);
88
+ else result.push(option);
89
+ }
90
+ return result;
91
+ }
92
+
93
+ /** The alone files of this run's shard (`--test-shard=<index>/<total>`), as node --test picks a shard's files. */
94
+ function aloneShard(files, options) {
95
+ const at = options.findIndex((option) => option === SHARD || option.startsWith(`${SHARD}=`));
96
+ if (at < 0) return files;
97
+ const [index, total] = (options[at] === SHARD ? options[at + 1] : options[at].slice(SHARD.length + 1)).split("/").map(Number);
98
+ return files.filter((_, position) => position % total === index - 1);
99
+ }
100
+
49
101
  /**
50
102
  * The node --test runs for these arguments: one in parallel within the budget, then the files that must run alone.
51
103
  * @param {string[]} args the arguments after `jskit test`
@@ -56,32 +108,55 @@ function planTestRuns(args, { cwd = process.cwd(), concurrency = testConcurrency
56
108
  throw new Error("jskit test takes the test concurrency from the machine's budget; remove --test-concurrency.");
57
109
  }
58
110
  const candidates = args.filter((argument) => !argument.startsWith("-"));
59
- // An argument that matches no file is an option's value (--test-reporter spec) or a typo node reports itself.
111
+ // An argument that matches no file is an option's value (--test-reporter spec) or a typo node reports itself; with
112
+ // none that matches, the run is node's default files.
60
113
  const matched = new Map(candidates.map((candidate) => [candidate, findTestFiles([candidate], cwd)]));
61
- const files = candidates.length > 0
62
- ? [...new Set([...matched.values()].flat())].sort()
63
- : findTestFiles([DEFAULT_PATTERN], cwd);
114
+ const named = [...new Set([...matched.values()].flat())].sort();
115
+ const files = named.length > 0 ? named : findTestFiles([DEFAULT_PATTERN], cwd);
64
116
  const { alone, problems } = readAloneFiles(files, cwd);
65
117
  if (problems.length > 0) throw new Error(`jskit test:\n- ${problems.join("\n- ")}`);
66
118
  const parallel = ["--test", `--test-concurrency=${concurrency}`];
67
- if (alone.length < 1) return { concurrency, alone, runs: [[...parallel, ...args]] };
119
+ if (alone.length < 1) return { concurrency, files: files.length, paths: files, alone, runs: [[...parallel, ...args]] };
68
120
 
69
121
  const options = args.filter((argument) => !(matched.get(argument)?.length > 0));
70
122
  const aloneFiles = new Set(alone.map((entry) => entry.file));
71
123
  const others = files.filter((file) => !aloneFiles.has(file));
124
+ const shardAlone = aloneShard([...aloneFiles], options);
72
125
  return {
73
126
  concurrency,
127
+ files: others.length,
128
+ paths: files,
74
129
  alone,
75
130
  runs: [
76
131
  ...(others.length > 0 ? [[...parallel, ...options, ...others]] : []),
77
- ["--test", "--test-concurrency=1", ...options, ...aloneFiles]
132
+ ...(shardAlone.length > 0 ? [["--test", "--test-concurrency=1", ...aloneRunOptions(options), ...shardAlone]] : [])
78
133
  ]
79
134
  };
80
135
  }
81
136
 
82
- function runNode(args) {
137
+ /**
138
+ * The run with the time reporter added before its own options (node takes the arguments after a file as files), writing
139
+ * to `destination`. Node pairs reporters and destinations by position, and gives a run with no reporter `spec` on
140
+ * stdout, and a lone reporter with no destination stdout; the run keeps those.
141
+ * @param {string[]} run ["--test", "--test-concurrency=<n>", ...its options and files]
142
+ */
143
+ function withTimeReporter(run, destination) {
144
+ const count = (option) => run.filter((argument) => argument === option || argument.startsWith(`${option}=`)).length;
145
+ const reporters = count(REPORTER);
146
+ const destinations = count(DESTINATION);
147
+ return [
148
+ ...run.slice(0, 2),
149
+ ...(reporters === 0 && destinations === 0 ? [`${REPORTER}=spec`, `${DESTINATION}=stdout`] : []),
150
+ `${REPORTER}=${TIME_REPORTER}`,
151
+ `${DESTINATION}=${destination}`,
152
+ ...(reporters === 1 && destinations === 0 ? [`${DESTINATION}=stdout`] : []),
153
+ ...run.slice(2)
154
+ ];
155
+ }
156
+
157
+ function runNode(args, env) {
83
158
  return new Promise((resolve, reject) => {
84
- const child = spawn(process.execPath, args, { stdio: "inherit" });
159
+ const child = spawn(process.execPath, args, { stdio: "inherit", env: { ...process.env, ...env } });
85
160
  const forward = (signal) => child.kill(signal);
86
161
  process.on("SIGINT", forward);
87
162
  process.on("SIGTERM", forward);
@@ -95,22 +170,54 @@ function runNode(args) {
95
170
  }
96
171
 
97
172
  /**
98
- * Runs `jskit test` and resolves to its exit code: the first failing run's, or 0.
173
+ * Runs `jskit test` and resolves to its exit code: the first failing run's, or 0. With `--databases`, each file running
174
+ * at once has a test database of its own (test-databases.mjs), and the run's concurrency is how many there are.
99
175
  * @param {string[]} args
100
176
  */
101
177
  async function runTests(args) {
102
- const plan = planTestRuns(args);
178
+ const nodeArgs = args.filter((argument) => argument !== DATABASES_OPTION);
179
+ let plan = planTestRuns(nodeArgs);
103
180
  const source = process.env[BUDGET_VARIABLE] ? BUDGET_VARIABLE : "half of this machine";
104
- process.stderr.write(`jskit test: ${plan.concurrency} test file(s) at a time (${source}).\n`);
181
+ // A database for each file that can run at once: a focused run of one file prepares one.
182
+ const databases = nodeArgs.length < args.length
183
+ ? await testDatabaseRunFor(Math.max(1, Math.min(plan.concurrency, plan.files)))
184
+ : null;
185
+ if (databases) {
186
+ process.stderr.write(`jskit test: ${databases.summary}.\n`);
187
+ plan = planTestRuns(nodeArgs, { concurrency: databases.concurrency });
188
+ }
189
+ process.stderr.write(`jskit test: ${plan.concurrency} test file(s) at a time (${databases ? "test databases" : source}).\n`);
105
190
  for (const { file, reason } of plan.alone) process.stderr.write(`jskit test: ${file} runs alone: ${reason}\n`);
191
+ const preload = [...(databases?.preload ? ["--import", pathToFileURL(databases.preload).href] : []), "--import", FILE_STOP];
192
+ const timesDirectory = mkdtempSync(path.join(os.tmpdir(), "jskit-test-times-"));
193
+ const stops = writeFileStops({ cwd: process.cwd(), files: plan.paths, directory: timesDirectory });
194
+ const collected = plan.runs.map((_run, index) => path.join(timesDirectory, `run-${index}.json`));
195
+ const started = performance.now();
106
196
  let exitCode = 0;
107
- for (const run of plan.runs) {
108
- const { code, signal } = await runNode(run);
109
- if (exitCode === 0) exitCode = code;
110
- // An interrupted run ends the whole command.
111
- if (signal) break;
197
+ let interrupted = false;
198
+ try {
199
+ for (const [index, run] of plan.runs.entries()) {
200
+ const env = {
201
+ NODE_COMPILE_CACHE: process.env.NODE_COMPILE_CACHE || path.resolve(COMPILE_CACHE),
202
+ [STOPS_VARIABLE]: stops,
203
+ ...databases?.env
204
+ };
205
+ const { code, signal } = await runNode([...preload, ...withTimeReporter(run, collected[index])], env);
206
+ if (exitCode === 0) exitCode = code;
207
+ // An interrupted run ends the whole command.
208
+ interrupted = Boolean(signal);
209
+ if (interrupted) break;
210
+ }
211
+ const times = interrupted
212
+ ? null
213
+ : reportTestTimes({ cwd: process.cwd(), collected, directory: timesDirectory, wallMs: performance.now() - started });
214
+ if (times) process.stderr.write(times.report);
215
+ if (times?.overruns.length > 0 && exitCode === 0) exitCode = 1;
216
+ } finally {
217
+ databases?.cleanup();
218
+ rmSync(timesDirectory, { recursive: true, force: true });
112
219
  }
113
220
  return exitCode;
114
221
  }
115
222
 
116
- export { planTestRuns, runTests };
223
+ export { planTestRuns, runTests, withTimeReporter };
@@ -0,0 +1,50 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ // `jskit test --databases`: each test file running at once gets a database of its own, a copy of TEST_DB_NAME restored
8
+ // from a schema snapshot, from the application's installed @jskit-ai/database-runtime and its knexfile.js, which loads
9
+ // .env and fixes the dialect. JSKIT's catalog itself depends on no database package. What the knexfile loads into the
10
+ // environment is read for the databases only: the test files get the environment `jskit test` was given, plus their
11
+ // own database and its connection (DB_NAME, TEST_DB_NAME, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD). A knexfile whose
12
+ // schema the migrations alone do not build also exports `testDatabaseSchema` ({ files, build }, see testDatabases.js).
13
+ const OPTION = "--databases";
14
+ const KNEXFILES = Object.freeze(["knexfile.js", "knexfile.mjs", "knexfile.cjs"]);
15
+ const TEST_DATABASES = "@jskit-ai/database-runtime/server/testDatabases";
16
+
17
+ /** Runs `load` and puts process.env back as it was, returning what `load` returned and the environment it saw. */
18
+ async function withOwnEnvironment(load) {
19
+ const before = { ...process.env };
20
+ try {
21
+ const result = await load();
22
+ return { result, environment: { ...process.env } };
23
+ } finally {
24
+ for (const name of Object.keys(process.env)) if (!Object.hasOwn(before, name)) delete process.env[name];
25
+ Object.assign(process.env, before);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The run's databases for `count` files at once, or one file at a time on the connected database.
31
+ * @param {number} count at most the run's test budget, and no more than its test files
32
+ * @param {string} [cwd]
33
+ * @returns {Promise<{ concurrency: number, env: Record<string, string>, preload: string, summary: string, cleanup(): void }>}
34
+ */
35
+ async function testDatabaseRunFor(count, cwd = process.cwd()) {
36
+ const knexfile = KNEXFILES.map((name) => path.join(cwd, name)).find((file) => existsSync(file));
37
+ if (!knexfile) throw new Error(`jskit test ${OPTION} reads the application's knexfile.js, and ${cwd} has none.`);
38
+ let testDatabases;
39
+ try {
40
+ testDatabases = createRequire(path.join(cwd, "package.json")).resolve(TEST_DATABASES);
41
+ } catch {
42
+ throw new Error(`jskit test ${OPTION} needs ${TEST_DATABASES}; install the application's JSKIT database driver.`);
43
+ }
44
+ const { result: { default: knexConfig, testDatabaseSchema: schema }, environment } = await withOwnEnvironment(async () =>
45
+ import(pathToFileURL(knexfile).href));
46
+ const { testDatabaseRun } = await import(pathToFileURL(testDatabases).href);
47
+ return testDatabaseRun({ knexConfig, environment, count, schema, appRoot: cwd });
48
+ }
49
+
50
+ export { OPTION as DATABASES_OPTION, testDatabaseRunFor };
@@ -0,0 +1,32 @@
1
+ // Preloaded (`--import`) by `jskit test` into every process of its node --test runs. In a test file's own process
2
+ // (node --test starts one per file, with NODE_TEST_CONTEXT set), it stops the file at the time `jskit test` gave it
3
+ // (fileStop in test-time-report.mjs): a file that never finishes, because a test has no timeout or a server, child
4
+ // process, timer or socket was left open, then fails by name and the rest of the run still finishes and reports. The
5
+ // runner's own process, worker threads and the processes a test starts do nothing.
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import process from "node:process";
9
+ import { isMainThread } from "node:worker_threads";
10
+
11
+ const STOPS_VARIABLE = "JSKIT_TEST_FILE_STOPS";
12
+ const STOPPED_PREFIX = "stopped-";
13
+
14
+ const stopsFile = process.env[STOPS_VARIABLE];
15
+ const runsTestFiles = process.execArgv.some((argument) => argument === "--test" || argument.startsWith("--test="));
16
+ if (stopsFile && isMainThread && process.env.NODE_TEST_CONTEXT && !runsTestFiles) {
17
+ // A process this file starts is not one of the run's files.
18
+ delete process.env[STOPS_VARIABLE];
19
+ const stop = JSON.parse(readFileSync(stopsFile, "utf8"))[path.resolve(process.argv[1] || "")];
20
+ if (stop) {
21
+ const started = performance.now();
22
+ // Unreferenced: a file that finishes exits as it would have.
23
+ setTimeout(() => {
24
+ const stopped = { ...stop, ms: Math.round(performance.now() - started) };
25
+ writeFileSync(path.join(path.dirname(stopsFile), `${STOPPED_PREFIX}${process.pid}.json`), JSON.stringify(stopped));
26
+ writeFileSync(2, `\njskit test: stopped ${stopped.file} after ${(stopped.ms / 1000).toFixed(1)} s, ${stopped.reason}.\n`);
27
+ process.exit(1);
28
+ }, stop.ms).unref();
29
+ }
30
+ }
31
+
32
+ export { STOPPED_PREFIX, STOPS_VARIABLE };
@@ -6,9 +6,16 @@ import { fileExists, toPosixPath } from "./files.mjs";
6
6
  // file at a time nor overwhelms the machine. A package script runs node's test runner through `jskit test`, passes no
7
7
  // fixed worker count, and the Playwright and Vitest configs its scripts use set their workers from testConcurrency().
8
8
  // The one exception is a test file's own first line, `// jskit-test: alone - <reason>`, which `jskit test` honours.
9
+ // A Playwright config those scripts use bounds its whole run (globalTimeout) and, when it starts the app (webServer),
10
+ // does so through browserTestConfig(): the production build, and a server, port and database for each worker.
9
11
 
10
12
  const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/typed-features#test-files-run-in-parallel";
11
13
  const BUDGET_MODULE = "@jskit-ai/jskit-catalog/test-budget";
14
+ const BROWSER_TESTS_MODULE = "@jskit-ai/jskit-catalog/browser-tests";
15
+ // `npm run develop`, `npm run dev[:surface]`, `node ./bin/develop.js`, `vite` and `vite dev`/`vite serve`: not
16
+ // `vite build` or `vite preview`.
17
+ const DEVELOPMENT_SERVER = /\bnpm\s+run\s+(?:develop|dev(?::[\w-]+)?)(?:\s|$)|\bbin\/develop\.js\b|(?:^|[\s/])vite(?:\s+(?:dev|serve)\b|\s+-|\s*$)/u;
18
+ const PINNED_BUDGET = /^(?:V64_TEST_CONCURRENCY|PLAYWRIGHT_WORKERS)=/u;
12
19
  const CONFIG_EXTENSIONS = Object.freeze(["ts", "mts", "cts", "js", "mjs", "cjs"]);
13
20
  const SCRIPT_FILE = /\.(?:[cm]?[jt]s|jsx|tsx)$/u;
14
21
  const FIXED_PLAYWRIGHT_WORKERS = /^(?:--workers(?:=.*)?|-j.*)$/u;
@@ -79,6 +86,43 @@ const RUNNERS = Object.freeze({
79
86
  }
80
87
  });
81
88
 
89
+ // The browser-test harness: one production-build server, port and database per Playwright worker, its workers from the
90
+ // budget and a globalTimeout.
91
+ const usesBrowserHarness = (source) => source.includes(BROWSER_TESTS_MODULE) && /\bbrowserTestConfig\s*\(/u.test(source);
92
+
93
+ // A webServer command that runs the development server instead of the production build.
94
+ function developmentServerCommand(source) {
95
+ for (const [, , command] of String(source).matchAll(/\bcommand\s*:\s*(["'`])(.*?)\1/gu)) {
96
+ if (DEVELOPMENT_SERVER.test(command)) return command;
97
+ }
98
+ return "";
99
+ }
100
+
101
+ // A Playwright config tests the production build, gives each worker its own server and data, and bounds the whole run.
102
+ function checkPlaywrightIsolation({ file, source, report }) {
103
+ const harness = `use browserTestConfig() from ${BROWSER_TESTS_MODULE}`;
104
+ const command = developmentServerCommand(source);
105
+ if (command) {
106
+ report({ file, line: lineOf(source, command), rule: "test/playwright-dev-server",
107
+ message: `webServer runs the development server (${command}); ${harness}, which serves the production build.` });
108
+ }
109
+ if (usesBrowserHarness(source)) {
110
+ if (/\bglobalTimeout\s*:\s*0\b/u.test(source)) {
111
+ report({ file, line: lineOf(source, "globalTimeout"), rule: "test/playwright-global-timeout",
112
+ message: "globalTimeout: 0 lets a run go on for hours; keep the harness's bound." });
113
+ }
114
+ return;
115
+ }
116
+ if (/\bwebServer\b/u.test(source) && !/\bworkers\s*:\s*1\b/u.test(source)) {
117
+ report({ file, line: lineOf(source, "webServer"), rule: "test/playwright-isolation",
118
+ message: `Playwright workers share one webServer and its database; ${harness}, which gives each worker its own.` });
119
+ }
120
+ if (!/\bglobalTimeout\s*:\s*[1-9]/u.test(source)) {
121
+ report({ file, line: lineOf(source, "globalTimeout"), rule: "test/playwright-global-timeout",
122
+ message: `nothing bounds the whole run; ${harness}, or set a globalTimeout.` });
123
+ }
124
+ }
125
+
82
126
  // A config sets the runner's workers from the budget, and Vitest's never turns file parallelism off.
83
127
  async function checkRunnerConfig({ root, configPath, directory, runner, report }) {
84
128
  const { rule, setting, configNames } = RUNNERS[runner];
@@ -91,9 +135,16 @@ async function checkRunnerConfig({ root, configPath, directory, runner, report }
91
135
  }
92
136
  const file = toPosixPath(path.relative(root, configPath));
93
137
  const source = await readFile(configPath, "utf8");
94
- if (!source.includes(BUDGET_MODULE) || !new RegExp(`\\b${setting}\\s*:\\s*testConcurrency\\(\\s*\\)`, "u").test(source)) {
138
+ if (runner === "playwright" && usesBrowserHarness(source)) {
139
+ // The harness takes its workers from the budget; a config that sets them itself overrides it.
140
+ if (/\bworkers\s*:/u.test(source)) {
141
+ report({ file, line: lineOf(source, "workers:"), rule,
142
+ message: "playwright's workers are set over browserTestConfig()'s; drop them, so the machine's test budget sets them." });
143
+ }
144
+ } else if (!source.includes(BUDGET_MODULE) || !new RegExp(`\\b${setting}\\s*:\\s*testConcurrency\\(\\s*\\)`, "u").test(source)) {
95
145
  report({ file, line: lineOf(source, `${setting}:`), rule, message: `${runner} runs at a fixed or default worker count; ${wanted}.` });
96
146
  }
147
+ if (runner === "playwright") checkPlaywrightIsolation({ file, source, report });
97
148
  if (runner === "vitest" && /\bfileParallelism\s*:\s*false\b/u.test(source)) {
98
149
  report({ file, line: lineOf(source, "fileParallelism"), rule,
99
150
  message: "Vitest runs one file at a time; remove fileParallelism: false and isolate the files instead." });
@@ -119,6 +170,10 @@ async function checkTestRuns({ projectRoot, manifests }) {
119
170
  if (tokens.some((token) => /^--test-concurrency(?:=|$)/u.test(token))) {
120
171
  at("test/fixed-concurrency", "sets --test-concurrency; drop it, so `jskit test` takes the concurrency from the machine's test budget.");
121
172
  }
173
+ const pinned = tokens.find((token) => PINNED_BUDGET.test(token));
174
+ if (pinned) {
175
+ at("test/fixed-concurrency", `pins ${pinned.split("=")[0]}; drop it, so the test runner or the machine sets the budget.`);
176
+ }
122
177
  for (const runner of Object.keys(RUNNERS)) {
123
178
  const args = runnerArguments(tokens, runner);
124
179
  if (!args) continue;
@@ -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 };