@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
@@ -1,21 +1,41 @@
1
1
  /// <reference path="../../virtual-modules.d.ts" />
2
2
  import "virtual:marko-run/__marko-run__router.js";
3
3
  import { fetch } from "@marko/run/router";
4
- import { startBackend } from "../../di/backend.js";
4
+ import { controller, startBackend } from "../../di/backend.js";
5
5
  import { bind } from "../../di/Container.js";
6
+ import type { DomainEvent } from "../../DomainEvent.js";
7
+ import { ContextEvents } from "../../messaging/ContextEvents.js";
6
8
  import { MessageBus } from "../../messaging/MessageBus.js";
9
+ import { RequestEvents } from "../../messaging/RequestEvents.js";
10
+ import type { TimerController } from "../../messaging/TimerController.js";
7
11
  import { FakeMessageBus } from "../shared/FakeMessageBus.js";
8
12
 
9
13
  /** The real routes and controllers, over a bus that only records: each test says what the application answers. */
10
14
  export function startPresentationApplication() {
11
- const messageBus = new FakeMessageBus();
15
+ // The events that the application publishes, as the test says: the controllers that stream hear them, of their request and of their context.
16
+ const listeners = new Set<(event: DomainEvent) => void>();
17
+ // They never end: a feed ends when the client of the test leaves.
18
+ const events: ContextEvents = {
19
+ subscribe: listener => {
20
+ listeners.add(listener);
21
+ return () => { listeners.delete(listener); };
22
+ },
23
+ signal: new AbortController().signal
24
+ };
25
+ const messageBus = new FakeMessageBus(event => { for (const listener of [...listeners]) listener(event); });
12
26
  const reportedErrors: unknown[] = [];
13
27
  const reportError = (error: unknown) => { reportedErrors.push(error); };
14
- startBackend({ reportError, replace: { shared: [bind(MessageBus).toValue(messageBus)] } });
28
+ startBackend({ reportError, replace: { shared: [bind(MessageBus).toValue(messageBus), bind(RequestEvents).toValue(events), bind(ContextEvents).toValue(events)] } });
15
29
 
16
30
  return {
17
31
  messageBus,
18
32
  reportedErrors,
33
+ /** Runs one turn of a timer controller, as the kernel does while the server runs, in a request of its own. Gives its rhythm. */
34
+ async tick(timer: abstract new (...args: never[]) => TimerController<object>): Promise<number> {
35
+ const turn = controller(timer);
36
+ await turn.tick();
37
+ return turn.every;
38
+ },
19
39
  async fetch(request: Request): Promise<Response> {
20
40
  const response = await fetch(request, {} as never);
21
41
  if (!response) throw new Error(`No route answers ${request.method} ${new URL(request.url).pathname}`);
@@ -1,19 +1,39 @@
1
1
  import { expect } from "vitest";
2
+ import type { TimerController } from "../../messaging/TimerController.js";
2
3
  import type { startPresentationApplication } from "./presentationApplication.js";
3
4
 
4
5
  type PresentationApplication = ReturnType<typeof startPresentationApplication>;
5
6
 
7
+ /** The body of a request, and its type: a form, a text as it is, or JSON. */
8
+ function bodyOf(body: unknown): RequestInit {
9
+ if (body === undefined) return {};
10
+ // A form, which fetch sends with its type: multipart/form-data for a FormData, application/x-www-form-urlencoded for URLSearchParams.
11
+ if (body instanceof FormData || body instanceof URLSearchParams) return { body };
12
+ return { body: typeof body === "string" ? body : JSON.stringify(body), headers: { "content-type": "application/json" } };
13
+ }
14
+
6
15
  export class PresentationScenario {
7
16
  private request: Request | undefined;
17
+ /** The client of the request: it leaves when the test has read the start of a stream. */
18
+ private readonly client = new AbortController();
19
+ private timer: (abstract new (...args: never[]) => TimerController<object>) | undefined;
8
20
 
9
21
  constructor(private readonly app: PresentationApplication) {}
10
22
 
11
- /** A request with a body sends it as JSON: a string is the text of the body as it is (`"{not json"`), any other value its JSON. */
12
- givenTheRequest(method: string, path: string, body?: unknown): this {
13
- this.request = new Request(`http://localhost${path}`, {
14
- method,
15
- ...(body === undefined ? {} : { body: typeof body === "string" ? body : JSON.stringify(body), headers: { "content-type": "application/json" } })
16
- });
23
+ /**
24
+ * A request with a body sends it as JSON: a string is the text of the body as it is (`"{not json"`), any other value its JSON. A
25
+ * FormData is sent as a form, multipart/form-data, with its files, and URLSearchParams as a form of URL encoding. The headers go with
26
+ * it, over the type that its body gives.
27
+ */
28
+ givenTheRequest(method: string, path: string, body?: unknown, headers: Readonly<Record<string, string>> = {}): this {
29
+ const sent = bodyOf(body);
30
+ this.request = new Request(`http://localhost${path}`, { method, signal: this.client.signal, ...sent, headers: { ...sent.headers as Record<string, string> | undefined, ...headers } });
31
+ return this;
32
+ }
33
+
34
+ /** The timer controller whose turn the test runs, instead of a request. */
35
+ givenTheTimer(timer: abstract new (...args: never[]) => TimerController<object>): this {
36
+ this.timer = timer;
17
37
  return this;
18
38
  }
19
39
 
@@ -27,12 +47,69 @@ export class PresentationScenario {
27
47
  return this;
28
48
  }
29
49
 
50
+ /** The events that the application publishes when it receives the last message, before it answers: a stream writes them. */
51
+ givenTheApplicationPublishes(...events: object[]): this {
52
+ this.app.messageBus.publishTheLast(events);
53
+ return this;
54
+ }
55
+
56
+ /** The application never answers the last message: its handler keeps running, while a stream writes what it published. */
57
+ givenTheApplicationKeepsRunning(): this {
58
+ this.app.messageBus.keepTheLastRunning();
59
+ return this;
60
+ }
61
+
30
62
  givenTheApplicationFails(error: Error): this {
31
63
  this.app.messageBus.failTheLast(error);
32
64
  return this;
33
65
  }
34
66
 
35
- async expectTheResponseToBe(status: number, body?: unknown, headers: Record<string, string> = {}): Promise<void> {
67
+ /** Runs one turn of the timer: it runs every `every` milliseconds, sends what the test expects, and reports no error. */
68
+ async expectTheTurnToRunEvery(every: number): Promise<void> {
69
+ if (this.timer === undefined) throw new Error("PresentationScenario: call givenTheTimer(...) before expectTheTurnToRunEvery(...).");
70
+ expect(await this.app.tick(this.timer)).toBe(every);
71
+ expect(this.app.reportedErrors).toEqual([]);
72
+ this.app.messageBus.verify();
73
+ }
74
+
75
+ /** Reads the whole stream of the response: its status, its headers (null for a header that it does not have), and its text. */
76
+ async expectTheStreamToBe(status: number, text: string, headers: Record<string, string | null> = {}): Promise<void> {
77
+ const response = await this.stream(status, headers);
78
+ expect(await response.text()).toBe(text);
79
+ this.app.messageBus.verify();
80
+ }
81
+
82
+ /**
83
+ * Reads the start of the stream of the response, as long as the text, then the client leaves: the test of a feed, or of a client that
84
+ * leaves. The signal that went with each DTO aborts then: the handler sees the client leave.
85
+ */
86
+ async expectTheStreamToStartWith(status: number, text: string, headers: Record<string, string | null> = {}): Promise<void> {
87
+ const reader = (await this.stream(status, headers)).body!.getReader();
88
+ const decoder = new TextDecoder();
89
+ let read = "";
90
+ while (read.length < text.length) {
91
+ const chunk = await reader.read();
92
+ if (chunk.done) break;
93
+ read += decoder.decode(chunk.value, { stream: true });
94
+ }
95
+ expect(read.slice(0, text.length)).toBe(text);
96
+ this.client.abort();
97
+ await reader.cancel();
98
+ await new Promise(resolve => { setTimeout(resolve, 0); });
99
+ this.app.messageBus.verify();
100
+ this.app.messageBus.verifyLeft();
101
+ }
102
+
103
+ private async stream(status: number, headers: Record<string, string | null>): Promise<Response> {
104
+ if (this.request === undefined) throw new Error("PresentationScenario: call givenTheRequest(...) before reading its stream.");
105
+ const response = await this.app.fetch(this.request);
106
+ expect(response.status).toBe(status);
107
+ for (const [name, value] of Object.entries(headers)) expect(response.headers.get(name), `header ${name}`).toBe(value);
108
+ return response;
109
+ }
110
+
111
+ /** Reads the response: its status, its body, and its headers, null for a header that it does not have. */
112
+ async expectTheResponseToBe(status: number, body?: unknown, headers: Record<string, string | null> = {}): Promise<void> {
36
113
  if (this.request === undefined) throw new Error("PresentationScenario: call givenTheRequest(...) before expectTheResponseToBe(...).");
37
114
  const response = await this.app.fetch(this.request);
38
115
 
@@ -1,10 +1,15 @@
1
- import type { MessageBus } from "../../messaging/MessageBus.js";
1
+ import type { MessageBus, Sending } from "../../messaging/MessageBus.js";
2
2
 
3
- type Outcome = { reply: unknown } | { error: Error };
4
- type Expectation = { message: object; outcome: Outcome | undefined; received: boolean };
3
+ type Outcome = { reply: unknown } | { error: Error } | { pending: true };
4
+ /** A message that the test expects: what the application answers, whether it came, what it publishes, and the signal that went with it. */
5
+ type Expectation = { message: object; outcome: Outcome | undefined; received: boolean; publishes: readonly object[]; signal?: AbortSignal };
6
+
7
+ /** A matcher of Vitest, as `expect.any(String)` gives it: a field of an expected message that the test cannot know, an id drawn by chance. */
8
+ const isMatcher = (value: object): value is { asymmetricMatch(other: unknown): boolean } => typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function";
5
9
 
6
10
  function same(left: unknown, right: unknown): boolean {
7
11
  if (Object.is(left, right)) return true;
12
+ if (typeof left === "object" && left !== null && isMatcher(left)) return left.asymmetricMatch(right);
8
13
  if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
9
14
  if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
10
15
  if (left instanceof Date) return left.getTime() === (right as Date).getTime();
@@ -17,8 +22,21 @@ export class FakeMessageBus implements MessageBus {
17
22
  private expectations: Expectation[] = [];
18
23
  private unexpected: object[] = [];
19
24
 
25
+ /** What the application publishes when it receives a message: a test of a controller that streams gives where the events go. */
26
+ constructor(private readonly publish: (event: object) => void = () => {}) {}
27
+
20
28
  expect(message: object): void {
21
- this.expectations.push({ message, outcome: undefined, received: false });
29
+ this.expectations.push({ message, outcome: undefined, received: false, publishes: [] });
30
+ }
31
+
32
+ /** The events that the application publishes when it receives the last message, before it answers. */
33
+ publishTheLast(events: readonly object[]): void {
34
+ this.last("publishes").publishes = events;
35
+ }
36
+
37
+ /** The application never answers the last message: its handler keeps running. */
38
+ keepTheLastRunning(): void {
39
+ this.last("keeps running").outcome = { pending: true };
22
40
  }
23
41
 
24
42
  replyToTheLast(output: unknown): void {
@@ -29,7 +47,7 @@ export class FakeMessageBus implements MessageBus {
29
47
  this.last("fails").outcome = { error };
30
48
  }
31
49
 
32
- send(message: object): Promise<never> {
50
+ send(message: object, sending: Sending = {}): Promise<never> {
33
51
  const expected = this.expectations.find(candidate => !candidate.received && same(candidate.message, message));
34
52
  if (!expected) {
35
53
  this.unexpected.push(message);
@@ -37,7 +55,10 @@ export class FakeMessageBus implements MessageBus {
37
55
  return Promise.reject(new Error(`The application sent a message nobody expected: ${message.constructor.name} ${JSON.stringify(message)} (still expected: ${pending})`));
38
56
  }
39
57
  expected.received = true;
58
+ expected.signal = sending.signal;
59
+ for (const event of expected.publishes) this.publish(event);
40
60
  if (!expected.outcome) return Promise.reject(new Error(`Nothing is prepared for the application to be answered: ${message.constructor.name}`));
61
+ if ("pending" in expected.outcome) return new Promise(() => {});
41
62
  return "error" in expected.outcome ? Promise.reject(expected.outcome.error) : Promise.resolve(expected.outcome.reply as never);
42
63
  }
43
64
 
@@ -50,6 +71,19 @@ export class FakeMessageBus implements MessageBus {
50
71
  if (problems.length) throw new Error(problems.join("\n"));
51
72
  }
52
73
 
74
+ /**
75
+ * Once the client left: each message came with the signal of its request, which aborted. Without it, the handler would not see the
76
+ * client leave.
77
+ */
78
+ verifyLeft(): void {
79
+ const problems = this.expectations.filter(({ received }) => received).flatMap(({ message, signal }) => {
80
+ const name = `${message.constructor.name} ${JSON.stringify(message)}`;
81
+ if (!signal) return [`the application sent ${name} without the signal of its request: its handler cannot see the client leave`];
82
+ return signal.aborted ? [] : [`the signal that went with ${name} did not abort when the client left`];
83
+ });
84
+ if (problems.length) throw new Error(problems.join("\n"));
85
+ }
86
+
53
87
  private last(step: string): Expectation {
54
88
  const last = this.expectations.at(-1);
55
89
  if (!last) throw new Error(`Say which message is expected before saying how the application ${step}.`);
package/src/vite.js CHANGED
@@ -5,6 +5,8 @@ import MagicString from "magic-string";
5
5
  import { normalizePath, parseSync } from "vite";
6
6
  import { declareDependencies } from "./di/declareDependencies.js";
7
7
 
8
+ export { adapter } from "./adapter.js";
9
+
8
10
  /** @import { Plugin } from "vite" */
9
11
  /** @import { KernelOptions } from "./vite.types.js" */
10
12
 
@@ -1,3 +1,4 @@
1
+ import type { Adapter } from "@marko/run/vite";
1
2
  import type { Plugin } from "vite";
2
3
 
3
4
  /** The types of vite.js, for the projects: TypeScript reads no types from the JavaScript of a package. Keep them the same as vite.js. */
@@ -12,3 +13,9 @@ export declare function kernel(options?: KernelOptions): Plugin[];
12
13
 
13
14
  /** Makes @marko/run write the types of the routes whole: `caits check` runs the checks at once, and some of them read these types. */
14
15
  export declare function wholeRouteTypes(): Plugin;
16
+
17
+ /**
18
+ * The adapter of @marko/run for a CAITS app: the server of `npm start` (the entry that it gives to `npm run build`) and that of `caits dev`
19
+ * boot the backend, start its timers, and stop it on SIGTERM or SIGINT; `caits dev` boots it again when its code changes.
20
+ */
21
+ export declare function adapter(): Adapter;
@@ -162,6 +162,9 @@ function listedPackages(layer) {
162
162
  };
163
163
  }
164
164
 
165
+ /** The controllers of the kernel, by what triggers them, and their module: a request, a timer, a request that a stream answers. */
166
+ const KERNEL_CONTROLLERS = /** @type {const} */ ({ Controller: "messaging/Controller", TimerController: "messaging/TimerController", StreamController: "messaging/StreamController" });
167
+
165
168
  /** @type {Rule[]} */
166
169
  export const rules = [
167
170
  // Kernel: the package @ingenioz-it/caits builds the containers and the controllers
@@ -178,11 +181,11 @@ export const rules = [
178
181
  },
179
182
  {
180
183
  name: "controllers-built-by-kernel",
181
- description: "Only the kernel makes a controller, with its dependencies. The other code asks the kernel for a controller, or sends a request through the router.",
184
+ description: "Only the kernel makes a controller, with its dependencies. The other code asks the kernel for a controller, or sends a request through the router. The AbortController of the platform, which stops a fetch, is not a controller of the app.",
182
185
  on: "file",
183
186
  check: ({ path, constructed, lines }) => {
184
187
  const route = locate(path).kind === "routes";
185
- return constructed.flatMap((name, index) => name === null || !/^[A-Z]\w*Controller$/.test(name) ? [] : [at(lines.constructed[index],
188
+ return constructed.flatMap((name, index) => name === null || name === "AbortController" || !/^[A-Z]\w*Controller$/.test(name) ? [] : [at(lines.constructed[index],
186
189
  `This file builds ${name} with \`new\`, but only the kernel builds a controller, with its dependencies. ${route ? `Ask the kernel for it with \`controller(${name})\`, from @ingenioz-it/caits/di/backend.` : "Send the request through the router instead."}`)]);
187
190
  }
188
191
  },
@@ -461,21 +464,37 @@ export const rules = [
461
464
  },
462
465
  {
463
466
  name: "controller-extends-controller",
464
- description: "A controller is an exported class …Controller that extends the Controller of the kernel.",
467
+ description: "A controller is an exported class …Controller that extends a controller of the kernel: Controller for a request, TimerController for a timer, StreamController for a stream.",
465
468
  on: "file",
466
469
  check: ({ path, classes }) => {
470
+ const ofKernel = (/** @type {string | null | undefined} */ parent) => typeof parent === "string" && Object.hasOwn(KERNEL_CONTROLLERS, parent);
467
471
  if (!/^src\/backend\/[^/]+\/(?:command|query)\/[^/]+\/[^/]+\.controller\.ts$/.test(path)
468
- || classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Controller$/.test(name) && parent === "Controller")) return;
469
- const declared = classes.find(({ name }) => name !== null && /Controller$/.test(name)) ?? classes.find(({ extends: parent }) => parent === "Controller") ?? classes[0];
472
+ || classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Controller$/.test(name) && ofKernel(parent))) return;
473
+ const declared = classes.find(({ name }) => name !== null && /Controller$/.test(name)) ?? classes.find(({ extends: parent }) => ofKernel(parent)) ?? classes[0];
470
474
  const expected = declared?.name ? (/Controller$/.test(declared.name) ? declared.name : `${declared.name}Controller`) : "…Controller";
471
- const rule = `a controller is an exported class …Controller that extends the Controller of the kernel. Write \`export class ${expected} extends Controller\`, with Controller from @ingenioz-it/caits/messaging/Controller.`;
475
+ const base = /\.timer\.controller\.ts$/.test(path) ? "TimerController" : /\.stream\.controller\.ts$/.test(path) ? "StreamController" : "Controller";
476
+ const rule = `a controller is an exported class …Controller that extends a controller of the kernel: Controller for a request, TimerController for a timer, StreamController for a stream. Write \`export class ${expected} extends ${base}\`, with ${base} from @ingenioz-it/caits/${KERNEL_CONTROLLERS[base]}.`;
472
477
  if (!declared) return `${fileName(path)} holds no exported class …Controller, but ${rule}`;
473
478
  const name = declared.name ?? "Its class";
474
479
  if (!declared.exported) return at(declared.line, `${name} is not exported, but ${rule}`);
475
- if (declared.extends !== "Controller") return at(declared.line, `${name} extends ${declared.extends ?? "nothing"}, but ${rule}`);
480
+ if (!ofKernel(declared.extends)) return at(declared.line, `${name} extends ${declared.extends ?? "nothing"}, but ${rule}`);
476
481
  return at(declared.line, `${name} does not end with Controller, but ${rule}`);
477
482
  }
478
483
  },
484
+ {
485
+ name: "controllers-read-the-request-of-caits",
486
+ description: "A controller reads the Request of CAITS, from @ingenioz-it/caits/messaging/Request, which gives the content of the body at each call. The Request of the web, global, reads its body once only: a controller neither names it nor builds a request.",
487
+ on: "file",
488
+ check: ({ path, imported, typeReferences, constructed, lines }) => {
489
+ if (!/^src\/backend\/[^/]+\/(?:command|query)\/[^/]+\/[^/]+\.controller\.ts$/.test(path)) return;
490
+ const ofCaits = imported.some(({ name, from }) => name === "Request" && from === "@ingenioz-it/caits/messaging/Request");
491
+ const named = typeReferences.flatMap((name, index) => name === "globalThis.Request" || (name === "Request" && !ofCaits) ? [at(lines.typeReferences[index],
492
+ `This controller names the Request of the web, which reads its body once only, but a controller reads the Request of CAITS, which gives the content of the body at each call. ${ofCaits ? "Write `Request`, which the file imports from CAITS." : "Import it: `import type { Request } from \"@ingenioz-it/caits/messaging/Request\"`."}`)] : []);
493
+ const built = constructed.flatMap((name, index) => name !== "Request" ? [] : [at(lines.constructed[index],
494
+ "This controller builds a Request, but a controller reads the request that CAITS gives to its methods. Remove `new Request(…)`.")]);
495
+ return [...named, ...built];
496
+ }
497
+ },
479
498
 
480
499
  // Events of the frontend: what its views, its handlers and its context map publish
481
500
 
@@ -3,12 +3,12 @@
3
3
  * Node API (`node:crypto`), or a folder of modules, ending with `/*` (`@ingenioz-it/caits/messaging/*`). A layer without a list imports
4
4
  * any package. The imports of the files of the project (`#context/…`, `./…`) are not packages: the other rules check them.
5
5
  * The domain holds the business rules: it imports only the primitives of the kernel. The application adds what a use case needs: its
6
- * handler publishes events. The presentation, the infra, di.ts and the context map have no list.
6
+ * handler publishes events, and reads the context of its request (its correlation id, and the signal of its client). The presentation, the infra, di.ts and the context map have no list.
7
7
  * The project starts from these lists in its quality/imports.mjs.
8
8
  */
9
9
  const domain = ["@ingenioz-it/caits/Result", "@ingenioz-it/caits/Token", "@ingenioz-it/caits/DomainEvent"];
10
10
 
11
11
  export const allowed = {
12
12
  domain,
13
- application: [...domain, "@ingenioz-it/caits/messaging/EventPublisher"]
13
+ application: [...domain, "@ingenioz-it/caits/messaging/EventPublisher", "@ingenioz-it/caits/di/RequestContext"]
14
14
  };
@@ -1,7 +1,14 @@
1
- /** The mutation tests. Stryker runs `caits mutation-suite` for each mutant: the unit tests, in a private copy of its sandbox. */
1
+ /**
2
+ * The mutation tests. Stryker runs the unit tests in Vitest, which stays started from one mutant to the next: for each mutant, the tests
3
+ * that run its code. A counter stops the code of a mutant that loops without end.
4
+ */
2
5
  export default {
3
- testRunner: "command",
4
- commandRunner: { command: "caits mutation-suite --bail=1 --no-isolate --maxWorkers=1" },
6
+ testRunner: "vitest",
7
+ // The tests find the code through the discovery of CAITS too, not only through their imports: the first run runs them all, and tells
8
+ // which test runs which code. The pool of Vitest by default: a fork of Node for the tests.
9
+ vitest: { configFile: "quality/vite.config.ts", related: false, pool: "forks" },
10
+ // A new project has no test yet. As soon as there is code, its mutants have no coverage, and fail the check.
11
+ allowEmpty: true,
5
12
  checkers: ["typescript"],
6
13
  tsconfigFile: "quality/caits/tsconfig.mutation.json",
7
14
  mutate: ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.contract.ts", "!src/**/test.di.ts", "!src/frontend/*/di.ts"],
@@ -13,8 +20,6 @@ export default {
13
20
  thresholds: { high: 100, low: 100, break: 100 },
14
21
  concurrency: "100%",
15
22
  timeoutMS: 15000,
16
- coverageAnalysis: "off",
17
- // The builds stay out of the sandbox: each suite builds its own (`caits mutation-suite`), and under `caits check`, the end-to-end tests
18
- // empty dist/ while Stryker copies the project.
23
+ // The builds stay out of the sandbox: under `caits check`, the end-to-end tests empty dist/ while Stryker copies the project.
19
24
  ignorePatterns: ["reports", "test-results", "playwright-report", "dist", ".marko-run"]
20
25
  };
@@ -1,7 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import marko from "@marko/run/vite";
3
3
  import { defineConfig } from "vitest/config";
4
- import { kernel, wholeRouteTypes } from "@ingenioz-it/caits/vite";
4
+ import { adapter, kernel, wholeRouteTypes } from "@ingenioz-it/caits/vite";
5
5
 
6
6
  /**
7
7
  * The folder of the stylesheets of the app. Sass looks in it after the folder of a stylesheet: each stylesheet of the routes loads the
@@ -10,13 +10,21 @@ import { kernel, wholeRouteTypes } from "@ingenioz-it/caits/vite";
10
10
  */
11
11
  const STYLE_FOLDER = fileURLToPath(new URL("../../src/routes/_frontend/style", import.meta.url));
12
12
 
13
+ /**
14
+ * The build folder. `caits check` runs the checks at once: it gives each one a build folder of its own (CAITS_OUT_DIR), else they empty
15
+ * the folder of each other. So do the processes of the mutation tests, which Stryker numbers (STRYKER_MUTATOR_WORKER): the Marko plugin
16
+ * empties the entries of the routes in the build folder, then writes them again, as a test loads a route. Without either, the build goes
17
+ * in dist/, which `npm start` runs.
18
+ */
19
+ const worker = process.env.STRYKER_MUTATOR_WORKER;
20
+ const OUT_DIR = worker === undefined ? process.env.CAITS_OUT_DIR || "dist" : `node_modules/.caits/mutation/${worker}`;
21
+
13
22
  export default defineConfig({
14
- plugins: [wholeRouteTypes(), marko(), kernel()],
15
- // `caits check` runs the checks at once: it gives each one a build folder of its own (CAITS_OUT_DIR), else they empty the folder of
16
- // each other. Without it, the build goes in dist/, which `npm start` runs.
23
+ // The adapter of CAITS: the server of `npm start` and that of `caits dev` boot the backend, start its timers, and stop it on SIGTERM.
24
+ plugins: [wholeRouteTypes(), marko({ adapter: adapter() }), kernel()],
17
25
  // The build keeps the names of the classes: the errors of CAITS name them, and the build would else number a class that has the name
18
26
  // of another, as a DTO of the backend and one of the frontend (`No handler takes AddMessageCommand$1`).
19
- build: { outDir: process.env.CAITS_OUT_DIR || "dist", rolldownOptions: { output: { keepNames: true } } },
27
+ build: { outDir: OUT_DIR, rolldownOptions: { output: { keepNames: true } } },
20
28
  // The checks write their reports while the development server runs: a report is not a file of the app, and reloads no page. The
21
29
  // sandbox of the mutation tests (.stryker-tmp/) copies the project, its tsconfig.json too: the server would empty its cache at each
22
30
  // copy, and lose the dependencies that it optimized.
@@ -1,82 +0,0 @@
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
- // The copy shares the packages of the sandbox, not the caches that the tools keep beside them (node_modules/.vite/, .vitest-cache/…): the
5
- // cache of Vite depends on the folder of the project, so each suite would write it again while the others read it.
6
- import { spawn } from "node:child_process";
7
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "node:fs";
8
- import { constants, tmpdir } from "node:os";
9
- import { join } from "node:path";
10
- import { painterOf } from "./terminal.mjs";
11
-
12
- const ENTRIES = ["src", "test", "quality", "dist", ".marko-run", "package.json", "tsconfig.json"];
13
-
14
- /** The report of Vitest, in the copy: Vitest writes it once the tests ran, also when one fails. */
15
- const REPORT = "vitest-report.json";
16
-
17
- /**
18
- * What the suite prints when the unit tests did not run. Stryker counts the mutant as killed, as for any exit code but 0:
19
- * `caits mutation-summary` finds these words in the report of Stryker, and does not.
20
- */
21
- export const NOT_RUN = "The unit tests did not run";
22
-
23
- /** The copies, in the temporary folder: `caits-mutation-<pid of their suite>-<random>`. */
24
- const COPY = /^caits-mutation-(\d+)-/;
25
-
26
- /** @param {string[]} args the options of Vitest, which Stryker gives */
27
- export async function run(args) {
28
- removeOrphans();
29
- const sandbox = process.cwd();
30
- const copy = mkdtempSync(join(tmpdir(), `caits-mutation-${process.pid}-`));
31
- const remove = () => rmSync(copy, { recursive: true, force: true });
32
- for (const entry of ENTRIES) {
33
- if (existsSync(join(sandbox, entry))) cpSync(join(sandbox, entry), join(copy, entry), { recursive: true });
34
- }
35
- // The packages and their commands (.bin/). The other entries that start with a dot are the caches and the files of the tools.
36
- mkdirSync(join(copy, "node_modules"));
37
- for (const entry of readdirSync(join(sandbox, "node_modules"))) {
38
- if (!entry.startsWith(".") || entry === ".bin") symlinkSync(join(sandbox, "node_modules", entry), join(copy, "node_modules", entry));
39
- }
40
-
41
- // 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
42
- // suites share.
43
- const { CAITS_OUT_DIR: _, ...env } = process.env;
44
- const vitest = ["vitest", "run", "--config", "quality/vite.config.ts", "--reporter=dot", "--reporter=json", `--outputFile.json=${REPORT}`, ...args];
45
- const suite = spawn("npx", vitest, { cwd: copy, env, stdio: "inherit" });
46
- // Ctrl+C and `caits check` stop the suite: the copy goes away with it. The exit code tells the signal: 130 for SIGINT, 143 for SIGTERM.
47
- for (const signal of /** @type {const} */ (["SIGINT", "SIGTERM"])) process.on(signal, () => { suite.kill(signal); remove(); process.exit(128 + constants.signals[signal]); });
48
- /** @type {number} */
49
- const status = await new Promise(resolve => suite.on("close", code => resolve(code ?? 1)));
50
- const ran = existsSync(join(copy, REPORT));
51
- remove();
52
- // Vitest stopped before the tests, on an error of its own or of the system: no test failed, and the mutant is not tested.
53
- if (status !== 0 && !ran) console.error(painterOf(process.stderr)("red", `${NOT_RUN}: Vitest stopped before them (exit ${status}). The mutant is not tested.`));
54
- return status;
55
- }
56
-
57
- /**
58
- * Stryker stops a suite that takes too long with SIGKILL, which no code can follow: its copy stays. Each suite removes the copies whose
59
- * suite no longer runs.
60
- */
61
- function removeOrphans() {
62
- for (const entry of readdirSync(tmpdir())) {
63
- const pid = COPY.exec(entry)?.[1];
64
- if (pid === undefined || runs(Number(pid))) continue;
65
- try {
66
- rmSync(join(tmpdir(), entry), { recursive: true, force: true });
67
- } catch {
68
- // The copy of another user.
69
- }
70
- }
71
- }
72
-
73
- /** @param {number} pid */
74
- function runs(pid) {
75
- try {
76
- process.kill(pid, 0);
77
- return true;
78
- } catch (error) {
79
- // EPERM: the process runs, but belongs to another user.
80
- return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM";
81
- }
82
- }