@testomatio/reporter 2.7.9-beta.1-markdown → 2.7.9-beta.3-markdown

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/README.md CHANGED
@@ -135,6 +135,7 @@ Bring this reporter on CI and never lose test results again!
135
135
  - [Gitlab](./docs/pipes/gitlab.md)
136
136
  - [CSV](./docs/pipes/csv.md)
137
137
  - [HTML report](./docs/pipes/html.md)
138
+ - [Markdown report](./docs/pipes/markdown.md)
138
139
  - [Bitbucket](./docs/pipes/bitbucket.md)
139
140
  - 🔗 [Linking Tests](./docs/linking-tests.md)
140
141
  - 📓 [JUnit](./docs/junit.md)
package/lib/client.js CHANGED
@@ -199,6 +199,9 @@ class Client {
199
199
  */
200
200
  const { rid, error = null, steps: originalSteps, title, suite_title } = testData;
201
201
  let steps = originalSteps;
202
+ // Capture step artifact paths BEFORE uploadStepArtifacts mutates them to URLs,
203
+ // so we can exclude them from the test-level artifacts list later.
204
+ const stepArtifactPaths = collectStepArtifactPaths(steps);
202
205
  // Upload artifacts from steps
203
206
  try {
204
207
  await this.uploadStepArtifacts(steps, rid);
@@ -208,8 +211,14 @@ class Client {
208
211
  }
209
212
  const uploadedFiles = [];
210
213
  const stackArtifactsEnabled = (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_STACK_ARTIFACTS);
211
- const { time = 0, example = null, files = [], filesBuffers = [], code = null, file, suite_id, test_id, timestamp, links, manuallyAttachedArtifacts, overwrite, tags, } = testData;
212
- let { message = '', meta = {} } = testData;
214
+ const { time = 0, example = null, filesBuffers = [], code = null, file, suite_id, test_id, timestamp, links, overwrite, tags, } = testData;
215
+ let { files = [], manuallyAttachedArtifacts, message = '', meta = {} } = testData;
216
+ if (stepArtifactPaths.size) {
217
+ files = files.filter(f => !isStepArtifact(f, stepArtifactPaths));
218
+ if (Array.isArray(manuallyAttachedArtifacts)) {
219
+ manuallyAttachedArtifacts = manuallyAttachedArtifacts.filter(a => !isStepArtifact(a, stepArtifactPaths));
220
+ }
221
+ }
213
222
  meta = Object.entries(meta)
214
223
  .filter(([, value]) => value !== null && value !== undefined)
215
224
  .reduce((acc, [key, value]) => {
@@ -371,6 +380,47 @@ class Client {
371
380
  }
372
381
  }
373
382
  exports.Client = Client;
383
+ /**
384
+ * Walks the step tree and returns the set of artifact path/url values
385
+ * referenced by `step.artifacts` at any depth.
386
+ *
387
+ * @param {any} steps
388
+ * @returns {Set<string>}
389
+ */
390
+ function collectStepArtifactPaths(steps) {
391
+ const paths = new Set();
392
+ if (!Array.isArray(steps))
393
+ 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
+ }
405
+ }
406
+ if (Array.isArray(step.steps))
407
+ walk(step.steps);
408
+ }
409
+ };
410
+ walk(steps);
411
+ return paths;
412
+ }
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
+ }
374
424
  /**
375
425
  *
376
426
  * @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,9 +112,10 @@ 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
- envVars: collectEnvironmentVariables(),
115
119
  };
116
120
  const md = renderDocument(data);
117
121
  fs_1.default.writeFileSync(outputPath, md, 'utf-8');
@@ -136,10 +140,46 @@ function renderDocument(data) {
136
140
  const sections = [];
137
141
  sections.push(renderHeader(data));
138
142
  sections.push(renderRunMetadata(data));
139
- sections.push(renderEnvSection(data.envVars));
143
+ sections.push(renderDescription(data.description));
144
+ sections.push(renderConfiguration(data.configuration));
140
145
  sections.push(renderTests(data.tests));
141
146
  return sections.filter(Boolean).join('\n\n') + '\n';
142
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
+ }
143
183
  function renderHeader(data) {
144
184
  const overall = String(data.status || 'unknown').toLowerCase();
145
185
  const headline = `# ${data.title} — ${overall.toUpperCase()}`;
@@ -176,36 +216,6 @@ function renderRunMetadata(data) {
176
216
  }
177
217
  return lines.join('\n');
178
218
  }
179
- function renderEnvSection(envVars) {
180
- if (!envVars)
181
- return '';
182
- const blocks = ['## Environment'];
183
- const groups = [
184
- { title: 'Testomat.io variables', vars: envVars.testomatio || {} },
185
- { title: 'S3 variables', vars: envVars.s3 || {} },
186
- ];
187
- for (const group of groups) {
188
- const entries = Object.entries(group.vars).filter(([, v]) => v && v.isSet);
189
- if (!entries.length)
190
- continue;
191
- entries.sort((a, b) => a[0].localeCompare(b[0]));
192
- const lines = [];
193
- lines.push('<details>');
194
- lines.push(`<summary>${group.title} (${entries.length})</summary>`);
195
- lines.push('');
196
- lines.push('| Variable | Value |');
197
- lines.push('| -------- | ----- |');
198
- for (const [name, info] of entries) {
199
- lines.push(`| \`${name}\` | ${mdTableCell(String(info.value ?? ''))} |`);
200
- }
201
- lines.push('');
202
- lines.push('</details>');
203
- blocks.push(lines.join('\n'));
204
- }
205
- if (blocks.length === 1)
206
- return '';
207
- return blocks.join('\n\n');
208
- }
209
219
  function renderTests(tests) {
210
220
  if (!Array.isArray(tests) || tests.length === 0) {
211
221
  return '## Tests\n\n_No test results recorded._';
@@ -240,22 +250,27 @@ function renderTest(test) {
240
250
  displayStatus = 'todo';
241
251
  }
242
252
  const duration = formatStepDuration(test.run_time);
243
- let header = `#### ${mdInline(title)}`;
244
- if (duration)
245
- header += ` — ${duration}`;
253
+ const header = `#### ${mdInline(title)}`;
246
254
  const lines = [header];
247
- const meta = [`- **Status:** ${displayStatus}`];
255
+ const rows = [['Status', displayStatus]];
248
256
  const retries = computeRetries(test);
249
257
  if (retries.retryCount > 0) {
250
- let retryLine = `- **Retries:** ${retries.retryCount}`;
258
+ let v = String(retries.retryCount);
251
259
  if (retries.flaky)
252
- retryLine += ' (flaky)';
253
- meta.push(retryLine);
260
+ v += ' (flaky)';
261
+ rows.push(['Retries', v]);
262
+ }
263
+ if (duration) {
264
+ rows.push(['Duration', duration]);
254
265
  }
255
266
  if (typeof test.test_id === 'string' && test.test_id) {
256
- meta.push(`- **Test ID:** \`${test.test_id}\``);
267
+ rows.push(['Test ID', `\`${test.test_id}\``]);
257
268
  }
258
- lines.push(meta.join('\n'));
269
+ const metaTable = ['| Key | Value |', '| --- | ----- |'];
270
+ for (const [k, v] of rows) {
271
+ metaTable.push(`| ${k} | ${v} |`);
272
+ }
273
+ lines.push(metaTable.join('\n'));
259
274
  const stepsBlock = renderSteps(test);
260
275
  if (stepsBlock)
261
276
  lines.push(stepsBlock);
@@ -285,10 +300,27 @@ function renderSteps(test) {
285
300
  }
286
301
  if (typeof steps === 'string' && steps.trim()) {
287
302
  const cleaned = steps.replace((0, utils_js_1.ansiRegExp)(), '').trim();
303
+ const parts = cleaned
304
+ .split(/<br\s*\/?>|\r?\n/i)
305
+ .map(s => s.trim())
306
+ .filter(Boolean);
307
+ if (parts.length > 1) {
308
+ const bullets = parts.map(line => formatStringStepBullet(line)).join('\n');
309
+ return `**Steps**\n\n${bullets}`;
310
+ }
288
311
  return `**Steps**\n\n${fence(cleaned)}`;
289
312
  }
290
313
  return '';
291
314
  }
315
+ function formatStringStepBullet(line) {
316
+ const match = line.match(/^(.*?)[\s ]+(\d+)\s*ms\s*$/i);
317
+ if (match) {
318
+ const title = mdInline(match[1].trim());
319
+ const dur = `${match[2]}ms`;
320
+ return `- ${title} _(${dur})_`;
321
+ }
322
+ return `- ${mdInline(line)}`;
323
+ }
292
324
  function renderStepTree(steps, depth) {
293
325
  const indent = ' '.repeat(depth);
294
326
  const lines = [];
@@ -400,7 +432,10 @@ function renderArtifacts(test) {
400
432
  }
401
433
  if (!items.length)
402
434
  return '';
403
- const lines = ['**Artifacts**', ''];
435
+ const lines = [];
436
+ lines.push('<details>');
437
+ lines.push(`<summary><strong>Artifacts</strong> (${items.length})</summary>`);
438
+ lines.push('');
404
439
  for (const item of items) {
405
440
  if (item.isImage) {
406
441
  lines.push(`- ![${mdInline(item.name)}](${item.href})`);
@@ -409,6 +444,8 @@ function renderArtifacts(test) {
409
444
  lines.push(`- [${mdInline(item.name)}](${item.href})`);
410
445
  }
411
446
  }
447
+ lines.push('');
448
+ lines.push('</details>');
412
449
  return lines.join('\n');
413
450
  }
414
451
  function normalizeArtifact(raw) {
@@ -507,11 +544,6 @@ function mdInline(text) {
507
544
  return '';
508
545
  return String(text).replace(/\r?\n/g, ' ').trim();
509
546
  }
510
- function mdTableCell(text) {
511
- if (text == null)
512
- return '';
513
- return String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>').trim();
514
- }
515
547
  function formatStepDuration(value) {
516
548
  if (typeof value !== 'number' || Number.isNaN(value) || value <= 0)
517
549
  return '';
@@ -672,27 +704,4 @@ function aggregateTestRetries(tests) {
672
704
  });
673
705
  return aggregated;
674
706
  }
675
- const SENSITIVE_PATTERNS = [/TOKEN/, /SECRET/, /PASSWORD/, /KEY/, /^TESTOMATIO$/];
676
- function isSensitiveVarName(name) {
677
- return SENSITIVE_PATTERNS.some(re => re.test(name));
678
- }
679
- function collectEnvironmentVariables() {
680
- const groups = { testomatio: {}, s3: {} };
681
- for (const [name, value] of Object.entries(process.env)) {
682
- if (value === undefined)
683
- continue;
684
- let group = null;
685
- if (name === 'TESTOMATIO' || name.startsWith('TESTOMATIO_'))
686
- group = 'testomatio';
687
- else if (name.startsWith('S3_'))
688
- group = 's3';
689
- if (!group)
690
- continue;
691
- let displayValue = value;
692
- if (isSensitiveVarName(name))
693
- displayValue = '***';
694
- groups[group][name] = { value: displayValue, isSet: true };
695
- }
696
- return groups;
697
- }
698
707
  module.exports = MarkdownPipe;
@@ -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.1-markdown",
3
+ "version": "2.7.9-beta.3-markdown",
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
@@ -215,6 +215,10 @@ class Client {
215
215
  const { rid, error = null, steps: originalSteps, title, suite_title } = testData;
216
216
  let steps = originalSteps;
217
217
 
218
+ // Capture step artifact paths BEFORE uploadStepArtifacts mutates them to URLs,
219
+ // so we can exclude them from the test-level artifacts list later.
220
+ const stepArtifactPaths = collectStepArtifactPaths(steps);
221
+
218
222
  // Upload artifacts from steps
219
223
  try {
220
224
  await this.uploadStepArtifacts(steps, rid);
@@ -228,7 +232,6 @@ class Client {
228
232
  const {
229
233
  time = 0,
230
234
  example = null,
231
- files = [],
232
235
  filesBuffers = [],
233
236
  code = null,
234
237
  file,
@@ -236,11 +239,17 @@ class Client {
236
239
  test_id,
237
240
  timestamp,
238
241
  links,
239
- manuallyAttachedArtifacts,
240
242
  overwrite,
241
243
  tags,
242
244
  } = testData;
243
- let { message = '', meta = {} } = testData;
245
+ let { files = [], manuallyAttachedArtifacts, message = '', meta = {} } = testData;
246
+
247
+ if (stepArtifactPaths.size) {
248
+ files = files.filter(f => !isStepArtifact(f, stepArtifactPaths));
249
+ if (Array.isArray(manuallyAttachedArtifacts)) {
250
+ manuallyAttachedArtifacts = manuallyAttachedArtifacts.filter(a => !isStepArtifact(a, stepArtifactPaths));
251
+ }
252
+ }
244
253
 
245
254
  meta = Object.entries(meta)
246
255
  .filter(([, value]) => value !== null && value !== undefined)
@@ -450,6 +459,43 @@ class Client {
450
459
  }
451
460
  }
452
461
 
462
+ /**
463
+ * Walks the step tree and returns the set of artifact path/url values
464
+ * referenced by `step.artifacts` at any depth.
465
+ *
466
+ * @param {any} steps
467
+ * @returns {Set<string>}
468
+ */
469
+ function collectStepArtifactPaths(steps) {
470
+ const paths = new Set();
471
+ 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
+ }
480
+ }
481
+ if (Array.isArray(step.steps)) walk(step.steps);
482
+ }
483
+ };
484
+ walk(steps);
485
+ return paths;
486
+ }
487
+
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
+
453
499
  /**
454
500
  *
455
501
  * @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,9 +134,10 @@ 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
- envVars: collectEnvironmentVariables(),
137
141
  };
138
142
 
139
143
  const md = renderDocument(data);
@@ -163,11 +167,44 @@ function renderDocument(data) {
163
167
  const sections = [];
164
168
  sections.push(renderHeader(data));
165
169
  sections.push(renderRunMetadata(data));
166
- sections.push(renderEnvSection(data.envVars));
170
+ sections.push(renderDescription(data.description));
171
+ sections.push(renderConfiguration(data.configuration));
167
172
  sections.push(renderTests(data.tests));
168
173
  return sections.filter(Boolean).join('\n\n') + '\n';
169
174
  }
170
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
+
171
208
  function renderHeader(data) {
172
209
  const overall = String(data.status || 'unknown').toLowerCase();
173
210
  const headline = `# ${data.title} — ${overall.toUpperCase()}`;
@@ -211,41 +248,6 @@ function renderRunMetadata(data) {
211
248
  return lines.join('\n');
212
249
  }
213
250
 
214
- function renderEnvSection(envVars) {
215
- if (!envVars) return '';
216
-
217
- const blocks = ['## Environment'];
218
-
219
- const groups = [
220
- { title: 'Testomat.io variables', vars: envVars.testomatio || {} },
221
- { title: 'S3 variables', vars: envVars.s3 || {} },
222
- ];
223
-
224
- for (const group of groups) {
225
- const entries = Object.entries(group.vars).filter(([, v]) => v && v.isSet);
226
- if (!entries.length) continue;
227
-
228
- entries.sort((a, b) => a[0].localeCompare(b[0]));
229
-
230
- const lines = [];
231
- lines.push('<details>');
232
- lines.push(`<summary>${group.title} (${entries.length})</summary>`);
233
- lines.push('');
234
- lines.push('| Variable | Value |');
235
- lines.push('| -------- | ----- |');
236
- for (const [name, info] of entries) {
237
- lines.push(`| \`${name}\` | ${mdTableCell(String(info.value ?? ''))} |`);
238
- }
239
- lines.push('');
240
- lines.push('</details>');
241
-
242
- blocks.push(lines.join('\n'));
243
- }
244
-
245
- if (blocks.length === 1) return '';
246
- return blocks.join('\n\n');
247
- }
248
-
249
251
  function renderTests(tests) {
250
252
  if (!Array.isArray(tests) || tests.length === 0) {
251
253
  return '## Tests\n\n_No test results recorded._';
@@ -286,23 +288,30 @@ function renderTest(test) {
286
288
  }
287
289
 
288
290
  const duration = formatStepDuration(test.run_time);
289
- let header = `#### ${mdInline(title)}`;
290
- if (duration) header += ` — ${duration}`;
291
+ const header = `#### ${mdInline(title)}`;
291
292
 
292
293
  const lines = [header];
293
294
 
294
- const meta = [`- **Status:** ${displayStatus}`];
295
+ const rows = [['Status', displayStatus]];
295
296
 
296
297
  const retries = computeRetries(test);
297
298
  if (retries.retryCount > 0) {
298
- let retryLine = `- **Retries:** ${retries.retryCount}`;
299
- if (retries.flaky) retryLine += ' (flaky)';
300
- meta.push(retryLine);
299
+ let v = String(retries.retryCount);
300
+ if (retries.flaky) v += ' (flaky)';
301
+ rows.push(['Retries', v]);
302
+ }
303
+ if (duration) {
304
+ rows.push(['Duration', duration]);
301
305
  }
302
306
  if (typeof test.test_id === 'string' && test.test_id) {
303
- meta.push(`- **Test ID:** \`${test.test_id}\``);
307
+ rows.push(['Test ID', `\`${test.test_id}\``]);
304
308
  }
305
- lines.push(meta.join('\n'));
309
+
310
+ const metaTable = ['| Key | Value |', '| --- | ----- |'];
311
+ for (const [k, v] of rows) {
312
+ metaTable.push(`| ${k} | ${v} |`);
313
+ }
314
+ lines.push(metaTable.join('\n'));
306
315
 
307
316
  const stepsBlock = renderSteps(test);
308
317
  if (stepsBlock) lines.push(stepsBlock);
@@ -335,12 +344,32 @@ function renderSteps(test) {
335
344
 
336
345
  if (typeof steps === 'string' && steps.trim()) {
337
346
  const cleaned = steps.replace(ansiRegExp(), '').trim();
347
+ const parts = cleaned
348
+ .split(/<br\s*\/?>|\r?\n/i)
349
+ .map(s => s.trim())
350
+ .filter(Boolean);
351
+
352
+ if (parts.length > 1) {
353
+ const bullets = parts.map(line => formatStringStepBullet(line)).join('\n');
354
+ return `**Steps**\n\n${bullets}`;
355
+ }
356
+
338
357
  return `**Steps**\n\n${fence(cleaned)}`;
339
358
  }
340
359
 
341
360
  return '';
342
361
  }
343
362
 
363
+ function formatStringStepBullet(line) {
364
+ const match = line.match(/^(.*?)[\s ]+(\d+)\s*ms\s*$/i);
365
+ if (match) {
366
+ const title = mdInline(match[1].trim());
367
+ const dur = `${match[2]}ms`;
368
+ return `- ${title} _(${dur})_`;
369
+ }
370
+ return `- ${mdInline(line)}`;
371
+ }
372
+
344
373
  function renderStepTree(steps, depth) {
345
374
  const indent = ' '.repeat(depth);
346
375
  const lines = [];
@@ -445,7 +474,10 @@ function renderArtifacts(test) {
445
474
 
446
475
  if (!items.length) return '';
447
476
 
448
- const lines = ['**Artifacts**', ''];
477
+ const lines = [];
478
+ lines.push('<details>');
479
+ lines.push(`<summary><strong>Artifacts</strong> (${items.length})</summary>`);
480
+ lines.push('');
449
481
  for (const item of items) {
450
482
  if (item.isImage) {
451
483
  lines.push(`- ![${mdInline(item.name)}](${item.href})`);
@@ -453,6 +485,8 @@ function renderArtifacts(test) {
453
485
  lines.push(`- [${mdInline(item.name)}](${item.href})`);
454
486
  }
455
487
  }
488
+ lines.push('');
489
+ lines.push('</details>');
456
490
  return lines.join('\n');
457
491
  }
458
492
 
@@ -549,11 +583,6 @@ function mdInline(text) {
549
583
  return String(text).replace(/\r?\n/g, ' ').trim();
550
584
  }
551
585
 
552
- function mdTableCell(text) {
553
- if (text == null) return '';
554
- return String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>').trim();
555
- }
556
-
557
586
  function formatStepDuration(value) {
558
587
  if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) return '';
559
588
  if (value < 1000) return `${value}ms`;
@@ -714,30 +743,4 @@ function aggregateTestRetries(tests) {
714
743
  return aggregated;
715
744
  }
716
745
 
717
- const SENSITIVE_PATTERNS = [/TOKEN/, /SECRET/, /PASSWORD/, /KEY/, /^TESTOMATIO$/];
718
-
719
- function isSensitiveVarName(name) {
720
- return SENSITIVE_PATTERNS.some(re => re.test(name));
721
- }
722
-
723
- function collectEnvironmentVariables() {
724
- const groups = { testomatio: {}, s3: {} };
725
-
726
- for (const [name, value] of Object.entries(process.env)) {
727
- if (value === undefined) continue;
728
-
729
- let group = null;
730
- if (name === 'TESTOMATIO' || name.startsWith('TESTOMATIO_')) group = 'testomatio';
731
- else if (name.startsWith('S3_')) group = 's3';
732
- if (!group) continue;
733
-
734
- let displayValue = value;
735
- if (isSensitiveVarName(name)) displayValue = '***';
736
-
737
- groups[group][name] = { value: displayValue, isSet: true };
738
- }
739
-
740
- return groups;
741
- }
742
-
743
746
  export default MarkdownPipe;
@@ -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'>