@zohodesk/unit-testing-framework 0.0.6-experimental → 0.0.7-experimental

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.
@@ -57,7 +57,10 @@ function getDefaultConfig(projectRoot) {
57
57
  coverageReporters: ['text', 'lcov', 'clover'],
58
58
  coveragePathIgnorePatterns: ['/node_modules/', '/tests/'],
59
59
  // --------------- Reporters ---------------
60
- reporters: ['default'],
60
+ reporters: ['default', ['html-report', {
61
+ outputDir: 'unit_reports',
62
+ fileName: 'report.html'
63
+ }]],
61
64
  // --------------- Parallelism & Performance ---------------
62
65
  maxWorkers: '50%',
63
66
  // Use half of available CPUs
@@ -0,0 +1,355 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _fs = _interopRequireDefault(require("fs"));
8
+ var _path = _interopRequireDefault(require("path"));
9
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
10
+ /**
11
+ * html-reporter.js
12
+ *
13
+ * A zero-dependency custom Jest reporter that generates a self-contained
14
+ * HTML report after each test run. The report includes:
15
+ * - Summary (total / passed / failed / skipped / duration)
16
+ * - Per-suite breakdown with expandable test cases
17
+ * - Failure messages and stack traces
18
+ * - Filtering controls (All / Passed / Failed / Skipped)
19
+ *
20
+ * Usage in reporters config:
21
+ * ['html-report'] → defaults to <projectRoot>/unit_reports/report.html
22
+ * ['html-report', { outputPath: '...' }] → custom output path
23
+ */
24
+
25
+ class HtmlReporter {
26
+ /**
27
+ * @param {object} globalConfig - Jest global config
28
+ * @param {object} reporterOptions
29
+ * @param {string} [reporterOptions.outputDir] - Directory for the HTML file (default: <rootDir>/unit_reports)
30
+ * @param {string} [reporterOptions.fileName] - HTML file name (default: report.html)
31
+ * @param {string} [reporterOptions.title] - Page title (default: Unit Test Report)
32
+ */
33
+ constructor(globalConfig, reporterOptions = {}) {
34
+ this.globalConfig = globalConfig;
35
+ this.options = reporterOptions;
36
+ this._suites = [];
37
+ this._startTime = 0;
38
+ }
39
+ onRunStart() {
40
+ this._startTime = Date.now();
41
+ this._suites = [];
42
+ }
43
+ onTestResult(_test, testResult) {
44
+ this._suites.push(testResult);
45
+ }
46
+ onRunComplete(_contexts, aggregatedResults) {
47
+ const elapsed = ((Date.now() - this._startTime) / 1000).toFixed(2);
48
+ const rootDir = this.globalConfig.rootDir || process.cwd();
49
+ const outputDir = this.options.outputDir ? _path.default.resolve(rootDir, this.options.outputDir) : _path.default.resolve(rootDir, 'unit_reports');
50
+ const fileName = this.options.fileName || 'report.html';
51
+ const title = this.options.title || 'Unit Test Report';
52
+ const outputPath = _path.default.join(outputDir, fileName);
53
+
54
+ // Ensure output directory exists
55
+ _fs.default.mkdirSync(outputDir, {
56
+ recursive: true
57
+ });
58
+ const html = this._generateHtml(aggregatedResults, elapsed, title);
59
+ _fs.default.writeFileSync(outputPath, html, 'utf-8');
60
+ console.log(`\n 📄 HTML report written to: ${outputPath}\n`);
61
+ }
62
+
63
+ // ── Private ────────────────────────────────────────────────
64
+
65
+ _generateHtml(results, elapsed, title) {
66
+ const {
67
+ numPassedTests,
68
+ numFailedTests,
69
+ numPendingTests,
70
+ numTotalTests,
71
+ numPassedTestSuites,
72
+ numFailedTestSuites,
73
+ numTotalTestSuites
74
+ } = results;
75
+ const suitesHtml = this._suites.map(suite => this._renderSuite(suite)).join('\n');
76
+ const timestamp = new Date().toLocaleString();
77
+ return `<!DOCTYPE html>
78
+ <html lang="en">
79
+ <head>
80
+ <meta charset="UTF-8" />
81
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
82
+ <title>${title}</title>
83
+ <style>
84
+ :root {
85
+ --pass: #2ecc71;
86
+ --fail: #e74c3c;
87
+ --skip: #f39c12;
88
+ --bg: #1a1a2e;
89
+ --card: #16213e;
90
+ --text: #eaeaea;
91
+ --muted: #8892a4;
92
+ --border: #2a2a4a;
93
+ }
94
+ * { margin: 0; padding: 0; box-sizing: border-box; }
95
+ body {
96
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
97
+ background: var(--bg);
98
+ color: var(--text);
99
+ padding: 24px;
100
+ line-height: 1.5;
101
+ }
102
+ .header {
103
+ text-align: center;
104
+ margin-bottom: 32px;
105
+ }
106
+ .header h1 { font-size: 1.8rem; margin-bottom: 4px; }
107
+ .header .meta { color: var(--muted); font-size: 0.85rem; }
108
+
109
+ /* ── Summary Cards ─── */
110
+ .summary {
111
+ display: flex;
112
+ gap: 16px;
113
+ justify-content: center;
114
+ flex-wrap: wrap;
115
+ margin-bottom: 28px;
116
+ }
117
+ .card {
118
+ background: var(--card);
119
+ border: 1px solid var(--border);
120
+ border-radius: 10px;
121
+ padding: 18px 28px;
122
+ min-width: 140px;
123
+ text-align: center;
124
+ }
125
+ .card .value { font-size: 2rem; font-weight: 700; }
126
+ .card .label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
127
+ .card.pass .value { color: var(--pass); }
128
+ .card.fail .value { color: var(--fail); }
129
+ .card.skip .value { color: var(--skip); }
130
+ .card.total .value { color: #58a6ff; }
131
+
132
+ /* ── Filters ─── */
133
+ .filters {
134
+ display: flex;
135
+ gap: 8px;
136
+ justify-content: center;
137
+ margin-bottom: 24px;
138
+ flex-wrap: wrap;
139
+ }
140
+ .filters button {
141
+ background: var(--card);
142
+ color: var(--text);
143
+ border: 1px solid var(--border);
144
+ padding: 6px 18px;
145
+ border-radius: 20px;
146
+ cursor: pointer;
147
+ font-size: 0.85rem;
148
+ transition: all 0.2s;
149
+ }
150
+ .filters button:hover,
151
+ .filters button.active { background: #58a6ff; color: #000; border-color: #58a6ff; }
152
+
153
+ /* ── Suites ─── */
154
+ .suite {
155
+ background: var(--card);
156
+ border: 1px solid var(--border);
157
+ border-radius: 10px;
158
+ margin-bottom: 16px;
159
+ overflow: hidden;
160
+ }
161
+ .suite-header {
162
+ display: flex;
163
+ align-items: center;
164
+ padding: 14px 18px;
165
+ cursor: pointer;
166
+ user-select: none;
167
+ gap: 10px;
168
+ }
169
+ .suite-header:hover { background: rgba(255,255,255,0.03); }
170
+ .suite-header .icon { font-size: 1.1rem; }
171
+ .suite-header .name {
172
+ flex: 1;
173
+ font-weight: 600;
174
+ font-size: 0.95rem;
175
+ word-break: break-all;
176
+ }
177
+ .suite-header .counts { font-size: 0.8rem; color: var(--muted); }
178
+ .suite-header .arrow {
179
+ transition: transform 0.2s;
180
+ color: var(--muted);
181
+ }
182
+ .suite.open .suite-header .arrow { transform: rotate(90deg); }
183
+
184
+ .suite-body { display: none; border-top: 1px solid var(--border); }
185
+ .suite.open .suite-body { display: block; }
186
+
187
+ .test-row {
188
+ display: flex;
189
+ align-items: flex-start;
190
+ padding: 10px 18px 10px 36px;
191
+ gap: 10px;
192
+ border-bottom: 1px solid var(--border);
193
+ font-size: 0.9rem;
194
+ }
195
+ .test-row:last-child { border-bottom: none; }
196
+ .test-row .status {
197
+ flex-shrink: 0;
198
+ width: 20px;
199
+ text-align: center;
200
+ }
201
+ .test-row .test-title { flex: 1; }
202
+ .test-row .duration { color: var(--muted); font-size: 0.8rem; flex-shrink: 0; }
203
+ .test-row.passed .status { color: var(--pass); }
204
+ .test-row.failed .status { color: var(--fail); }
205
+ .test-row.pending .status { color: var(--skip); }
206
+
207
+ .failure-msg {
208
+ background: rgba(231, 76, 60, 0.1);
209
+ border-left: 3px solid var(--fail);
210
+ margin: 6px 0 4px 30px;
211
+ padding: 10px 14px;
212
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
213
+ font-size: 0.8rem;
214
+ white-space: pre-wrap;
215
+ word-break: break-word;
216
+ color: #f5a5a5;
217
+ border-radius: 4px;
218
+ }
219
+
220
+ .footer {
221
+ text-align: center;
222
+ color: var(--muted);
223
+ font-size: 0.75rem;
224
+ margin-top: 32px;
225
+ padding-top: 16px;
226
+ border-top: 1px solid var(--border);
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <div class="header">
232
+ <h1>${title}</h1>
233
+ <div class="meta">${timestamp} &nbsp;·&nbsp; Duration: ${elapsed}s</div>
234
+ </div>
235
+
236
+ <div class="summary">
237
+ <div class="card total"><div class="value">${numTotalTests}</div><div class="label">Total</div></div>
238
+ <div class="card pass"><div class="value">${numPassedTests}</div><div class="label">Passed</div></div>
239
+ <div class="card fail"><div class="value">${numFailedTests}</div><div class="label">Failed</div></div>
240
+ <div class="card skip"><div class="value">${numPendingTests}</div><div class="label">Skipped</div></div>
241
+ <div class="card"><div class="value">${numTotalTestSuites}</div><div class="label">Suites</div></div>
242
+ </div>
243
+
244
+ <div class="filters">
245
+ <button class="active" data-filter="all">All</button>
246
+ <button data-filter="passed">Passed</button>
247
+ <button data-filter="failed">Failed</button>
248
+ <button data-filter="pending">Skipped</button>
249
+ </div>
250
+
251
+ <div id="suites">
252
+ ${suitesHtml}
253
+ </div>
254
+
255
+ <div class="footer">
256
+ Generated by @zohodesk/unit-testing-framework
257
+ </div>
258
+
259
+ <script>
260
+ // Toggle suite expand/collapse
261
+ document.querySelectorAll('.suite-header').forEach(header => {
262
+ header.addEventListener('click', () => {
263
+ header.parentElement.classList.toggle('open');
264
+ });
265
+ });
266
+
267
+ // Filter buttons
268
+ document.querySelectorAll('.filters button').forEach(btn => {
269
+ btn.addEventListener('click', () => {
270
+ document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
271
+ btn.classList.add('active');
272
+ const filter = btn.dataset.filter;
273
+ document.querySelectorAll('.suite').forEach(suite => {
274
+ if (filter === 'all') {
275
+ suite.style.display = '';
276
+ suite.querySelectorAll('.test-row').forEach(r => r.style.display = '');
277
+ } else {
278
+ const rows = suite.querySelectorAll('.test-row');
279
+ let visible = 0;
280
+ rows.forEach(r => {
281
+ if (r.classList.contains(filter)) {
282
+ r.style.display = '';
283
+ visible++;
284
+ } else {
285
+ r.style.display = 'none';
286
+ }
287
+ });
288
+ suite.style.display = visible > 0 ? '' : 'none';
289
+ }
290
+ });
291
+ });
292
+ });
293
+
294
+ // Auto-expand suites with failures
295
+ document.querySelectorAll('.suite').forEach(suite => {
296
+ if (suite.querySelector('.test-row.failed')) {
297
+ suite.classList.add('open');
298
+ }
299
+ });
300
+ </script>
301
+ </body>
302
+ </html>`;
303
+ }
304
+ _renderSuite(suite) {
305
+ const suitePath = suite.testFilePath || 'Unknown suite';
306
+ // Show path relative to rootDir for readability
307
+ const rootDir = this.globalConfig.rootDir || process.cwd();
308
+ const relativePath = _path.default.relative(rootDir, suitePath);
309
+ const passed = suite.numPassingTests || 0;
310
+ const failed = suite.numFailingTests || 0;
311
+ const pending = suite.numPendingTests || 0;
312
+ const suiteIcon = failed > 0 ? '✖' : pending > 0 && passed === 0 ? '○' : '✔';
313
+ const suiteIconColor = failed > 0 ? 'var(--fail)' : 'var(--pass)';
314
+ const testsHtml = (suite.testResults || []).map(t => this._renderTest(t)).join('\n');
315
+ return `
316
+ <div class="suite">
317
+ <div class="suite-header">
318
+ <span class="icon" style="color:${suiteIconColor}">${suiteIcon}</span>
319
+ <span class="name">${this._escape(relativePath)}</span>
320
+ <span class="counts">${passed} passed · ${failed} failed · ${pending} skipped</span>
321
+ <span class="arrow">▶</span>
322
+ </div>
323
+ <div class="suite-body">
324
+ ${testsHtml}
325
+ </div>
326
+ </div>`;
327
+ }
328
+ _renderTest(testResult) {
329
+ const status = testResult.status; // 'passed' | 'failed' | 'pending'
330
+ const icon = status === 'passed' ? '✔' : status === 'failed' ? '✖' : '○';
331
+ const duration = testResult.duration != null ? `${testResult.duration}ms` : '';
332
+ const title = testResult.ancestorTitles ? [...testResult.ancestorTitles, testResult.title].join(' › ') : testResult.title;
333
+ let failureHtml = '';
334
+ if (status === 'failed' && testResult.failureMessages?.length) {
335
+ failureHtml = testResult.failureMessages.map(msg => `<div class="failure-msg">${this._escape(this._stripAnsi(msg))}</div>`).join('\n');
336
+ }
337
+ return `
338
+ <div class="test-row ${status}">
339
+ <span class="status">${icon}</span>
340
+ <div class="test-title">
341
+ ${this._escape(title)}
342
+ ${failureHtml}
343
+ </div>
344
+ <span class="duration">${duration}</span>
345
+ </div>`;
346
+ }
347
+ _escape(str) {
348
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
349
+ }
350
+ _stripAnsi(str) {
351
+ // eslint-disable-next-line no-control-regex
352
+ return String(str).replace(/\x1B\[[0-9;]*m/g, '');
353
+ }
354
+ }
355
+ exports.default = HtmlReporter;
@@ -4,7 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.resolveReporters = resolveReporters;
7
- var _nodePath = _interopRequireDefault(require("node:path"));
7
+ var _path = _interopRequireDefault(require("path"));
8
8
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
9
9
  /**
10
10
  * reporter-handler.js
@@ -22,7 +22,8 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
22
22
  // Do not add import.meta.url workarounds here.
23
23
 
24
24
  const BUILTIN_ALIASES = {
25
- 'framework-default': _nodePath.default.resolve(__dirname, 'default-reporter.js')
25
+ 'framework-default': _path.default.resolve(__dirname, 'default-reporter.js'),
26
+ 'html-report': _path.default.resolve(__dirname, 'html-reporter.js')
26
27
  };
27
28
 
28
29
  /**
@@ -49,7 +50,7 @@ function resolveEntry(entry, projectRoot) {
49
50
  }
50
51
 
51
52
  // Relative paths → resolve from consumer project root
52
- const abs = _nodePath.default.resolve(projectRoot, name);
53
+ const abs = _path.default.resolve(projectRoot, name);
53
54
  return opts ? [abs, opts] : abs;
54
55
  }
55
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zohodesk/unit-testing-framework",
3
- "version": "0.0.6-experimental",
3
+ "version": "0.0.7-experimental",
4
4
  "description": "A modular Jest-based unit testing framework",
5
5
  "main": "./build/index.js",
6
6
  "exports": {