@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.
- package/bin/caits.js +20 -7
- package/bin/init.js +16 -1
- package/package.json +8 -5
- package/quality/architecture/adapters/scss.mjs +2 -2
- package/quality/architecture/facts.mjs +14 -6
- package/quality/dev.mjs +6 -3
- package/quality/mutation-summary.mjs +30 -20
- package/quality/mutation.mjs +170 -14
- package/src/adapter.js +94 -0
- package/src/di/Container.ts +27 -0
- package/src/di/RequestContext.ts +8 -2
- package/src/di/backend.ts +12 -0
- package/src/di/createBackend.ts +189 -19
- package/src/messaging/ContextEvents.ts +11 -0
- package/src/messaging/Controller.ts +35 -73
- package/src/messaging/EventSubscriber.ts +1 -1
- package/src/messaging/InProcessMessageBus.ts +6 -5
- package/src/messaging/MessageBus.ts +4 -1
- package/src/messaging/Request.ts +116 -0
- package/src/messaging/RequestEvents.ts +8 -0
- package/src/messaging/StreamController.ts +261 -0
- package/src/messaging/TimerController.ts +25 -0
- package/src/messaging/sse.ts +23 -0
- package/src/server/entry.ts +37 -0
- package/src/server/kernel.ts +19 -0
- package/src/server/packages.d.ts +12 -0
- package/src/server/serve.js +121 -0
- package/src/testing/backend/applications.ts +4 -1
- package/src/testing/backend/presentationApplication.ts +23 -3
- package/src/testing/backend/presentationScenario.ts +84 -7
- package/src/testing/shared/FakeMessageBus.ts +39 -5
- package/src/vite.js +2 -0
- package/src/vite.types.d.ts +7 -0
- package/template/caits/architecture.mjs +26 -7
- package/template/caits/imports.mjs +2 -2
- package/template/caits/stryker.mjs +11 -6
- package/template/caits/vite.ts +13 -5
- 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),
|
|
28
|
-
code is that of Stryker, or 1 when a mutant
|
|
29
|
-
|
|
30
|
-
|
|
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"
|
|
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
|
-
|
|
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",
|
|
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.
|
|
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": "
|
|
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
|
-
"
|
|
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.
|
|
104
|
-
"@stryker-mutator/typescript-checker": "^10.
|
|
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: [],
|
|
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
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
|
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
|
-
* @
|
|
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,
|
|
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
|
|
35
|
-
const killed = count("Killed")
|
|
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 +
|
|
39
|
-
killedTotal += killed; validTotal += valid;
|
|
40
|
-
rows.push({ file, killed, survived, noCoverage,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)
|
|
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 (
|
|
56
|
-
|
|
57
|
-
|
|
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
|
}
|
package/quality/mutation.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|