ortoni-report 3.0.5 → 4.0.1-beta.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.
Files changed (46) hide show
  1. package/changelog.md +30 -0
  2. package/dist/chunk-45EJSEX2.mjs +632 -0
  3. package/dist/chunk-75EAJL2U.mjs +632 -0
  4. package/dist/chunk-FGIYOFIC.mjs +632 -0
  5. package/dist/chunk-FHKWBHU6.mjs +633 -0
  6. package/dist/chunk-GLICR3VS.mjs +637 -0
  7. package/dist/chunk-HFO6XSKC.mjs +633 -0
  8. package/dist/chunk-HOZD6YIV.mjs +634 -0
  9. package/dist/chunk-IJO2YIFE.mjs +637 -0
  10. package/dist/chunk-INS3E7E6.mjs +638 -0
  11. package/dist/chunk-JEIWNUQY.mjs +632 -0
  12. package/dist/chunk-JPLAGYR7.mjs +632 -0
  13. package/dist/chunk-MPZLDOCN.mjs +631 -0
  14. package/dist/chunk-NM6ULN2O.mjs +632 -0
  15. package/dist/chunk-OZS6QIJS.mjs +638 -0
  16. package/dist/chunk-P57227VN.mjs +633 -0
  17. package/dist/chunk-QMTRYN5N.js +635 -0
  18. package/dist/chunk-TI33PMMQ.mjs +639 -0
  19. package/dist/chunk-Z5NBP5TS.mjs +635 -0
  20. package/dist/cli/cli.cjs +678 -0
  21. package/dist/cli/cli.d.cts +1 -0
  22. package/dist/cli/cli.js +609 -18
  23. package/dist/cli/cli.mjs +89 -9
  24. package/dist/index.html +21 -0
  25. package/dist/ortoni-report.cjs +2134 -0
  26. package/dist/ortoni-report.d.cts +111 -0
  27. package/dist/ortoni-report.d.mts +3 -12
  28. package/dist/ortoni-report.d.ts +3 -12
  29. package/dist/ortoni-report.js +201 -326
  30. package/dist/ortoni-report.mjs +78 -746
  31. package/package.json +4 -5
  32. package/readme.md +26 -33
  33. package/dist/chunk-AY2PKDHU.mjs +0 -69
  34. package/dist/chunk-OOALU4XG.mjs +0 -72
  35. package/dist/chunk-ZSIRUQUA.mjs +0 -68
  36. package/dist/style/main.css +0 -80
  37. package/dist/views/analytics.hbs +0 -103
  38. package/dist/views/head.hbs +0 -11
  39. package/dist/views/main.hbs +0 -1295
  40. package/dist/views/project.hbs +0 -238
  41. package/dist/views/sidebar.hbs +0 -244
  42. package/dist/views/summaryCard.hbs +0 -15
  43. package/dist/views/testIcons.hbs +0 -13
  44. package/dist/views/testPanel.hbs +0 -45
  45. package/dist/views/testStatus.hbs +0 -9
  46. package/dist/views/userInfo.hbs +0 -260
@@ -54,17 +54,23 @@ var FileManager = class {
54
54
  }
55
55
  }
56
56
  }
57
- writeReportFile(filename, content) {
57
+ writeReportFile(filename, data) {
58
+ const templatePath = import_path.default.join(__dirname, "..", "index.html");
59
+ let html = import_fs.default.readFileSync(templatePath, "utf-8");
60
+ const reportJSON = JSON.stringify({
61
+ data
62
+ });
63
+ html = html.replace("__ORTONI_TEST_REPORTDATA__", reportJSON);
64
+ import_fs.default.writeFileSync(filename, html);
65
+ return filename;
66
+ }
67
+ writeRawFile(filename, data) {
58
68
  const outputPath = import_path.default.join(process.cwd(), this.folderPath, filename);
59
- import_fs.default.writeFileSync(outputPath, content);
69
+ import_fs.default.mkdirSync(import_path.default.dirname(outputPath), { recursive: true });
70
+ const content = typeof data === "string" ? data : JSON.stringify(data, null, 2);
71
+ import_fs.default.writeFileSync(outputPath, content, "utf-8");
60
72
  return outputPath;
61
73
  }
62
- readCssContent() {
63
- return import_fs.default.readFileSync(
64
- import_path.default.resolve(__dirname, "style", "main.css"),
65
- "utf-8"
66
- );
67
- }
68
74
  copyTraceViewerAssets(skip) {
69
75
  if (skip) return;
70
76
  const traceViewerFolder = import_path.default.join(
@@ -99,157 +105,48 @@ var FileManager = class {
99
105
  }
100
106
  };
101
107
 
102
- // src/helpers/HTMLGenerator.ts
103
- var import_path3 = __toESM(require("path"));
104
-
105
108
  // src/utils/groupProjects.ts
106
109
  function groupResults(config, results) {
107
110
  if (config.showProject) {
108
111
  const groupedResults = results.reduce((acc, result, index) => {
109
112
  const testId = `${result.filePath}:${result.projectName}:${result.title}`;
113
+ const key = `${testId}-${result.key}-${result.retryAttemptCount}`;
110
114
  const { filePath, suite, projectName } = result;
111
115
  acc[filePath] = acc[filePath] || {};
112
116
  acc[filePath][suite] = acc[filePath][suite] || {};
113
117
  acc[filePath][suite][projectName] = acc[filePath][suite][projectName] || [];
114
- acc[filePath][suite][projectName].push({ ...result, index, testId });
118
+ acc[filePath][suite][projectName].push({ ...result, index, testId, key });
115
119
  return acc;
116
120
  }, {});
117
121
  return groupedResults;
118
122
  } else {
119
123
  const groupedResults = results.reduce((acc, result, index) => {
120
124
  const testId = `${result.filePath}:${result.projectName}:${result.title}`;
125
+ const key = `${testId}-${result.key}-${result.retryAttemptCount}`;
121
126
  const { filePath, suite } = result;
122
127
  acc[filePath] = acc[filePath] || {};
123
128
  acc[filePath][suite] = acc[filePath][suite] || [];
124
- acc[filePath][suite].push({ ...result, index, testId });
129
+ acc[filePath][suite].push({ ...result, index, testId, key });
125
130
  return acc;
126
131
  }, {});
127
132
  return groupedResults;
128
133
  }
129
134
  }
130
135
 
131
- // src/utils/utils.ts
132
- var import_path2 = __toESM(require("path"));
133
- function msToTime(duration) {
134
- const milliseconds = Math.floor(duration % 1e3);
135
- const seconds = Math.floor(duration / 1e3 % 60);
136
- const minutes = Math.floor(duration / (1e3 * 60) % 60);
137
- const hours = Math.floor(duration / (1e3 * 60 * 60) % 24);
138
- let result = "";
139
- if (hours > 0) {
140
- result += `${hours}h:`;
141
- }
142
- if (minutes > 0 || hours > 0) {
143
- result += `${minutes < 10 ? "0" + minutes : minutes}m:`;
144
- }
145
- if (seconds > 0 || minutes > 0 || hours > 0) {
146
- result += `${seconds < 10 ? "0" + seconds : seconds}s`;
147
- }
148
- if (milliseconds > 0 && !(seconds > 0 || minutes > 0 || hours > 0)) {
149
- result += `${milliseconds}ms`;
150
- } else if (milliseconds > 0) {
151
- result += `:${milliseconds < 100 ? "0" + milliseconds : milliseconds}ms`;
152
- }
153
- return result;
154
- }
155
- function normalizeFilePath(filePath) {
156
- const normalizedPath = import_path2.default.normalize(filePath);
157
- return import_path2.default.basename(normalizedPath);
158
- }
159
- function formatDate(date) {
160
- const day = String(date.getDate()).padStart(2, "0");
161
- const month = date.toLocaleString("default", { month: "short" });
162
- const year = date.getFullYear();
163
- const time = date.toLocaleTimeString();
164
- return `${day}-${month}-${year} ${time}`;
165
- }
166
- function safeStringify(obj, indent = 2) {
167
- const cache = /* @__PURE__ */ new Set();
168
- const json = JSON.stringify(
169
- obj,
170
- (key, value) => {
171
- if (typeof value === "object" && value !== null) {
172
- if (cache.has(value)) {
173
- return;
174
- }
175
- cache.add(value);
176
- }
177
- return value;
178
- },
179
- indent
180
- );
181
- cache.clear();
182
- return json;
183
- }
184
- function ensureHtmlExtension(filename) {
185
- const ext = import_path2.default.extname(filename);
186
- if (ext && ext.toLowerCase() === ".html") {
187
- return filename;
188
- }
189
- return `${filename}.html`;
190
- }
191
- function escapeHtml(unsafe) {
192
- if (typeof unsafe !== "string") {
193
- return String(unsafe);
194
- }
195
- return unsafe.replace(/[&<"']/g, function(match) {
196
- const escapeMap = {
197
- "&": "&amp;",
198
- "<": "&lt;",
199
- ">": "&gt;",
200
- '"': "&quot;",
201
- "'": "&#039;"
202
- };
203
- return escapeMap[match] || match;
204
- });
205
- }
206
- function formatDateUTC(date) {
207
- return date.toISOString();
208
- }
209
- function formatDateLocal(isoString) {
210
- const date = new Date(isoString);
211
- const options = {
212
- year: "numeric",
213
- month: "short",
214
- day: "2-digit",
215
- hour: "2-digit",
216
- minute: "2-digit",
217
- hour12: true,
218
- timeZoneName: "shortOffset"
219
- };
220
- return new Intl.DateTimeFormat(void 0, options).format(date);
221
- }
222
- function formatDateNoTimezone(isoString) {
223
- const date = new Date(isoString);
224
- return date.toLocaleString("en-US", {
225
- dateStyle: "medium",
226
- timeStyle: "short"
227
- });
228
- }
229
-
230
136
  // src/helpers/HTMLGenerator.ts
231
- var import_fs2 = __toESM(require("fs"));
232
- var import_handlebars = __toESM(require("handlebars"));
233
137
  var HTMLGenerator = class {
234
138
  constructor(ortoniConfig, dbManager) {
235
139
  this.ortoniConfig = ortoniConfig;
236
- this.registerHandlebarsHelpers();
237
- this.registerPartials();
238
140
  this.dbManager = dbManager;
239
141
  }
240
- async generateHTML(filteredResults, totalDuration, cssContent, results, projectSet) {
142
+ async generateFinalReport(filteredResults, totalDuration, results, projectSet) {
241
143
  const data = await this.prepareReportData(
242
144
  filteredResults,
243
145
  totalDuration,
244
146
  results,
245
147
  projectSet
246
148
  );
247
- const templateSource = import_fs2.default.readFileSync(
248
- import_path3.default.resolve(__dirname, "views", "main.hbs"),
249
- "utf-8"
250
- );
251
- const template = import_handlebars.default.compile(templateSource);
252
- return template({ ...data, inlineCss: cssContent });
149
+ return data;
253
150
  }
254
151
  async getReportData() {
255
152
  return {
@@ -259,18 +156,6 @@ var HTMLGenerator = class {
259
156
  slowTests: await this.dbManager.getSlowTests()
260
157
  };
261
158
  }
262
- async chartTrendData() {
263
- return {
264
- labels: (await this.getReportData()).trends.map(
265
- (t) => formatDateNoTimezone(t.run_date)
266
- ),
267
- passed: (await this.getReportData()).trends.map((t) => t.passed),
268
- failed: (await this.getReportData()).trends.map((t) => t.failed),
269
- avgDuration: (await this.getReportData()).trends.map(
270
- (t) => t.avg_duration
271
- )
272
- };
273
- }
274
159
  async prepareReportData(filteredResults, totalDuration, results, projectSet) {
275
160
  const totalTests = filteredResults.length;
276
161
  const passedTests = results.filter((r) => r.status === "passed").length;
@@ -288,8 +173,7 @@ var HTMLGenerator = class {
288
173
  results,
289
174
  projectSet
290
175
  );
291
- const utcRunDate = formatDateUTC(/* @__PURE__ */ new Date());
292
- const localRunDate = formatDateLocal(utcRunDate);
176
+ const lastRunDate = (/* @__PURE__ */ new Date()).toLocaleString();
293
177
  const testHistories = await Promise.all(
294
178
  results.map(async (result) => {
295
179
  const testId = `${result.filePath}:${result.projectName}:${result.title}`;
@@ -301,34 +185,42 @@ var HTMLGenerator = class {
301
185
  })
302
186
  );
303
187
  return {
304
- utcRunDate,
305
- localRunDate,
306
- testHistories,
307
- logo: this.ortoniConfig.logo || void 0,
308
- totalDuration,
309
- results,
310
- retryCount: results.filter((r) => r.isRetry).length,
311
- passCount: passedTests,
312
- failCount: failed,
313
- skipCount: results.filter((r) => r.status === "skipped").length,
314
- flakyCount: flakyTests,
315
- totalCount: filteredResults.length,
316
- groupedResults: groupResults(this.ortoniConfig, results),
317
- projectName: this.ortoniConfig.projectName,
318
- authorName: this.ortoniConfig.authorName,
319
- meta: this.ortoniConfig.meta,
320
- testType: this.ortoniConfig.testType,
321
- preferredTheme: this.ortoniConfig.preferredTheme,
322
- successRate,
323
- lastRunDate: formatDate(/* @__PURE__ */ new Date()),
324
- projects: projectSet,
325
- allTags: Array.from(allTags),
326
- showProject: this.ortoniConfig.showProject || false,
327
- title: this.ortoniConfig.title || "Ortoni Playwright Test Report",
328
- chartType: this.ortoniConfig.chartType || "pie",
329
- reportAnalyticsData: await this.getReportData(),
330
- chartData: await this.chartTrendData(),
331
- ...this.extractProjectStats(projectResults)
188
+ summary: {
189
+ overAllResult: {
190
+ pass: passedTests,
191
+ fail: failed,
192
+ skip: results.filter((r) => r.status === "skipped").length,
193
+ retry: results.filter((r) => r.retryAttemptCount).length,
194
+ flaky: flakyTests,
195
+ total: filteredResults.length
196
+ },
197
+ successRate,
198
+ lastRunDate,
199
+ totalDuration,
200
+ stats: this.extractProjectStats(projectResults)
201
+ },
202
+ testResult: {
203
+ tests: groupResults(this.ortoniConfig, results),
204
+ testHistories,
205
+ allTags: Array.from(allTags),
206
+ set: projectSet
207
+ },
208
+ userConfig: {
209
+ projectName: this.ortoniConfig.projectName,
210
+ authorName: this.ortoniConfig.authorName,
211
+ type: this.ortoniConfig.testType,
212
+ title: this.ortoniConfig.title
213
+ },
214
+ userMeta: {
215
+ meta: this.ortoniConfig.meta
216
+ },
217
+ preferences: {
218
+ logo: this.ortoniConfig.logo || void 0,
219
+ showProject: this.ortoniConfig.showProject || false
220
+ },
221
+ analytics: {
222
+ reportData: await this.getReportData()
223
+ }
332
224
  };
333
225
  }
334
226
  calculateProjectResults(filteredResults, results, projectSet) {
@@ -346,7 +238,7 @@ var HTMLGenerator = class {
346
238
  (r) => r.status === "failed" || r.status === "timedOut"
347
239
  ).length,
348
240
  skippedTests: allProjectTests.filter((r) => r.status === "skipped").length,
349
- retryTests: allProjectTests.filter((r) => r.isRetry).length,
241
+ retryTests: allProjectTests.filter((r) => r.retryAttemptCount).length,
350
242
  flakyTests: allProjectTests.filter((r) => r.status === "flaky").length,
351
243
  totalTests: projectTests.length
352
244
  };
@@ -363,59 +255,18 @@ var HTMLGenerator = class {
363
255
  flakyTests: projectResults.map((result) => result.flakyTests)
364
256
  };
365
257
  }
366
- registerHandlebarsHelpers() {
367
- import_handlebars.default.registerHelper("joinWithSpace", (array) => array.join(" "));
368
- import_handlebars.default.registerHelper("json", (context) => safeStringify(context));
369
- import_handlebars.default.registerHelper(
370
- "eq",
371
- (actualStatus, expectedStatus) => actualStatus === expectedStatus
372
- );
373
- import_handlebars.default.registerHelper(
374
- "includes",
375
- (actualStatus, expectedStatus) => actualStatus.includes(expectedStatus)
376
- );
377
- import_handlebars.default.registerHelper("gr", (count) => count > 0);
378
- import_handlebars.default.registerHelper("or", function(a3, b2) {
379
- return a3 || b2;
380
- });
381
- import_handlebars.default.registerHelper("concat", function(...args) {
382
- args.pop();
383
- return args.join("");
384
- });
385
- }
386
- registerPartials() {
387
- [
388
- "head",
389
- "sidebar",
390
- "testPanel",
391
- "summaryCard",
392
- "userInfo",
393
- "project",
394
- "testStatus",
395
- "testIcons",
396
- "analytics"
397
- ].forEach((partialName) => {
398
- import_handlebars.default.registerPartial(
399
- partialName,
400
- import_fs2.default.readFileSync(
401
- import_path3.default.resolve(__dirname, "views", `${partialName}.hbs`),
402
- "utf-8"
403
- )
404
- );
405
- });
406
- }
407
258
  };
408
259
 
409
260
  // src/helpers/resultProcessor .ts
410
261
  var import_ansi_to_html = __toESM(require("ansi-to-html"));
411
- var import_path5 = __toESM(require("path"));
262
+ var import_path4 = __toESM(require("path"));
412
263
 
413
264
  // src/utils/attachFiles.ts
414
- var import_path4 = __toESM(require("path"));
415
- var import_fs4 = __toESM(require("fs"));
265
+ var import_path2 = __toESM(require("path"));
266
+ var import_fs3 = __toESM(require("fs"));
416
267
 
417
268
  // src/helpers/markdownConverter.ts
418
- var import_fs3 = __toESM(require("fs"));
269
+ var import_fs2 = __toESM(require("fs"));
419
270
 
420
271
  // node_modules/marked/lib/marked.esm.js
421
272
  function M() {
@@ -1540,108 +1391,44 @@ var Ft = T.parse;
1540
1391
  var Qt = b.lex;
1541
1392
 
1542
1393
  // src/helpers/markdownConverter.ts
1543
- function convertMarkdownToHtml(markdownPath, htmlOutputPath, stepsError, resultError) {
1544
- const hasMarkdown = import_fs3.default.existsSync(markdownPath);
1545
- const markdownContent = hasMarkdown ? import_fs3.default.readFileSync(markdownPath, "utf-8") : "";
1394
+ function convertMarkdownToHtml(markdownPath, htmlOutputPath) {
1395
+ const hasMarkdown = import_fs2.default.existsSync(markdownPath);
1396
+ const markdownContent = hasMarkdown ? import_fs2.default.readFileSync(markdownPath, "utf-8") : "";
1546
1397
  const markdownHtml = hasMarkdown ? k(markdownContent) : "";
1547
- const stepsHtml = stepsError.filter((step) => step.snippet?.trim()).map(
1548
- (step) => `
1549
- <div>
1550
- <pre><code>${step.snippet}</code></pre>
1551
- ${step.location ? `<p><em>Location: ${escapeHtml(step.location)}</em></p>` : ""}
1552
- </div>`
1553
- ).join("\n");
1554
- const errorHtml = resultError.map((error) => `<pre><code>${error}</code></pre>`).join("\n");
1555
- const fullHtml = `
1556
- <!DOCTYPE html>
1557
- <html lang="en">
1558
- <head>
1559
- <meta charset="UTF-8" />
1560
- <title>Ortoni Error Report</title>
1561
- <style>
1562
- body { font-family: sans-serif; padding: 2rem; line-height: 1.6; max-width: 900px; margin: auto; }
1563
- code, pre { background: #f4f4f4; padding: 0.5rem; border-radius: 5px; display: block; overflow-x: auto; }
1564
- h1, h2, h3 { color: #444; }
1565
- hr { margin: 2em 0; }
1566
- #copyBtn {
1567
- background-color: #007acc;
1568
- color: white;
1569
- border: none;
1570
- padding: 0.5rem 1rem;
1571
- margin-bottom: 1rem;
1572
- border-radius: 5px;
1573
- cursor: pointer;
1574
- }
1575
- #copyBtn:hover {
1576
- background-color: #005fa3;
1577
- }
1578
- </style>
1579
- </head>
1580
- <body>
1581
- <button id="copyBtn">\u{1F4CB} Copy All</button>
1582
- <script>
1583
- document.getElementById("copyBtn").addEventListener("click", () => {
1584
- const content = document.getElementById("markdownContent").innerText;
1585
- navigator.clipboard.writeText(content).then(() => {
1586
- // change button text to indicate success
1587
- const button = document.getElementById("copyBtn");
1588
- button.textContent = "\u2705 Copied!";
1589
- setTimeout(() => {
1590
- button.textContent = "\u{1F4CB} Copy All"
1591
- }, 2000);
1592
- }).catch(err => {
1593
- console.error("Failed to copy text: ", err);
1594
- alert("Failed to copy text. Please try manually.");
1595
- });
1596
- });
1597
- </script>
1598
- <div id="markdownContent">
1599
- <h1>Instructions</h1>
1600
- <ul>
1601
- <li>Following Playwright test failed.</li>
1602
- <li>Explain why, be concise, respect Playwright best practices.</li>
1603
- <li>Provide a snippet of code with the fix, if possible.</li>
1604
- </ul>
1605
- <h1>Error Details</h1>
1606
- ${errorHtml || "<p>No errors found.</p>"}
1607
- ${stepsHtml || "<p>No step data available.</p>"}
1608
- ${markdownHtml || ""}
1609
- </div>
1610
- </body>
1611
- </html>
1612
- `;
1613
- import_fs3.default.writeFileSync(htmlOutputPath, fullHtml, "utf-8");
1398
+ const drawerHtml = `${markdownHtml || ""}`;
1399
+ import_fs2.default.writeFileSync(htmlOutputPath, drawerHtml.trim(), "utf-8");
1614
1400
  if (hasMarkdown) {
1615
- import_fs3.default.unlinkSync(markdownPath);
1401
+ import_fs2.default.unlinkSync(markdownPath);
1616
1402
  }
1617
1403
  }
1618
1404
 
1619
1405
  // src/utils/attachFiles.ts
1620
1406
  function attachFiles(subFolder, result, testResult, config, steps, errors) {
1621
1407
  const folderPath = config.folderPath || "ortoni-report";
1622
- const attachmentsFolder = import_path4.default.join(
1408
+ const attachmentsFolder = import_path2.default.join(
1623
1409
  folderPath,
1624
1410
  "ortoni-data",
1625
1411
  "attachments",
1626
1412
  subFolder
1627
1413
  );
1628
- if (!import_fs4.default.existsSync(attachmentsFolder)) {
1629
- import_fs4.default.mkdirSync(attachmentsFolder, { recursive: true });
1414
+ if (!import_fs3.default.existsSync(attachmentsFolder)) {
1415
+ import_fs3.default.mkdirSync(attachmentsFolder, { recursive: true });
1630
1416
  }
1631
1417
  if (!result.attachments) return;
1632
1418
  const { base64Image } = config;
1633
1419
  testResult.screenshots = [];
1420
+ testResult.videoPath = [];
1634
1421
  result.attachments.forEach((attachment) => {
1635
1422
  const { contentType, name, path: attachmentPath, body } = attachment;
1636
1423
  if (!attachmentPath && !body) return;
1637
- const fileName = attachmentPath ? import_path4.default.basename(attachmentPath) : `${name}.${getFileExtension(contentType)}`;
1638
- const relativePath = import_path4.default.join(
1424
+ const fileName = attachmentPath ? import_path2.default.basename(attachmentPath) : `${name}.${getFileExtension(contentType)}`;
1425
+ const relativePath = import_path2.default.join(
1639
1426
  "ortoni-data",
1640
1427
  "attachments",
1641
1428
  subFolder,
1642
1429
  fileName
1643
1430
  );
1644
- const fullPath = import_path4.default.join(attachmentsFolder, fileName);
1431
+ const fullPath = import_path2.default.join(attachmentsFolder, fileName);
1645
1432
  if (contentType === "image/png") {
1646
1433
  handleImage(
1647
1434
  attachmentPath,
@@ -1684,13 +1471,13 @@ function handleImage(attachmentPath, body, base64Image, fullPath, relativePath,
1684
1471
  let screenshotPath = "";
1685
1472
  if (attachmentPath) {
1686
1473
  try {
1687
- const screenshotContent = import_fs4.default.readFileSync(
1474
+ const screenshotContent = import_fs3.default.readFileSync(
1688
1475
  attachmentPath,
1689
1476
  base64Image ? "base64" : void 0
1690
1477
  );
1691
1478
  screenshotPath = base64Image ? `data:image/png;base64,${screenshotContent}` : relativePath;
1692
1479
  if (!base64Image) {
1693
- import_fs4.default.copyFileSync(attachmentPath, fullPath);
1480
+ import_fs3.default.copyFileSync(attachmentPath, fullPath);
1694
1481
  }
1695
1482
  } catch (error) {
1696
1483
  console.error(
@@ -1707,13 +1494,17 @@ function handleImage(attachmentPath, body, base64Image, fullPath, relativePath,
1707
1494
  }
1708
1495
  function handleAttachment(attachmentPath, fullPath, relativePath, resultKey, testResult, steps, errors) {
1709
1496
  if (attachmentPath) {
1710
- import_fs4.default.copyFileSync(attachmentPath, fullPath);
1711
- testResult[resultKey] = relativePath;
1497
+ import_fs3.default.copyFileSync(attachmentPath, fullPath);
1498
+ if (resultKey === "videoPath") {
1499
+ testResult[resultKey]?.push(relativePath);
1500
+ } else if (resultKey === "tracePath") {
1501
+ testResult[resultKey] = relativePath;
1502
+ }
1712
1503
  }
1713
1504
  if (resultKey === "markdownPath" && errors) {
1714
1505
  const htmlPath = fullPath.replace(/\.md$/, ".html");
1715
1506
  const htmlRelativePath = relativePath.replace(/\.md$/, ".html");
1716
- convertMarkdownToHtml(fullPath, htmlPath, steps || [], errors || []);
1507
+ convertMarkdownToHtml(fullPath, htmlPath);
1717
1508
  testResult[resultKey] = htmlRelativePath;
1718
1509
  return;
1719
1510
  }
@@ -1728,6 +1519,61 @@ function getFileExtension(contentType) {
1728
1519
  return extensions[contentType] || "unknown";
1729
1520
  }
1730
1521
 
1522
+ // src/utils/utils.ts
1523
+ var import_path3 = __toESM(require("path"));
1524
+ function normalizeFilePath(filePath) {
1525
+ const normalizedPath = import_path3.default.normalize(filePath);
1526
+ return import_path3.default.basename(normalizedPath);
1527
+ }
1528
+ function ensureHtmlExtension(filename) {
1529
+ const ext = import_path3.default.extname(filename);
1530
+ if (ext && ext.toLowerCase() === ".html") {
1531
+ return filename;
1532
+ }
1533
+ return `${filename}.html`;
1534
+ }
1535
+ function escapeHtml(unsafe) {
1536
+ if (typeof unsafe !== "string") {
1537
+ return String(unsafe);
1538
+ }
1539
+ return unsafe.replace(/[&<"']/g, function(match) {
1540
+ const escapeMap = {
1541
+ "&": "&amp;",
1542
+ "<": "&lt;",
1543
+ ">": "&gt;",
1544
+ '"': "&quot;",
1545
+ "'": "&#039;"
1546
+ };
1547
+ return escapeMap[match] || match;
1548
+ });
1549
+ }
1550
+ function formatDateLocal(dateInput) {
1551
+ const date = typeof dateInput === "string" ? new Date(dateInput) : dateInput;
1552
+ const options = {
1553
+ year: "numeric",
1554
+ month: "short",
1555
+ day: "2-digit",
1556
+ hour: "2-digit",
1557
+ minute: "2-digit",
1558
+ hour12: true,
1559
+ timeZoneName: "short"
1560
+ // or "Asia/Kolkata"
1561
+ };
1562
+ return new Intl.DateTimeFormat(void 0, options).format(date);
1563
+ }
1564
+ function extractSuites(titlePath) {
1565
+ const tagPattern = /@[\w]+/g;
1566
+ const suiteParts = titlePath.slice(3, titlePath.length - 1).map((p) => p.replace(tagPattern, "").trim());
1567
+ return {
1568
+ hierarchy: suiteParts.join(" > "),
1569
+ // full hierarchy
1570
+ topLevelSuite: suiteParts[0] ?? "",
1571
+ // first suite
1572
+ parentSuite: suiteParts[suiteParts.length - 1] ?? ""
1573
+ // last suite
1574
+ };
1575
+ }
1576
+
1731
1577
  // src/helpers/resultProcessor .ts
1732
1578
  var TestResultProcessor = class {
1733
1579
  constructor(projectRoot) {
@@ -1743,19 +1589,21 @@ var TestResultProcessor = class {
1743
1589
  const tagPattern = /@[\w]+/g;
1744
1590
  const title = test.title.replace(tagPattern, "").trim();
1745
1591
  const suite = test.titlePath()[3].replace(tagPattern, "").trim();
1592
+ const suiteAndTitle = extractSuites(test.titlePath());
1593
+ const suiteHierarchy = suiteAndTitle.hierarchy;
1746
1594
  const testResult = {
1747
- port: ortoniConfig.port || 2004,
1595
+ suiteHierarchy,
1596
+ key: test.id,
1748
1597
  annotations: test.annotations,
1749
1598
  testTags: test.tags,
1750
1599
  location: `${filePath}:${location.line}:${location.column}`,
1751
- retry: result.retry > 0 ? "retry" : "",
1752
- isRetry: result.retry,
1600
+ retryAttemptCount: result.retry,
1753
1601
  projectName,
1754
1602
  suite,
1755
1603
  title,
1756
1604
  status,
1757
1605
  flaky: test.outcome(),
1758
- duration: msToTime(result.duration),
1606
+ duration: result.duration,
1759
1607
  errors: result.errors.map(
1760
1608
  (e) => this.ansiToHtml.toHtml(escapeHtml(e.stack || e.toString()))
1761
1609
  ),
@@ -1767,7 +1615,8 @@ var TestResultProcessor = class {
1767
1615
  ),
1768
1616
  filePath,
1769
1617
  filters: projectSet,
1770
- base64Image: ortoniConfig.base64Image
1618
+ base64Image: ortoniConfig.base64Image,
1619
+ testId: `${filePath}:${projectName}:${title}`
1771
1620
  };
1772
1621
  attachFiles(
1773
1622
  test.id,
@@ -1781,7 +1630,7 @@ var TestResultProcessor = class {
1781
1630
  }
1782
1631
  processSteps(steps) {
1783
1632
  return steps.map((step) => {
1784
- const stepLocation = step.location ? `${import_path5.default.relative(this.projectRoot, step.location.file)}:${step.location.line}:${step.location.column}` : "";
1633
+ const stepLocation = step.location ? `${import_path4.default.relative(this.projectRoot, step.location.file)}:${step.location.line}:${step.location.column}` : "";
1785
1634
  return {
1786
1635
  snippet: this.ansiToHtml.toHtml(escapeHtml(step.error?.snippet || "")),
1787
1636
  title: step.title,
@@ -1793,16 +1642,16 @@ var TestResultProcessor = class {
1793
1642
 
1794
1643
  // src/utils/expressServer.ts
1795
1644
  var import_express = __toESM(require("express"));
1796
- var import_path6 = __toESM(require("path"));
1645
+ var import_path5 = __toESM(require("path"));
1797
1646
  var import_child_process = require("child_process");
1798
1647
  function startReportServer(reportFolder, reportFilename, port = 2004, open2) {
1799
1648
  const app = (0, import_express.default)();
1800
1649
  app.use(import_express.default.static(reportFolder));
1801
1650
  app.get("/", (_req, res) => {
1802
1651
  try {
1803
- res.sendFile(import_path6.default.resolve(reportFolder, reportFilename));
1652
+ res.sendFile(import_path5.default.resolve(reportFolder, reportFilename));
1804
1653
  } catch (error) {
1805
- console.error("Ortoni-Report: Error sending report file:", error);
1654
+ console.error("Ortoni Report: Error sending report file:", error);
1806
1655
  res.status(500).send("Error loading report");
1807
1656
  }
1808
1657
  });
@@ -1816,21 +1665,21 @@ Press Ctrl+C to stop.`
1816
1665
  try {
1817
1666
  openBrowser(`http://localhost:${port}`);
1818
1667
  } catch (error) {
1819
- console.error("Ortoni-Report: Error opening browser:", error);
1668
+ console.error("Ortoni Report: Error opening browser:", error);
1820
1669
  }
1821
1670
  }
1822
1671
  });
1823
1672
  server.on("error", (error) => {
1824
1673
  if (error.code === "EADDRINUSE") {
1825
1674
  console.error(
1826
- `Ortoni-Report: Port ${port} is already in use. Trying a different port...`
1675
+ `Ortoni Report: Port ${port} is already in use. Trying a different port...`
1827
1676
  );
1828
1677
  } else {
1829
- console.error("Ortoni-Report: Server error:", error);
1678
+ console.error("Ortoni Report: Server error:", error);
1830
1679
  }
1831
1680
  });
1832
1681
  } catch (error) {
1833
- console.error("Ortoni-Report: Error starting the server:", error);
1682
+ console.error("Ortoni Report: Error starting the server:", error);
1834
1683
  }
1835
1684
  }
1836
1685
  function openBrowser(url) {
@@ -1848,7 +1697,7 @@ function openBrowser(url) {
1848
1697
  (0, import_child_process.spawn)(command, [url]);
1849
1698
  }
1850
1699
  } catch (error) {
1851
- console.error("Ortoni-Report: Error opening the browser:", error);
1700
+ console.error("Ortoni Report: Error opening the browser:", error);
1852
1701
  }
1853
1702
  }
1854
1703
 
@@ -1907,7 +1756,7 @@ var DatabaseManager = class {
1907
1756
  run_id INTEGER,
1908
1757
  test_id TEXT,
1909
1758
  status TEXT,
1910
- duration TEXT,
1759
+ duration INTEGER, -- store duration as raw ms
1911
1760
  error_message TEXT,
1912
1761
  FOREIGN KEY (run_id) REFERENCES test_runs (id)
1913
1762
  );
@@ -1967,6 +1816,7 @@ var DatabaseManager = class {
1967
1816
  `${result.filePath}:${result.projectName}:${result.title}`,
1968
1817
  result.status,
1969
1818
  result.duration,
1819
+ // store raw ms
1970
1820
  result.errors.join("\n")
1971
1821
  ]);
1972
1822
  }
@@ -2033,7 +1883,7 @@ var DatabaseManager = class {
2033
1883
  (SELECT COUNT(*) FROM test_results) as totalTests,
2034
1884
  (SELECT COUNT(*) FROM test_results WHERE status = 'passed') as passed,
2035
1885
  (SELECT COUNT(*) FROM test_results WHERE status = 'failed') as failed,
2036
- (SELECT AVG(CAST(duration AS FLOAT)) FROM test_results) as avgDuration
1886
+ (SELECT AVG(duration) FROM test_results) as avgDuration
2037
1887
  `);
2038
1888
  const passRate = summary.totalTests ? (summary.passed / summary.totalTests * 100).toFixed(2) : 0;
2039
1889
  return {
@@ -2043,6 +1893,7 @@ var DatabaseManager = class {
2043
1893
  failed: summary.failed,
2044
1894
  passRate: parseFloat(passRate.toString()),
2045
1895
  avgDuration: Math.round(summary.avgDuration || 0)
1896
+ // raw ms avg
2046
1897
  };
2047
1898
  } catch (error) {
2048
1899
  console.error("OrtoniReport: Error getting summary data:", error);
@@ -2067,7 +1918,7 @@ var DatabaseManager = class {
2067
1918
  SELECT trun.run_date,
2068
1919
  SUM(CASE WHEN tr.status = 'passed' THEN 1 ELSE 0 END) AS passed,
2069
1920
  SUM(CASE WHEN tr.status = 'failed' THEN 1 ELSE 0 END) AS failed,
2070
- AVG(CAST(tr.duration AS FLOAT)) AS avg_duration
1921
+ AVG(tr.duration) AS avg_duration
2071
1922
  FROM test_results tr
2072
1923
  JOIN test_runs trun ON tr.run_id = trun.id
2073
1924
  GROUP BY trun.run_date
@@ -2080,6 +1931,7 @@ var DatabaseManager = class {
2080
1931
  ...row,
2081
1932
  run_date: formatDateLocal(row.run_date),
2082
1933
  avg_duration: Math.round(row.avg_duration || 0)
1934
+ // raw ms avg
2083
1935
  }));
2084
1936
  } catch (error) {
2085
1937
  console.error("OrtoniReport: Error getting trends data:", error);
@@ -2097,7 +1949,8 @@ var DatabaseManager = class {
2097
1949
  SELECT
2098
1950
  test_id,
2099
1951
  COUNT(*) AS total,
2100
- SUM(CASE WHEN status = 'flaky' THEN 1 ELSE 0 END) AS flaky
1952
+ SUM(CASE WHEN status = 'flaky' THEN 1 ELSE 0 END) AS flaky,
1953
+ AVG(duration) AS avg_duration
2101
1954
  FROM test_results
2102
1955
  GROUP BY test_id
2103
1956
  HAVING flaky > 0
@@ -2121,7 +1974,7 @@ var DatabaseManager = class {
2121
1974
  `
2122
1975
  SELECT
2123
1976
  test_id,
2124
- AVG(CAST(duration AS FLOAT)) AS avg_duration
1977
+ AVG(duration) AS avg_duration
2125
1978
  FROM test_results
2126
1979
  GROUP BY test_id
2127
1980
  ORDER BY avg_duration DESC
@@ -2132,6 +1985,7 @@ var DatabaseManager = class {
2132
1985
  return rows.map((row) => ({
2133
1986
  test_id: row.test_id,
2134
1987
  avg_duration: Math.round(row.avg_duration || 0)
1988
+ // raw ms avg
2135
1989
  }));
2136
1990
  } catch (error) {
2137
1991
  console.error("OrtoniReport: Error getting slow tests:", error);
@@ -2141,7 +1995,7 @@ var DatabaseManager = class {
2141
1995
  };
2142
1996
 
2143
1997
  // src/ortoni-report.ts
2144
- var import_path7 = __toESM(require("path"));
1998
+ var import_path6 = __toESM(require("path"));
2145
1999
  var OrtoniReport = class {
2146
2000
  constructor(ortoniConfig = {}) {
2147
2001
  this.ortoniConfig = ortoniConfig;
@@ -2172,8 +2026,9 @@ var OrtoniReport = class {
2172
2026
  this.testResultProcessor = new TestResultProcessor(config.rootDir);
2173
2027
  this.fileManager.ensureReportDirectory();
2174
2028
  await this.dbManager.initialize(
2175
- import_path7.default.join(this.folderPath, "ortoni-data-history.sqlite")
2029
+ import_path6.default.join(this.folderPath, "ortoni-data-history.sqlite")
2176
2030
  );
2031
+ this.shardConfig = config?.shard;
2177
2032
  }
2178
2033
  onStdOut(chunk, _test, _result) {
2179
2034
  if (this.reportsCount == 1 && this.showConsoleLogs) {
@@ -2190,7 +2045,7 @@ var OrtoniReport = class {
2190
2045
  );
2191
2046
  this.results.push(testResult);
2192
2047
  } catch (error) {
2193
- console.error("OrtoniReport: Error processing test end:", error);
2048
+ console.error("Ortoni Report: Error processing test end:", error);
2194
2049
  }
2195
2050
  }
2196
2051
  printsToStdio() {
@@ -2206,35 +2061,55 @@ var OrtoniReport = class {
2206
2061
  this.overAllStatus = result.status;
2207
2062
  if (this.shouldGenerateReport) {
2208
2063
  const filteredResults = this.results.filter(
2209
- (r) => r.status !== "skipped" && !r.isRetry
2064
+ (r) => r.status !== "skipped"
2210
2065
  );
2211
- const totalDuration = msToTime(result.duration);
2212
- const cssContent = this.fileManager.readCssContent();
2066
+ const totalDuration = result.duration;
2067
+ if (this.shardConfig && this.shardConfig.total > 1) {
2068
+ const shard = this.shardConfig;
2069
+ const shardFile = `ortoni-shard-${shard.current}-of-${shard.total}.json`;
2070
+ const shardData = {
2071
+ status: result.status,
2072
+ duration: totalDuration,
2073
+ results: this.results,
2074
+ projectSet: Array.from(this.projectSet),
2075
+ userConfig: {
2076
+ projectName: this.ortoniConfig.projectName,
2077
+ authorName: this.ortoniConfig.authorName,
2078
+ type: this.ortoniConfig.testType,
2079
+ title: this.ortoniConfig.title
2080
+ },
2081
+ userMeta: {
2082
+ meta: this.ortoniConfig.meta
2083
+ }
2084
+ };
2085
+ this.fileManager.writeRawFile(shardFile, shardData);
2086
+ this.shouldGenerateReport = false;
2087
+ return;
2088
+ }
2213
2089
  const runId = await this.dbManager.saveTestRun();
2214
2090
  if (runId !== null) {
2215
2091
  await this.dbManager.saveTestResults(runId, this.results);
2216
- const html = await this.htmlGenerator.generateHTML(
2092
+ const finalReportData = await this.htmlGenerator.generateFinalReport(
2217
2093
  filteredResults,
2218
2094
  totalDuration,
2219
- cssContent,
2220
2095
  this.results,
2221
2096
  this.projectSet
2222
2097
  );
2223
2098
  this.outputPath = this.fileManager.writeReportFile(
2224
2099
  this.outputFilename,
2225
- html
2100
+ finalReportData
2226
2101
  );
2227
2102
  } else {
2228
- console.error("OrtoniReport: Error saving test run to database");
2103
+ console.error("Ortoni Report: Error saving test run to database");
2229
2104
  }
2230
2105
  } else {
2231
2106
  console.error(
2232
- "OrtoniReport: Report generation skipped due to error in Playwright worker!"
2107
+ "Ortoni Report: Report generation skipped due to error in Playwright worker!"
2233
2108
  );
2234
2109
  }
2235
2110
  } catch (error) {
2236
2111
  this.shouldGenerateReport = false;
2237
- console.error("OrtoniReport: Error generating report:", error);
2112
+ console.error("Ortoni Report: Error generating report:", error);
2238
2113
  }
2239
2114
  }
2240
2115
  async onExit() {
@@ -2242,7 +2117,7 @@ var OrtoniReport = class {
2242
2117
  await this.dbManager.close();
2243
2118
  if (this.shouldGenerateReport) {
2244
2119
  this.fileManager.copyTraceViewerAssets(this.skipTraceViewer);
2245
- console.info(`Ortoni HTML report generated at ${this.outputPath}`);
2120
+ console.info(`Ortoni Report generated at ${this.outputPath}`);
2246
2121
  this.serverManager.startServer(
2247
2122
  this.folderPath,
2248
2123
  this.outputFilename,
@@ -2252,7 +2127,7 @@ var OrtoniReport = class {
2252
2127
  });
2253
2128
  }
2254
2129
  } catch (error) {
2255
- console.error("OrtoniReport: Error in onExit:", error);
2130
+ console.error("Ortoni Report: Error in onExit:", error);
2256
2131
  }
2257
2132
  }
2258
2133
  };