@ingenioz-it/caits 0.6.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 (57) 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 +39 -11
  6. package/quality/architecture/project.mjs +8 -6
  7. package/quality/dev.mjs +6 -3
  8. package/quality/mutation-summary.mjs +30 -20
  9. package/quality/mutation.mjs +170 -14
  10. package/src/adapter.js +94 -0
  11. package/src/di/Container.ts +27 -0
  12. package/src/di/RequestContext.ts +8 -2
  13. package/src/di/backend.ts +12 -0
  14. package/src/di/createBackend.ts +197 -25
  15. package/src/di/declareDependencies.js +25 -5
  16. package/src/di/discover.ts +19 -9
  17. package/src/di/discoverFrontends.ts +4 -8
  18. package/src/di/discovered.ts +2 -2
  19. package/src/di/discoveredFrontends.ts +2 -2
  20. package/src/messaging/BackendEventBus.ts +2 -3
  21. package/src/messaging/ContextEvents.ts +11 -0
  22. package/src/messaging/ContextMap.ts +3 -4
  23. package/src/messaging/Controller.ts +36 -75
  24. package/src/messaging/EventSubscriber.ts +1 -1
  25. package/src/messaging/InProcessMessageBus.ts +8 -9
  26. package/src/messaging/MessageBus.ts +4 -2
  27. package/src/messaging/Request.ts +116 -0
  28. package/src/messaging/RequestEvents.ts +8 -0
  29. package/src/messaging/StreamController.ts +261 -0
  30. package/src/messaging/TimerController.ts +25 -0
  31. package/src/messaging/sse.ts +23 -0
  32. package/src/server/entry.ts +37 -0
  33. package/src/server/kernel.ts +19 -0
  34. package/src/server/packages.d.ts +12 -0
  35. package/src/server/serve.js +121 -0
  36. package/src/state/createStore.ts +1 -2
  37. package/src/testing/backend/application.ts +1 -1
  38. package/src/testing/backend/applicationScenario.ts +6 -7
  39. package/src/testing/backend/applications.ts +4 -1
  40. package/src/testing/backend/context-map.ts +1 -1
  41. package/src/testing/backend/integration.ts +1 -1
  42. package/src/testing/backend/presentationApplication.ts +23 -3
  43. package/src/testing/backend/presentationScenario.ts +84 -7
  44. package/src/testing/frontend/applicationScenario.ts +2 -3
  45. package/src/testing/frontend/applications.ts +3 -3
  46. package/src/testing/frontend/frontends.ts +3 -4
  47. package/src/testing/shared/FakeMessageBus.ts +39 -5
  48. package/src/virtual-modules.d.ts +0 -2
  49. package/src/vite.js +2 -2
  50. package/src/vite.types.d.ts +7 -0
  51. package/template/caits/architecture.mjs +162 -113
  52. package/template/caits/imports.mjs +2 -2
  53. package/template/caits/stryker.mjs +11 -6
  54. package/template/caits/vite.ts +15 -5
  55. package/template/project/AGENTS +1 -1
  56. package/quality/mutation-suite.mjs +0 -82
  57. package/src/messaging/Input.ts +0 -4
@@ -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,5 +1,4 @@
1
1
  import { expect } from "vitest";
2
- import type { Input } from "../../messaging/Input.js";
3
2
  import type { TestApplication } from "./applications.js";
4
3
 
5
4
  /**
@@ -10,7 +9,7 @@ import type { TestApplication } from "./applications.js";
10
9
  export class ApplicationScenario implements PromiseLike<void> {
11
10
  private readonly expected: object[] = [];
12
11
 
13
- constructor(private readonly app: TestApplication, private readonly dto: Input<void>) {}
12
+ constructor(private readonly app: TestApplication, private readonly dto: object) {}
14
13
 
15
14
  expectItToPublish(event: object): this {
16
15
  this.expected.push(event);
@@ -32,5 +31,5 @@ export class ApplicationScenario implements PromiseLike<void> {
32
31
  }
33
32
 
34
33
  export const applicationScenario = (app: TestApplication) => ({
35
- givenTheDto: (dto: Input<void>) => new ApplicationScenario(app, dto)
34
+ givenTheDto: (dto: object) => new ApplicationScenario(app, dto)
36
35
  });
@@ -4,7 +4,7 @@ import type { Binding } from "../../di/Container.js";
4
4
  import { createFrontendWithBus } from "../../di/createFrontend.js";
5
5
  import { discoveredFrontends } from "../../di/discoveredFrontends.js";
6
6
  import type { Fetcher } from "../../di/Fetcher.js";
7
- import type { Input } from "../../messaging/Input.js";
7
+ import { UnknownInputError } from "../../messaging/InProcessMessageBus.js";
8
8
  import type { Token } from "../../Token.js";
9
9
 
10
10
  /** The test.di.ts of each context that has one: the bindings that replace, in tests, those that reach outside the browser. */
@@ -37,7 +37,7 @@ export function createTestApplication() {
37
37
 
38
38
  const ownerOf = (input: object): string => {
39
39
  const name = Object.keys(discoveredFrontends).find(context => discoveredFrontends[context].useCases.some(({ input: type }) => type === input.constructor));
40
- if (name === undefined) throw new Error(`No context handles ${input.constructor.name}`);
40
+ if (name === undefined) throw new UnknownInputError(input.constructor.name);
41
41
  return name;
42
42
  };
43
43
 
@@ -50,7 +50,7 @@ export function createTestApplication() {
50
50
 
51
51
  return {
52
52
  /** Sends an input DTO to the message bus of the context that handles it. */
53
- send: <Output>(input: Input<Output>): Promise<Output> => frontend(ownerOf(input)).bus.send(input),
53
+ send: (input: object): Promise<unknown> => frontend(ownerOf(input)).bus.send(input),
54
54
  /** The dependency with this token, built now if no handler needed it before. */
55
55
  get: <T>(token: Token<T>): T => {
56
56
  const found = holders(token);
@@ -1,5 +1,4 @@
1
1
  import { FrontendEventBus } from "../../messaging/FrontendEventBus.js";
2
- import type { Input } from "../../messaging/Input.js";
3
2
  import { createStore, type StoreDefinition } from "../../state/createStore.js";
4
3
  import type { EventPublisher } from "../../messaging/EventPublisher.js";
5
4
  import { discoveredFrontends } from "../../di/discoveredFrontends.js";
@@ -9,7 +8,7 @@ type EventOf<F> = F extends { events: EventPublisher<infer E> } ? E : never;
9
8
  type StoreOf<F> = F extends { store: infer S } ? S : undefined;
10
9
 
11
10
  /** The presentation of a context in a test: its frontend, the DTOs that its store sent, and the events that it published. */
12
- export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: StoreOf<F>; sent: Input<unknown>[]; published: EventOf<F>[] };
11
+ export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: StoreOf<F>; sent: object[]; published: EventOf<F>[] };
13
12
 
14
13
  /**
15
14
  * The presentation of a context, as its views and its components get it: its EventPublisher and its store (none, for a context
@@ -19,11 +18,11 @@ export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: S
19
18
  */
20
19
  export function testPresentation<Name extends keyof Frontends>(name: Name): TestPresentation<Frontends[Name]> {
21
20
  const events = new FrontendEventBus<EventOf<Frontends[Name]>>();
22
- const sent: Input<unknown>[] = [];
21
+ const sent: object[] = [];
23
22
  const published: EventOf<Frontends[Name]>[] = [];
24
23
  events.subscribe(event => { published.push(event); });
25
24
  const definition = discoveredFrontends[name].store as StoreDefinition<unknown, EventOf<Frontends[Name]>> | undefined;
26
- const store = definition && createStore(definition, { events, bus: { send: <Output>(input: Input<Output>) => { sent.push(input); return Promise.resolve(undefined as Output); } } });
25
+ const store = definition && createStore(definition, { events, bus: { send: (input: object) => { sent.push(input); return Promise.resolve(undefined); } } });
27
26
  return { events, store, sent, published } as unknown as TestPresentation<Frontends[Name]>;
28
27
  }
29
28
 
@@ -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}.`);
@@ -4,7 +4,6 @@ declare module "virtual:kernel/backend" {
4
4
  import type { Translation } from "@ingenioz-it/caits/messaging/ContextMap";
5
5
  type Modules = Record<string, Record<string, unknown>>;
6
6
  export const bindings: Record<string, readonly Binding[]>;
7
- export const dtos: Modules;
8
7
  export const handlers: Modules;
9
8
  export const controllers: Modules;
10
9
  export const maps: Record<string, Translation[]>;
@@ -21,7 +20,6 @@ declare module "virtual:kernel/frontend" {
21
20
  import type { StoreDefinition } from "@ingenioz-it/caits/state/createStore";
22
21
  type Modules = Record<string, Record<string, unknown>>;
23
22
  export const bindings: Record<string, readonly Binding[]>;
24
- export const dtos: Modules;
25
23
  export const handlers: Modules;
26
24
  export const stores: Record<string, StoreDefinition<unknown, unknown>>;
27
25
  export const maps: Record<string, readonly Line[]>;
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
 
@@ -58,7 +60,6 @@ function dependencies(source) {
58
60
  const CONTEXT_MODULES = {
59
61
  "virtual:kernel/backend": [
60
62
  ["bindings", "backend/*/di.ts", true],
61
- ["dtos", "backend/*/{command,query}/*/*.dto.ts"],
62
63
  ["handlers", "backend/*/{command,query}/*/*.handler.ts"],
63
64
  ["controllers", "backend/*/{command,query}/*/*.controller.ts"],
64
65
  ["maps", "backend/*/context-map.ts", true]
@@ -66,7 +67,6 @@ const CONTEXT_MODULES = {
66
67
  "virtual:kernel/backend-test": [["bindings", "backend/*/test.di.ts", true]],
67
68
  "virtual:kernel/frontend": [
68
69
  ["bindings", "frontend/*/di.ts", true],
69
- ["dtos", "frontend/*/{command,query}/*/*.dto.ts"],
70
70
  ["handlers", "frontend/*/{command,query}/*/*.handler.ts"],
71
71
  ["stores", "frontend/*/presentation/store/*Store.ts", true],
72
72
  ["maps", "frontend/*/context-map.ts", true]
@@ -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;