@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.
Files changed (93) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +36 -2
  3. package/bin/caits.js +186 -0
  4. package/bin/init.js +359 -0
  5. package/package.json +114 -4
  6. package/quality/architecture/adapters/marko.mjs +23 -0
  7. package/quality/architecture/adapters/scss.mjs +52 -0
  8. package/quality/architecture/adapters/typescript.mjs +13 -0
  9. package/quality/architecture/facts.mjs +159 -0
  10. package/quality/architecture/project.mjs +148 -0
  11. package/quality/architecture.mjs +116 -0
  12. package/quality/check.mjs +104 -0
  13. package/quality/dev.mjs +110 -0
  14. package/quality/e2e.mjs +125 -0
  15. package/quality/mutation-suite.mjs +31 -0
  16. package/quality/mutation-summary.mjs +41 -0
  17. package/quality/mutation.mjs +30 -0
  18. package/quality/route-types.mjs +40 -0
  19. package/quality/test-docs/page.mjs +301 -0
  20. package/quality/test-docs/playwright-reporter.mjs +55 -0
  21. package/quality/test-docs/test-docs.mjs +180 -0
  22. package/quality/test-docs/vitest-reporter.mjs +38 -0
  23. package/src/DomainEvent.ts +4 -0
  24. package/src/Result.ts +6 -0
  25. package/src/Token.ts +8 -0
  26. package/src/di/Container.ts +123 -0
  27. package/src/di/DataDirectory.ts +6 -0
  28. package/src/di/Fetcher.ts +6 -0
  29. package/src/di/RequestContext.ts +6 -0
  30. package/src/di/backend.ts +19 -0
  31. package/src/di/createBackend.ts +106 -0
  32. package/src/di/createFrontend.ts +53 -0
  33. package/src/di/declareDependencies.js +93 -0
  34. package/src/di/discover.ts +39 -0
  35. package/src/di/discoverFrontends.ts +36 -0
  36. package/src/di/discovered.ts +6 -0
  37. package/src/di/discoveredFrontends.ts +6 -0
  38. package/src/di/frontend.ts +33 -0
  39. package/src/http/failureOf.ts +10 -0
  40. package/src/messaging/BackendEventBus.ts +60 -0
  41. package/src/messaging/ContextMap.ts +22 -0
  42. package/src/messaging/Controller.ts +109 -0
  43. package/src/messaging/EventPublisher.ts +12 -0
  44. package/src/messaging/EventSubscriber.ts +4 -0
  45. package/src/messaging/FrontendEventBus.ts +38 -0
  46. package/src/messaging/InProcessMessageBus.ts +35 -0
  47. package/src/messaging/Input.ts +4 -0
  48. package/src/messaging/MessageBus.ts +12 -0
  49. package/src/messaging/ReportError.ts +9 -0
  50. package/src/state/Loadable.ts +13 -0
  51. package/src/state/createStore.ts +45 -0
  52. package/src/state/follow.ts +7 -0
  53. package/src/state/request.ts +21 -0
  54. package/src/testing/backend/application.ts +12 -0
  55. package/src/testing/backend/applicationScenario.ts +60 -0
  56. package/src/testing/backend/applications.ts +62 -0
  57. package/src/testing/backend/context-map.ts +12 -0
  58. package/src/testing/backend/infra.ts +6 -0
  59. package/src/testing/backend/integration.ts +8 -0
  60. package/src/testing/backend/presentation.ts +14 -0
  61. package/src/testing/backend/presentationApplication.ts +25 -0
  62. package/src/testing/backend/presentationScenario.ts +47 -0
  63. package/src/testing/frontend/application.ts +12 -0
  64. package/src/testing/frontend/applicationScenario.ts +36 -0
  65. package/src/testing/frontend/applications.ts +70 -0
  66. package/src/testing/frontend/frontends.ts +28 -0
  67. package/src/testing/frontend/infra.ts +2 -0
  68. package/src/testing/frontend/marko.ts +12 -0
  69. package/src/testing/frontend/presentation.ts +7 -0
  70. package/src/testing/shared/FakeMessageBus.ts +58 -0
  71. package/src/testing/shared/tempDirectories.ts +15 -0
  72. package/src/virtual-modules.d.ts +33 -0
  73. package/src/vite.js +162 -0
  74. package/src/vite.types.d.ts +14 -0
  75. package/template/caits/architecture.mjs +467 -0
  76. package/template/caits/eslint.js +32 -0
  77. package/template/caits/marko-modules.d.ts +4 -0
  78. package/template/caits/playwright.ts +35 -0
  79. package/template/caits/stryker.mjs +20 -0
  80. package/template/caits/tsconfig.json +21 -0
  81. package/template/caits/tsconfig.mutation.json +5 -0
  82. package/template/caits/vite.ts +34 -0
  83. package/template/empty/src/routes/_frontend/+page.marko +9 -0
  84. package/template/empty/test/e2e/home.spec.ts +6 -0
  85. package/template/project/AGENTS +28 -0
  86. package/template/project/gitignore +16 -0
  87. package/template/project/nvmrc +1 -0
  88. package/template/project/quality/architecture.mjs +9 -0
  89. package/template/project/quality/eslint.config.js +6 -0
  90. package/template/project/quality/playwright.config.ts +10 -0
  91. package/template/project/quality/stryker.config.mjs +6 -0
  92. package/template/project/quality/vite.config.ts +5 -0
  93. package/template/project/tsconfig.json +4 -0
@@ -0,0 +1,301 @@
1
+ /** @import { Describe, Element, Group, Item, Run, State, Test } from "./test-docs.mjs" */
2
+ /** What holds tests: a group (with its sub-groups), an element, or a describe. @typedef {{ items?: Item[]; files?: Map<string, Describe>; elements?: Map<string, Element>; children?: Map<string, Group> }} Part */
3
+ /** @typedef {Record<State, number> & { tests: number }} Count */
4
+
5
+ /** @type {Record<State, string>} */
6
+ const MARKS = { passed: "✓", failed: "✗", skipped: "○" };
7
+ const ERROR_LENGTH = 3000;
8
+ const NAV_DEPTH = 3;
9
+
10
+ /**
11
+ * The whole page: the runs ({ kind, title, command, result }), a menu of the groups, and each test as one line of documentation.
12
+ * @param {{ generatedAt: Date; runs: Run[]; root: Group }} page
13
+ */
14
+ export function renderPage({ generatedAt, runs, root }) {
15
+ const projects = sorted(root.children);
16
+ const e2e = runs.find(run => run.kind === "e2e");
17
+ const missingE2e = e2e?.result === null
18
+ ? `<section class="project" id="end-to-end"><h2>End-to-end</h2><p class="note">Not run yet. Run <code>${e2e.command}</code>.</p></section>`
19
+ : "";
20
+ return `<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1">
25
+ <title>Test documentation</title>
26
+ <style>${STYLE}</style>
27
+ </head>
28
+ <body>
29
+ <div class="layout">
30
+ <nav aria-label="Groups">
31
+ <a class="nav-home" href="#top">Test documentation</a>
32
+ <ul>${missingE2e ? `<li><a href="#end-to-end">End-to-end</a></li>` : ""}${projects.map(project => navItem(project, 1)).join("")}</ul>
33
+ </nav>
34
+ <main id="top">
35
+ <header>
36
+ <h1>Test documentation</h1>
37
+ <p class="lead">Each line is one test, and tells one thing that the code does. The end-to-end tests describe the whole application. The other tests describe each layer of each context.</p>
38
+ <div class="runs">${runs.map(runCard).join("")}</div>
39
+ <p class="generated">Page written ${date(generatedAt)}.</p>
40
+ </header>
41
+ <div class="toolbar">
42
+ <input type="search" id="search" placeholder="Search the tests" aria-label="Search the tests">
43
+ <label><input type="checkbox" id="failures"> Failures only</label>
44
+ <span id="shown" aria-live="polite"></span>
45
+ </div>
46
+ <p class="note" id="nothing" hidden>No test matches.</p>
47
+ ${missingE2e}${projects.map(renderProject).join("")}
48
+ </main>
49
+ </div>
50
+ <script>${SCRIPT}</script>
51
+ </body>
52
+ </html>
53
+ `;
54
+ }
55
+
56
+ /** @param {Run} run */
57
+ function runCard({ title, command, result }) {
58
+ if (result === null) return `<div class="run"><div class="run-title">${title}</div><div class="muted">Not run yet. Run <code>${command}</code>.</div></div>`;
59
+ const total = countOf(result.tests);
60
+ return `<div class="run${total.failed ? " has-failures" : ""}"><div class="run-title">${title}</div><div class="muted">${date(new Date(result.ranAt))} · <code>${command}</code></div><div>${summary(total)}</div></div>`;
61
+ }
62
+
63
+ /**
64
+ * @param {Group} group
65
+ * @param {number} depth
66
+ * @returns {string}
67
+ */
68
+ function navItem(group, depth) {
69
+ const children = depth < NAV_DEPTH ? sorted(group.children) : [];
70
+ const total = countOf(testsOf(group));
71
+ return `<li><a href="#${group.id}"${total.failed ? ` class="has-failures"` : ""}>${escape(group.title)}</a> <span class="count">${total.tests}</span>${children.length ? `<ul>${children.map(child => navItem(child, depth + 1)).join("")}</ul>` : ""}</li>`;
72
+ }
73
+
74
+ /** @param {Group} project */
75
+ function renderProject(project) {
76
+ return `<section class="project" id="${project.id}" data-group>
77
+ <h2>${escape(project.title)} ${badge(project)}</h2>
78
+ ${renderElements(project)}${sorted(project.children).map(child => renderGroup(child, 3)).join("")}
79
+ </section>
80
+ `;
81
+ }
82
+
83
+ /**
84
+ * @param {Group} group
85
+ * @param {number} level
86
+ * @returns {string}
87
+ */
88
+ function renderGroup(group, level) {
89
+ return `<details open class="group level-${level}" id="${group.id}" data-group>
90
+ <summary><h${level}>${escape(group.title)}</h${level}> ${badge(group)}</summary>
91
+ <div class="group-body">${renderElements(group)}${sorted(group.children).map(child => renderGroup(child, level + 1)).join("")}</div>
92
+ </details>
93
+ `;
94
+ }
95
+
96
+ /** @param {Group} group */
97
+ function renderElements(group) {
98
+ return [...group.elements.values()].sort((a, b) => a.title.localeCompare(b.title)).map(element => {
99
+ const files = [...element.files.entries()].sort(([a], [b]) => a.localeCompare(b));
100
+ const body = files.map(([file, describe]) => `<div class="file" data-group><div class="path">${escape(file)}</div>${renderItems(describe.items, file, [...group.path, element.title])}</div>`).join("");
101
+ if (element.title === "") return body;
102
+ return `<div class="element" data-group><div class="element-title"><code>${escape(element.title)}</code> ${badge(element)}</div>${body}</div>`;
103
+ }).join("");
104
+ }
105
+
106
+ /**
107
+ * @param {Item[]} items
108
+ * @param {string} file
109
+ * @param {string[]} context
110
+ * @returns {string}
111
+ */
112
+ function renderItems(items, file, context) {
113
+ /** @type {string[]} */
114
+ let tests = [];
115
+ let html = "";
116
+ const flush = () => { if (tests.length) html += `<ul class="tests">${tests.join("")}</ul>`; tests = []; };
117
+ for (const item of items) {
118
+ if (item.test) { tests.push(renderTest(item.test, context)); continue; }
119
+ flush();
120
+ html += `<div class="describe" data-group><div class="describe-title">${escape(item.describe.title)}</div><div class="describe-body">${renderItems(item.describe.items, file, [...context, item.describe.title])}</div></div>`;
121
+ }
122
+ flush();
123
+ return html;
124
+ }
125
+
126
+ /**
127
+ * @param {Test} test
128
+ * @param {string[]} context
129
+ */
130
+ function renderTest(test, context) {
131
+ const search = [...context, test.file, test.name].join(" ").toLowerCase();
132
+ const error = test.error ? `<pre class="error">${escape(test.error.length > ERROR_LENGTH ? `${test.error.slice(0, ERROR_LENGTH)}…` : test.error)}</pre>` : "";
133
+ return `<li class="test ${test.state}" data-search="${escape(search)}"><span class="mark" title="${test.state}">${MARKS[test.state]}</span><div><span class="name">${testName(test.name)}</span>${error}</div></li>`;
134
+ }
135
+
136
+ /**
137
+ * "Given …, When …, Then …" becomes three lines; any other name stays as it is.
138
+ * @param {string} name
139
+ */
140
+ function testName(name) {
141
+ const steps = /^Given (.+?), When (.+?), Then (.+)$/s.exec(name);
142
+ if (!steps) return escape(name);
143
+ return `<span class="steps"><span><b>Given</b> ${escape(steps[1])}</span><span><b>When</b> ${escape(steps[2])}</span><span><b>Then</b> ${escape(steps[3])}</span></span>`;
144
+ }
145
+
146
+ /** @param {Part} group */
147
+ function badge(group) {
148
+ const total = countOf(testsOf(group));
149
+ return `<span class="count">${total.tests} ${total.tests === 1 ? "test" : "tests"}${total.failed ? ` · <span class="failed-count">${total.failed} failed</span>` : ""}${total.skipped ? ` · ${total.skipped} skipped` : ""}</span>`;
150
+ }
151
+
152
+ /** @param {Count} total */
153
+ function summary(total) {
154
+ return [`${total.passed} passed`, total.failed ? `<span class="failed-count">${total.failed} failed</span>` : "", total.skipped ? `${total.skipped} skipped` : ""].filter(Boolean).join(" · ");
155
+ }
156
+
157
+ /**
158
+ * @param {Iterable<Test>} tests
159
+ * @returns {Count}
160
+ */
161
+ function countOf(tests) {
162
+ const total = { tests: 0, passed: 0, failed: 0, skipped: 0 };
163
+ for (const test of tests) { total.tests++; total[test.state]++; }
164
+ return total;
165
+ }
166
+
167
+ /**
168
+ * All the tests of a group (with its sub-groups), of an element, or of a describe.
169
+ * @param {Part} part
170
+ * @returns {Generator<Test>}
171
+ */
172
+ function* testsOf(part) {
173
+ if (part.items) {
174
+ for (const item of part.items) yield* item.test ? [item.test] : testsOf(item.describe);
175
+ return;
176
+ }
177
+ for (const describe of part.files?.values() ?? []) yield* testsOf(describe);
178
+ for (const element of part.elements?.values() ?? []) yield* testsOf(element);
179
+ for (const child of part.children?.values() ?? []) yield* testsOf(child);
180
+ }
181
+
182
+ /**
183
+ * The groups by their order, then by their title. The groups without an order keep the order of the run.
184
+ * @type {(children: Map<string, Group>) => Group[]}
185
+ */
186
+ const sorted = children => [...children.values()].sort((a, b) => {
187
+ if (a.order === undefined || b.order === undefined) return a.order === b.order ? 0 : a.title.localeCompare(b.title);
188
+ return a.order - b.order || a.title.localeCompare(b.title);
189
+ });
190
+ /** @type {(number: number) => string} */
191
+ const pad = number => String(number).padStart(2, "0");
192
+ /** @type {(at: Date) => string} */
193
+ const date = at => `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
194
+ /** @type {(text: unknown) => string} */
195
+ const escape = text => String(text).replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
196
+
197
+ const STYLE = `
198
+ :root {
199
+ color-scheme: light dark;
200
+ --bg: #f7f7f5; --surface: #ffffff; --text: #1d1f23; --muted: #646a73; --border: #e2e4e8;
201
+ --accent: #2f5bd3; --passed: #1a7f37; --failed: #c62828; --skipped: #8a8f98; --code-bg: #eef0f3; --failed-bg: #fdecec;
202
+ }
203
+ @media (prefers-color-scheme: dark) {
204
+ :root {
205
+ --bg: #131519; --surface: #1a1d22; --text: #e4e6ea; --muted: #9aa1ab; --border: #2b2f36;
206
+ --accent: #7ea4ff; --passed: #3fb950; --failed: #ff7070; --skipped: #7d848e; --code-bg: #23272e; --failed-bg: #3a1d1f;
207
+ }
208
+ }
209
+ * { box-sizing: border-box; }
210
+ [hidden] { display: none !important; }
211
+ html { scroll-padding-top: 64px; }
212
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
213
+ code, .path, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
214
+ code { font-size: .88em; background: var(--code-bg); padding: .05em .35em; border-radius: 4px; }
215
+ a { color: var(--accent); text-decoration: none; }
216
+ a:hover { text-decoration: underline; }
217
+ .layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); }
218
+ nav { position: sticky; top: 0; height: 100vh; overflow: auto; padding: 20px 16px; border-right: 1px solid var(--border); background: var(--surface); font-size: 14px; }
219
+ nav ul { list-style: none; margin: 0; padding: 0; }
220
+ nav ul ul { padding-left: 14px; }
221
+ nav li { margin: 3px 0; }
222
+ nav > ul > li { margin-top: 12px; }
223
+ nav > ul > li > a { font-weight: 600; color: var(--text); }
224
+ nav a.has-failures::before { content: "● "; color: var(--failed); }
225
+ .nav-home { display: block; font-weight: 700; color: var(--text); margin-bottom: 4px; }
226
+ main { padding: 28px 40px 80px; max-width: 1040px; min-width: 0; }
227
+ h1 { margin: 0 0 8px; font-size: 28px; letter-spacing: -.01em; }
228
+ .lead { margin: 0 0 16px; color: var(--muted); max-width: 70ch; }
229
+ .runs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }
230
+ .run { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--passed); border-radius: 8px; padding: 10px 14px; }
231
+ .run.has-failures { border-left-color: var(--failed); }
232
+ .run-title { font-weight: 600; }
233
+ .generated, .muted, .count, .path { color: var(--muted); }
234
+ .generated { font-size: 13px; margin: 10px 0 0; }
235
+ .toolbar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin: 20px 0 8px; padding: 10px 0; background: var(--bg); border-bottom: 1px solid var(--border); }
236
+ .toolbar input[type=search] { flex: 1 1 240px; min-width: 0; font: inherit; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); }
237
+ .toolbar label { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
238
+ #shown { color: var(--muted); font-size: 13px; }
239
+ .note { color: var(--muted); }
240
+ .project { margin-top: 36px; }
241
+ h2 { font-size: 22px; margin: 0 0 8px; padding-bottom: 6px; border-bottom: 2px solid var(--border); }
242
+ h3, h4, h5 { display: inline; margin: 0; }
243
+ h3 { font-size: 18px; }
244
+ h4 { font-size: 16px; }
245
+ h5 { font-size: 15px; }
246
+ .count { font-size: 13px; font-weight: 400; }
247
+ .failed-count { color: var(--failed); font-weight: 600; }
248
+ details.group { margin: 10px 0; }
249
+ details.group > summary { cursor: pointer; padding: 4px 0; }
250
+ details.level-3 > summary { padding: 8px 0 4px; }
251
+ .group-body { padding-left: 18px; border-left: 1px solid var(--border); margin-left: 5px; }
252
+ .element { margin: 12px 0 18px; }
253
+ .element-title { margin-bottom: 4px; }
254
+ .element-title code { font-size: 14px; font-weight: 600; }
255
+ .file { margin: 6px 0 10px; }
256
+ .path { font-size: 12px; overflow-wrap: anywhere; }
257
+ .describe { margin: 6px 0 6px; }
258
+ .describe-body { padding-left: 16px; }
259
+ .describe-title { font-weight: 600; }
260
+ ul.tests { list-style: none; margin: 2px 0 6px; padding: 0; }
261
+ li.test { display: grid; grid-template-columns: 1.3em minmax(0, 1fr); gap: 2px; padding: 1px 0; }
262
+ .mark { font-weight: 700; text-align: center; }
263
+ .passed .mark { color: var(--passed); }
264
+ .failed .mark, .failed .name { color: var(--failed); }
265
+ .skipped .mark, .skipped .name { color: var(--skipped); }
266
+ .name { display: block; overflow-wrap: anywhere; }
267
+ .name::first-letter { text-transform: uppercase; }
268
+ .steps > span { display: block; }
269
+ pre.error { margin: 4px 0 8px; padding: 8px 10px; background: var(--failed-bg); border-radius: 6px; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
270
+ @media (max-width: 820px) {
271
+ html { scroll-padding-top: 104px; }
272
+ .layout { grid-template-columns: 1fr; }
273
+ nav { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); padding: 16px; }
274
+ main { padding: 16px 16px 64px; }
275
+ .group-body { padding-left: 10px; }
276
+ }
277
+ `;
278
+
279
+ const SCRIPT = `
280
+ const search = document.getElementById("search");
281
+ const failures = document.getElementById("failures");
282
+ const shown = document.getElementById("shown");
283
+ const nothing = document.getElementById("nothing");
284
+ const tests = [...document.querySelectorAll("li.test")];
285
+ const groups = [...document.querySelectorAll("[data-group]")];
286
+ function apply() {
287
+ const words = search.value.toLowerCase().split(/\\s+/).filter(Boolean);
288
+ let count = 0;
289
+ for (const test of tests) {
290
+ test.hidden = (failures.checked && !test.classList.contains("failed")) || !words.every(word => test.dataset.search.includes(word));
291
+ if (!test.hidden) count++;
292
+ }
293
+ for (const group of groups) group.hidden = !group.querySelector("li.test:not([hidden])");
294
+ if (words.length || failures.checked) for (const details of document.querySelectorAll("details")) details.open = true;
295
+ shown.textContent = count === tests.length ? tests.length + " tests" : count + " of " + tests.length + " tests";
296
+ nothing.hidden = count > 0;
297
+ }
298
+ search.addEventListener("input", apply);
299
+ failures.addEventListener("change", apply);
300
+ apply();
301
+ `;
@@ -0,0 +1,55 @@
1
+ import { fromProjectRoot, saveRun, withoutColors, writeTestDocs } from "./test-docs.mjs";
2
+
3
+ /** @import { FullConfig, Reporter, Suite, TestCase } from "@playwright/test/reporter" */
4
+ /** @import { State } from "./test-docs.mjs" */
5
+
6
+ /** @type {Record<ReturnType<TestCase["outcome"]>, State>} */
7
+ const STATES = { expected: "passed", flaky: "passed", unexpected: "failed", skipped: "skipped" };
8
+
9
+ /**
10
+ * Keeps the tests of the Playwright run, then writes the test documentation page.
11
+ * @implements {Reporter}
12
+ */
13
+ export default class TestDocsReporter {
14
+ /** @type {Suite | undefined} */
15
+ suite;
16
+
17
+ printsToStdio() {
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * @param {FullConfig} _config
23
+ * @param {Suite} suite
24
+ */
25
+ onBegin(_config, suite) {
26
+ this.suite = suite;
27
+ }
28
+
29
+ onEnd() {
30
+ const tests = (this.suite?.allTests() ?? []).map(test => {
31
+ const error = test.results.at(-1)?.errors[0];
32
+ return {
33
+ file: fromProjectRoot(test.location.file),
34
+ describes: describesOf(test),
35
+ name: test.title,
36
+ state: STATES[test.outcome()],
37
+ ...(error ? { error: withoutColors(error.message ?? error.value ?? "") } : {})
38
+ };
39
+ });
40
+ saveRun("e2e", tests);
41
+ console.log(`Test documentation: ${fromProjectRoot(writeTestDocs())}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {TestCase} test
47
+ * @returns {string[]}
48
+ */
49
+ function describesOf(test) {
50
+ const names = [];
51
+ /** @type {Suite | undefined} */
52
+ let suite = test.parent;
53
+ for (; suite?.type === "describe"; suite = suite.parent) names.unshift(suite.title);
54
+ return names;
55
+ }
@@ -0,0 +1,180 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import { renderPage } from "./page.mjs";
4
+
5
+ /** @typedef {"passed" | "failed" | "skipped"} State */
6
+ /** A test of a run: its file, the names of its describes, its name, its state, and its error. @typedef {{ file: string; describes: string[]; name: string; state: State; error?: string }} Test */
7
+ /** The tests of a run, and when it ran. @typedef {{ ranAt: string; tests: Test[] }} RunResult */
8
+ /** A run of the page: unit or end-to-end, with its last result, or null. @typedef {{ kind: string; title: string; command: string; result: RunResult | null }} Run */
9
+ /** A describe of a file: its title, its tests and its describes in their order. @typedef {{ title: string; items: Item[]; describes: Map<string, Describe> }} Describe */
10
+ /** @typedef {{ test: Test; describe?: undefined } | { describe: Describe; test?: undefined }} Item */
11
+ /** An element of a group (a class, a file), with the describes of each of its files. @typedef {{ title: string; files: Map<string, Describe> }} Element */
12
+ /**
13
+ * A group of the page: a project, a context, a layer. A group without an order keeps the order of the run.
14
+ * @typedef {{ title: string; order: number | undefined; path: string[]; id: string; children: Map<string, Group>; elements: Map<string, Element> }} Group
15
+ */
16
+
17
+ /** The root of the project: the tests run from it. */
18
+ const projectRoot = process.cwd();
19
+ const directory = join(projectRoot, "reports/test-docs");
20
+
21
+ const RUNS = {
22
+ e2e: { title: "End-to-end tests", command: "npm run test:e2e" },
23
+ unit: { title: "Unit tests", command: "npm run test:coverage" }
24
+ };
25
+
26
+ /** @type {(file: string) => unknown} */
27
+ const readJson = file => JSON.parse(readFileSync(file, "utf8"));
28
+
29
+ /** @type {Record<string, number>} */
30
+ const PROJECTS = { "End-to-end": 0, Backend: 1, Frontend: 2, Other: 3 };
31
+ const LAYERS = ["presentation", "application", "domain", "infra"];
32
+
33
+ /** A path relative to the project root, with "/" on every system. @type {(file: string) => string} */
34
+ export const fromProjectRoot = file => relative(projectRoot, file).split(sep).join("/");
35
+
36
+ /** Removes the terminal colors from an error message. @type {(text: string) => string} */
37
+ // eslint-disable-next-line no-control-regex -- a color of the terminal starts with the character ESC.
38
+ export const withoutColors = text => text.replace(/\u001b\[[0-9;]*m/g, "");
39
+
40
+ /**
41
+ * Writes a file of the documentation whole: `caits check` runs the unit and the end-to-end tests at once, and each run reads the file of
42
+ * the other one. The text goes in a file of its own, then takes the name of the file.
43
+ * @param {string} file
44
+ * @param {string} text
45
+ */
46
+ function writeWhole(file, text) {
47
+ const draft = `${file}.${process.pid}.tmp`;
48
+ writeFileSync(draft, text);
49
+ renameSync(draft, file);
50
+ }
51
+
52
+ /**
53
+ * Keeps the tests of one run.
54
+ * @param {"unit" | "e2e"} kind
55
+ * @param {Test[]} tests
56
+ */
57
+ export function saveRun(kind, tests) {
58
+ mkdirSync(directory, { recursive: true });
59
+ writeWhole(join(directory, `${kind}.json`), JSON.stringify({ ranAt: new Date().toISOString(), tests }, null, 2));
60
+ }
61
+
62
+ /** Writes reports/test-docs/index.html from the last unit run and the last e2e run. */
63
+ export function writeTestDocs() {
64
+ /** @type {Run[]} */
65
+ const runs = Object.entries(RUNS).map(([kind, run]) => ({ kind, ...run, result: readRun(kind) }));
66
+ const root = node("", 0, []);
67
+ for (const run of runs) for (const test of run.result?.tests ?? []) add(root, test);
68
+ mkdirSync(directory, { recursive: true });
69
+ const page = join(directory, "index.html");
70
+ writeWhole(page, renderPage({ generatedAt: new Date(), runs, root }));
71
+ return page;
72
+ }
73
+
74
+ /**
75
+ * @param {string} kind
76
+ * @returns {RunResult | null}
77
+ */
78
+ function readRun(kind) {
79
+ const file = join(directory, `${kind}.json`);
80
+ return existsSync(file) ? /** @type {RunResult} */ (readJson(file)) : null;
81
+ }
82
+
83
+ /** A group without an order keeps the order of the run: the e2e features stay in the order of the spec file. @type {(title: string, order: number | undefined) => { title: string; order: number | undefined }} */
84
+ const group = (title, order) => ({ title, order });
85
+ /** @type {(name: string) => string} */
86
+ const humanize = name => name.charAt(0).toUpperCase() + name.slice(1).replace(/[-_]+/g, " ");
87
+
88
+ /**
89
+ * Where a test goes in the page: the groups (project, context, layer), the element in the last group, and its describes.
90
+ * @param {Test} test
91
+ */
92
+ function placeOf({ file, describes }) {
93
+ const parts = file.split("/");
94
+ const folders = parts.slice(0, -1);
95
+ const stem = parts[parts.length - 1].replace(/\.(test|spec)\.[cm]?[jt]s$/, "");
96
+
97
+ if (folders[0] === "test" && folders[1] === "e2e") {
98
+ return { groups: [group("End-to-end", PROJECTS["End-to-end"]), group(describes[0] ?? humanize(stem), undefined)], element: "", describes: describes.slice(1) };
99
+ }
100
+ if (folders[0] === "src" && (folders[1] === "backend" || folders[1] === "frontend")) {
101
+ const project = group(humanize(folders[1]), PROJECTS[humanize(folders[1])]);
102
+ const [context, layer, ...rest] = folders.slice(2);
103
+ if (context === undefined) return { groups: [project, group(humanize(stem.split(".")[0]), 1)], element: stem, describes };
104
+ if (layer === undefined) return { groups: [project, group(humanize(context), 0)], element: stem, describes };
105
+ const layerOrder = LAYERS.includes(layer) ? LAYERS.indexOf(layer) : LAYERS.length;
106
+ return { groups: [project, group(humanize(context), 0), group(humanize(layer), layerOrder)], element: rest.join(" / ") || stem, describes };
107
+ }
108
+ return { groups: [group("Other", PROJECTS.Other), group(folders.join("/") || ".", 0)], element: stem, describes };
109
+ }
110
+
111
+ /**
112
+ * @param {string} title
113
+ * @param {number | undefined} order
114
+ * @param {string[]} path
115
+ * @returns {Group}
116
+ */
117
+ function node(title, order, path) {
118
+ return { title, order, path, id: path.map(slug).join("--"), children: new Map(), elements: new Map() };
119
+ }
120
+
121
+ /**
122
+ * @param {string} title
123
+ * @returns {Element}
124
+ */
125
+ function elementNode(title) {
126
+ return { title, files: new Map() };
127
+ }
128
+
129
+ /**
130
+ * @param {string} title
131
+ * @returns {Describe}
132
+ */
133
+ function describeNode(title) {
134
+ return { title, items: [], describes: new Map() };
135
+ }
136
+
137
+ /**
138
+ * Puts a test in its place: in its groups, its element, its file and its describes, each made when the page does not have it yet.
139
+ * @param {Group} root
140
+ * @param {Test} test
141
+ */
142
+ function add(root, test) {
143
+ const { groups, element, describes } = placeOf(test);
144
+ let current = root;
145
+ for (const { title, order } of groups) {
146
+ const parent = current;
147
+ current = entryOf(parent.children, title, () => node(title, order, [...parent.path, title]));
148
+ }
149
+ const { files } = entryOf(current.elements, element, () => elementNode(element));
150
+ let describe = entryOf(files, test.file, () => describeNode(""));
151
+ for (const title of describes) {
152
+ const parent = describe;
153
+ describe = entryOf(parent.describes, title, () => {
154
+ const child = describeNode(title);
155
+ parent.items.push({ describe: child });
156
+ return child;
157
+ });
158
+ }
159
+ describe.items.push({ test });
160
+ }
161
+
162
+ /**
163
+ * The value of a key of a map: `make` makes it, and the map keeps it, when the map does not have it yet.
164
+ * @template Value
165
+ * @param {Map<string, Value>} map
166
+ * @param {string} key
167
+ * @param {() => Value} make
168
+ * @returns {Value}
169
+ */
170
+ function entryOf(map, key, make) {
171
+ let value = map.get(key);
172
+ if (value === undefined) {
173
+ value = make();
174
+ map.set(key, value);
175
+ }
176
+ return value;
177
+ }
178
+
179
+ /** @type {(title: string) => string} */
180
+ const slug = title => title.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -0,0 +1,38 @@
1
+ import { fromProjectRoot, saveRun, withoutColors, writeTestDocs } from "./test-docs.mjs";
2
+
3
+ /** @import { Reporter, TestCase, TestModule } from "vitest/node" */
4
+ /** @import { Test } from "./test-docs.mjs" */
5
+
6
+ /**
7
+ * Keeps the tests of the Vitest run, then writes the test documentation page.
8
+ * @implements {Reporter}
9
+ */
10
+ export default class TestDocsReporter {
11
+ /** @param {ReadonlyArray<TestModule>} testModules */
12
+ onTestRunEnd(testModules) {
13
+ /** @type {Test[]} */
14
+ const tests = testModules.flatMap(testModule => [...testModule.children.allTests()].map(testCase => {
15
+ const result = testCase.result();
16
+ const error = result.errors?.[0];
17
+ return {
18
+ file: fromProjectRoot(testModule.moduleId),
19
+ describes: describesOf(testCase),
20
+ name: testCase.name,
21
+ state: result.state === "failed" ? "failed" : result.state === "passed" ? "passed" : "skipped",
22
+ ...(error ? { error: withoutColors([error.message, error.diff].filter(Boolean).join("\n\n")) } : {})
23
+ };
24
+ }));
25
+ saveRun("unit", tests);
26
+ console.log(`Test documentation: ${fromProjectRoot(writeTestDocs())}`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {TestCase} testCase
32
+ * @returns {string[]}
33
+ */
34
+ function describesOf(testCase) {
35
+ const names = [];
36
+ for (let suite = testCase.parent; suite.type === "suite"; suite = suite.parent) names.unshift(suite.name);
37
+ return names;
38
+ }
@@ -0,0 +1,4 @@
1
+ /** An event a context publishes: a fact that the context map may turn into input DTOs of other contexts. The publisher never waits for them. */
2
+ export abstract class DomainEvent {
3
+ declare protected readonly fact?: true;
4
+ }
package/src/Result.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type Ok<T> = { readonly ok: true; readonly value: T };
2
+ export type Err<E> = { readonly ok: false; readonly error: E };
3
+ export type Result<T, E> = Ok<T> | Err<E>;
4
+
5
+ export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
6
+ export const err = <E>(error: E): Err<E> => ({ ok: false, error });
package/src/Token.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Stands for a dependency, with its type: a binding says what it stands for, and a test reads or replaces the dependency by its token.
3
+ * A token declared without a name gets the name of its constant from the build (see declareDependencies).
4
+ */
5
+ export class Token<T> {
6
+ declare protected readonly type?: T;
7
+ constructor(readonly name = "") {}
8
+ }