@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,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
|
+
}
|
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
|
+
}
|