@sentinelqa/playwright-reporter 0.1.17 → 0.1.18

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.
@@ -37,6 +37,51 @@ const escapeHtml = (value) => value
37
37
  .replace(/>/g, ">")
38
38
  .replace(/"/g, """)
39
39
  .replace(/'/g, "'");
40
+ const ansiToHtml = (value) => {
41
+ const parts = value.split(/(\u001b\[[0-9;]*m)/g);
42
+ const html = [];
43
+ const openTags = [];
44
+ const closeAll = () => {
45
+ while (openTags.length > 0) {
46
+ html.push("</span>");
47
+ openTags.pop();
48
+ }
49
+ };
50
+ for (const part of parts) {
51
+ const match = part.match(/^\u001b\[([0-9;]*)m$/);
52
+ if (!match) {
53
+ html.push(escapeHtml(part));
54
+ continue;
55
+ }
56
+ const codes = match[1]
57
+ .split(";")
58
+ .filter(Boolean)
59
+ .map((entry) => Number.parseInt(entry, 10));
60
+ if (codes.length === 0 || codes.includes(0)) {
61
+ closeAll();
62
+ continue;
63
+ }
64
+ for (const code of codes) {
65
+ const className = code === 1
66
+ ? "ansi-bold"
67
+ : code === 31
68
+ ? "ansi-red"
69
+ : code === 32
70
+ ? "ansi-green"
71
+ : code === 33
72
+ ? "ansi-yellow"
73
+ : code === 36
74
+ ? "ansi-cyan"
75
+ : null;
76
+ if (!className)
77
+ continue;
78
+ html.push(`<span class="${className}">`);
79
+ openTags.push(className);
80
+ }
81
+ }
82
+ closeAll();
83
+ return html.join("");
84
+ };
40
85
  const ensureDir = (dirPath) => {
41
86
  fs_1.default.mkdirSync(dirPath, { recursive: true });
42
87
  };
@@ -254,9 +299,22 @@ const renderArtifact = (artifact) => {
254
299
  const label = escapeHtml(artifact.label);
255
300
  if (artifact.kind === "trace") {
256
301
  return `
257
- <div class="artifact-link">
258
- <span class="artifact-kind">Trace</span>
259
- <a href="${href}" target="_blank" rel="noreferrer">${label}</a>
302
+ <div class="artifact-link artifact-link-trace">
303
+ <div class="artifact-trace-row">
304
+ <div class="artifact-trace-meta">
305
+ <span class="artifact-kind">Trace</span>
306
+ <a href="${href}" target="_blank" rel="noreferrer">${label}</a>
307
+ </div>
308
+ <a
309
+ class="trace-button"
310
+ href="${href}"
311
+ target="_blank"
312
+ rel="noreferrer"
313
+ data-trace-path="${href}"
314
+ >
315
+ View Trace
316
+ </a>
317
+ </div>
260
318
  </div>
261
319
  `;
262
320
  }
@@ -267,7 +325,7 @@ const renderArtifact = (artifact) => {
267
325
  <span class="artifact-kind">Screenshot</span>
268
326
  <a href="${href}" target="_blank" rel="noreferrer">${label}</a>
269
327
  </div>
270
- <img src="${href}" alt="${label}" loading="lazy" />
328
+ <img src="${href}" alt="${label}" loading="lazy" data-preview-image="${href}" />
271
329
  </div>
272
330
  `;
273
331
  }
@@ -289,6 +347,42 @@ const renderArtifact = (artifact) => {
289
347
  </div>
290
348
  `;
291
349
  };
350
+ const renderArtifactGroups = (artifacts) => {
351
+ if (artifacts.length === 0) {
352
+ return `<div class="empty-state">No test-linked artifacts were detected for this result.</div>`;
353
+ }
354
+ const groups = [
355
+ {
356
+ title: "Screenshots",
357
+ items: artifacts.filter((artifact) => artifact.kind === "screenshot")
358
+ },
359
+ {
360
+ title: "Videos",
361
+ items: artifacts.filter((artifact) => artifact.kind === "video")
362
+ },
363
+ {
364
+ title: "Traces",
365
+ items: artifacts.filter((artifact) => artifact.kind === "trace")
366
+ },
367
+ {
368
+ title: "Other files",
369
+ items: artifacts.filter((artifact) => !["screenshot", "video", "trace"].includes(artifact.kind))
370
+ }
371
+ ].filter((group) => group.items.length > 0);
372
+ return groups
373
+ .map((group) => `
374
+ <details class="artifact-group" ${group.title === "Screenshots" ? "open" : ""}>
375
+ <summary class="artifact-group-summary">
376
+ <span>${escapeHtml(group.title)}</span>
377
+ <span class="artifact-group-count">(${group.items.length})</span>
378
+ </summary>
379
+ <div class="artifact-grid">
380
+ ${group.items.map((artifact) => renderArtifact(artifact)).join("\n")}
381
+ </div>
382
+ </details>
383
+ `)
384
+ .join("\n");
385
+ };
292
386
  const renderTestCard = (test) => {
293
387
  const statusClass = test.status === "passed" ? "status-passed" : "status-failed";
294
388
  const fileLine = test.file ? `<div class="meta-item">${escapeHtml(test.file)}</div>` : "";
@@ -296,11 +390,25 @@ const renderTestCard = (test) => {
296
390
  ? `<div class="meta-item">Project: ${escapeHtml(test.projectName)}</div>`
297
391
  : "";
298
392
  const errorBlock = test.errors.length > 0
299
- ? `<pre>${escapeHtml(test.errors.join("\n\n"))}</pre>`
393
+ ? (() => {
394
+ const rawError = escapeHtml(test.errors.join("\n\n"));
395
+ return `<div class="error-block" data-collapsed="true">
396
+ <div class="error-actions">
397
+ <button
398
+ type="button"
399
+ class="copy-button"
400
+ data-copy-error="${rawError}"
401
+ aria-label="Copy error"
402
+ >
403
+ Copy
404
+ </button>
405
+ </div>
406
+ <pre class="error-preview">${ansiToHtml(test.errors.join("\n\n"))}</pre>
407
+ <button type="button" class="expand-button" data-expand-error>Expand full error</button>
408
+ </div>`;
409
+ })()
300
410
  : `<pre>No error message was attached to this result.</pre>`;
301
- const artifactMarkup = test.artifacts.length > 0
302
- ? test.artifacts.map((artifact) => renderArtifact(artifact)).join("\n")
303
- : `<div class="empty-state">No test-linked artifacts were detected for this result.</div>`;
411
+ const artifactMarkup = renderArtifactGroups(test.artifacts);
304
412
  return `
305
413
  <details class="test-card">
306
414
  <summary class="test-summary">
@@ -331,20 +439,7 @@ const renderAdditionalArtifacts = (artifacts) => {
331
439
  if (artifacts.length === 0) {
332
440
  return "";
333
441
  }
334
- return `
335
- <div class="artifact-list">
336
- ${artifacts
337
- .map((artifact) => `
338
- <div class="artifact-link">
339
- <span class="artifact-kind">${escapeHtml(artifact.kind)}</span>
340
- <a href="${escapeHtml(artifact.relativePath)}" target="_blank" rel="noreferrer">
341
- ${escapeHtml(artifact.label)}
342
- </a>
343
- </div>
344
- `)
345
- .join("\n")}
346
- </div>
347
- `;
442
+ return renderArtifactGroups(artifacts);
348
443
  };
349
444
  const tryMapRemainingArtifactsToTests = (tests, artifactPaths, reportDir, usedRelativePaths, claimedSourcePaths) => {
350
445
  const candidateTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
@@ -531,6 +626,45 @@ const buildHtml = (tests, summary, extraArtifacts) => {
531
626
  font-size: 13px;
532
627
  color: #d5dde8;
533
628
  }
629
+ .ansi-bold { font-weight: 700; }
630
+ .ansi-red { color: #fb7185; }
631
+ .ansi-green { color: #4ade80; }
632
+ .ansi-yellow { color: #facc15; }
633
+ .ansi-cyan { color: #67e8f9; }
634
+ .error-block[data-collapsed="true"] .error-preview {
635
+ max-height: 180px;
636
+ overflow: hidden;
637
+ position: relative;
638
+ }
639
+ .error-actions {
640
+ display: flex;
641
+ justify-content: flex-end;
642
+ }
643
+ .error-block[data-collapsed="true"] .error-preview::after {
644
+ content: "";
645
+ position: absolute;
646
+ inset: auto 0 0 0;
647
+ height: 56px;
648
+ background: linear-gradient(180deg, rgba(13, 17, 23, 0), rgba(13, 17, 23, 1));
649
+ }
650
+ .copy-button,
651
+ .expand-button {
652
+ margin-top: 12px;
653
+ border: 1px solid rgba(125, 211, 252, 0.28);
654
+ background: rgba(125, 211, 252, 0.08);
655
+ color: var(--accent);
656
+ border-radius: 999px;
657
+ padding: 8px 12px;
658
+ font-size: 12px;
659
+ font-weight: 600;
660
+ text-transform: uppercase;
661
+ letter-spacing: 0.06em;
662
+ cursor: pointer;
663
+ }
664
+ .copy-button {
665
+ margin-top: 0;
666
+ margin-left: auto;
667
+ }
534
668
  .artifact-grid {
535
669
  display: grid;
536
670
  gap: 14px;
@@ -543,11 +677,15 @@ const buildHtml = (tests, summary, extraArtifacts) => {
543
677
  background: rgba(9, 13, 20, 0.9);
544
678
  padding: 12px;
545
679
  }
680
+ .artifact-link-trace {
681
+ padding: 14px;
682
+ }
546
683
  .artifact-card img, .artifact-card video {
547
684
  width: 100%;
548
685
  border-radius: 10px;
549
686
  margin-top: 12px;
550
687
  background: #05070b;
688
+ cursor: zoom-in;
551
689
  }
552
690
  .artifact-meta {
553
691
  display: flex;
@@ -567,11 +705,70 @@ const buildHtml = (tests, summary, extraArtifacts) => {
567
705
  text-transform: uppercase;
568
706
  letter-spacing: 0.08em;
569
707
  }
708
+ .artifact-trace-row {
709
+ display: flex;
710
+ justify-content: space-between;
711
+ gap: 12px;
712
+ align-items: center;
713
+ }
714
+ .artifact-trace-meta {
715
+ display: flex;
716
+ gap: 10px;
717
+ align-items: center;
718
+ flex-wrap: wrap;
719
+ }
720
+ .trace-button {
721
+ display: inline-flex;
722
+ align-items: center;
723
+ justify-content: center;
724
+ padding: 8px 12px;
725
+ border-radius: 999px;
726
+ border: 1px solid rgba(125, 211, 252, 0.28);
727
+ background: rgba(125, 211, 252, 0.08);
728
+ color: var(--accent);
729
+ font-size: 12px;
730
+ font-weight: 600;
731
+ text-transform: uppercase;
732
+ letter-spacing: 0.06em;
733
+ white-space: nowrap;
734
+ }
735
+ .trace-button:hover {
736
+ text-decoration: none;
737
+ background: rgba(125, 211, 252, 0.14);
738
+ }
570
739
  .artifact-list {
571
740
  display: grid;
572
741
  gap: 12px;
573
742
  margin-top: 16px;
574
743
  }
744
+ .artifact-group {
745
+ margin-top: 12px;
746
+ border: 1px solid rgba(39, 48, 66, 0.9);
747
+ border-radius: 14px;
748
+ background: rgba(9, 13, 20, 0.42);
749
+ overflow: hidden;
750
+ }
751
+ .artifact-group-summary {
752
+ display: flex;
753
+ justify-content: space-between;
754
+ align-items: center;
755
+ gap: 12px;
756
+ padding: 14px 16px;
757
+ cursor: pointer;
758
+ list-style: none;
759
+ font-weight: 600;
760
+ }
761
+ .artifact-group-summary::-webkit-details-marker {
762
+ display: none;
763
+ }
764
+ .artifact-group-count {
765
+ color: var(--muted);
766
+ font-weight: 500;
767
+ }
768
+ .artifact-group .artifact-grid {
769
+ padding: 0 16px 16px;
770
+ margin-top: 0;
771
+ }
575
772
  .section-shell ul {
576
773
  margin: 12px 0 0 18px;
577
774
  color: var(--text);
@@ -600,6 +797,45 @@ const buildHtml = (tests, summary, extraArtifacts) => {
600
797
  color: var(--muted);
601
798
  font-size: 14px;
602
799
  }
800
+ .preview-overlay {
801
+ position: fixed;
802
+ inset: 0;
803
+ background: rgba(4, 8, 14, 0.88);
804
+ display: none;
805
+ align-items: center;
806
+ justify-content: center;
807
+ padding: 32px;
808
+ z-index: 999;
809
+ }
810
+ .preview-overlay.is-open {
811
+ display: flex;
812
+ }
813
+ .preview-shell {
814
+ max-width: min(1200px, 96vw);
815
+ max-height: 92vh;
816
+ position: relative;
817
+ }
818
+ .preview-shell img {
819
+ display: block;
820
+ max-width: 100%;
821
+ max-height: 92vh;
822
+ border-radius: 16px;
823
+ border: 1px solid rgba(39, 48, 66, 0.9);
824
+ box-shadow: 0 20px 80px rgba(0, 0, 0, 0.5);
825
+ }
826
+ .preview-close {
827
+ position: absolute;
828
+ top: 12px;
829
+ right: 12px;
830
+ border: 1px solid rgba(255, 255, 255, 0.12);
831
+ background: rgba(9, 13, 20, 0.75);
832
+ color: #fff;
833
+ border-radius: 999px;
834
+ width: 40px;
835
+ height: 40px;
836
+ cursor: pointer;
837
+ font-size: 18px;
838
+ }
603
839
  @media (max-width: 720px) {
604
840
  .hero-badge {
605
841
  position: static;
@@ -607,6 +843,10 @@ const buildHtml = (tests, summary, extraArtifacts) => {
607
843
  }
608
844
  .test-summary { flex-direction: column; }
609
845
  .meta-stack { min-width: 0; }
846
+ .artifact-trace-row {
847
+ flex-direction: column;
848
+ align-items: flex-start;
849
+ }
610
850
  }
611
851
  </style>
612
852
  </head>
@@ -675,6 +915,96 @@ const buildHtml = (tests, summary, extraArtifacts) => {
675
915
  Generated by <a href="${SENTINEL_URL}" target="_blank" rel="noreferrer">Sentinel Playwright Reporter</a>.
676
916
  </footer>
677
917
  </div>
918
+ <div class="preview-overlay" id="preview-overlay" aria-hidden="true">
919
+ <div class="preview-shell">
920
+ <button type="button" class="preview-close" id="preview-close" aria-label="Close preview">×</button>
921
+ <img id="preview-image" alt="Screenshot preview" />
922
+ </div>
923
+ </div>
924
+ <script>
925
+ (function () {
926
+ document.querySelectorAll("[data-trace-path]").forEach(function (button) {
927
+ var tracePath = button.getAttribute("data-trace-path");
928
+ if (!tracePath) return;
929
+ try {
930
+ if (window.location.protocol === "http:" || window.location.protocol === "https:") {
931
+ var traceUrl = new URL(tracePath, window.location.href).href;
932
+ button.setAttribute(
933
+ "href",
934
+ "https://trace.playwright.dev/?trace=" + encodeURIComponent(traceUrl)
935
+ );
936
+ }
937
+ } catch (_error) {
938
+ // Keep the raw trace file link as fallback.
939
+ }
940
+ });
941
+
942
+ document.querySelectorAll("[data-expand-error]").forEach(function (button) {
943
+ button.addEventListener("click", function () {
944
+ var block = button.closest(".error-block");
945
+ if (!block) return;
946
+ var isCollapsed = block.getAttribute("data-collapsed") !== "false";
947
+ block.setAttribute("data-collapsed", isCollapsed ? "false" : "true");
948
+ button.textContent = isCollapsed ? "Collapse error" : "Expand full error";
949
+ });
950
+ });
951
+
952
+ document.querySelectorAll("[data-copy-error]").forEach(function (button) {
953
+ button.addEventListener("click", async function () {
954
+ var rawError = button.getAttribute("data-copy-error");
955
+ if (!rawError) return;
956
+ var text = rawError
957
+ .replace(/&quot;/g, '"')
958
+ .replace(/&#39;/g, "'")
959
+ .replace(/&lt;/g, "<")
960
+ .replace(/&gt;/g, ">")
961
+ .replace(/&amp;/g, "&");
962
+ try {
963
+ await navigator.clipboard.writeText(text);
964
+ var previousText = button.textContent;
965
+ button.textContent = "Copied";
966
+ setTimeout(function () {
967
+ button.textContent = previousText || "Copy";
968
+ }, 1200);
969
+ } catch (_error) {
970
+ button.textContent = "Copy failed";
971
+ setTimeout(function () {
972
+ button.textContent = "Copy";
973
+ }, 1200);
974
+ }
975
+ });
976
+ });
977
+
978
+ var overlay = document.getElementById("preview-overlay");
979
+ var previewImage = document.getElementById("preview-image");
980
+ var previewClose = document.getElementById("preview-close");
981
+ if (overlay && previewImage && previewClose) {
982
+ var closePreview = function () {
983
+ overlay.classList.remove("is-open");
984
+ overlay.setAttribute("aria-hidden", "true");
985
+ previewImage.removeAttribute("src");
986
+ };
987
+
988
+ document.querySelectorAll("[data-preview-image]").forEach(function (image) {
989
+ image.addEventListener("click", function () {
990
+ var src = image.getAttribute("data-preview-image");
991
+ if (!src) return;
992
+ previewImage.setAttribute("src", src);
993
+ overlay.classList.add("is-open");
994
+ overlay.setAttribute("aria-hidden", "false");
995
+ });
996
+ });
997
+
998
+ previewClose.addEventListener("click", closePreview);
999
+ overlay.addEventListener("click", function (event) {
1000
+ if (event.target === overlay) closePreview();
1001
+ });
1002
+ window.addEventListener("keydown", function (event) {
1003
+ if (event.key === "Escape") closePreview();
1004
+ });
1005
+ }
1006
+ })();
1007
+ </script>
678
1008
  </body>
679
1009
  </html>`;
680
1010
  };
package/dist/reporter.js CHANGED
@@ -15,6 +15,17 @@ const formatTerminalLink = (label, target) => {
15
15
  return label;
16
16
  return `\u001B]8;;${target}\u0007${label}\u001B]8;;\u0007`;
17
17
  };
18
+ const colorize = (value, code) => {
19
+ if (!process.stdout.isTTY)
20
+ return value;
21
+ return `\u001b[${code}m${value}\u001b[0m`;
22
+ };
23
+ const bold = (value) => colorize(value, "1");
24
+ const green = (value) => colorize(value, "32");
25
+ const cyan = (value) => colorize(value, "36");
26
+ const yellow = (value) => colorize(value, "33");
27
+ const dim = (value) => colorize(value, "2");
28
+ const magenta = (value) => colorize(value, "35");
18
29
  class SentinelReporter {
19
30
  constructor(options) {
20
31
  this.failedCount = 0;
@@ -46,18 +57,32 @@ class SentinelReporter {
46
57
  }
47
58
  }
48
59
  printLocalReport(localReportPath) {
49
- console.log("");
50
- console.log("✔ Artifacts collected");
51
- console.log("✔ Sentinel debugging report generated");
52
- console.log("");
53
- console.log("Report location:");
54
60
  const relativeReportPath = path_1.default
55
61
  .relative(process.cwd(), localReportPath)
56
62
  .replace(/\\/g, "/");
57
63
  const displayPath = relativeReportPath.startsWith(".")
58
64
  ? relativeReportPath
59
65
  : `./${relativeReportPath}`;
60
- console.log(formatTerminalLink(displayPath, (0, url_1.pathToFileURL)(localReportPath).href));
66
+ const openCommand = `open ${displayPath}`;
67
+ console.log("");
68
+ console.log(green("✔ Artifacts collected"));
69
+ console.log(green("✔ Sentinel HTML debugging report created"));
70
+ console.log("");
71
+ console.log(bold("Report"));
72
+ console.log(` ${cyan(formatTerminalLink(displayPath, (0, url_1.pathToFileURL)(localReportPath).href))}`);
73
+ console.log("");
74
+ console.log(bold("Open"));
75
+ console.log(` ${cyan(openCommand)}`);
76
+ console.log("");
77
+ console.log(yellow("Tip"));
78
+ console.log(` ${dim("Upload runs to Sentinel Cloud for CI history,")}`);
79
+ console.log(` ${dim("shareable debugging links, and AI summaries.")}`);
80
+ console.log("");
81
+ console.log(` ${cyan(formatTerminalLink("https://sentinelqa.com", "https://sentinelqa.com"))}`);
82
+ console.log("");
83
+ console.log(` ${magenta("★ If this reporter helped you debug faster,")}`);
84
+ console.log(` ${dim("consider starring the project:")}`);
85
+ console.log(` ${cyan(formatTerminalLink("https://github.com/sentinelqa/playwright-reporter", "https://github.com/sentinelqa/playwright-reporter"))}`);
61
86
  }
62
87
  async onEnd() {
63
88
  const hasSentinelToken = Boolean(process.env.SENTINEL_TOKEN);
@@ -76,13 +101,6 @@ class SentinelReporter {
76
101
  this.printLocalReport(localReport.htmlPath);
77
102
  console.log("");
78
103
  if (!hasSentinelToken) {
79
- console.log("Optional:");
80
- console.log("Upload runs to Sentinel Cloud for:");
81
- console.log("• CI history");
82
- console.log("• shareable run links");
83
- console.log("• AI failure summaries");
84
- console.log("");
85
- console.log(`Learn more: ${formatTerminalLink("https://sentinelqa.com", "https://sentinelqa.com")}`);
86
104
  return;
87
105
  }
88
106
  console.log("Sentinel upload skipped for this local run.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",