@testomatio/reporter 2.7.9-beta.2-markdown → 2.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.
package/lib/client.js CHANGED
@@ -214,9 +214,10 @@ class Client {
214
214
  const { time = 0, example = null, filesBuffers = [], code = null, file, suite_id, test_id, timestamp, links, overwrite, tags, } = testData;
215
215
  let { files = [], manuallyAttachedArtifacts, message = '', meta = {} } = testData;
216
216
  if (stepArtifactPaths.size) {
217
- files = files.filter(f => !isStepArtifact(f, stepArtifactPaths));
217
+ const isStepArtifact = item => stepArtifactPaths.has(typeof item === 'object' ? item?.path : item);
218
+ files = files.filter(f => !isStepArtifact(f));
218
219
  if (Array.isArray(manuallyAttachedArtifacts)) {
219
- manuallyAttachedArtifacts = manuallyAttachedArtifacts.filter(a => !isStepArtifact(a, stepArtifactPaths));
220
+ manuallyAttachedArtifacts = manuallyAttachedArtifacts.filter(a => !isStepArtifact(a));
220
221
  }
221
222
  }
222
223
  meta = Object.entries(meta)
@@ -385,42 +386,27 @@ exports.Client = Client;
385
386
  * referenced by `step.artifacts` at any depth.
386
387
  *
387
388
  * @param {any} steps
389
+ * @param {Set<string>} [paths]
388
390
  * @returns {Set<string>}
389
391
  */
390
- function collectStepArtifactPaths(steps) {
391
- const paths = new Set();
392
+ function collectStepArtifactPaths(steps, paths = new Set()) {
392
393
  if (!Array.isArray(steps))
393
394
  return paths;
394
- const walk = arr => {
395
- for (const step of arr) {
396
- if (!step)
397
- continue;
398
- if (Array.isArray(step.artifacts)) {
399
- for (const a of step.artifacts) {
400
- if (typeof a === 'string')
401
- paths.add(a);
402
- else if (a && typeof a === 'object' && typeof a.path === 'string')
403
- paths.add(a.path);
404
- }
395
+ for (const step of steps) {
396
+ if (!step)
397
+ continue;
398
+ if (Array.isArray(step.artifacts)) {
399
+ for (const a of step.artifacts) {
400
+ if (typeof a === 'string')
401
+ paths.add(a);
402
+ else if (a && typeof a === 'object' && typeof a.path === 'string')
403
+ paths.add(a.path);
405
404
  }
406
- if (Array.isArray(step.steps))
407
- walk(step.steps);
408
405
  }
409
- };
410
- walk(steps);
406
+ collectStepArtifactPaths(step.steps, paths);
407
+ }
411
408
  return paths;
412
409
  }
413
- /**
414
- * @param {string|{path?: string}|null|undefined} item
415
- * @param {Set<string>} paths
416
- * @returns {boolean}
417
- */
418
- function isStepArtifact(item, paths) {
419
- if (!item)
420
- return false;
421
- const p = typeof item === 'object' ? item.path : item;
422
- return typeof p === 'string' && paths.has(p);
423
- }
424
410
  /**
425
411
  *
426
412
  * @param {TestData} testData
@@ -9,11 +9,12 @@ declare class HtmlPipe {
9
9
  htmlOutputPath: string;
10
10
  filenameMsg: string;
11
11
  tests: any[];
12
+ configuration: any;
12
13
  htmlReportDir: string;
13
14
  htmlReportName: string;
14
15
  templateFolderPath: string;
15
16
  templateHtmlPath: string;
16
- createRun(): Promise<void>;
17
+ createRun(params?: {}): Promise<void>;
17
18
  prepareRun(): Promise<void>;
18
19
  updateRun(): void;
19
20
  /**
package/lib/pipe/html.js CHANGED
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const picocolors_1 = __importDefault(require("picocolors"));
11
11
  const handlebars_1 = __importDefault(require("handlebars"));
12
+ const marked_1 = require("marked");
12
13
  const file_url_1 = __importDefault(require("file-url"));
13
14
  const utils_js_1 = require("../utils/utils.js");
14
15
  const constants_js_1 = require("../constants.js");
@@ -26,6 +27,7 @@ class HtmlPipe {
26
27
  this.htmlOutputPath = '';
27
28
  this.filenameMsg = '';
28
29
  this.tests = [];
30
+ this.configuration = null;
29
31
  if (this.isHtml) {
30
32
  this.isEnabled = true;
31
33
  this.htmlReportDir = process.env.TESTOMATIO_HTML_REPORT_FOLDER || constants_js_1.HTML_REPORT.FOLDER;
@@ -49,8 +51,10 @@ class HtmlPipe {
49
51
  debug(picocolors_1.default.yellow('HTML Pipe:'), `Save HTML report: ${this.isEnabled}`, `HTML report folder: ${this.htmlReportDir}, report name: ${this.htmlReportName}`);
50
52
  }
51
53
  }
52
- async createRun() {
53
- // empty
54
+ async createRun(params = {}) {
55
+ if (params?.configuration && typeof params.configuration === 'object') {
56
+ this.configuration = { ...(this.configuration || {}), ...params.configuration };
57
+ }
54
58
  }
55
59
  async prepareRun() { }
56
60
  updateRun() {
@@ -200,6 +204,8 @@ class HtmlPipe {
200
204
  runUrl: this.store.runUrl || '',
201
205
  executionTime: testExecutionSumTime(aggregatedTests),
202
206
  executionDate: getCurrentDateTimeFormatted(),
207
+ description: runParams.description || this.store.coverageDescription || this.store.description || '',
208
+ configuration: buildDisplayConfiguration(this.configuration || this.store.configuration || runParams.configuration || null),
203
209
  tests: aggregatedTests,
204
210
  envVars: collectEnvironmentVariables(),
205
211
  };
@@ -245,6 +251,11 @@ class HtmlPipe {
245
251
  }
246
252
  #loadReportHelpers() {
247
253
  handlebars_1.default.registerHelper('getTestsByStatus', (tests, status) => tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length);
254
+ handlebars_1.default.registerHelper('markdown', value => {
255
+ if (typeof value !== 'string' || !value.trim())
256
+ return '';
257
+ return new handlebars_1.default.SafeString(marked_1.marked.parse(value, { async: false }));
258
+ });
248
259
  handlebars_1.default.registerHelper('formatDuration', milliseconds => {
249
260
  if (!milliseconds || milliseconds === 0)
250
261
  return '0ms';
@@ -948,4 +959,26 @@ function loadTracesFromFiles(test) {
948
959
  }
949
960
  }
950
961
  }
962
+ function buildDisplayConfiguration(configuration) {
963
+ if (!configuration || typeof configuration !== 'object')
964
+ return null;
965
+ const entries = Object.entries(configuration).filter(([k]) => k !== 'tests' && k !== 'suites');
966
+ if (!entries.length)
967
+ return null;
968
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
969
+ return entries.map(([key, value]) => ({ key, value: formatConfigDisplayValue(value) }));
970
+ }
971
+ function formatConfigDisplayValue(value) {
972
+ if (value == null)
973
+ return '';
974
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
975
+ return String(value);
976
+ }
977
+ try {
978
+ return JSON.stringify(value);
979
+ }
980
+ catch (_) {
981
+ return String(value);
982
+ }
983
+ }
951
984
  module.exports = HtmlPipe;
@@ -9,9 +9,10 @@ declare class MarkdownPipe {
9
9
  markdownOutputPath: string;
10
10
  filenameMsg: string;
11
11
  tests: any[];
12
+ configuration: any;
12
13
  markdownReportDir: string;
13
14
  markdownReportName: string;
14
- createRun(): Promise<void>;
15
+ createRun(params?: {}): Promise<void>;
15
16
  prepareRun(): Promise<void>;
16
17
  updateRun(): void;
17
18
  /**
@@ -24,6 +24,7 @@ class MarkdownPipe {
24
24
  this.markdownOutputPath = '';
25
25
  this.filenameMsg = '';
26
26
  this.tests = [];
27
+ this.configuration = null;
27
28
  if (!this.isMarkdown)
28
29
  return;
29
30
  this.isEnabled = true;
@@ -45,8 +46,10 @@ class MarkdownPipe {
45
46
  utils_js_1.fileSystem.createDir(this.markdownReportDir);
46
47
  debug(picocolors_1.default.yellow('Markdown Pipe:'), `Save Markdown report: ${this.isEnabled}`, `Markdown report folder: ${this.markdownReportDir}, report name: ${this.markdownReportName}`);
47
48
  }
48
- async createRun() {
49
- // empty
49
+ async createRun(params = {}) {
50
+ if (params?.configuration && typeof params.configuration === 'object') {
51
+ this.configuration = { ...(this.configuration || {}), ...params.configuration };
52
+ }
50
53
  }
51
54
  async prepareRun() { }
52
55
  updateRun() {
@@ -109,6 +112,8 @@ class MarkdownPipe {
109
112
  isParallel: runParams?.isParallel,
110
113
  executionTime: testExecutionSumTime(aggregated),
111
114
  executionDate: getCurrentDateTimeFormatted(),
115
+ description: runParams?.description || this.store.coverageDescription || this.store.description || '',
116
+ configuration: this.configuration || this.store.configuration || runParams?.configuration || null,
112
117
  tests: aggregated,
113
118
  stats,
114
119
  };
@@ -135,9 +140,46 @@ function renderDocument(data) {
135
140
  const sections = [];
136
141
  sections.push(renderHeader(data));
137
142
  sections.push(renderRunMetadata(data));
143
+ sections.push(renderDescription(data.description));
144
+ sections.push(renderConfiguration(data.configuration));
138
145
  sections.push(renderTests(data.tests));
139
146
  return sections.filter(Boolean).join('\n\n') + '\n';
140
147
  }
148
+ function renderDescription(description) {
149
+ if (typeof description !== 'string')
150
+ return '';
151
+ const trimmed = description.trim();
152
+ if (!trimmed)
153
+ return '';
154
+ return `## Description\n\n${trimmed}`;
155
+ }
156
+ function renderConfiguration(configuration) {
157
+ if (!configuration || typeof configuration !== 'object')
158
+ return '';
159
+ const entries = Object.entries(configuration).filter(([k]) => k !== 'tests' && k !== 'suites');
160
+ if (!entries.length)
161
+ return '';
162
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
163
+ const lines = ['## Configuration', '', '| Key | Value |', '| --- | ----- |'];
164
+ for (const [k, v] of entries) {
165
+ lines.push(`| \`${k}\` | ${formatConfigValue(v)} |`);
166
+ }
167
+ return lines.join('\n');
168
+ }
169
+ function formatConfigValue(value) {
170
+ if (value == null)
171
+ return '';
172
+ if (typeof value === 'boolean' || typeof value === 'number')
173
+ return String(value);
174
+ if (typeof value === 'string')
175
+ return mdInline(value).replace(/\|/g, '\\|');
176
+ try {
177
+ return `\`${JSON.stringify(value)}\``;
178
+ }
179
+ catch (_) {
180
+ return String(value);
181
+ }
182
+ }
141
183
  function renderHeader(data) {
142
184
  const overall = String(data.status || 'unknown').toLowerCase();
143
185
  const headline = `# ${data.title} — ${overall.toUpperCase()}`;
@@ -47,12 +47,13 @@ declare class TestomatioPipe implements Pipe {
47
47
  prepareRun(opts: any): Promise<string[]>;
48
48
  /**
49
49
  * Creates a new run on Testomat.io
50
- * @param {{isBatchEnabled?: boolean, kind?: string}} params
50
+ * @param {{isBatchEnabled?: boolean, kind?: string, configuration?: Record<string, any>}} params
51
51
  * @returns Promise<void>
52
52
  */
53
53
  createRun(params?: {
54
54
  isBatchEnabled?: boolean;
55
55
  kind?: string;
56
+ configuration?: Record<string, any>;
56
57
  }): Promise<void>;
57
58
  runUrl: string;
58
59
  runPublicUrl: any;
@@ -164,7 +164,7 @@ class TestomatioPipe {
164
164
  }
165
165
  /**
166
166
  * Creates a new run on Testomat.io
167
- * @param {{isBatchEnabled?: boolean, kind?: string}} params
167
+ * @param {{isBatchEnabled?: boolean, kind?: string, configuration?: Record<string, any>}} params
168
168
  * @returns Promise<void>
169
169
  */
170
170
  async createRun(params = {}) {
@@ -202,6 +202,14 @@ class TestomatioPipe {
202
202
  suites: coverageConfiguration.suites?.map(id => id.replace(/^S/, '')) || [],
203
203
  };
204
204
  }
205
+ // Merge caller-supplied configuration (e.g. { exploratory: true }) into runParams.configuration.
206
+ // Caller values win on key conflict; coverage-derived tests/suites lists are preserved when not overridden.
207
+ if (params.configuration && typeof params.configuration === 'object') {
208
+ configuration = { ...(configuration || {}), ...params.configuration };
209
+ if (this.store) {
210
+ this.store.configuration = { ...(this.store.configuration || {}), ...params.configuration };
211
+ }
212
+ }
205
213
  const runParams = Object.fromEntries(Object.entries({
206
214
  ci_build_url: buildUrl,
207
215
  api_key: this.apiKey.trim(),
@@ -163,6 +163,126 @@
163
163
  transform: none;
164
164
  }
165
165
 
166
+ .description-section {
167
+ padding: 24px 30px 0;
168
+ background: var(--gray-50);
169
+ }
170
+
171
+ .description-card {
172
+ background: white;
173
+ border: 1px solid var(--gray-200);
174
+ border-radius: var(--border-radius);
175
+ padding: 20px 24px;
176
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
177
+ }
178
+
179
+ .description-title {
180
+ font-size: 16px;
181
+ font-weight: 600;
182
+ color: var(--gray-700);
183
+ margin: 0 0 12px;
184
+ display: flex;
185
+ align-items: center;
186
+ gap: 8px;
187
+ }
188
+
189
+ .description-title i { color: var(--gray-500); }
190
+
191
+ .markdown-body { color: var(--gray-700); line-height: 1.55; font-size: 14px; }
192
+ .markdown-body p { margin: 0 0 10px; }
193
+ .markdown-body p:last-child { margin-bottom: 0; }
194
+ .markdown-body h1,
195
+ .markdown-body h2,
196
+ .markdown-body h3 {
197
+ margin: 14px 0 8px;
198
+ color: var(--gray-800);
199
+ line-height: 1.3;
200
+ }
201
+ .markdown-body h1 { font-size: 20px; }
202
+ .markdown-body h2 { font-size: 17px; }
203
+ .markdown-body h3 { font-size: 15px; }
204
+ .markdown-body ul,
205
+ .markdown-body ol { margin: 6px 0 10px 22px; padding: 0; }
206
+ .markdown-body li { margin: 2px 0; }
207
+ .markdown-body code {
208
+ background: var(--gray-100);
209
+ border-radius: 4px;
210
+ padding: 1px 5px;
211
+ font-size: 12.5px;
212
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
213
+ }
214
+ .markdown-body pre {
215
+ background: var(--gray-100);
216
+ border-radius: 6px;
217
+ padding: 10px 12px;
218
+ overflow-x: auto;
219
+ margin: 8px 0;
220
+ }
221
+ .markdown-body pre code { background: transparent; padding: 0; }
222
+ .markdown-body a { color: var(--primary, #2563eb); text-decoration: underline; }
223
+ .markdown-body blockquote {
224
+ margin: 8px 0;
225
+ padding: 4px 12px;
226
+ border-left: 3px solid var(--gray-300);
227
+ color: var(--gray-600);
228
+ }
229
+
230
+ .configuration-section {
231
+ padding: 16px 30px 0;
232
+ background: var(--gray-50);
233
+ }
234
+
235
+ .configuration-card {
236
+ background: white;
237
+ border: 1px solid var(--gray-200);
238
+ border-radius: var(--border-radius);
239
+ padding: 20px 24px;
240
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
241
+ }
242
+
243
+ .configuration-title {
244
+ font-size: 16px;
245
+ font-weight: 600;
246
+ color: var(--gray-700);
247
+ margin: 0 0 12px;
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 8px;
251
+ }
252
+
253
+ .configuration-title i { color: var(--gray-500); }
254
+
255
+ .configuration-table {
256
+ width: 100%;
257
+ border-collapse: collapse;
258
+ font-size: 14px;
259
+ }
260
+
261
+ .configuration-table th,
262
+ .configuration-table td {
263
+ padding: 6px 12px;
264
+ text-align: left;
265
+ border-bottom: 1px solid var(--gray-100);
266
+ }
267
+
268
+ .configuration-table tr:last-child th,
269
+ .configuration-table tr:last-child td { border-bottom: 0; }
270
+
271
+ .configuration-table th {
272
+ font-weight: 600;
273
+ color: var(--gray-600);
274
+ width: 30%;
275
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
276
+ font-size: 12.5px;
277
+ }
278
+
279
+ .configuration-table td {
280
+ color: var(--gray-800);
281
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
282
+ font-size: 12.5px;
283
+ word-break: break-word;
284
+ }
285
+
166
286
  .stats-section {
167
287
  padding: 30px;
168
288
  background: var(--gray-50);
@@ -2078,6 +2198,39 @@
2078
2198
  </div>
2079
2199
  </header>
2080
2200
 
2201
+ {{#if description}}
2202
+ <section class='description-section'>
2203
+ <div class='description-card'>
2204
+ <h2 class='description-title'>
2205
+ <i class='fas fa-info-circle'></i>
2206
+ Description
2207
+ </h2>
2208
+ <div class='description-body markdown-body'>{{markdown description}}</div>
2209
+ </div>
2210
+ </section>
2211
+ {{/if}}
2212
+
2213
+ {{#if configuration}}
2214
+ <section class='configuration-section'>
2215
+ <div class='configuration-card'>
2216
+ <h2 class='configuration-title'>
2217
+ <i class='fas fa-sliders-h'></i>
2218
+ Configuration
2219
+ </h2>
2220
+ <table class='configuration-table'>
2221
+ <tbody>
2222
+ {{#each configuration}}
2223
+ <tr>
2224
+ <th>{{this.key}}</th>
2225
+ <td>{{this.value}}</td>
2226
+ </tr>
2227
+ {{/each}}
2228
+ </tbody>
2229
+ </table>
2230
+ </div>
2231
+ </section>
2232
+ {{/if}}
2233
+
2081
2234
  <section class='stats-section'>
2082
2235
  <div class='stats-grid'>
2083
2236
  <div class='chart-container'>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.7.9-beta.2-markdown",
3
+ "version": "2.8.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -36,6 +36,7 @@
36
36
  "json-cycle": "^1.3.0",
37
37
  "lodash.memoize": "^4.1.2",
38
38
  "lodash.merge": "^4.6.2",
39
+ "marked": "^14.1.4",
39
40
  "minimatch": "^10.2.4",
40
41
  "picocolors": "^1.0.1",
41
42
  "pretty-ms": "^7.0.1",
package/src/client.js CHANGED
@@ -245,9 +245,10 @@ class Client {
245
245
  let { files = [], manuallyAttachedArtifacts, message = '', meta = {} } = testData;
246
246
 
247
247
  if (stepArtifactPaths.size) {
248
- files = files.filter(f => !isStepArtifact(f, stepArtifactPaths));
248
+ const isStepArtifact = item => stepArtifactPaths.has(typeof item === 'object' ? item?.path : item);
249
+ files = files.filter(f => !isStepArtifact(f));
249
250
  if (Array.isArray(manuallyAttachedArtifacts)) {
250
- manuallyAttachedArtifacts = manuallyAttachedArtifacts.filter(a => !isStepArtifact(a, stepArtifactPaths));
251
+ manuallyAttachedArtifacts = manuallyAttachedArtifacts.filter(a => !isStepArtifact(a));
251
252
  }
252
253
  }
253
254
 
@@ -464,38 +465,24 @@ class Client {
464
465
  * referenced by `step.artifacts` at any depth.
465
466
  *
466
467
  * @param {any} steps
468
+ * @param {Set<string>} [paths]
467
469
  * @returns {Set<string>}
468
470
  */
469
- function collectStepArtifactPaths(steps) {
470
- const paths = new Set();
471
+ function collectStepArtifactPaths(steps, paths = new Set()) {
471
472
  if (!Array.isArray(steps)) return paths;
472
- const walk = arr => {
473
- for (const step of arr) {
474
- if (!step) continue;
475
- if (Array.isArray(step.artifacts)) {
476
- for (const a of step.artifacts) {
477
- if (typeof a === 'string') paths.add(a);
478
- else if (a && typeof a === 'object' && typeof a.path === 'string') paths.add(a.path);
479
- }
473
+ for (const step of steps) {
474
+ if (!step) continue;
475
+ if (Array.isArray(step.artifacts)) {
476
+ for (const a of step.artifacts) {
477
+ if (typeof a === 'string') paths.add(a);
478
+ else if (a && typeof a === 'object' && typeof a.path === 'string') paths.add(a.path);
480
479
  }
481
- if (Array.isArray(step.steps)) walk(step.steps);
482
480
  }
483
- };
484
- walk(steps);
481
+ collectStepArtifactPaths(step.steps, paths);
482
+ }
485
483
  return paths;
486
484
  }
487
485
 
488
- /**
489
- * @param {string|{path?: string}|null|undefined} item
490
- * @param {Set<string>} paths
491
- * @returns {boolean}
492
- */
493
- function isStepArtifact(item, paths) {
494
- if (!item) return false;
495
- const p = typeof item === 'object' ? item.path : item;
496
- return typeof p === 'string' && paths.has(p);
497
- }
498
-
499
486
  /**
500
487
  *
501
488
  * @param {TestData} testData
package/src/pipe/html.js CHANGED
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import pc from 'picocolors';
6
6
  import handlebars from 'handlebars';
7
+ import { marked } from 'marked';
7
8
  import fileUrl from 'file-url';
8
9
  import { fileSystem, isSameTest, ansiRegExp, formatStep } from '../utils/utils.js';
9
10
  import { HTML_REPORT } from '../constants.js';
@@ -27,6 +28,7 @@ class HtmlPipe {
27
28
  this.htmlOutputPath = '';
28
29
  this.filenameMsg = '';
29
30
  this.tests = [];
31
+ this.configuration = null;
30
32
 
31
33
  if (this.isHtml) {
32
34
  this.isEnabled = true;
@@ -61,8 +63,10 @@ class HtmlPipe {
61
63
  }
62
64
  }
63
65
 
64
- async createRun() {
65
- // empty
66
+ async createRun(params = {}) {
67
+ if (params?.configuration && typeof params.configuration === 'object') {
68
+ this.configuration = { ...(this.configuration || {}), ...params.configuration };
69
+ }
66
70
  }
67
71
 
68
72
  async prepareRun() {}
@@ -250,6 +254,10 @@ class HtmlPipe {
250
254
  runUrl: this.store.runUrl || '',
251
255
  executionTime: testExecutionSumTime(aggregatedTests),
252
256
  executionDate: getCurrentDateTimeFormatted(),
257
+ description: runParams.description || this.store.coverageDescription || this.store.description || '',
258
+ configuration: buildDisplayConfiguration(
259
+ this.configuration || this.store.configuration || runParams.configuration || null,
260
+ ),
253
261
  tests: aggregatedTests,
254
262
  envVars: collectEnvironmentVariables(),
255
263
  };
@@ -304,6 +312,11 @@ class HtmlPipe {
304
312
  (tests, status) => tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length,
305
313
  );
306
314
 
315
+ handlebars.registerHelper('markdown', value => {
316
+ if (typeof value !== 'string' || !value.trim()) return '';
317
+ return new handlebars.SafeString(marked.parse(value, { async: false }));
318
+ });
319
+
307
320
  handlebars.registerHelper('formatDuration', milliseconds => {
308
321
  if (!milliseconds || milliseconds === 0) return '0ms';
309
322
 
@@ -1110,4 +1123,24 @@ function loadTracesFromFiles(test) {
1110
1123
  }
1111
1124
  }
1112
1125
 
1126
+ function buildDisplayConfiguration(configuration) {
1127
+ if (!configuration || typeof configuration !== 'object') return null;
1128
+ const entries = Object.entries(configuration).filter(([k]) => k !== 'tests' && k !== 'suites');
1129
+ if (!entries.length) return null;
1130
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
1131
+ return entries.map(([key, value]) => ({ key, value: formatConfigDisplayValue(value) }));
1132
+ }
1133
+
1134
+ function formatConfigDisplayValue(value) {
1135
+ if (value == null) return '';
1136
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
1137
+ return String(value);
1138
+ }
1139
+ try {
1140
+ return JSON.stringify(value);
1141
+ } catch (_) {
1142
+ return String(value);
1143
+ }
1144
+ }
1145
+
1113
1146
  export default HtmlPipe;
@@ -24,6 +24,7 @@ class MarkdownPipe {
24
24
  this.markdownOutputPath = '';
25
25
  this.filenameMsg = '';
26
26
  this.tests = [];
27
+ this.configuration = null;
27
28
 
28
29
  if (!this.isMarkdown) return;
29
30
 
@@ -52,8 +53,10 @@ class MarkdownPipe {
52
53
  );
53
54
  }
54
55
 
55
- async createRun() {
56
- // empty
56
+ async createRun(params = {}) {
57
+ if (params?.configuration && typeof params.configuration === 'object') {
58
+ this.configuration = { ...(this.configuration || {}), ...params.configuration };
59
+ }
57
60
  }
58
61
 
59
62
  async prepareRun() {}
@@ -131,6 +134,8 @@ class MarkdownPipe {
131
134
  isParallel: runParams?.isParallel,
132
135
  executionTime: testExecutionSumTime(aggregated),
133
136
  executionDate: getCurrentDateTimeFormatted(),
137
+ description: runParams?.description || this.store.coverageDescription || this.store.description || '',
138
+ configuration: this.configuration || this.store.configuration || runParams?.configuration || null,
134
139
  tests: aggregated,
135
140
  stats,
136
141
  };
@@ -162,10 +167,44 @@ function renderDocument(data) {
162
167
  const sections = [];
163
168
  sections.push(renderHeader(data));
164
169
  sections.push(renderRunMetadata(data));
170
+ sections.push(renderDescription(data.description));
171
+ sections.push(renderConfiguration(data.configuration));
165
172
  sections.push(renderTests(data.tests));
166
173
  return sections.filter(Boolean).join('\n\n') + '\n';
167
174
  }
168
175
 
176
+ function renderDescription(description) {
177
+ if (typeof description !== 'string') return '';
178
+ const trimmed = description.trim();
179
+ if (!trimmed) return '';
180
+ return `## Description\n\n${trimmed}`;
181
+ }
182
+
183
+ function renderConfiguration(configuration) {
184
+ if (!configuration || typeof configuration !== 'object') return '';
185
+ const entries = Object.entries(configuration).filter(([k]) => k !== 'tests' && k !== 'suites');
186
+ if (!entries.length) return '';
187
+
188
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
189
+
190
+ const lines = ['## Configuration', '', '| Key | Value |', '| --- | ----- |'];
191
+ for (const [k, v] of entries) {
192
+ lines.push(`| \`${k}\` | ${formatConfigValue(v)} |`);
193
+ }
194
+ return lines.join('\n');
195
+ }
196
+
197
+ function formatConfigValue(value) {
198
+ if (value == null) return '';
199
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
200
+ if (typeof value === 'string') return mdInline(value).replace(/\|/g, '\\|');
201
+ try {
202
+ return `\`${JSON.stringify(value)}\``;
203
+ } catch (_) {
204
+ return String(value);
205
+ }
206
+ }
207
+
169
208
  function renderHeader(data) {
170
209
  const overall = String(data.status || 'unknown').toLowerCase();
171
210
  const headline = `# ${data.title} — ${overall.toUpperCase()}`;
@@ -194,7 +194,7 @@ class TestomatioPipe {
194
194
 
195
195
  /**
196
196
  * Creates a new run on Testomat.io
197
- * @param {{isBatchEnabled?: boolean, kind?: string}} params
197
+ * @param {{isBatchEnabled?: boolean, kind?: string, configuration?: Record<string, any>}} params
198
198
  * @returns Promise<void>
199
199
  */
200
200
  async createRun(params = {}) {
@@ -236,6 +236,15 @@ class TestomatioPipe {
236
236
  suites: coverageConfiguration.suites?.map(id => id.replace(/^S/, '')) || [],
237
237
  };
238
238
  }
239
+
240
+ // Merge caller-supplied configuration (e.g. { exploratory: true }) into runParams.configuration.
241
+ // Caller values win on key conflict; coverage-derived tests/suites lists are preserved when not overridden.
242
+ if (params.configuration && typeof params.configuration === 'object') {
243
+ configuration = { ...(configuration || {}), ...params.configuration };
244
+ if (this.store) {
245
+ this.store.configuration = { ...(this.store.configuration || {}), ...params.configuration };
246
+ }
247
+ }
239
248
  const runParams = Object.fromEntries(
240
249
  Object.entries({
241
250
  ci_build_url: buildUrl,
@@ -163,6 +163,126 @@
163
163
  transform: none;
164
164
  }
165
165
 
166
+ .description-section {
167
+ padding: 24px 30px 0;
168
+ background: var(--gray-50);
169
+ }
170
+
171
+ .description-card {
172
+ background: white;
173
+ border: 1px solid var(--gray-200);
174
+ border-radius: var(--border-radius);
175
+ padding: 20px 24px;
176
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
177
+ }
178
+
179
+ .description-title {
180
+ font-size: 16px;
181
+ font-weight: 600;
182
+ color: var(--gray-700);
183
+ margin: 0 0 12px;
184
+ display: flex;
185
+ align-items: center;
186
+ gap: 8px;
187
+ }
188
+
189
+ .description-title i { color: var(--gray-500); }
190
+
191
+ .markdown-body { color: var(--gray-700); line-height: 1.55; font-size: 14px; }
192
+ .markdown-body p { margin: 0 0 10px; }
193
+ .markdown-body p:last-child { margin-bottom: 0; }
194
+ .markdown-body h1,
195
+ .markdown-body h2,
196
+ .markdown-body h3 {
197
+ margin: 14px 0 8px;
198
+ color: var(--gray-800);
199
+ line-height: 1.3;
200
+ }
201
+ .markdown-body h1 { font-size: 20px; }
202
+ .markdown-body h2 { font-size: 17px; }
203
+ .markdown-body h3 { font-size: 15px; }
204
+ .markdown-body ul,
205
+ .markdown-body ol { margin: 6px 0 10px 22px; padding: 0; }
206
+ .markdown-body li { margin: 2px 0; }
207
+ .markdown-body code {
208
+ background: var(--gray-100);
209
+ border-radius: 4px;
210
+ padding: 1px 5px;
211
+ font-size: 12.5px;
212
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
213
+ }
214
+ .markdown-body pre {
215
+ background: var(--gray-100);
216
+ border-radius: 6px;
217
+ padding: 10px 12px;
218
+ overflow-x: auto;
219
+ margin: 8px 0;
220
+ }
221
+ .markdown-body pre code { background: transparent; padding: 0; }
222
+ .markdown-body a { color: var(--primary, #2563eb); text-decoration: underline; }
223
+ .markdown-body blockquote {
224
+ margin: 8px 0;
225
+ padding: 4px 12px;
226
+ border-left: 3px solid var(--gray-300);
227
+ color: var(--gray-600);
228
+ }
229
+
230
+ .configuration-section {
231
+ padding: 16px 30px 0;
232
+ background: var(--gray-50);
233
+ }
234
+
235
+ .configuration-card {
236
+ background: white;
237
+ border: 1px solid var(--gray-200);
238
+ border-radius: var(--border-radius);
239
+ padding: 20px 24px;
240
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
241
+ }
242
+
243
+ .configuration-title {
244
+ font-size: 16px;
245
+ font-weight: 600;
246
+ color: var(--gray-700);
247
+ margin: 0 0 12px;
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 8px;
251
+ }
252
+
253
+ .configuration-title i { color: var(--gray-500); }
254
+
255
+ .configuration-table {
256
+ width: 100%;
257
+ border-collapse: collapse;
258
+ font-size: 14px;
259
+ }
260
+
261
+ .configuration-table th,
262
+ .configuration-table td {
263
+ padding: 6px 12px;
264
+ text-align: left;
265
+ border-bottom: 1px solid var(--gray-100);
266
+ }
267
+
268
+ .configuration-table tr:last-child th,
269
+ .configuration-table tr:last-child td { border-bottom: 0; }
270
+
271
+ .configuration-table th {
272
+ font-weight: 600;
273
+ color: var(--gray-600);
274
+ width: 30%;
275
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
276
+ font-size: 12.5px;
277
+ }
278
+
279
+ .configuration-table td {
280
+ color: var(--gray-800);
281
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
282
+ font-size: 12.5px;
283
+ word-break: break-word;
284
+ }
285
+
166
286
  .stats-section {
167
287
  padding: 30px;
168
288
  background: var(--gray-50);
@@ -2078,6 +2198,39 @@
2078
2198
  </div>
2079
2199
  </header>
2080
2200
 
2201
+ {{#if description}}
2202
+ <section class='description-section'>
2203
+ <div class='description-card'>
2204
+ <h2 class='description-title'>
2205
+ <i class='fas fa-info-circle'></i>
2206
+ Description
2207
+ </h2>
2208
+ <div class='description-body markdown-body'>{{markdown description}}</div>
2209
+ </div>
2210
+ </section>
2211
+ {{/if}}
2212
+
2213
+ {{#if configuration}}
2214
+ <section class='configuration-section'>
2215
+ <div class='configuration-card'>
2216
+ <h2 class='configuration-title'>
2217
+ <i class='fas fa-sliders-h'></i>
2218
+ Configuration
2219
+ </h2>
2220
+ <table class='configuration-table'>
2221
+ <tbody>
2222
+ {{#each configuration}}
2223
+ <tr>
2224
+ <th>{{this.key}}</th>
2225
+ <td>{{this.value}}</td>
2226
+ </tr>
2227
+ {{/each}}
2228
+ </tbody>
2229
+ </table>
2230
+ </div>
2231
+ </section>
2232
+ {{/if}}
2233
+
2081
2234
  <section class='stats-section'>
2082
2235
  <div class='stats-grid'>
2083
2236
  <div class='chart-container'>