@jskit-ai/jskit-catalog 0.2.30 → 0.2.32

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,116 @@
1
+ import { spawn } from "node:child_process";
2
+ import { globSync, readFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { toPosixPath } from "./files.mjs";
7
+ import { BUDGET_VARIABLE, testConcurrency } from "./test-budget.mjs";
8
+
9
+ // `jskit test [node options] [files or globs]`: node --test, running test files in parallel within the machine's test
10
+ // 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.
12
+
13
+ // node --test's own default (Node 26) when it is given no files.
14
+ const DEFAULT_PATTERN = "**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs,ts,mts,cts}";
15
+ const MARKER = /^\s*\/\/\s*jskit-test:(.*)$/u;
16
+ const ALONE = /^\s*alone\s+-\s+(\S.*)$/u;
17
+
18
+ function findTestFiles(patterns, cwd) {
19
+ const entries = globSync(patterns, { cwd, withFileTypes: true, exclude: (entry) => entry.name === "node_modules" });
20
+ const files = entries
21
+ .filter((entry) => entry.isFile() || entry.isSymbolicLink())
22
+ .map((entry) => toPosixPath(path.relative(cwd, path.join(entry.parentPath, entry.name))));
23
+ return [...new Set(files)].sort();
24
+ }
25
+
26
+ /**
27
+ * The files of a run that must run alone, from their first line. A marker that is malformed or not on the first line
28
+ * (after a shebang) is an error, so a file never runs in parallel because its marker was missed.
29
+ * @returns {{ alone: Array<{ file: string, reason: string }>, problems: string[] }}
30
+ */
31
+ function readAloneFiles(files, cwd) {
32
+ const alone = [];
33
+ const problems = [];
34
+ for (const file of files) {
35
+ const lines = readFileSync(path.resolve(cwd, file), "utf8").split("\n");
36
+ const top = lines[0]?.startsWith("#!") ? 1 : 0;
37
+ lines.forEach((line, index) => {
38
+ const marker = MARKER.exec(line);
39
+ if (!marker) return;
40
+ const reason = ALONE.exec(marker[1])?.[1]?.trim();
41
+ if (index !== top) problems.push(`${file}:${index + 1}: a jskit-test marker belongs on the file's first line.`);
42
+ else if (!reason) problems.push(`${file}:${index + 1}: write it as \`// jskit-test: alone - <why this file cannot run beside others>\`.`);
43
+ else alone.push({ file, reason });
44
+ });
45
+ }
46
+ return { alone, problems };
47
+ }
48
+
49
+ /**
50
+ * The node --test runs for these arguments: one in parallel within the budget, then the files that must run alone.
51
+ * @param {string[]} args the arguments after `jskit test`
52
+ * @param {{ cwd?: string, concurrency?: number }} [options]
53
+ */
54
+ function planTestRuns(args, { cwd = process.cwd(), concurrency = testConcurrency() } = {}) {
55
+ if (args.some((argument) => /^--test-concurrency(?:=|$)/u.test(argument))) {
56
+ throw new Error("jskit test takes the test concurrency from the machine's budget; remove --test-concurrency.");
57
+ }
58
+ 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.
60
+ 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);
64
+ const { alone, problems } = readAloneFiles(files, cwd);
65
+ if (problems.length > 0) throw new Error(`jskit test:\n- ${problems.join("\n- ")}`);
66
+ const parallel = ["--test", `--test-concurrency=${concurrency}`];
67
+ if (alone.length < 1) return { concurrency, alone, runs: [[...parallel, ...args]] };
68
+
69
+ const options = args.filter((argument) => !(matched.get(argument)?.length > 0));
70
+ const aloneFiles = new Set(alone.map((entry) => entry.file));
71
+ const others = files.filter((file) => !aloneFiles.has(file));
72
+ return {
73
+ concurrency,
74
+ alone,
75
+ runs: [
76
+ ...(others.length > 0 ? [[...parallel, ...options, ...others]] : []),
77
+ ["--test", "--test-concurrency=1", ...options, ...aloneFiles]
78
+ ]
79
+ };
80
+ }
81
+
82
+ function runNode(args) {
83
+ return new Promise((resolve, reject) => {
84
+ const child = spawn(process.execPath, args, { stdio: "inherit" });
85
+ const forward = (signal) => child.kill(signal);
86
+ process.on("SIGINT", forward);
87
+ process.on("SIGTERM", forward);
88
+ child.once("error", reject);
89
+ child.once("exit", (code, signal) => {
90
+ process.off("SIGINT", forward);
91
+ process.off("SIGTERM", forward);
92
+ resolve({ code: code ?? 128 + (os.constants.signals[signal] ?? 0), signal });
93
+ });
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Runs `jskit test` and resolves to its exit code: the first failing run's, or 0.
99
+ * @param {string[]} args
100
+ */
101
+ async function runTests(args) {
102
+ const plan = planTestRuns(args);
103
+ 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`);
105
+ for (const { file, reason } of plan.alone) process.stderr.write(`jskit test: ${file} runs alone: ${reason}\n`);
106
+ 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;
112
+ }
113
+ return exitCode;
114
+ }
115
+
116
+ export { planTestRuns, runTests };
@@ -0,0 +1,148 @@
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
+
10
+ const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/typed-features#test-files-run-in-parallel";
11
+ const BUDGET_MODULE = "@jskit-ai/jskit-catalog/test-budget";
12
+ const CONFIG_EXTENSIONS = Object.freeze(["ts", "mts", "cts", "js", "mjs", "cjs"]);
13
+ const SCRIPT_FILE = /\.(?:[cm]?[jt]s|jsx|tsx)$/u;
14
+ const FIXED_PLAYWRIGHT_WORKERS = /^(?:--workers(?:=.*)?|-j.*)$/u;
15
+ const FIXED_VITEST_WORKERS = /^--(?:max-?[wW]orkers|min-?[wW]orkers|no-file-?[pP]arallelism|file-?[pP]arallelism)(?:=.*)?$/u;
16
+
17
+ function commandsOf(script) {
18
+ return String(script || "")
19
+ .split(/&&|\|\||[;|\n]/u)
20
+ .map((command) => command.trim().split(/\s+/u).map((token) => token.replace(/^['"(]+|['")]+$/gu, "")).filter(Boolean))
21
+ .filter((tokens) => tokens.length > 0);
22
+ }
23
+
24
+ const isProgram = (token, name) => token === name || token.endsWith(`/${name}`);
25
+
26
+ // node's own options come before its script; --test among them runs the test runner.
27
+ function runsNodeTest(tokens) {
28
+ return tokens.some((token, index) => {
29
+ if (!isProgram(token, "node")) return false;
30
+ for (const option of tokens.slice(index + 1)) {
31
+ if (option === "--test") return true;
32
+ if (SCRIPT_FILE.test(option)) return false;
33
+ }
34
+ return false;
35
+ });
36
+ }
37
+
38
+ function runnerArguments(tokens, runner) {
39
+ const index = tokens.findIndex((token, position) => isProgram(token, runner) &&
40
+ (runner !== "playwright" || tokens[position + 1] === "test"));
41
+ return index < 0 ? null : tokens.slice(index + 1);
42
+ }
43
+
44
+ function configOption(args) {
45
+ const index = args.findIndex((token) => token === "--config" || token === "-c" || token.startsWith("--config="));
46
+ if (index < 0) return "";
47
+ return args[index].startsWith("--config=") ? args[index].slice("--config=".length) : args[index + 1] || "";
48
+ }
49
+
50
+ function lineOf(contents, needle, after = "") {
51
+ const text = String(contents);
52
+ const offset = text.indexOf(needle, after ? Math.max(0, text.indexOf(after)) : 0);
53
+ return offset < 0 ? 1 : text.slice(0, offset).split("\n").length;
54
+ }
55
+
56
+ async function findConfig(directory, explicit, names) {
57
+ if (explicit) return path.resolve(directory, explicit);
58
+ for (const name of names) {
59
+ for (const extension of CONFIG_EXTENSIONS) {
60
+ const candidate = path.join(directory, `${name}.config.${extension}`);
61
+ if (await fileExists(candidate)) return candidate;
62
+ }
63
+ }
64
+ return "";
65
+ }
66
+
67
+ const RUNNERS = Object.freeze({
68
+ playwright: {
69
+ rule: "test/playwright-workers",
70
+ configNames: ["playwright"],
71
+ setting: "workers",
72
+ fixedArgument: FIXED_PLAYWRIGHT_WORKERS
73
+ },
74
+ vitest: {
75
+ rule: "test/vitest-workers",
76
+ configNames: ["vitest", "vite"],
77
+ setting: "maxWorkers",
78
+ fixedArgument: FIXED_VITEST_WORKERS
79
+ }
80
+ });
81
+
82
+ // A config sets the runner's workers from the budget, and Vitest's never turns file parallelism off.
83
+ async function checkRunnerConfig({ root, configPath, directory, runner, report }) {
84
+ const { rule, setting, configNames } = RUNNERS[runner];
85
+ const wanted = `set \`${setting}: testConcurrency()\` from ${BUDGET_MODULE}`;
86
+ if (!configPath || !(await fileExists(configPath))) {
87
+ const missing = configPath || path.join(directory, `${configNames[0]}.config.*`);
88
+ report({ file: toPosixPath(path.relative(root, missing)), line: 1, rule,
89
+ message: `A script runs ${runner} without this config: add it and ${wanted}.` });
90
+ return;
91
+ }
92
+ const file = toPosixPath(path.relative(root, configPath));
93
+ const source = await readFile(configPath, "utf8");
94
+ if (!source.includes(BUDGET_MODULE) || !new RegExp(`\\b${setting}\\s*:\\s*testConcurrency\\(\\s*\\)`, "u").test(source)) {
95
+ report({ file, line: lineOf(source, `${setting}:`), rule, message: `${runner} runs at a fixed or default worker count; ${wanted}.` });
96
+ }
97
+ if (runner === "vitest" && /\bfileParallelism\s*:\s*false\b/u.test(source)) {
98
+ report({ file, line: lineOf(source, "fileParallelism"), rule,
99
+ message: "Vitest runs one file at a time; remove fileParallelism: false and isolate the files instead." });
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {{ projectRoot: string, manifests: ReadonlyArray<{ absolutePath: string, contents: string, value: object }> }} input
105
+ */
106
+ async function checkTestRuns({ projectRoot, manifests }) {
107
+ const root = path.resolve(projectRoot);
108
+ const findings = [];
109
+ const report = (finding) => findings.push(finding);
110
+ const configs = new Map();
111
+ for (const manifest of manifests) {
112
+ const file = toPosixPath(path.relative(root, manifest.absolutePath)) || "package.json";
113
+ const directory = path.dirname(manifest.absolutePath);
114
+ for (const [name, script] of Object.entries(manifest.value?.scripts || {})) {
115
+ const line = lineOf(manifest.contents, `"${name}"`, '"scripts"');
116
+ const at = (rule, message) => report({ file, line, rule, message: `scripts.${name} ${message}` });
117
+ for (const tokens of commandsOf(script)) {
118
+ 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.");
119
+ if (tokens.some((token) => /^--test-concurrency(?:=|$)/u.test(token))) {
120
+ at("test/fixed-concurrency", "sets --test-concurrency; drop it, so `jskit test` takes the concurrency from the machine's test budget.");
121
+ }
122
+ for (const runner of Object.keys(RUNNERS)) {
123
+ const args = runnerArguments(tokens, runner);
124
+ if (!args) continue;
125
+ if (args.some((token) => RUNNERS[runner].fixedArgument.test(token))) {
126
+ at(RUNNERS[runner].rule, `passes ${runner} a fixed worker count; drop it, so the config's testConcurrency() sets it.`);
127
+ }
128
+ const configPath = await findConfig(directory, configOption(args), RUNNERS[runner].configNames);
129
+ configs.set(`${runner}\0${configPath || directory}`, { runner, configPath, directory });
130
+ }
131
+ }
132
+ }
133
+ }
134
+ for (const config of configs.values()) await checkRunnerConfig({ root, report, ...config });
135
+ findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
136
+ return { ok: findings.length < 1, findings, manifests: manifests.length };
137
+ }
138
+
139
+ function formatTestRunsReport(result) {
140
+ const lines = result.findings.flatMap(({ file, line, rule, message }) => [`${file}:${line} ${rule} ${message}`, ` ${GUIDE}`]);
141
+ lines.push(result.ok
142
+ ? `JSKIT test runs use the machine's test budget in ${result.manifests} manifest(s).`
143
+ : `JSKIT test runs: ${result.findings.length} error(s). Each test run takes its concurrency from the machine's test budget; ` +
144
+ "a file that must run alone says why on its first line (`// jskit-test: alone - <reason>`).");
145
+ return lines.join("\n");
146
+ }
147
+
148
+ export { checkTestRuns, formatTestRunsReport };
@@ -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