@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,93 @@
1
+ // The pages an app's own packages give the router, for the navigation rules: each package.json route in
2
+ // jskit.metadata.ui.routes with the component its client entry's routeComponents names.
3
+ import { readdir, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { DEFAULT_IMPORT, balancedFrom, blankJsComments, enclosingObject, localComponent } from "./material3-source.mjs";
6
+
7
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ const ROUTE_COMPONENTS = /\brouteComponents\s*=\s*(?:Object\s*\.\s*freeze\s*\(\s*)?(?=\{)/u;
9
+ // One routeComponents entry: `key: () => import("./page.vue")` or `"key": ImportedPage`.
10
+ const ROUTE_COMPONENT = /(?<=[{,]\s*)(?:(["'])([^"']+)\1|([A-Za-z_$][\w$]*))\s*:\s*(?:\(\s*\)\s*=>\s*import\s*\(\s*(["'])([^"']+)\4\s*\)|([A-Za-z_$][\w$]*))(?=\s*[,}])/gu;
11
+
12
+ // The file a package's "./client" export names, relative to the app: packages/shelves/src/client/index.js.
13
+ function clientEntryOf(manifest, directory) {
14
+ const target = manifest?.exports?.["./client"];
15
+ const entry = typeof target === "string" ? target : target?.import || target?.default;
16
+ return typeof entry === "string" ? path.posix.join(directory, entry) : "";
17
+ }
18
+
19
+ // The app's .vue file of each routeComponents key in a client entry, from its literal map: an entry another module
20
+ // builds, or one naming a file outside the app, has none.
21
+ function readRouteComponents(entry, code, known) {
22
+ const components = new Map();
23
+ const start = ROUTE_COMPONENTS.exec(code);
24
+ const map = start ? balancedFrom(code, start.index + start[0].length)?.text || "" : "";
25
+ const imports = new Map([...code.matchAll(DEFAULT_IMPORT)].map(([, name, specifier]) => [name, specifier]));
26
+ for (const match of map.matchAll(ROUTE_COMPONENT)) {
27
+ const specifier = match[5] ?? imports.get(match[6]);
28
+ const file = specifier ? localComponent(entry, specifier, known) : null;
29
+ if (file) components.set(match[2] ?? match[3], file);
30
+ }
31
+ return components;
32
+ }
33
+
34
+ /**
35
+ * The pages an app's own packages give the router: each packages/<name>/package.json's jskit.metadata.ui.routes entry
36
+ * the kernel registers (not autoRegister: false), with the component its client entry's literal routeComponents map
37
+ * names for componentKey. The kernel adds each at the top level, so no layout renders it: it is under the shell's top
38
+ * app bar only when its component (or App.vue) draws the bar. Its parent and surface are its meta.jskit, as declared.
39
+ * @param {string} root
40
+ * @param {Set<string>} known The app's files; a package whose client entry or component is not among them is skipped.
41
+ */
42
+ async function readPackageRoutes(root, known) {
43
+ let directories = [];
44
+ try {
45
+ directories = (await readdir(path.join(root, "packages"), { withFileTypes: true })).filter((entry) => entry.isDirectory());
46
+ } catch (error) {
47
+ if (error?.code !== "ENOENT") throw error;
48
+ }
49
+ const pages = [];
50
+ for (const directory of directories.map((entry) => `packages/${entry.name}`).sort()) {
51
+ const manifestFile = `${directory}/package.json`;
52
+ let manifestSource;
53
+ let manifest;
54
+ try {
55
+ manifestSource = await readFile(path.join(root, manifestFile), "utf8");
56
+ manifest = JSON.parse(manifestSource);
57
+ } catch (error) {
58
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) continue;
59
+ throw error;
60
+ }
61
+ const routes = manifest?.jskit?.metadata?.ui?.routes;
62
+ const entry = clientEntryOf(manifest, directory);
63
+ if (!Array.isArray(routes) || !known.has(entry)) continue;
64
+ const components = readRouteComponents(entry, blankJsComments(await readFile(path.join(root, entry), "utf8")), known);
65
+ for (const route of routes) {
66
+ const file = isRecord(route) && route.autoRegister !== false ? components.get(String(route.componentKey || "").trim()) : null;
67
+ if (!file) continue;
68
+ const id = String(route.id || "").trim();
69
+ const jskit = isRecord(route.meta?.jskit) ? route.meta.jskit : {};
70
+ const parent = typeof jskit.parent === "string" ? jskit.parent.trim() : isRecord(jskit.parent) ? jskit.parent : "";
71
+ // Where package.json declares the route, and its parent, for findings.
72
+ const declared = new RegExp(String.raw`"id"\s*:\s*${JSON.stringify(id).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}`, "u").exec(manifestSource);
73
+ const object = declared ? enclosingObject(manifestSource, declared.index) : null;
74
+ const parentKey = object ? /"parent"\s*:/u.exec(object.text) : null;
75
+ pages.push({
76
+ file,
77
+ manifest: manifestFile,
78
+ source: manifestSource,
79
+ id,
80
+ path: String(route.path || "").trim(),
81
+ scope: String(route.scope || "surface").trim().toLowerCase() === "global" ? "global" : "surface",
82
+ surface: String(route.surface || "").trim().toLowerCase(),
83
+ parent,
84
+ surfaceRoot: Boolean(jskit.surface),
85
+ offset: declared?.index ?? 0,
86
+ parentOffset: parentKey ? object.start + parentKey.index : declared?.index ?? 0
87
+ });
88
+ }
89
+ }
90
+ return pages;
91
+ }
92
+
93
+ export { readPackageRoutes };
@@ -0,0 +1,19 @@
1
+ import os from "node:os";
2
+ import process from "node:process";
3
+
4
+ // The machine's test budget: how many test files (node --test), Playwright workers or Vitest workers a run may use at
5
+ // once. A test runner that hands out slots exports V64_TEST_CONCURRENCY for each run; anywhere else a run takes half
6
+ // of the machine, so a test suite neither runs one file at a time nor overwhelms the machine (a 2-vCPU VPS stays at 1).
7
+ const BUDGET_VARIABLE = "V64_TEST_CONCURRENCY";
8
+
9
+ /**
10
+ * @param {{ env?: Record<string, string | undefined>, parallelism?: number }} [options]
11
+ * @returns {number} a whole number of at least 1
12
+ */
13
+ function testConcurrency({ env = process.env, parallelism = os.availableParallelism() } = {}) {
14
+ const configured = String(env[BUDGET_VARIABLE] ?? "").trim();
15
+ if (/^[1-9]\d*$/u.test(configured)) return Number(configured);
16
+ return Math.max(1, Math.floor(parallelism / 2));
17
+ }
18
+
19
+ export { BUDGET_VARIABLE, testConcurrency };
@@ -0,0 +1,223 @@
1
+ import { spawn } from "node:child_process";
2
+ import { globSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { pathToFileURL } from "node:url";
7
+ import { toPosixPath } from "./files.mjs";
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";
12
+
13
+ // `jskit test [node options] [files or globs]`: node --test, running test files in parallel within the machine's test
14
+ // budget (test-budget.mjs). Its arguments go to node --test unchanged. A file that truly must run alone says why on its
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.
20
+
21
+ // node --test's own default (Node 26) when it is given no files.
22
+ const DEFAULT_PATTERN = "**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs,ts,mts,cts}";
23
+ const MARKER = /^\s*\/\/\s*jskit-test:(.*)$/u;
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"]);
34
+
35
+ function findTestFiles(patterns, cwd) {
36
+ const entries = globSync(patterns, { cwd, withFileTypes: true, exclude: (entry) => entry.name === "node_modules" });
37
+ const files = entries
38
+ .filter((entry) => entry.isFile() || entry.isSymbolicLink())
39
+ .map((entry) => toPosixPath(path.relative(cwd, path.join(entry.parentPath, entry.name))));
40
+ return [...new Set(files)].sort();
41
+ }
42
+
43
+ /**
44
+ * The files of a run that must run alone, from their first line. A marker that is malformed or not on the first line
45
+ * (after a shebang) is an error, so a file never runs in parallel because its marker was missed.
46
+ * @returns {{ alone: Array<{ file: string, reason: string }>, problems: string[] }}
47
+ */
48
+ function readAloneFiles(files, cwd) {
49
+ const alone = [];
50
+ const problems = [];
51
+ for (const file of files) {
52
+ const lines = readFileSync(path.resolve(cwd, file), "utf8").split("\n");
53
+ const top = lines[0]?.startsWith("#!") ? 1 : 0;
54
+ lines.forEach((line, index) => {
55
+ const marker = MARKER.exec(line);
56
+ if (!marker) return;
57
+ const reason = ALONE.exec(marker[1])?.[1]?.trim();
58
+ if (index !== top) problems.push(`${file}:${index + 1}: a jskit-test marker belongs on the file's first line.`);
59
+ else if (!reason) problems.push(`${file}:${index + 1}: write it as \`// jskit-test: alone - <why this file cannot run beside others>\`.`);
60
+ else alone.push({ file, reason });
61
+ });
62
+ }
63
+ return { alone, problems };
64
+ }
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
+
101
+ /**
102
+ * The node --test runs for these arguments: one in parallel within the budget, then the files that must run alone.
103
+ * @param {string[]} args the arguments after `jskit test`
104
+ * @param {{ cwd?: string, concurrency?: number }} [options]
105
+ */
106
+ function planTestRuns(args, { cwd = process.cwd(), concurrency = testConcurrency() } = {}) {
107
+ if (args.some((argument) => /^--test-concurrency(?:=|$)/u.test(argument))) {
108
+ throw new Error("jskit test takes the test concurrency from the machine's budget; remove --test-concurrency.");
109
+ }
110
+ const candidates = args.filter((argument) => !argument.startsWith("-"));
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.
113
+ const matched = new Map(candidates.map((candidate) => [candidate, findTestFiles([candidate], cwd)]));
114
+ const named = [...new Set([...matched.values()].flat())].sort();
115
+ const files = named.length > 0 ? named : findTestFiles([DEFAULT_PATTERN], cwd);
116
+ const { alone, problems } = readAloneFiles(files, cwd);
117
+ if (problems.length > 0) throw new Error(`jskit test:\n- ${problems.join("\n- ")}`);
118
+ const parallel = ["--test", `--test-concurrency=${concurrency}`];
119
+ if (alone.length < 1) return { concurrency, files: files.length, paths: files, alone, runs: [[...parallel, ...args]] };
120
+
121
+ const options = args.filter((argument) => !(matched.get(argument)?.length > 0));
122
+ const aloneFiles = new Set(alone.map((entry) => entry.file));
123
+ const others = files.filter((file) => !aloneFiles.has(file));
124
+ const shardAlone = aloneShard([...aloneFiles], options);
125
+ return {
126
+ concurrency,
127
+ files: others.length,
128
+ paths: files,
129
+ alone,
130
+ runs: [
131
+ ...(others.length > 0 ? [[...parallel, ...options, ...others]] : []),
132
+ ...(shardAlone.length > 0 ? [["--test", "--test-concurrency=1", ...aloneRunOptions(options), ...shardAlone]] : [])
133
+ ]
134
+ };
135
+ }
136
+
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) {
158
+ return new Promise((resolve, reject) => {
159
+ const child = spawn(process.execPath, args, { stdio: "inherit", env: { ...process.env, ...env } });
160
+ const forward = (signal) => child.kill(signal);
161
+ process.on("SIGINT", forward);
162
+ process.on("SIGTERM", forward);
163
+ child.once("error", reject);
164
+ child.once("exit", (code, signal) => {
165
+ process.off("SIGINT", forward);
166
+ process.off("SIGTERM", forward);
167
+ resolve({ code: code ?? 128 + (os.constants.signals[signal] ?? 0), signal });
168
+ });
169
+ });
170
+ }
171
+
172
+ /**
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.
175
+ * @param {string[]} args
176
+ */
177
+ async function runTests(args) {
178
+ const nodeArgs = args.filter((argument) => argument !== DATABASES_OPTION);
179
+ let plan = planTestRuns(nodeArgs);
180
+ const source = process.env[BUDGET_VARIABLE] ? BUDGET_VARIABLE : "half of this machine";
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`);
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();
196
+ let exitCode = 0;
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 });
219
+ }
220
+ return exitCode;
221
+ }
222
+
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 };
@@ -0,0 +1,203 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileExists, toPosixPath } from "./files.mjs";
4
+
5
+ // The test-run part of plain `jskit check`: every test run uses the machine's test budget, so a suite neither runs one
6
+ // file at a time nor overwhelms the machine. A package script runs node's test runner through `jskit test`, passes no
7
+ // fixed worker count, and the Playwright and Vitest configs its scripts use set their workers from testConcurrency().
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.
11
+
12
+ const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/typed-features#test-files-run-in-parallel";
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;
19
+ const CONFIG_EXTENSIONS = Object.freeze(["ts", "mts", "cts", "js", "mjs", "cjs"]);
20
+ const SCRIPT_FILE = /\.(?:[cm]?[jt]s|jsx|tsx)$/u;
21
+ const FIXED_PLAYWRIGHT_WORKERS = /^(?:--workers(?:=.*)?|-j.*)$/u;
22
+ const FIXED_VITEST_WORKERS = /^--(?:max-?[wW]orkers|min-?[wW]orkers|no-file-?[pP]arallelism|file-?[pP]arallelism)(?:=.*)?$/u;
23
+
24
+ function commandsOf(script) {
25
+ return String(script || "")
26
+ .split(/&&|\|\||[;|\n]/u)
27
+ .map((command) => command.trim().split(/\s+/u).map((token) => token.replace(/^['"(]+|['")]+$/gu, "")).filter(Boolean))
28
+ .filter((tokens) => tokens.length > 0);
29
+ }
30
+
31
+ const isProgram = (token, name) => token === name || token.endsWith(`/${name}`);
32
+
33
+ // node's own options come before its script; --test among them runs the test runner.
34
+ function runsNodeTest(tokens) {
35
+ return tokens.some((token, index) => {
36
+ if (!isProgram(token, "node")) return false;
37
+ for (const option of tokens.slice(index + 1)) {
38
+ if (option === "--test") return true;
39
+ if (SCRIPT_FILE.test(option)) return false;
40
+ }
41
+ return false;
42
+ });
43
+ }
44
+
45
+ function runnerArguments(tokens, runner) {
46
+ const index = tokens.findIndex((token, position) => isProgram(token, runner) &&
47
+ (runner !== "playwright" || tokens[position + 1] === "test"));
48
+ return index < 0 ? null : tokens.slice(index + 1);
49
+ }
50
+
51
+ function configOption(args) {
52
+ const index = args.findIndex((token) => token === "--config" || token === "-c" || token.startsWith("--config="));
53
+ if (index < 0) return "";
54
+ return args[index].startsWith("--config=") ? args[index].slice("--config=".length) : args[index + 1] || "";
55
+ }
56
+
57
+ function lineOf(contents, needle, after = "") {
58
+ const text = String(contents);
59
+ const offset = text.indexOf(needle, after ? Math.max(0, text.indexOf(after)) : 0);
60
+ return offset < 0 ? 1 : text.slice(0, offset).split("\n").length;
61
+ }
62
+
63
+ async function findConfig(directory, explicit, names) {
64
+ if (explicit) return path.resolve(directory, explicit);
65
+ for (const name of names) {
66
+ for (const extension of CONFIG_EXTENSIONS) {
67
+ const candidate = path.join(directory, `${name}.config.${extension}`);
68
+ if (await fileExists(candidate)) return candidate;
69
+ }
70
+ }
71
+ return "";
72
+ }
73
+
74
+ const RUNNERS = Object.freeze({
75
+ playwright: {
76
+ rule: "test/playwright-workers",
77
+ configNames: ["playwright"],
78
+ setting: "workers",
79
+ fixedArgument: FIXED_PLAYWRIGHT_WORKERS
80
+ },
81
+ vitest: {
82
+ rule: "test/vitest-workers",
83
+ configNames: ["vitest", "vite"],
84
+ setting: "maxWorkers",
85
+ fixedArgument: FIXED_VITEST_WORKERS
86
+ }
87
+ });
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
+
126
+ // A config sets the runner's workers from the budget, and Vitest's never turns file parallelism off.
127
+ async function checkRunnerConfig({ root, configPath, directory, runner, report }) {
128
+ const { rule, setting, configNames } = RUNNERS[runner];
129
+ const wanted = `set \`${setting}: testConcurrency()\` from ${BUDGET_MODULE}`;
130
+ if (!configPath || !(await fileExists(configPath))) {
131
+ const missing = configPath || path.join(directory, `${configNames[0]}.config.*`);
132
+ report({ file: toPosixPath(path.relative(root, missing)), line: 1, rule,
133
+ message: `A script runs ${runner} without this config: add it and ${wanted}.` });
134
+ return;
135
+ }
136
+ const file = toPosixPath(path.relative(root, configPath));
137
+ const source = await readFile(configPath, "utf8");
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)) {
145
+ report({ file, line: lineOf(source, `${setting}:`), rule, message: `${runner} runs at a fixed or default worker count; ${wanted}.` });
146
+ }
147
+ if (runner === "playwright") checkPlaywrightIsolation({ file, source, report });
148
+ if (runner === "vitest" && /\bfileParallelism\s*:\s*false\b/u.test(source)) {
149
+ report({ file, line: lineOf(source, "fileParallelism"), rule,
150
+ message: "Vitest runs one file at a time; remove fileParallelism: false and isolate the files instead." });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * @param {{ projectRoot: string, manifests: ReadonlyArray<{ absolutePath: string, contents: string, value: object }> }} input
156
+ */
157
+ async function checkTestRuns({ projectRoot, manifests }) {
158
+ const root = path.resolve(projectRoot);
159
+ const findings = [];
160
+ const report = (finding) => findings.push(finding);
161
+ const configs = new Map();
162
+ for (const manifest of manifests) {
163
+ const file = toPosixPath(path.relative(root, manifest.absolutePath)) || "package.json";
164
+ const directory = path.dirname(manifest.absolutePath);
165
+ for (const [name, script] of Object.entries(manifest.value?.scripts || {})) {
166
+ const line = lineOf(manifest.contents, `"${name}"`, '"scripts"');
167
+ const at = (rule, message) => report({ file, line, rule, message: `scripts.${name} ${message}` });
168
+ for (const tokens of commandsOf(script)) {
169
+ if (runsNodeTest(tokens)) at("test/node-test", "runs node --test directly; use `jskit test`, which runs test files in parallel within the machine's test budget.");
170
+ if (tokens.some((token) => /^--test-concurrency(?:=|$)/u.test(token))) {
171
+ at("test/fixed-concurrency", "sets --test-concurrency; drop it, so `jskit test` takes the concurrency from the machine's test budget.");
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
+ }
177
+ for (const runner of Object.keys(RUNNERS)) {
178
+ const args = runnerArguments(tokens, runner);
179
+ if (!args) continue;
180
+ if (args.some((token) => RUNNERS[runner].fixedArgument.test(token))) {
181
+ at(RUNNERS[runner].rule, `passes ${runner} a fixed worker count; drop it, so the config's testConcurrency() sets it.`);
182
+ }
183
+ const configPath = await findConfig(directory, configOption(args), RUNNERS[runner].configNames);
184
+ configs.set(`${runner}\0${configPath || directory}`, { runner, configPath, directory });
185
+ }
186
+ }
187
+ }
188
+ }
189
+ for (const config of configs.values()) await checkRunnerConfig({ root, report, ...config });
190
+ findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
191
+ return { ok: findings.length < 1, findings, manifests: manifests.length };
192
+ }
193
+
194
+ function formatTestRunsReport(result) {
195
+ const lines = result.findings.flatMap(({ file, line, rule, message }) => [`${file}:${line} ${rule} ${message}`, ` ${GUIDE}`]);
196
+ lines.push(result.ok
197
+ ? `JSKIT test runs use the machine's test budget in ${result.manifests} manifest(s).`
198
+ : `JSKIT test runs: ${result.findings.length} error(s). Each test run takes its concurrency from the machine's test budget; ` +
199
+ "a file that must run alone says why on its first line (`// jskit-test: alone - <reason>`).");
200
+ return lines.join("\n");
201
+ }
202
+
203
+ export { checkTestRuns, formatTestRunsReport };