@ingenioz-it/caits 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/caits.js +20 -7
  2. package/bin/init.js +16 -1
  3. package/package.json +8 -5
  4. package/quality/architecture/adapters/scss.mjs +2 -2
  5. package/quality/architecture/facts.mjs +14 -6
  6. package/quality/dev.mjs +6 -3
  7. package/quality/mutation-summary.mjs +30 -20
  8. package/quality/mutation.mjs +170 -14
  9. package/src/adapter.js +94 -0
  10. package/src/di/Container.ts +27 -0
  11. package/src/di/RequestContext.ts +8 -2
  12. package/src/di/backend.ts +12 -0
  13. package/src/di/createBackend.ts +189 -19
  14. package/src/messaging/ContextEvents.ts +11 -0
  15. package/src/messaging/Controller.ts +35 -73
  16. package/src/messaging/EventSubscriber.ts +1 -1
  17. package/src/messaging/InProcessMessageBus.ts +6 -5
  18. package/src/messaging/MessageBus.ts +4 -1
  19. package/src/messaging/Request.ts +116 -0
  20. package/src/messaging/RequestEvents.ts +8 -0
  21. package/src/messaging/StreamController.ts +261 -0
  22. package/src/messaging/TimerController.ts +25 -0
  23. package/src/messaging/sse.ts +23 -0
  24. package/src/server/entry.ts +37 -0
  25. package/src/server/kernel.ts +19 -0
  26. package/src/server/packages.d.ts +12 -0
  27. package/src/server/serve.js +121 -0
  28. package/src/testing/backend/applications.ts +4 -1
  29. package/src/testing/backend/presentationApplication.ts +23 -3
  30. package/src/testing/backend/presentationScenario.ts +84 -7
  31. package/src/testing/shared/FakeMessageBus.ts +39 -5
  32. package/src/vite.js +2 -0
  33. package/src/vite.types.d.ts +7 -0
  34. package/template/caits/architecture.mjs +26 -7
  35. package/template/caits/imports.mjs +2 -2
  36. package/template/caits/stryker.mjs +11 -6
  37. package/template/caits/vite.ts +13 -5
  38. package/quality/mutation-suite.mjs +0 -82
package/bin/caits.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { relative, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
6
  import { commandOf, painterOf } from "../quality/terminal.mjs";
7
- import { defaultsVersions, DEFAULTS_FOLDER, EXAMPLE_SOURCE, init, install, otherVersions, SOURCE, update, VERSION } from "./init.js";
7
+ import { defaultsVersions, DEFAULTS_FOLDER, EXAMPLE_SOURCE, init, install, missingPackages, otherVersions, SOURCE, update, VERSION } from "./init.js";
8
8
 
9
9
  const USAGE = `Usage: caits <command>
10
10
 
@@ -24,16 +24,17 @@ const USAGE = `Usage: caits <command>
24
24
  check <script>… Runs the npm scripts of the checks at once. Shows the output of the checks that fail, and only theirs.
25
25
  architecture Checks the rules of quality/architecture.mjs.
26
26
  e2e Runs the end-to-end tests on the production build.
27
- mutation Runs the mutation tests (Stryker), then gives the score of each file and the mutants that survived. Its exit
28
- code is that of Stryker, or 1 when a mutant was not tested (its unit tests did not run).
29
- mutation-summary Gives the mutation score of each file (--survivors: the mutants that survived, and those not tested).
30
- mutation-suite The unit tests, as Stryker runs them for each mutant.
27
+ mutation Runs the mutation tests (Stryker), runs again with more time the mutants that the clock stopped, then gives
28
+ the score of each file and the mutants that survived. Its exit code is that of Stryker, or 1 when a mutant
29
+ is not judged (its tests ended in an error that is not a failed test), or when the second run puts the
30
+ score under the threshold.
31
+ mutation-summary Gives the mutation score of each file (--survivors: the mutants that survived, and those not judged).
31
32
 
32
33
  --help, -h Shows this help, also after a command (caits init --help).
33
34
  --version Shows the version of CAITS.`;
34
35
 
35
36
  /** The commands that run a tool of quality/: their code is in the folder quality/ of CAITS. */
36
- const TOOLS = new Set(["dev", "check", "architecture", "e2e", "mutation", "mutation-summary", "mutation-suite"]);
37
+ const TOOLS = new Set(["dev", "check", "architecture", "e2e", "mutation", "mutation-summary"]);
37
38
 
38
39
  /** The options of `caits init`. */
39
40
  const INIT_OPTIONS = /** @type {const} */ ({ caits: { type: "string" }, example: { type: "boolean" }, "example-source": { type: "string" }, "no-install": { type: "boolean" } });
@@ -85,7 +86,7 @@ async function main(command, args) {
85
86
  if (command === "init") return runInit(args);
86
87
  if (command === "update") return runUpdate(args);
87
88
  if (TOOLS.has(command)) {
88
- if (command !== "mutation-suite") warnOfOldDefaults(process.cwd());
89
+ warnOfOldDefaults(process.cwd());
89
90
  // The tools load Vite, Marko and the other packages of the project: only the command that runs imports its code.
90
91
  const tool = /** @type {{ run: (args: string[]) => Promise<number | void> }} */ (await importTool(command));
91
92
  return tool.run(args);
@@ -171,6 +172,7 @@ function runUpdate(args) {
171
172
  const written = update(process.cwd());
172
173
  console.log(paint("green", `CAITS ${VERSION}: wrote ${count(written.length, "file")} in ${DEFAULTS_FOLDER}/.`));
173
174
  printOtherVersions(process.cwd());
175
+ printMissingPackages(process.cwd());
174
176
  return 0;
175
177
  }
176
178
 
@@ -182,6 +184,17 @@ function printOtherVersions(root) {
182
184
  for (const { key, project, caits } of others) console.log(` ${key}: ${JSON.stringify(project)} (CAITS suggests ${JSON.stringify(caits)})`);
183
185
  }
184
186
 
187
+ /** @param {string} root */
188
+ function printMissingPackages(root) {
189
+ const { dependencies, devDependencies } = missingPackages(root);
190
+ if (dependencies.length + devDependencies.length === 0) return;
191
+ console.log(paint("yellow", `CAITS ${VERSION} needs packages that package.json does not have. Add them:`));
192
+ /** @param {string[]} names */
193
+ const quoted = names => names.map(name => `"${name}"`).join(" ");
194
+ if (dependencies.length > 0) console.log(` ${paint("cyan", `npm install ${quoted(dependencies)}`)}`);
195
+ if (devDependencies.length > 0) console.log(` ${paint("cyan", `npm install --save-dev ${quoted(devDependencies)}`)}`);
196
+ }
197
+
185
198
  /** @param {string} root */
186
199
  function warnOfOldDefaults(root) {
187
200
  const versions = defaultsVersions(root);
package/bin/init.js CHANGED
@@ -111,7 +111,8 @@ const TOOLS = {
111
111
  dependencies: ["@marko/run", "marko"],
112
112
  devDependencies: [
113
113
  "@eslint/js", "@marko/compiler", "@marko/type-check", "@playwright/test", "@stryker-mutator/core", "@stryker-mutator/typescript-checker",
114
- "@types/node", "@vitest/coverage-v8", "eslint", "happy-dom", "sass", "typescript", "typescript-eslint", "vite", "vitest"
114
+ "@stryker-mutator/vitest-runner", "@types/node", "@vitest/coverage-v8", "eslint", "happy-dom", "sass", "typescript", "typescript-eslint",
115
+ "vite", "vitest"
115
116
  ]
116
117
  };
117
118
  /** @param {string[]} names */
@@ -253,6 +254,20 @@ export function otherVersions(root) {
253
254
  }));
254
255
  }
255
256
 
257
+ /**
258
+ * The packages that CAITS needs and that package.json does not have, in either section, as npm installs them: `name@version`. A new
259
+ * version of CAITS may need a package more: `caits update` tells which.
260
+ * @param {string} root
261
+ * @returns {{ dependencies: string[]; devDependencies: string[] }}
262
+ */
263
+ export function missingPackages(root) {
264
+ const file = join(root, "package.json");
265
+ const project = existsSync(file) ? readPackage(file) : undefined;
266
+ /** @param {Record<string, string>} section */
267
+ const missing = section => project === undefined ? [] : Object.entries(section).filter(([name]) => dependencyOf(project, name) === undefined).map(([name, version]) => `${name}@${version}`);
268
+ return { dependencies: missing(SUGGESTED.dependencies), devDependencies: missing(SUGGESTED.devDependencies) };
269
+ }
270
+
256
271
  /**
257
272
  * Installs the packages of package.json, then the browser of the end-to-end tests. Gives false when a command fails.
258
273
  * @param {string} root
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingenioz-it/caits",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CAITS (Clean Architecture In TypeScript): the technical base of a Marko project with a backend and a frontend.",
5
5
  "keywords": [
6
6
  "clean-architecture",
@@ -55,12 +55,14 @@
55
55
  "test": "vitest run",
56
56
  "test:watch": "vitest",
57
57
  "test:coverage": "vitest run --coverage",
58
- "test:mutation": "stryker run stryker.config.json",
58
+ "test:mutation": "node test/mutation.js",
59
59
  "test:template": "node test/template.js",
60
60
  "check": "npm run typecheck && npm run lint && npm run test:coverage"
61
61
  },
62
62
  "dependencies": {
63
- "magic-string": "^0.30.21"
63
+ "compression": "^1.8.2",
64
+ "magic-string": "^0.30.21",
65
+ "serve-static": "^2.2.1"
64
66
  },
65
67
  "peerDependencies": {
66
68
  "@eslint/js": "^10.0.1",
@@ -100,8 +102,9 @@
100
102
  "@marko/run": "^0.11.13",
101
103
  "@marko/type-check": "^3.2.1",
102
104
  "@playwright/test": "^1.64.0",
103
- "@stryker-mutator/core": "^10.0.0",
104
- "@stryker-mutator/typescript-checker": "^10.0.0",
105
+ "@stryker-mutator/core": "^10.1.1",
106
+ "@stryker-mutator/typescript-checker": "^10.1.1",
107
+ "@stryker-mutator/vitest-runner": "^10.1.1",
105
108
  "@types/node": "^26.6.4",
106
109
  "@vitest/coverage-v8": "^5.0.3",
107
110
  "eslint": "^10.11.0",
@@ -50,8 +50,8 @@ export function readScss(path, text, paths) {
50
50
  return isIn(STYLE_FOLDER, specifier, paths) ? `/${posix.join(STYLE_FOLDER, specifier)}` : specifier;
51
51
  });
52
52
  return {
53
- imports, imported: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [], comments,
54
- lines: { imports: found.map(({ line }) => line), constructed: [], objectTypes: [], tags: [], comments: commentLines }
53
+ imports, imported: [], exports: [], defaultArray: null, classes: [], constructed: [], typeReferences: [], calls: [], objectTypes: [], strings: [], tags: [], comments,
54
+ lines: { imports: found.map(({ line }) => line), constructed: [], typeReferences: [], objectTypes: [], tags: [], comments: commentLines }
55
55
  };
56
56
  }
57
57
 
@@ -31,13 +31,13 @@
31
31
  /**
32
32
  * The lines of the facts that are strings, in the same order: `lines.imports[2]` is the line of `imports[2]`. A tag that Marko finds
33
33
  * without an import counts as an import, at the line of the tag.
34
- * @typedef {{ imports: number[]; constructed: number[]; objectTypes: number[]; tags: number[]; comments: number[] }} Lines
34
+ * @typedef {{ imports: number[]; constructed: number[]; typeReferences: number[]; objectTypes: number[]; tags: number[]; comments: number[] }} Lines
35
35
  */
36
36
  /**
37
37
  * What the rules read in a file: see factsOf.
38
38
  * @typedef {{
39
39
  * imports: string[]; imported: Imported[]; exports: Export[]; defaultArray: Item[] | null; classes: Class[]; constructed: (string | null)[];
40
- * calls: Call[]; objectTypes: string[]; strings: string[]; tags: string[]; comments: string[]; lines: Lines
40
+ * typeReferences: string[]; calls: Call[]; objectTypes: string[]; strings: string[]; tags: string[]; comments: string[]; lines: Lines
41
41
  * }} Facts
42
42
  */
43
43
  /** A comment that a parser gives apart from the tree: its text, and where it starts in the file. @typedef {{ value: string; start: number }} LooseComment */
@@ -81,6 +81,8 @@ const rootOf = node => {
81
81
  while (node?.type === "MemberExpression" || node?.type === "CallExpression") node = node.type === "MemberExpression" ? node.object : node.callee;
82
82
  return node?.type === "Identifier" ? node.name : null;
83
83
  };
84
+ /** The name of a type, qualified or not: `Note`, `globalThis.Request`. @type {(node: Node) => string} */
85
+ const typeNameOf = node => node.type === "TSQualifiedName" ? `${typeNameOf(node.left)}.${typeNameOf(node.right)}` : node.name;
84
86
  /** @type {(node: Node) => string | null} */
85
87
  const keyOf = node => node.key?.name ?? stringOf(node.key);
86
88
 
@@ -164,12 +166,14 @@ const classOf = (node, exported, lineOf, text) => ({
164
166
  * call takes first (X), and the classes that the item builds, or null;
165
167
  * - classes: the classes, with what they extend and implement, their properties, and the return types of their methods;
166
168
  * - constructed: the class of each `new X(…)`;
169
+ * - typeReferences: the name of each type that the file names (`Note`, `globalThis.Request`), its arguments too (`Map<string, Note>` names Map
170
+ * and Note);
167
171
  * - calls: each call, with the value of its arguments that are strings (null for the others);
168
172
  * - objectTypes: the value of each `type: "…"` of an object literal;
169
173
  * - strings: every string of the code (comments and import paths aside);
170
174
  * - tags: the name of each Marko tag (Marko only);
171
175
  * - comments: the text of each comment: those that the parser gives, those that the tree holds, and the comments of a Marko template;
172
- * - lines: the line of each import, `new`, `type: "…"`, tag and comment. The facts that are objects have their own line.
176
+ * - lines: the line of each import, `new`, type that the file names, `type: "…"`, tag and comment. The facts that are objects have their own line.
173
177
  * A node of oxc has its offset in the text (start); a node of Babel has its line too (loc), which counts from the first line of the file.
174
178
  * @param {Node} program
175
179
  * @param {string} text
@@ -182,9 +186,9 @@ export function factsOf(program, text, comments = []) {
182
186
  const lineOf = node => node.loc?.start?.line ?? lineAt(node.start ?? 0);
183
187
  /** @type {Facts} */
184
188
  const facts = {
185
- imports: [], imported: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [],
186
- comments: comments.map(({ value }) => value),
187
- lines: { imports: [], constructed: [], objectTypes: [], tags: [], comments: comments.map(({ start }) => lineAt(start)) }
189
+ imports: [], imported: [], exports: [], defaultArray: null, classes: [], constructed: [], typeReferences: [], calls: [], objectTypes: [], strings: [],
190
+ tags: [], comments: comments.map(({ value }) => value),
191
+ lines: { imports: [], constructed: [], typeReferences: [], objectTypes: [], tags: [], comments: comments.map(({ start }) => lineAt(start)) }
188
192
  };
189
193
  /** @type {(source: string, node: Node) => void} */
190
194
  const addImport = (source, node) => {
@@ -235,6 +239,10 @@ export function factsOf(program, text, comments = []) {
235
239
  facts.lines.constructed.push(lineOf(node));
236
240
  for (const { node: element, item } of items) if (element.start <= node.start && node.end <= element.end) item.constructed.push(nameOf(node.callee));
237
241
  }
242
+ if (node.type === "TSTypeReference") {
243
+ facts.typeReferences.push(typeNameOf(node.typeName));
244
+ facts.lines.typeReferences.push(lineOf(node));
245
+ }
238
246
  if (node.type === "CallExpression") facts.calls.push({ name: nameOf(node.callee), root: rootOf(node.callee), args: node.arguments.map(stringOf), line: lineOf(node) });
239
247
  if (node.type === "ObjectExpression") {
240
248
  for (const property of node.properties) {
package/quality/dev.mjs CHANGED
@@ -4,13 +4,15 @@
4
4
  // answer, so `marko-run dev` never starts. This command gives both ports, so @marko/run does not check them: PORT, and HMR_PORT
5
5
  // (24678 when not set). Two projects can run their servers at the same time, each with its two ports.
6
6
  // If a port is already in use, the server stops with EADDRINUSE.
7
- // It uses the Vite configuration of the project, quality/vite.config.ts: Marko, and the plugin of CAITS.
7
+ // It uses the Vite configuration of the project, quality/vite.config.ts: Marko, and the plugin of CAITS. The server is that of the adapter
8
+ // of CAITS: it boots the backend, boots it again when its code changes, and stops it, and the command, on SIGTERM or SIGINT (Ctrl+C).
8
9
  // @marko/run writes the entry files of the server in the build folder. `npm run build` (`npm run check` runs it) empties dist/: the
9
10
  // server writes them in a folder of its own, else it fails at the next change of a file. @marko/vite refuses an entry file whose path
10
11
  // starts with a dot: the folder is in node_modules/, as the cache of Vite.
11
12
  import { existsSync } from "node:fs";
12
- import adapter from "@marko/run/adapter";
13
13
  import { loadConfigFromFile } from "vite";
14
+ import { adapter } from "../src/adapter.js";
15
+ import { stopOnSignals } from "../src/server/serve.js";
14
16
  import { painterOf } from "./terminal.mjs";
15
17
 
16
18
  export const VITE_CONFIG = "quality/vite.config.ts";
@@ -30,7 +32,7 @@ export async function run(args) {
30
32
  const port = Number(process.env.PORT ?? 3000);
31
33
  const hmrPort = Number(process.env.HMR_PORT ?? 24678);
32
34
  const marko = adapter();
33
- // The adapter of Node, which @marko/run gives, has the two methods.
35
+ // The adapter of CAITS has the two methods.
34
36
  const getEntryFile = marko.getEntryFile?.bind(marko);
35
37
  const startDev = marko.startDev?.bind(marko);
36
38
  if (!getEntryFile || !startDev) throw new Error(`The adapter ${marko.name} of @marko/run cannot start a development server.`);
@@ -88,6 +90,7 @@ export async function run(args) {
88
90
  };
89
91
 
90
92
  await start();
93
+ stopOnSignals(async () => { await running.close(); });
91
94
  }
92
95
 
93
96
  /** Whether the Vite configuration of the project loads. When it does not, says why. */
@@ -1,8 +1,7 @@
1
1
  // `caits mutation-summary`: the mutation score of each file, from the report of Stryker. With --survivors, the mutants that survived.
2
2
  // In a terminal: the score in green when every mutant is killed, else in red; the score of each file the same way, and its path in cyan;
3
- // for each survivor, its line dim, its status in red, its mutator in yellow.
3
+ // for each survivor, its line dim, its status in red, its mutator in yellow, and why a mutant is not judged, dim.
4
4
  import { existsSync, readFileSync } from "node:fs";
5
- import { NOT_RUN } from "./mutation-suite.mjs";
6
5
  import { painterOf } from "./terminal.mjs";
7
6
 
8
7
  /** The report that Stryker writes in JSON (quality/caits/stryker.mjs). */
@@ -13,46 +12,57 @@ export const REPORT = "reports/mutation/mutation.json";
13
12
  /** @type {(file: string) => unknown} */
14
13
  const readJson = file => JSON.parse(readFileSync(file, "utf8"));
15
14
 
16
- /** Stryker counts a suite that stopped before its tests as a kill: the mutant is not tested (`caits mutation-suite`). @type {(m: Mutant) => boolean} */
17
- const notTested = m => m.status === "Killed" && Boolean(m.statusReason?.includes(NOT_RUN));
15
+ /**
16
+ * Stryker leaves out of the score a mutant whose tests ended in an error that is not a failed test: an error outside the tests, or a test
17
+ * runner that stopped. Nothing tells whether a test sees the mutant: it is not judged. `caits mutation` kills a mutant whose test files
18
+ * failed to load, and nothing else. @type {(m: Mutant) => boolean}
19
+ */
20
+ const notJudged = m => m.status === "RuntimeError";
18
21
 
19
22
  /** @type {(m: Mutant) => string} */
20
- const tag = m => (m.status === "NoCoverage" ? "[no cov]" : m.status === "Survived" ? "[survived]" : "[not tested]");
23
+ const tag = m => (m.status === "NoCoverage" ? "[no cov]" : m.status === "Survived" ? "[survived]" : "[not judged]");
21
24
 
22
25
  /**
23
26
  * @param {string[]} args
24
- * @returns {number} 1 when a mutant is not tested
27
+ * @param {{ breakAt?: number | null }} [options] `breakAt`: the score under which the run fails, the threshold `break` of Stryker
28
+ * @returns {number} 1 when a mutant is not judged, or when the score is under `breakAt`
25
29
  */
26
- export function run(args) {
30
+ export function run(args, { breakAt = null } = {}) {
27
31
  if (!existsSync(REPORT)) throw new Error(`${REPORT} is missing: run the mutation tests first (npm run test:mutation).`);
28
32
  const report = /** @type {{ files: Record<string, { source: string; mutants: Mutant[] }> }} */ (readJson(REPORT));
29
33
  const rows = [];
30
- let killedTotal = 0, validTotal = 0, notTestedTotal = 0;
34
+ let killedTotal = 0, validTotal = 0, notJudgedTotal = 0;
31
35
  for (const [file, data] of Object.entries(report.files)) {
32
36
  /** @type {(status: string) => number} */
33
37
  const count = status => data.mutants.filter(m => m.status === status).length;
34
- const untested = data.mutants.filter(notTested).length;
35
- const killed = count("Killed") - untested + count("Timeout");
38
+ const unjudged = data.mutants.filter(notJudged).length;
39
+ const killed = count("Killed") + count("Timeout");
36
40
  const survived = count("Survived");
37
41
  const noCoverage = count("NoCoverage");
38
- const valid = killed + survived + noCoverage + untested;
39
- killedTotal += killed; validTotal += valid; notTestedTotal += untested;
40
- rows.push({ file, killed, survived, noCoverage, untested, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage" || notTested(m)), source: data.source.split("\n") });
42
+ const valid = killed + survived + noCoverage + unjudged;
43
+ killedTotal += killed; validTotal += valid; notJudgedTotal += unjudged;
44
+ rows.push({ file, killed, survived, noCoverage, unjudged, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage" || notJudged(m)), source: data.source.split("\n") });
41
45
  }
42
46
  rows.sort((a, b) => a.score - b.score);
43
47
  const verbose = args.includes("--survivors");
44
48
  const paint = painterOf(process.stdout);
45
49
  /** @type {(killed: number, valid: number) => "green" | "red"} */
46
50
  const colorOf = (killed, valid) => killed === valid ? "green" : "red";
47
- console.log(`${paint([colorOf(killedTotal, validTotal), "bold"], `Mutation score: ${(validTotal ? (100 * killedTotal) / validTotal : 100).toFixed(1)}% (${killedTotal} killed of ${validTotal} valid mutants)`)}\n`);
51
+ const score = validTotal ? (100 * killedTotal) / validTotal : 100;
52
+ console.log(`${paint([colorOf(killedTotal, validTotal), "bold"], `Mutation score: ${score.toFixed(1)}% (${killedTotal} killed of ${validTotal} valid mutants)`)}\n`);
48
53
  for (const row of rows) {
49
- const valid = row.killed + row.survived + row.noCoverage + row.untested;
50
- console.log(`${paint(colorOf(row.killed, valid), `${row.score.toFixed(0).padStart(4)}%`)} ${paint("cyan", row.file)} (killed ${row.killed}, survived ${row.survived}, no coverage ${row.noCoverage}${row.untested ? `, not tested ${row.untested}` : ""})`);
54
+ const valid = row.killed + row.survived + row.noCoverage + row.unjudged;
55
+ console.log(`${paint(colorOf(row.killed, valid), `${row.score.toFixed(0).padStart(4)}%`)} ${paint("cyan", row.file)} (killed ${row.killed}, survived ${row.survived}, no coverage ${row.noCoverage}${row.unjudged ? `, not judged ${row.unjudged}` : ""})`);
51
56
  if (verbose && row.survivors.length) {
52
- for (const m of row.survivors) console.log(` ${paint("dim", `L${m.location.start.line}`)} ${paint("red", tag(m))} ${paint("yellow", m.mutatorName)}: ${(row.source[m.location.start.line - 1] ?? "").trim().slice(0, 90)} ${paint("dim", "→")} ${String(m.replacement ?? "").slice(0, 60)}`);
57
+ for (const m of row.survivors) {
58
+ console.log(` ${paint("dim", `L${m.location.start.line}`)} ${paint("red", tag(m))} ${paint("yellow", m.mutatorName)}: ${(row.source[m.location.start.line - 1] ?? "").trim().slice(0, 90)} ${paint("dim", "→")} ${String(m.replacement ?? "").slice(0, 60)}`);
59
+ if (notJudged(m) && m.statusReason) console.log(` ${paint("dim", m.statusReason.split("\n")[0].slice(0, 120))}`);
60
+ }
53
61
  }
54
62
  }
55
- if (!notTestedTotal) return 0;
56
- console.log(`\n${paint("red", `${notTestedTotal} ${notTestedTotal === 1 ? "mutant is" : "mutants are"} not tested: the unit tests did not run, and Stryker counts ${notTestedTotal === 1 ? "it" : "them"} as killed. Run the mutation tests again. Why they did not run: reports/mutation/index.html.`)}`);
57
- return 1;
63
+ if (notJudgedTotal) {
64
+ const one = notJudgedTotal === 1;
65
+ console.log(`\n${paint("red", `${notJudgedTotal} ${one ? "mutant is" : "mutants are"} not judged: ${one ? "its" : "their"} tests ended in an error that is not a failed test, and Stryker leaves ${one ? "it" : "them"} out of the score. Run the mutation tests again; when ${one ? "it" : "one"} comes back, write a test that sees what it changes.`)}`);
66
+ }
67
+ return notJudgedTotal > 0 || (breakAt !== null && score < breakAt) ? 1 : 0;
58
68
  }
@@ -1,29 +1,185 @@
1
1
  // `caits mutation`: the mutation tests (Stryker, with quality/stryker.config.mjs), then the score of each file and the mutants that
2
2
  // survived, whether Stryker passed or not. Stryker alone ends with its threshold when the score is too low: the survivors say what to fix.
3
- // The run fails also when a mutant is not tested, which Stryker counts as killed (`caits mutation-summary`).
3
+ // Stryker stops the tests of a mutant that run too long, and counts the mutant as killed: the mutant may make the code loop without end.
4
+ // Its counter stops most of these loops, as it counts the runs of the code of the mutant, whatever the load of the machine. The clock
5
+ // stops the rest, but the load slows a mutant that survives, which runs the most tests, past its time: the mutants that the clock stopped
6
+ // run again, with more time and fewer tests at once, and their second result counts. A mutant whose test files failed to load is killed:
7
+ // its code throws as soon as a test imports it, every time, as a failed test does, where Stryker counts an error outside the tests. The
8
+ // run fails when a mutant is not judged (`caits mutation-summary`).
4
9
  import { spawn } from "node:child_process";
5
- import { existsSync, rmSync } from "node:fs";
10
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
6
14
  import { REPORT, run as summary } from "./mutation-summary.mjs";
15
+ import { painterOf } from "./terminal.mjs";
7
16
 
8
17
  /** The configuration of Stryker in a project: it imports the defaults of CAITS (quality/caits/stryker.mjs). */
9
18
  export const STRYKER_CONFIG = "quality/stryker.config.mjs";
10
19
 
20
+ /** The page of the report, which Stryker writes with the report in a line of its script (quality/caits/stryker.mjs). */
21
+ const PAGE = "reports/mutation/index.html";
22
+
23
+ /** The line of the page that holds the report. */
24
+ const PAGE_REPORT = /^( *app\.report = ).*;$/m;
25
+
26
+ /** The second run of the mutants that the clock stopped: more time for each, fewer at once, and the report in JSON only. */
27
+ const SECOND_RUN = ["--concurrency", "4", "--timeoutFactor", "3", "--reporters", "json"];
28
+
29
+ /** How Stryker starts the reason of an error outside the tests: then come the test files that failed to load, then the other errors. */
30
+ const OUTSIDE = "An error occurred outside of a test run: ";
31
+
32
+ /** A test file that failed to load, as Stryker gives it on a line of its own: `<its path>: <the message>`. */
33
+ const LOAD_FAILURE = /^\/.*\.(?:test|spec)\.[cm]?[jt]sx?: /;
34
+
35
+ /** The temporary folders of the runs, in the temporary folder of the system: `caits-mutation-<pid of their run>-<random>`. */
36
+ const TEMPORARY = /^caits-mutation-(\d+)-/;
37
+
38
+ /** @typedef {{ status: string; statusReason?: string; mutatorName: string; replacement?: string; location: { start: { line: number; column: number }; end: { line: number; column: number } } }} Mutant */
39
+ /** @typedef {{ files: Record<string, { mutants: Mutant[] }> }} Report */
40
+
11
41
  /**
12
42
  * @param {string[]} args the options of Stryker
13
- * @returns {Promise<number>} the exit code of Stryker, or 1 when a mutant is not tested
43
+ * @param {string} [config] the configuration of Stryker: a module, or JSON
44
+ * @returns {Promise<number>} the exit code of Stryker, or 1 when a mutant is not judged, or when the score is under the threshold after
45
+ * the second run
14
46
  */
15
- export async function run(args) {
16
- if (!existsSync(STRYKER_CONFIG)) throw new Error(`${STRYKER_CONFIG} is missing: it gives the configuration of the mutation tests. Run \`npx caits init .\` to write it.`);
47
+ export async function run(args, config = STRYKER_CONFIG) {
48
+ if (!existsSync(config)) throw new Error(`${config} is missing: it gives the configuration of the mutation tests. Run \`npx caits init .\` to write it.`);
17
49
  // A run that stops before its report must not show the summary of the run before.
18
50
  rmSync(REPORT, { force: true });
19
- // Ctrl+C and `caits check` stop the whole group of processes: Stryker and its test runners stop with this command.
20
- const stryker = spawn("npx", ["stryker", "run", STRYKER_CONFIG, ...args], { stdio: "inherit" });
21
- /** @type {number} */
22
- const status = await new Promise(resolve => {
23
- stryker.on("error", () => resolve(1));
24
- stryker.on("close", code => resolve(code ?? 1));
51
+ removeOrphans();
52
+ // The tests write their temporary files in a folder of this run: a test that Stryker stops cannot remove them. In WSL, the temporary
53
+ // folder of the system is in memory.
54
+ const temporary = mkdtempSync(join(tmpdir(), `caits-mutation-${process.pid}-`));
55
+ try {
56
+ const status = await stryker([config, ...args], temporary);
57
+ if (!existsSync(REPORT)) return status;
58
+ const first = readReport();
59
+ let report = first;
60
+ const late = Object.entries(first.files).flatMap(([file, { mutants }]) => mutants.filter(stoppedByTheClock).map(mutant => ({ file, mutant })));
61
+ if (late.length > 0) {
62
+ const one = late.length === 1;
63
+ console.log(`\n${late.length} ${one ? "mutant" : "mutants"} ran out of time on the clock, which the load of the machine slows: ${one ? "it runs" : "they run"} again, with more time and fewer tests at once.\n`);
64
+ rmSync(REPORT);
65
+ const ranges = [...new Set(late.map(({ file, mutant }) => `${file}:${mutant.location.start.line}-${mutant.location.end.line}`))];
66
+ await stryker([config, ...args, "--mutate", ranges.join(","), ...SECOND_RUN], temporary);
67
+ if (!existsSync(REPORT)) {
68
+ writeFileSync(REPORT, JSON.stringify(first));
69
+ console.log("");
70
+ summary(["--survivors"]);
71
+ console.log(`\n${painterOf(process.stdout)("red", "The second run stopped before its report: the mutants that the clock stopped count as killed, as Stryker counts them. Run the mutation tests again.")}`);
72
+ return 1;
73
+ }
74
+ report = withSecondResults(first, readReport());
75
+ }
76
+ report = withLoadFailuresKilled(report);
77
+ writeFileSync(REPORT, JSON.stringify(report));
78
+ // The page shows the report too: Stryker breaks each < apart, so that no text of the report ends the script.
79
+ if (existsSync(PAGE)) writeFileSync(PAGE, readFileSync(PAGE, "utf8").replace(PAGE_REPORT, (_, start) => `${start}${JSON.stringify(report).replaceAll("<", '<"+"')};`));
80
+ console.log("");
81
+ return summary(["--survivors"], { breakAt: await breakOf(config) }) || status;
82
+ } finally {
83
+ rmSync(temporary, { recursive: true, force: true });
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Runs Stryker. Ctrl+C and `caits check` stop the whole group of processes: Stryker and its test runners stop with this command.
89
+ * @param {string[]} args
90
+ * @param {string} temporary the temporary folder of the tests
91
+ * @returns {Promise<number>}
92
+ */
93
+ function stryker(args, temporary) {
94
+ const running = spawn("npx", ["stryker", "run", ...args], { stdio: "inherit", env: { ...process.env, TMPDIR: temporary } });
95
+ return new Promise(resolve => {
96
+ running.on("error", () => resolve(1));
97
+ running.on("close", code => resolve(code ?? 1));
25
98
  });
26
- if (!existsSync(REPORT)) return status;
27
- console.log("");
28
- return summary(["--survivors"]) || status;
99
+ }
100
+
101
+ /** @type {(file: string) => unknown} */
102
+ const readJson = file => JSON.parse(readFileSync(file, "utf8"));
103
+
104
+ /** @returns {Report} */
105
+ const readReport = () => /** @type {Report} */ (readJson(REPORT));
106
+
107
+ /** The clock stopped the mutant: Stryker gives a reason only when its counter stops it. @type {(mutant: Mutant) => boolean} */
108
+ const stoppedByTheClock = mutant => mutant.status === "Timeout" && !mutant.statusReason?.startsWith("Hit limit reached");
109
+
110
+ /** @type {(file: string, mutant: Mutant) => string} */
111
+ const keyOf = (file, { mutatorName, replacement, location: { start, end } }) => [file, start.line, start.column, end.line, end.column, mutatorName, replacement].join(" ");
112
+
113
+ /**
114
+ * The first report, with the results of the second run for the mutants that the clock stopped. The second run tests the whole lines of
115
+ * these mutants: its other mutants keep their first result.
116
+ * @param {Report} first
117
+ * @param {Report} second
118
+ * @returns {Report}
119
+ */
120
+ function withSecondResults(first, second) {
121
+ const again = new Map(Object.entries(second.files).flatMap(([file, { mutants }]) => mutants.map(mutant => [keyOf(file, mutant), mutant])));
122
+ const files = Object.fromEntries(Object.entries(first.files).map(([file, data]) => [file, {
123
+ ...data,
124
+ mutants: data.mutants.map(mutant => {
125
+ const result = stoppedByTheClock(mutant) ? again.get(keyOf(file, mutant)) : undefined;
126
+ return result === undefined ? mutant : { ...mutant, status: result.status, statusReason: result.statusReason };
127
+ })
128
+ }]));
129
+ return { ...first, files };
130
+ }
131
+
132
+ /**
133
+ * The test files of the mutant failed to load, and nothing else: each line of its reason is a test file that failed to load. An error
134
+ * after the tests, or a message on more than one line, has a line that is not one, and the mutant stays not judged.
135
+ * @type {(mutant: Mutant) => boolean}
136
+ */
137
+ const failedToLoad = ({ status, statusReason }) => status === "RuntimeError" && typeof statusReason === "string" && statusReason.startsWith(OUTSIDE)
138
+ && statusReason.slice(OUTSIDE.length).split("\n").every(line => LOAD_FAILURE.test(line));
139
+
140
+ /**
141
+ * The report, where a mutant whose test files failed to load is killed.
142
+ * @param {Report} report
143
+ * @returns {Report}
144
+ */
145
+ function withLoadFailuresKilled(report) {
146
+ const files = Object.fromEntries(Object.entries(report.files).map(([file, data]) => [file, {
147
+ ...data,
148
+ mutants: data.mutants.map(mutant => failedToLoad(mutant) ? { ...mutant, status: "Killed", statusReason: `The test files that import its code failed to load. ${mutant.statusReason}` } : mutant)
149
+ }]));
150
+ return { ...report, files };
151
+ }
152
+
153
+ /**
154
+ * The threshold `break` of the configuration: the score under which Stryker fails, none by default.
155
+ * @param {string} config
156
+ * @returns {Promise<number | null>}
157
+ */
158
+ async function breakOf(config) {
159
+ const module = config.endsWith(".json") ? { default: readJson(config) } : /** @type {{ default: unknown }} */ (await import(pathToFileURL(resolve(config)).href));
160
+ return /** @type {{ thresholds?: { break?: number | null } }} */ (module.default).thresholds?.break ?? null;
161
+ }
162
+
163
+ /** Removes the temporary folders whose run no longer runs: Ctrl+C and `caits check` stop a run before its end. */
164
+ function removeOrphans() {
165
+ for (const entry of readdirSync(tmpdir())) {
166
+ const pid = TEMPORARY.exec(entry)?.[1];
167
+ if (pid === undefined || runs(Number(pid))) continue;
168
+ try {
169
+ rmSync(join(tmpdir(), entry), { recursive: true, force: true });
170
+ } catch {
171
+ // The folder of another user.
172
+ }
173
+ }
174
+ }
175
+
176
+ /** @param {number} pid */
177
+ function runs(pid) {
178
+ try {
179
+ process.kill(pid, 0);
180
+ return true;
181
+ } catch (error) {
182
+ // EPERM: the process runs, but belongs to another user.
183
+ return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM";
184
+ }
29
185
  }
package/src/adapter.js ADDED
@@ -0,0 +1,94 @@
1
+ // The adapter of @marko/run for a CAITS app: quality/caits/vite.ts gives it to the plugin of @marko/run. JavaScript, not TypeScript: Node
2
+ // loads it with the configuration of Vite, and strips no types in node_modules, where a project installs the kernel.
3
+ // - `npm run build` builds the server of `npm start` from server/entry.ts: it boots the backend, and stops it on SIGTERM or SIGINT.
4
+ // - `caits dev` serves the app with the dev server of @marko/run, in a server of its own (server/serve.js): it boots the backend through
5
+ // Vite, boots it again when a module that the backend imports changes, and stops it when it closes.
6
+ import { fileURLToPath } from "node:url";
7
+ import baseAdapter, { createDevServer } from "@marko/run/adapter";
8
+ import { serve } from "./server/serve.js";
9
+
10
+ /** @import { Adapter } from "@marko/run/vite" */
11
+ /** @import { ModuleNode, ViteDevServer } from "vite" */
12
+
13
+ const ENTRY = fileURLToPath(new URL("./server/entry.ts", import.meta.url));
14
+ const KERNEL = fileURLToPath(new URL("./server/kernel.ts", import.meta.url));
15
+
16
+ /**
17
+ * The adapter of @marko/run that serves a CAITS app: that of Node, whose server boots the backend and stops it.
18
+ * @returns {Adapter}
19
+ */
20
+ export function adapter() {
21
+ return {
22
+ ...baseAdapter(),
23
+ name: "caits",
24
+ getEntryFile: () => ENTRY,
25
+ startDev: async ({ config, options }) => {
26
+ const vite = await createDevServer(config);
27
+ /** @type {Awaited<ReturnType<typeof serve>>} */
28
+ let served;
29
+ try {
30
+ served = await serve({
31
+ handle: vite.middlewares,
32
+ port: options.port ?? 3000,
33
+ boot: async () => /** @type {typeof import("./server/kernel.js")} */ (await vite.ssrLoadModule(KERNEL)).boot()
34
+ });
35
+ } catch (error) {
36
+ // A first boot that fails, or a port in use: the server of Vite and its watcher would keep the command alive, and nothing would
37
+ // answer. The command ends, with the error.
38
+ await vite.close();
39
+ throw error;
40
+ }
41
+ vite.watcher.on("change", file => {
42
+ if (!imports(vite, KERNEL, file)) return;
43
+ forget(vite, file);
44
+ void served.reboot();
45
+ });
46
+ // The words of the server of @marko/run: who waits for the server reads them.
47
+ console.log(`\n Server listening at\n http://localhost:${served.port}\n`);
48
+ return {
49
+ port: served.port,
50
+ close: async () => {
51
+ await served.close();
52
+ await vite.close();
53
+ }
54
+ };
55
+ }
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Whether a module that Vite loaded imports a file, itself or through the modules that it imports.
61
+ * @param {ViteDevServer} vite
62
+ * @param {string} id
63
+ * @param {string} file
64
+ */
65
+ function imports(vite, id, file) {
66
+ /** @type {Set<ModuleNode>} */
67
+ const seen = new Set();
68
+ /** @type {(module: ModuleNode | undefined) => boolean} */
69
+ const visit = module => {
70
+ if (!module || seen.has(module)) return false;
71
+ seen.add(module);
72
+ return module.file === file || [...module.importedModules].some(visit);
73
+ };
74
+ return visit(vite.moduleGraph.getModuleById(id));
75
+ }
76
+
77
+ /**
78
+ * Makes Vite forget a file that changed, and the modules that import it: the backend that boots again loads their new code. Vite would
79
+ * forget them itself, but after the watchers of the project see the change.
80
+ * @param {ViteDevServer} vite
81
+ * @param {string} file
82
+ */
83
+ function forget(vite, file) {
84
+ /** @type {Set<ModuleNode>} */
85
+ const seen = new Set();
86
+ /** @type {(module: ModuleNode) => void} */
87
+ const visit = module => {
88
+ if (seen.has(module)) return;
89
+ seen.add(module);
90
+ vite.moduleGraph.invalidateModule(module);
91
+ for (const importer of module.importers) visit(importer);
92
+ };
93
+ for (const module of vite.moduleGraph.getModulesByFile(file) ?? []) visit(module);
94
+ }