@ingenioz-it/caits 0.0.0-stage → 0.5.1
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/LICENSE +5 -0
- package/README.md +36 -2
- package/bin/caits.js +186 -0
- package/bin/init.js +359 -0
- package/package.json +114 -4
- package/quality/architecture/adapters/marko.mjs +23 -0
- package/quality/architecture/adapters/scss.mjs +52 -0
- package/quality/architecture/adapters/typescript.mjs +13 -0
- package/quality/architecture/facts.mjs +159 -0
- package/quality/architecture/project.mjs +148 -0
- package/quality/architecture.mjs +116 -0
- package/quality/check.mjs +104 -0
- package/quality/dev.mjs +110 -0
- package/quality/e2e.mjs +125 -0
- package/quality/mutation-suite.mjs +31 -0
- package/quality/mutation-summary.mjs +41 -0
- package/quality/mutation.mjs +30 -0
- package/quality/route-types.mjs +40 -0
- package/quality/test-docs/page.mjs +301 -0
- package/quality/test-docs/playwright-reporter.mjs +55 -0
- package/quality/test-docs/test-docs.mjs +180 -0
- package/quality/test-docs/vitest-reporter.mjs +38 -0
- package/src/DomainEvent.ts +4 -0
- package/src/Result.ts +6 -0
- package/src/Token.ts +8 -0
- package/src/di/Container.ts +123 -0
- package/src/di/DataDirectory.ts +6 -0
- package/src/di/Fetcher.ts +6 -0
- package/src/di/RequestContext.ts +6 -0
- package/src/di/backend.ts +19 -0
- package/src/di/createBackend.ts +106 -0
- package/src/di/createFrontend.ts +53 -0
- package/src/di/declareDependencies.js +93 -0
- package/src/di/discover.ts +39 -0
- package/src/di/discoverFrontends.ts +36 -0
- package/src/di/discovered.ts +6 -0
- package/src/di/discoveredFrontends.ts +6 -0
- package/src/di/frontend.ts +33 -0
- package/src/http/failureOf.ts +10 -0
- package/src/messaging/BackendEventBus.ts +60 -0
- package/src/messaging/ContextMap.ts +22 -0
- package/src/messaging/Controller.ts +109 -0
- package/src/messaging/EventPublisher.ts +12 -0
- package/src/messaging/EventSubscriber.ts +4 -0
- package/src/messaging/FrontendEventBus.ts +38 -0
- package/src/messaging/InProcessMessageBus.ts +35 -0
- package/src/messaging/Input.ts +4 -0
- package/src/messaging/MessageBus.ts +12 -0
- package/src/messaging/ReportError.ts +9 -0
- package/src/state/Loadable.ts +13 -0
- package/src/state/createStore.ts +45 -0
- package/src/state/follow.ts +7 -0
- package/src/state/request.ts +21 -0
- package/src/testing/backend/application.ts +12 -0
- package/src/testing/backend/applicationScenario.ts +60 -0
- package/src/testing/backend/applications.ts +62 -0
- package/src/testing/backend/context-map.ts +12 -0
- package/src/testing/backend/infra.ts +6 -0
- package/src/testing/backend/integration.ts +8 -0
- package/src/testing/backend/presentation.ts +14 -0
- package/src/testing/backend/presentationApplication.ts +25 -0
- package/src/testing/backend/presentationScenario.ts +47 -0
- package/src/testing/frontend/application.ts +12 -0
- package/src/testing/frontend/applicationScenario.ts +36 -0
- package/src/testing/frontend/applications.ts +70 -0
- package/src/testing/frontend/frontends.ts +28 -0
- package/src/testing/frontend/infra.ts +2 -0
- package/src/testing/frontend/marko.ts +12 -0
- package/src/testing/frontend/presentation.ts +7 -0
- package/src/testing/shared/FakeMessageBus.ts +58 -0
- package/src/testing/shared/tempDirectories.ts +15 -0
- package/src/virtual-modules.d.ts +33 -0
- package/src/vite.js +162 -0
- package/src/vite.types.d.ts +14 -0
- package/template/caits/architecture.mjs +467 -0
- package/template/caits/eslint.js +32 -0
- package/template/caits/marko-modules.d.ts +4 -0
- package/template/caits/playwright.ts +35 -0
- package/template/caits/stryker.mjs +20 -0
- package/template/caits/tsconfig.json +21 -0
- package/template/caits/tsconfig.mutation.json +5 -0
- package/template/caits/vite.ts +34 -0
- package/template/empty/src/routes/_frontend/+page.marko +9 -0
- package/template/empty/test/e2e/home.spec.ts +6 -0
- package/template/project/AGENTS +28 -0
- package/template/project/gitignore +16 -0
- package/template/project/nvmrc +1 -0
- package/template/project/quality/architecture.mjs +9 -0
- package/template/project/quality/eslint.config.js +6 -0
- package/template/project/quality/playwright.config.ts +10 -0
- package/template/project/quality/stryker.config.mjs +6 -0
- package/template/project/quality/vite.config.ts +5 -0
- package/template/project/tsconfig.json +4 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `caits check <script>…`: runs the npm scripts of the checks at once, then tells which pass and which fail. It shows the output of the
|
|
2
|
+
// checks that fail, and only theirs: a check that passes takes one line, for the developer and for an AI agent, who reads less.
|
|
3
|
+
// The builds of Marko and the starts of Vitest write their files in the build folder: the checks that run at once would empty the
|
|
4
|
+
// folder of each other. Each check gets a folder of its own in CAITS_OUT_DIR, which quality/caits/vite.ts reads. The end-to-end tests
|
|
5
|
+
// build in dist/ all the same: `npm start` runs dist/index.mjs.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { constants } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const OUT_DIRS = "node_modules/.caits/check";
|
|
12
|
+
|
|
13
|
+
/** @type {(file: string) => unknown} */
|
|
14
|
+
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
|
|
15
|
+
|
|
16
|
+
/** @param {number} milliseconds */
|
|
17
|
+
const durationOf = milliseconds => {
|
|
18
|
+
const seconds = Math.round(milliseconds / 1000);
|
|
19
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** @param {string[]} args the npm scripts of the checks */
|
|
23
|
+
export async function run(args) {
|
|
24
|
+
return check(process.cwd(), args);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runs each npm script of `scripts` in the project of `root`, all at once, each in a group of processes of its own: the end of the
|
|
29
|
+
* command, or Ctrl+C, stops the servers and the workers that a check starts. `print` gets each line of the report. Gives the exit code:
|
|
30
|
+
* 0 when all the checks pass.
|
|
31
|
+
* @param {string} root
|
|
32
|
+
* @param {string[]} scripts
|
|
33
|
+
* @param {(line: string) => void} [print]
|
|
34
|
+
* @returns {Promise<number>}
|
|
35
|
+
*/
|
|
36
|
+
export async function check(root, scripts, print = line => console.log(line)) {
|
|
37
|
+
if (scripts.length === 0) {
|
|
38
|
+
print("Give the npm scripts of the checks: caits check typecheck lint test");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
// npm says nothing of a missing script when it runs silent: the command names them, and runs nothing.
|
|
42
|
+
const manifest = join(root, "package.json");
|
|
43
|
+
const known = existsSync(manifest) ? /** @type {{ scripts?: Record<string, string> }} */ (readJson(manifest)).scripts ?? {} : {};
|
|
44
|
+
const missing = scripts.filter(script => !Object.hasOwn(known, script));
|
|
45
|
+
if (missing.length > 0) {
|
|
46
|
+
print(`package.json has no script ${missing.join(", ")}.`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const width = Math.max(...scripts.map(script => script.length));
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
print(`${scripts.length} checks at once: ${scripts.join(", ")}`);
|
|
52
|
+
|
|
53
|
+
const steps = scripts.map(script => start(root, script));
|
|
54
|
+
const stop = () => { for (const step of steps) step.stop(); };
|
|
55
|
+
/** @param {NodeJS.Signals} signal Ctrl+C (SIGINT) exits with 130, SIGTERM with 143, as a shell tells a command that a signal stops. */
|
|
56
|
+
const interrupt = signal => { stop(); process.exit(128 + constants.signals[signal]); };
|
|
57
|
+
process.once("SIGINT", interrupt);
|
|
58
|
+
process.once("SIGTERM", interrupt);
|
|
59
|
+
try {
|
|
60
|
+
const results = await Promise.all(steps.map(async step => {
|
|
61
|
+
const result = await step.done;
|
|
62
|
+
print(`${result.passed ? "✓" : "✗"} ${step.script.padEnd(width)} ${durationOf(result.duration)}`);
|
|
63
|
+
return { script: step.script, ...result };
|
|
64
|
+
}));
|
|
65
|
+
const failed = results.filter(result => !result.passed);
|
|
66
|
+
for (const { script, output } of failed) print(`\n── ${script} ──\n${output.trimEnd()}`);
|
|
67
|
+
const total = durationOf(Date.now() - started);
|
|
68
|
+
print(failed.length === 0
|
|
69
|
+
? `\nAll ${results.length} checks passed in ${total}.`
|
|
70
|
+
: `\n${failed.length} of ${results.length} checks failed: ${failed.map(({ script }) => script).join(", ")}.`);
|
|
71
|
+
return failed.length === 0 ? 0 : 1;
|
|
72
|
+
} finally {
|
|
73
|
+
process.off("SIGINT", interrupt);
|
|
74
|
+
process.off("SIGTERM", interrupt);
|
|
75
|
+
stop();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Starts `npm run <script>`, with its build folder, and keeps its output.
|
|
81
|
+
* @param {string} root
|
|
82
|
+
* @param {string} script
|
|
83
|
+
*/
|
|
84
|
+
function start(root, script) {
|
|
85
|
+
const began = Date.now();
|
|
86
|
+
const env = { ...process.env, CAITS_OUT_DIR: join(OUT_DIRS, script.replaceAll(":", "-")) };
|
|
87
|
+
const child = spawn("npm", ["run", "--silent", script], { cwd: root, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
88
|
+
let output = "";
|
|
89
|
+
child.stdout.on("data", chunk => { output += chunk; });
|
|
90
|
+
child.stderr.on("data", chunk => { output += chunk; });
|
|
91
|
+
/** @type {Promise<{ passed: boolean; duration: number; output: string }>} */
|
|
92
|
+
const done = new Promise(resolve => {
|
|
93
|
+
child.on("error", error => resolve({ passed: false, duration: Date.now() - began, output: `${output}${error.message}\n` }));
|
|
94
|
+
child.on("close", code => resolve({ passed: code === 0, duration: Date.now() - began, output }));
|
|
95
|
+
});
|
|
96
|
+
const stop = () => {
|
|
97
|
+
try {
|
|
98
|
+
if (child.pid) process.kill(-child.pid);
|
|
99
|
+
} catch {
|
|
100
|
+
// The group stopped already.
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
return { script, done, stop };
|
|
104
|
+
}
|
package/quality/dev.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `caits dev`: starts the development server, as `marko-run dev` does, on the port PORT (3000 when not set).
|
|
2
|
+
// Before it starts, `marko-run dev` connects to the port of the server and to the port of HMR to find out whether they are free, and
|
|
3
|
+
// it waits for an answer with no time limit. With WSL `networkingMode=mirrored`, a connection to a closed port of 127.0.0.1 gets no
|
|
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
|
+
// (24678 when not set). Two projects can run their servers at the same time, each with its two ports.
|
|
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.
|
|
8
|
+
// @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
|
+
// 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
|
+
// starts with a dot: the folder is in node_modules/, as the cache of Vite.
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import adapter from "@marko/run/adapter";
|
|
13
|
+
import { loadConfigFromFile } from "vite";
|
|
14
|
+
|
|
15
|
+
export const VITE_CONFIG = "quality/vite.config.ts";
|
|
16
|
+
export const OUT_DIR = "node_modules/.caits/dev";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* When a file of its configuration changes (quality/vite.config.ts, an .env file…), Vite restarts its server: it makes a new one, and
|
|
20
|
+
* keeps only the middlewares of Vite. @marko/run adds the middleware of the pages after Vite makes the server: the new server answered
|
|
21
|
+
* 404 to each page. Thus Vite asks `caits dev` for the restart, and `caits dev` closes the server of @marko/run, then starts a new one:
|
|
22
|
+
* - when the configuration does not load (an error in the file), the server that runs stays: Vite asks again at the next change;
|
|
23
|
+
* - a change that comes during a restart makes one more restart, after it: the new server reads the configuration after the change;
|
|
24
|
+
* - when the new server cannot start, the command stops, and tells why.
|
|
25
|
+
* @param {string[]} args the arguments of the server
|
|
26
|
+
*/
|
|
27
|
+
export async function run(args) {
|
|
28
|
+
if (!existsSync(VITE_CONFIG)) throw new Error(`${VITE_CONFIG} is missing: it gives the configuration of Vite. Run \`npx caits init .\` to write it.`);
|
|
29
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
30
|
+
const hmrPort = Number(process.env.HMR_PORT ?? 24678);
|
|
31
|
+
const marko = adapter();
|
|
32
|
+
// The adapter of Node, which @marko/run gives, has the two methods.
|
|
33
|
+
const getEntryFile = marko.getEntryFile?.bind(marko);
|
|
34
|
+
const startDev = marko.startDev?.bind(marko);
|
|
35
|
+
if (!getEntryFile || !startDev) throw new Error(`The adapter ${marko.name} of @marko/run cannot start a development server.`);
|
|
36
|
+
const entry = await getEntryFile();
|
|
37
|
+
/** @type {import("@marko/run/adapter").SpawnedServer} the server of @marko/run */
|
|
38
|
+
let running;
|
|
39
|
+
/** @type {import("vite").ViteDevServer} its Vite server */
|
|
40
|
+
let vite;
|
|
41
|
+
/** @type {Promise<void> | undefined} */
|
|
42
|
+
let restarting;
|
|
43
|
+
let changedAgain = false;
|
|
44
|
+
|
|
45
|
+
const start = async () => {
|
|
46
|
+
running = await startDev({
|
|
47
|
+
entry,
|
|
48
|
+
config: {
|
|
49
|
+
root: process.cwd(),
|
|
50
|
+
configFile: VITE_CONFIG,
|
|
51
|
+
build: { outDir: OUT_DIR },
|
|
52
|
+
server: { hmr: { port: hmrPort } },
|
|
53
|
+
plugins: [restartsTakenBy(server => {
|
|
54
|
+
vite = server;
|
|
55
|
+
server.restart = restart;
|
|
56
|
+
})]
|
|
57
|
+
},
|
|
58
|
+
options: { cwd: process.cwd(), args, port }
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const restartOnce = async () => {
|
|
63
|
+
if (!(await configurationLoads())) return;
|
|
64
|
+
await running.close();
|
|
65
|
+
// @marko/run waits half a second for the close of Vite. The new server needs the port of HMR: it waits for the end of the close.
|
|
66
|
+
await vite.close();
|
|
67
|
+
await start();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const restart = () => {
|
|
71
|
+
if (restarting) {
|
|
72
|
+
changedAgain = true;
|
|
73
|
+
return restarting;
|
|
74
|
+
}
|
|
75
|
+
restarting = (async () => {
|
|
76
|
+
do {
|
|
77
|
+
changedAgain = false;
|
|
78
|
+
await restartOnce();
|
|
79
|
+
} while (changedAgain);
|
|
80
|
+
})().catch(error => {
|
|
81
|
+
console.error(`\nThe development server stopped: ${error instanceof Error ? error.message : error}\nCorrect the error, then start the server again: npm run dev`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}).finally(() => {
|
|
84
|
+
restarting = undefined;
|
|
85
|
+
});
|
|
86
|
+
return restarting;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
await start();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether the Vite configuration of the project loads. When it does not, says why. */
|
|
93
|
+
async function configurationLoads() {
|
|
94
|
+
try {
|
|
95
|
+
await loadConfigFromFile({ command: "serve", mode: "development" }, VITE_CONFIG, process.cwd(), "silent");
|
|
96
|
+
return true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`\n${VITE_CONFIG} does not load: the server keeps the configuration that it has.\n${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A plugin that gives the Vite server to `caits dev`, which takes its restarts (see run).
|
|
105
|
+
* @param {(server: import("vite").ViteDevServer) => void} take
|
|
106
|
+
* @returns {import("vite").Plugin}
|
|
107
|
+
*/
|
|
108
|
+
function restartsTakenBy(take) {
|
|
109
|
+
return { name: "caits-dev-restart", apply: "serve", configureServer: take };
|
|
110
|
+
}
|
package/quality/e2e.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `caits e2e`: runs the end-to-end tests of quality/playwright.config.ts on the production build.
|
|
2
|
+
// Playwright can start the servers itself (webServer), but it first connects to its address to find out whether a server runs there.
|
|
3
|
+
// With WSL `networkingMode=mirrored`, a connection to a closed port of 127.0.0.1 gets no answer, so Playwright waits forever. This
|
|
4
|
+
// command starts each server of webServer itself, waits with a time limit until it answers, then lets Playwright use them.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import { constants } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
export const PLAYWRIGHT_CONFIG = "quality/playwright.config.ts";
|
|
13
|
+
|
|
14
|
+
/** How long a server has to answer, when its webServer gives no timeout, in milliseconds. */
|
|
15
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
16
|
+
|
|
17
|
+
/** @typedef {{ command: string; url: string; cwd?: string; env?: Record<string, string>; timeout: number }} WebServer */
|
|
18
|
+
|
|
19
|
+
/** @type {(file: string) => Promise<unknown>} */
|
|
20
|
+
const importFile = file => import(pathToFileURL(file).href);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stops a process that runs in a group of processes of its own, and the processes that it started.
|
|
24
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
25
|
+
*/
|
|
26
|
+
function stopGroup(child) {
|
|
27
|
+
try {
|
|
28
|
+
if (child.pid) process.kill(-child.pid);
|
|
29
|
+
} catch {
|
|
30
|
+
// The group stopped already.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Starts the servers, empties the folder of the data of each (DATA_DIR) first, then runs Playwright. The end of the command stops the
|
|
36
|
+
* servers, also when Ctrl+C (SIGINT) or `caits check` (SIGTERM) stops it: the exit code then tells the signal, 130 or 143.
|
|
37
|
+
* @param {string[]} args the arguments of Playwright
|
|
38
|
+
* @returns {Promise<number>}
|
|
39
|
+
*/
|
|
40
|
+
export async function run(args) {
|
|
41
|
+
const file = join(process.cwd(), PLAYWRIGHT_CONFIG);
|
|
42
|
+
if (!existsSync(file)) throw new Error(`${PLAYWRIGHT_CONFIG} is missing: it gives the end-to-end tests and their server. Run \`npx caits init .\` to write it.`);
|
|
43
|
+
const { default: config } = /** @type {{ default: { webServer?: Partial<WebServer> | Partial<WebServer>[] } }} */ (await importFile(file));
|
|
44
|
+
const webServers = [config.webServer ?? []].flat();
|
|
45
|
+
|
|
46
|
+
/** @type {{ stop: () => void }[]} */
|
|
47
|
+
const servers = [];
|
|
48
|
+
/** @type {import("node:child_process").ChildProcess | undefined} */
|
|
49
|
+
let playwright;
|
|
50
|
+
const stop = () => {
|
|
51
|
+
if (playwright) stopGroup(playwright);
|
|
52
|
+
for (const server of servers) server.stop();
|
|
53
|
+
};
|
|
54
|
+
/** @param {NodeJS.Signals} signal */
|
|
55
|
+
const interrupt = signal => {
|
|
56
|
+
stop();
|
|
57
|
+
process.exit(128 + constants.signals[signal]);
|
|
58
|
+
};
|
|
59
|
+
process.once("SIGINT", interrupt);
|
|
60
|
+
process.once("SIGTERM", interrupt);
|
|
61
|
+
try {
|
|
62
|
+
for (const { command, url, cwd, env = {}, timeout = DEFAULT_TIMEOUT } of webServers) {
|
|
63
|
+
if (!command || !url) throw new Error(`each webServer of ${PLAYWRIGHT_CONFIG} must give a command and a url`);
|
|
64
|
+
if (env.DATA_DIR) rmSync(env.DATA_DIR, { recursive: true, force: true });
|
|
65
|
+
servers.push(await startServer({ command, url, cwd, env, timeout }));
|
|
66
|
+
}
|
|
67
|
+
// Playwright runs in a group of processes of its own too, with its browsers: `stop` stops them all. It reads nothing from the terminal.
|
|
68
|
+
const env = { ...process.env, E2E_REUSE_SERVER: "1" };
|
|
69
|
+
const started = spawn("npx", ["playwright", "test", "--config", PLAYWRIGHT_CONFIG, ...args], { stdio: ["ignore", "inherit", "inherit"], detached: true, env });
|
|
70
|
+
playwright = started;
|
|
71
|
+
return await new Promise(resolve => {
|
|
72
|
+
started.on("error", () => resolve(1));
|
|
73
|
+
started.on("close", code => resolve(code ?? 1));
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
process.off("SIGINT", interrupt);
|
|
77
|
+
process.off("SIGTERM", interrupt);
|
|
78
|
+
stop();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Runs the command of a server in a group of processes of its own, so that `stop` also stops the processes that it starts, then waits
|
|
84
|
+
* until the server answers at its URL, for `timeout` milliseconds at most. The port of the URL must be free: else the tests would run on
|
|
85
|
+
* the server that uses it, with the build of another project, or of an older run.
|
|
86
|
+
* @param {WebServer} webServer
|
|
87
|
+
* @returns {Promise<{ stop: () => void }>}
|
|
88
|
+
*/
|
|
89
|
+
export async function startServer({ command, url, cwd, env = {}, timeout }) {
|
|
90
|
+
const { hostname, port, protocol } = new URL(url);
|
|
91
|
+
const portNumber = Number(port) || (protocol === "https:" ? 443 : 80);
|
|
92
|
+
if (!(await isFree(hostname.replace(/^\[|\]$/g, ""), portNumber))) {
|
|
93
|
+
throw new Error(`Port ${portNumber} is in use: a server runs at ${url} already. Stop it, then run the end-to-end tests again.`);
|
|
94
|
+
}
|
|
95
|
+
const server = spawn(command, { cwd, shell: true, stdio: "inherit", detached: true, env: { ...process.env, ...env } });
|
|
96
|
+
const stop = () => stopGroup(server);
|
|
97
|
+
process.on("exit", stop);
|
|
98
|
+
for (const end = Date.now() + timeout; Date.now() < end;) {
|
|
99
|
+
if (server.exitCode !== null) throw new Error(`The server stopped (${command})`);
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(500) });
|
|
102
|
+
if (response.ok) return { stop };
|
|
103
|
+
} catch {
|
|
104
|
+
// The server does not answer yet.
|
|
105
|
+
}
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
107
|
+
}
|
|
108
|
+
stop();
|
|
109
|
+
throw new Error(`The server did not answer at ${url} in ${timeout / 1000} s (${command})`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a server can listen on a port. A listen fails at once when the port is in use: a connection to a closed port can wait forever
|
|
114
|
+
* (see above). Another error (a port that only the system can use) does not tell that a server runs there.
|
|
115
|
+
* @param {string} hostname
|
|
116
|
+
* @param {number} port
|
|
117
|
+
* @returns {Promise<boolean>}
|
|
118
|
+
*/
|
|
119
|
+
function isFree(hostname, port) {
|
|
120
|
+
return new Promise(resolve => {
|
|
121
|
+
const probe = createServer();
|
|
122
|
+
probe.once("error", (/** @type {NodeJS.ErrnoException} */ error) => resolve(error.code !== "EADDRINUSE"));
|
|
123
|
+
probe.listen(port, hostname, () => probe.close(() => resolve(true)));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `caits mutation-suite`: the command that Stryker runs for each mutant. It runs the unit tests from a private copy of the sandbox.
|
|
2
|
+
// Stryker runs many suites at once in a single sandbox, and every Vitest start rewrites the files that the Marko plugin generates
|
|
3
|
+
// (dist/, .marko-run/): suites that run at the same time would fail at random, and a failed run counts as a killed mutant.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
6
|
+
import { constants, tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const ENTRIES = ["src", "test", "quality", "dist", ".marko-run", "package.json", "tsconfig.json"];
|
|
10
|
+
|
|
11
|
+
/** @param {string[]} args the options of Vitest, which Stryker gives */
|
|
12
|
+
export async function run(args) {
|
|
13
|
+
const sandbox = process.cwd();
|
|
14
|
+
const copy = mkdtempSync(join(tmpdir(), "caits-mutation-"));
|
|
15
|
+
const remove = () => rmSync(copy, { recursive: true, force: true });
|
|
16
|
+
for (const entry of ENTRIES) {
|
|
17
|
+
if (existsSync(join(sandbox, entry))) cpSync(join(sandbox, entry), join(copy, entry), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
symlinkSync(join(sandbox, "node_modules"), join(copy, "node_modules"));
|
|
20
|
+
|
|
21
|
+
// The suite builds in the dist/ of its copy: the build folder that `caits check` gives (CAITS_OUT_DIR) is in node_modules/, which the
|
|
22
|
+
// suites share.
|
|
23
|
+
const { CAITS_OUT_DIR: _, ...env } = process.env;
|
|
24
|
+
const suite = spawn("npx", ["vitest", "run", "--config", "quality/vite.config.ts", "--reporter=dot", ...args], { cwd: copy, env, stdio: "inherit" });
|
|
25
|
+
// Stryker stops a suite that takes too long: the copy goes away with it. The exit code tells the signal: 130 for SIGINT, 143 for SIGTERM.
|
|
26
|
+
for (const signal of /** @type {const} */ (["SIGINT", "SIGTERM"])) process.on(signal, () => { suite.kill(signal); remove(); process.exit(128 + constants.signals[signal]); });
|
|
27
|
+
/** @type {number} */
|
|
28
|
+
const status = await new Promise(resolve => suite.on("close", code => resolve(code ?? 1)));
|
|
29
|
+
remove();
|
|
30
|
+
return status;
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// `caits mutation-summary`: the mutation score of each file, from the report of Stryker. With --survivors, the mutants that survived.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/** The report that Stryker writes in JSON (quality/caits/stryker.mjs). */
|
|
5
|
+
export const REPORT = "reports/mutation/mutation.json";
|
|
6
|
+
|
|
7
|
+
/** @typedef {{ status: string; mutatorName: string; replacement?: string; location: { start: { line: number } } }} Mutant */
|
|
8
|
+
|
|
9
|
+
/** @type {(file: string) => unknown} */
|
|
10
|
+
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string[]} args
|
|
14
|
+
* @returns {number}
|
|
15
|
+
*/
|
|
16
|
+
export function run(args) {
|
|
17
|
+
if (!existsSync(REPORT)) throw new Error(`${REPORT} is missing: run the mutation tests first (npm run test:mutation).`);
|
|
18
|
+
const report = /** @type {{ files: Record<string, { source: string; mutants: Mutant[] }> }} */ (readJson(REPORT));
|
|
19
|
+
const rows = [];
|
|
20
|
+
let killedTotal = 0, validTotal = 0;
|
|
21
|
+
for (const [file, data] of Object.entries(report.files)) {
|
|
22
|
+
/** @type {(status: string) => number} */
|
|
23
|
+
const count = status => data.mutants.filter(m => m.status === status).length;
|
|
24
|
+
const killed = count("Killed") + count("Timeout");
|
|
25
|
+
const survived = count("Survived");
|
|
26
|
+
const noCoverage = count("NoCoverage");
|
|
27
|
+
const valid = killed + survived + noCoverage;
|
|
28
|
+
killedTotal += killed; validTotal += valid;
|
|
29
|
+
rows.push({ file, killed, survived, noCoverage, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage"), source: data.source.split("\n") });
|
|
30
|
+
}
|
|
31
|
+
rows.sort((a, b) => a.score - b.score);
|
|
32
|
+
const verbose = args.includes("--survivors");
|
|
33
|
+
console.log(`Mutation score: ${(validTotal ? (100 * killedTotal) / validTotal : 100).toFixed(1)}% (${killedTotal} killed of ${validTotal} valid mutants)\n`);
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
console.log(`${row.score.toFixed(0).padStart(4)}% ${row.file} (killed ${row.killed}, survived ${row.survived}, no coverage ${row.noCoverage})`);
|
|
36
|
+
if (verbose && row.survivors.length) {
|
|
37
|
+
for (const m of row.survivors) console.log(` L${m.location.start.line} ${m.status === "NoCoverage" ? "[no cov]" : "[survived]"} ${m.mutatorName}: ${(row.source[m.location.start.line - 1] ?? "").trim().slice(0, 90)} → ${String(m.replacement ?? "").slice(0, 60)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// `caits mutation`: the mutation tests (Stryker, with quality/stryker.config.mjs), then the score of each file and the mutants that
|
|
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
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { REPORT, run as summary } from "./mutation-summary.mjs";
|
|
6
|
+
|
|
7
|
+
/** The configuration of Stryker in a project: it imports the defaults of CAITS (quality/caits/stryker.mjs). */
|
|
8
|
+
export const STRYKER_CONFIG = "quality/stryker.config.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string[]} args the options of Stryker
|
|
12
|
+
* @returns {Promise<number>} the exit code of Stryker
|
|
13
|
+
*/
|
|
14
|
+
export async function run(args) {
|
|
15
|
+
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.`);
|
|
16
|
+
// A run that stops before its report must not show the summary of the run before.
|
|
17
|
+
rmSync(REPORT, { force: true });
|
|
18
|
+
// Ctrl+C and `caits check` stop the whole group of processes: Stryker and its test runners stop with this command.
|
|
19
|
+
const stryker = spawn("npx", ["stryker", "run", STRYKER_CONFIG, ...args], { stdio: "inherit" });
|
|
20
|
+
/** @type {number} */
|
|
21
|
+
const status = await new Promise(resolve => {
|
|
22
|
+
stryker.on("error", () => resolve(1));
|
|
23
|
+
stryker.on("close", code => resolve(code ?? 1));
|
|
24
|
+
});
|
|
25
|
+
if (existsSync(REPORT)) {
|
|
26
|
+
console.log("");
|
|
27
|
+
summary(["--survivors"]);
|
|
28
|
+
}
|
|
29
|
+
return status;
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The types of the routes: @marko/run writes them in .marko-run/routes.d.ts each time it reads the routes (a build, a start of Vitest or
|
|
2
|
+
// of the development server). The lint reads them: they give the global `Run` of the handlers. Git ignores .marko-run/, so a new project
|
|
3
|
+
// or a new clone has no types until one of these commands runs: the ESLint configuration of CAITS (quality/caits/eslint.js) writes them
|
|
4
|
+
// first, with this function.
|
|
5
|
+
import { rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createServer } from "vite";
|
|
8
|
+
|
|
9
|
+
export const VITE_CONFIG = "quality/vite.config.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @marko/run writes the entry files of the routes in the build folder: each run has one of its own, as `caits dev` has, which it removes
|
|
13
|
+
* at its end. Two lints can run at once: one in the editor, one in the terminal.
|
|
14
|
+
*/
|
|
15
|
+
export const OUT_DIR = "node_modules/.caits/route-types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Writes the types of the routes of the project of `root`: starts Vite with the configuration of the project, without a server, a
|
|
19
|
+
* watcher or HMR, and loads the router of @marko/run, which writes them. It takes less than a second.
|
|
20
|
+
* @param {string} root
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
export async function writeRouteTypes(root) {
|
|
24
|
+
const outDir = join(OUT_DIR, String(process.pid));
|
|
25
|
+
const vite = await createServer({
|
|
26
|
+
root,
|
|
27
|
+
configFile: join(root, VITE_CONFIG),
|
|
28
|
+
logLevel: "error",
|
|
29
|
+
appType: "custom",
|
|
30
|
+
build: { outDir },
|
|
31
|
+
server: { middlewareMode: true, hmr: false, ws: false, watch: null },
|
|
32
|
+
optimizeDeps: { noDiscovery: true }
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
await vite.environments.ssr.transformRequest("@marko/run/router");
|
|
36
|
+
} finally {
|
|
37
|
+
await vite.close();
|
|
38
|
+
rmSync(join(root, outDir), { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|