@sentinelqa/playwright-reporter 0.1.10 → 0.1.12

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/dist/index.js CHANGED
@@ -70,6 +70,7 @@ function withSentinel(config, options = {}) {
70
70
  const existingHtmlReporter = reporters.find((entry) => getReporterName(entry) === "html");
71
71
  const existingJsonOutputFile = getReporterOptions(existingJsonReporter).outputFile;
72
72
  const existingHtmlOutputFolder = getReporterOptions(existingHtmlReporter).outputFolder;
73
+ const shouldUsePlaywrightHtmlReporter = Boolean(process.env.SENTINEL_TOKEN) || typeof existingHtmlOutputFolder === "string";
73
74
  const testResultsDir = options.testResultsDir || config.outputDir || DEFAULT_TEST_RESULTS_DIR;
74
75
  const playwrightReportDir = options.playwrightReportDir ||
75
76
  (typeof existingHtmlOutputFolder === "string"
@@ -84,10 +85,18 @@ function withSentinel(config, options = {}) {
84
85
  .map((entry) => normalizePath(entry))));
85
86
  nextConfig.outputDir = testResultsDir;
86
87
  setReporterOptions(reporters, "json", { outputFile: playwrightJsonPath });
87
- setReporterOptions(reporters, "html", {
88
- outputFolder: playwrightReportDir,
89
- open: "never"
90
- });
88
+ if (shouldUsePlaywrightHtmlReporter) {
89
+ setReporterOptions(reporters, "html", {
90
+ outputFolder: playwrightReportDir,
91
+ open: "never"
92
+ });
93
+ }
94
+ else {
95
+ const htmlReporterIndex = reporters.findIndex((entry) => getReporterName(entry) === "html");
96
+ if (htmlReporterIndex !== -1) {
97
+ reporters.splice(htmlReporterIndex, 1);
98
+ }
99
+ }
91
100
  const sentinelReporterPath = require.resolve("./reporter");
92
101
  const sentinelReporterOptions = {
93
102
  project: options.project || null,
@@ -141,41 +141,89 @@ const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) =>
141
141
  testId
142
142
  };
143
143
  };
144
- const flattenTests = (node, parentTitles = []) => {
144
+ const createReportTest = (test, titlePath) => {
145
+ const results = Array.isArray(test?.results) ? test.results : [];
146
+ const lastResult = results.length > 0 ? results[results.length - 1] : null;
147
+ const errors = results.flatMap((result) => Array.isArray(result?.errors)
148
+ ? result.errors
149
+ .map((error) => error?.message || error?.stack || String(error || ""))
150
+ .filter(Boolean)
151
+ : []);
152
+ const duration = results.reduce((total, result) => total + (Number(result?.duration) || 0), 0);
153
+ const id = [
154
+ test?.location?.file || "unknown",
155
+ test?.projectName || "default",
156
+ titlePath.join(" > ")
157
+ ].join("::");
158
+ return {
159
+ id,
160
+ title: test?.title || titlePath[titlePath.length - 1] || "Untitled test",
161
+ titlePath,
162
+ file: test?.location?.file || null,
163
+ projectName: test?.projectName || null,
164
+ status: test?.status || lastResult?.status || "unknown",
165
+ duration,
166
+ errors,
167
+ artifacts: []
168
+ };
169
+ };
170
+ const collectTests = (node, parentTitles = []) => {
171
+ const nextTitles = node?.title ? [...parentTitles, node.title] : parentTitles;
172
+ const collected = [];
173
+ if (Array.isArray(node?.tests)) {
174
+ for (const test of node.tests) {
175
+ collected.push(createReportTest(test, [...nextTitles, test?.title].filter(Boolean)));
176
+ }
177
+ }
178
+ if (Array.isArray(node?.specs)) {
179
+ for (const spec of node.specs) {
180
+ const specTitles = [...nextTitles, spec?.title].filter(Boolean);
181
+ const specTests = Array.isArray(spec?.tests) ? spec.tests : [];
182
+ for (const test of specTests) {
183
+ collected.push(createReportTest(test, specTitles));
184
+ }
185
+ }
186
+ }
187
+ if (Array.isArray(node?.suites)) {
188
+ for (const suite of node.suites) {
189
+ collected.push(...collectTests(suite, nextTitles));
190
+ }
191
+ }
192
+ return collected;
193
+ };
194
+ const collectTestRefs = (node, parentTitles = []) => {
145
195
  const nextTitles = node?.title ? [...parentTitles, node.title] : parentTitles;
146
- const currentTests = Array.isArray(node?.tests)
147
- ? node.tests.map((test) => {
148
- const titlePath = [...nextTitles, test.title].filter(Boolean);
149
- const results = Array.isArray(test.results) ? test.results : [];
150
- const lastResult = results.length > 0 ? results[results.length - 1] : null;
151
- const errors = results.flatMap((result) => Array.isArray(result?.errors)
152
- ? result.errors
153
- .map((error) => error?.message || error?.stack || String(error || ""))
154
- .filter(Boolean)
155
- : []);
156
- const duration = results.reduce((total, result) => total + (Number(result?.duration) || 0), 0);
196
+ const refs = [];
197
+ if (Array.isArray(node?.tests)) {
198
+ for (const test of node.tests) {
199
+ const titlePath = [...nextTitles, test?.title].filter(Boolean);
157
200
  const id = [
158
- test.location?.file || "unknown",
159
- test.projectName || "default",
201
+ test?.location?.file || "unknown",
202
+ test?.projectName || "default",
160
203
  titlePath.join(" > ")
161
204
  ].join("::");
162
- return {
163
- id,
164
- title: test.title || "Untitled test",
165
- titlePath,
166
- file: test.location?.file || null,
167
- projectName: test.projectName || null,
168
- status: test.status || lastResult?.status || "unknown",
169
- duration,
170
- errors,
171
- artifacts: []
172
- };
173
- })
174
- : [];
175
- const childTests = Array.isArray(node?.suites)
176
- ? node.suites.flatMap((suite) => flattenTests(suite, nextTitles))
177
- : [];
178
- return [...currentTests, ...childTests];
205
+ refs.push({ id, resultList: Array.isArray(test?.results) ? test.results : [] });
206
+ }
207
+ }
208
+ if (Array.isArray(node?.specs)) {
209
+ for (const spec of node.specs) {
210
+ const titlePath = [...nextTitles, spec?.title].filter(Boolean);
211
+ for (const test of Array.isArray(spec?.tests) ? spec.tests : []) {
212
+ const id = [
213
+ test?.location?.file || "unknown",
214
+ test?.projectName || "default",
215
+ titlePath.join(" > ")
216
+ ].join("::");
217
+ refs.push({ id, resultList: Array.isArray(test?.results) ? test.results : [] });
218
+ }
219
+ }
220
+ }
221
+ if (Array.isArray(node?.suites)) {
222
+ for (const suite of node.suites) {
223
+ refs.push(...collectTestRefs(suite, nextTitles));
224
+ }
225
+ }
226
+ return refs;
179
227
  };
180
228
  const summarizeTests = (tests) => {
181
229
  return tests.reduce((summary, test) => {
@@ -235,8 +283,8 @@ const renderTestCard = (test) => {
235
283
  ? test.artifacts.map((artifact) => renderArtifact(artifact)).join("\n")
236
284
  : `<div class="empty-state">No test-linked artifacts were detected for this result.</div>`;
237
285
  return `
238
- <section class="test-card">
239
- <div class="test-head">
286
+ <details class="test-card">
287
+ <summary class="test-summary">
240
288
  <div>
241
289
  <div class="status-pill ${statusClass}">${escapeHtml(test.status)}</div>
242
290
  <h3>${escapeHtml(test.titlePath.join(" > ") || test.title)}</h3>
@@ -246,7 +294,7 @@ const renderTestCard = (test) => {
246
294
  ${projectLine}
247
295
  <div class="meta-item">Duration: ${escapeHtml(formatDuration(test.duration))}</div>
248
296
  </div>
249
- </div>
297
+ </summary>
250
298
  <div class="panel">
251
299
  <h4>Error</h4>
252
300
  ${errorBlock}
@@ -257,7 +305,7 @@ const renderTestCard = (test) => {
257
305
  ${artifactMarkup}
258
306
  </div>
259
307
  </div>
260
- </section>
308
+ </details>
261
309
  `;
262
310
  };
263
311
  const renderAdditionalArtifacts = (artifacts) => {
@@ -318,41 +366,56 @@ const buildHtml = (tests, summary, extraArtifacts) => {
318
366
  padding: 40px 20px 80px;
319
367
  }
320
368
  .hero {
321
- padding: 28px;
369
+ position: relative;
370
+ padding: 22px;
322
371
  border: 1px solid var(--panel-border);
323
372
  border-radius: 24px;
324
373
  background: rgba(13, 17, 23, 0.88);
325
374
  backdrop-filter: blur(12px);
326
375
  }
376
+ .hero-badge {
377
+ position: absolute;
378
+ top: 18px;
379
+ right: 18px;
380
+ display: inline-flex;
381
+ align-items: center;
382
+ padding: 6px 10px;
383
+ border-radius: 999px;
384
+ border: 1px solid rgba(125, 211, 252, 0.28);
385
+ background: rgba(125, 211, 252, 0.08);
386
+ color: var(--accent);
387
+ font-size: 11px;
388
+ letter-spacing: 0.06em;
389
+ text-transform: uppercase;
390
+ }
327
391
  .eyebrow {
328
392
  color: var(--accent);
329
393
  text-transform: uppercase;
330
394
  letter-spacing: 0.18em;
331
- font-size: 12px;
332
- margin-bottom: 12px;
395
+ font-size: 10px;
396
+ margin-bottom: 10px;
333
397
  }
334
398
  h1, h2, h3, h4 { margin: 0; }
335
- h1 { font-size: clamp(32px, 5vw, 52px); line-height: 1; }
399
+ h1 { font-size: clamp(24px, 4vw, 38px); line-height: 1.05; }
336
400
  .hero p {
337
- margin: 14px 0 0;
401
+ margin: 10px 0 0;
338
402
  color: var(--muted);
339
- max-width: 760px;
340
- font-size: 16px;
403
+ max-width: 640px;
404
+ font-size: 14px;
341
405
  line-height: 1.6;
342
406
  }
343
- .summary-grid, .mode-grid {
407
+ .summary-grid {
344
408
  display: grid;
345
409
  gap: 16px;
346
- margin-top: 24px;
410
+ margin-top: 18px;
347
411
  }
348
412
  .summary-grid { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
349
- .mode-grid { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
350
- .summary-card, .mode-card, .section-shell, .test-card {
413
+ .summary-card, .section-shell, .test-card {
351
414
  border: 1px solid var(--panel-border);
352
415
  border-radius: 20px;
353
416
  background: var(--panel);
354
417
  }
355
- .summary-card, .mode-card {
418
+ .summary-card {
356
419
  padding: 18px;
357
420
  }
358
421
  .summary-label {
@@ -377,27 +440,21 @@ const buildHtml = (tests, summary, extraArtifacts) => {
377
440
  color: var(--muted);
378
441
  line-height: 1.6;
379
442
  }
380
- .diagram {
381
- margin-top: 18px;
382
- padding: 18px;
383
- border-radius: 16px;
384
- background: rgba(125, 211, 252, 0.06);
385
- border: 1px dashed rgba(125, 211, 252, 0.3);
386
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
387
- white-space: pre-line;
388
- color: #d7e2f0;
389
- }
390
- .mode-card h3 { font-size: 18px; }
391
- .mode-card p { color: var(--muted); line-height: 1.6; }
392
443
  .test-card {
393
- padding: 20px;
394
444
  margin-top: 18px;
445
+ overflow: hidden;
395
446
  }
396
- .test-head {
447
+ .test-summary {
397
448
  display: flex;
398
449
  justify-content: space-between;
399
450
  gap: 20px;
400
451
  align-items: flex-start;
452
+ list-style: none;
453
+ padding: 20px;
454
+ cursor: pointer;
455
+ }
456
+ .test-summary::-webkit-details-marker {
457
+ display: none;
401
458
  }
402
459
  .status-pill {
403
460
  display: inline-flex;
@@ -417,9 +474,10 @@ const buildHtml = (tests, summary, extraArtifacts) => {
417
474
  gap: 6px;
418
475
  color: var(--muted);
419
476
  font-size: 14px;
477
+ text-align: right;
420
478
  }
421
479
  .panel {
422
- margin-top: 18px;
480
+ margin: 0 20px 18px;
423
481
  padding: 16px;
424
482
  background: rgba(13, 17, 23, 0.74);
425
483
  border: 1px solid rgba(39, 48, 66, 0.9);
@@ -480,13 +538,27 @@ const buildHtml = (tests, summary, extraArtifacts) => {
480
538
  border-radius: 14px;
481
539
  padding: 16px;
482
540
  }
541
+ .failed-list-head {
542
+ display: flex;
543
+ justify-content: space-between;
544
+ gap: 12px;
545
+ align-items: center;
546
+ }
547
+ .failed-count {
548
+ color: var(--muted);
549
+ font-size: 14px;
550
+ }
483
551
  footer {
484
552
  margin-top: 28px;
485
553
  color: var(--muted);
486
554
  font-size: 14px;
487
555
  }
488
556
  @media (max-width: 720px) {
489
- .test-head { flex-direction: column; }
557
+ .hero-badge {
558
+ position: static;
559
+ margin-bottom: 12px;
560
+ }
561
+ .test-summary { flex-direction: column; }
490
562
  .meta-stack { min-width: 0; }
491
563
  }
492
564
  </style>
@@ -494,8 +566,9 @@ const buildHtml = (tests, summary, extraArtifacts) => {
494
566
  <body>
495
567
  <div class="page">
496
568
  <header class="hero">
569
+ <a class="hero-badge" href="${SENTINEL_URL}" target="_blank" rel="noreferrer">Powered by sentinelqa.com</a>
497
570
  <div class="eyebrow">Playwright Reporter for CI Debugging</div>
498
- <h1>Sentinel Playwright Reporter</h1>
571
+ <h1>Playwright Reporter</h1>
499
572
  <p>
500
573
  A Playwright reporter that collects traces, screenshots, videos, and logs into
501
574
  a single debugging report. Designed to make CI failures easier to diagnose.
@@ -521,33 +594,10 @@ const buildHtml = (tests, summary, extraArtifacts) => {
521
594
  </header>
522
595
 
523
596
  <section class="section-shell">
524
- <h2>Modes</h2>
525
- <div class="mode-grid">
526
- <div class="mode-card">
527
- <h3>Local Mode</h3>
528
- <p>Generates debugging reports locally with traces, screenshots, videos, and logs copied into this report folder.</p>
529
- </div>
530
- <div class="mode-card">
531
- <h3>Cloud Mode</h3>
532
- <p>Uploads CI runs to Sentinel for team debugging, CI history, shareable links, and AI-generated failure summaries.</p>
533
- </div>
597
+ <div class="failed-list-head">
598
+ <h2>Failed Tests</h2>
599
+ <div class="failed-count">${failedTests.length} failed</div>
534
600
  </div>
535
- </section>
536
-
537
- <section class="section-shell">
538
- <h2>Flow</h2>
539
- <p>This reporter keeps the setup local-first and only adds Sentinel Cloud when a token is configured.</p>
540
- <div class="diagram">Playwright CI
541
-
542
- Sentinel Reporter
543
-
544
- Artifacts Collected
545
-
546
- Local Debug Page OR Sentinel Cloud</div>
547
- </section>
548
-
549
- <section class="section-shell">
550
- <h2>Failed Tests</h2>
551
601
  ${failedTests.length > 0
552
602
  ? failedTests.map((test) => renderTestCard(test)).join("\n")
553
603
  : `<div class="empty-state">No failed tests were found in this run. The local report still includes collected artifacts below.</div>`}
@@ -559,6 +609,19 @@ Local Debug Page OR Sentinel Cloud</div>
559
609
  ${renderAdditionalArtifacts(extraArtifacts)}
560
610
  </section>
561
611
 
612
+ <section class="section-shell">
613
+ <h2>Optional: Sentinel Cloud</h2>
614
+ <div class="artifact-list">
615
+ <div class="artifact-link">Upload runs to Sentinel Cloud for:</div>
616
+ <div class="artifact-link">• CI history</div>
617
+ <div class="artifact-link">• shareable run links</div>
618
+ <div class="artifact-link">• AI failure summaries</div>
619
+ <div class="artifact-link">
620
+ <a href="${SENTINEL_URL}" target="_blank" rel="noreferrer">More on sentinelqa.com</a>
621
+ </div>
622
+ </div>
623
+ </section>
624
+
562
625
  <footer>
563
626
  Generated by <a href="${SENTINEL_URL}" target="_blank" rel="noreferrer">Sentinel Playwright Reporter</a>.
564
627
  </footer>
@@ -586,7 +649,8 @@ function generateLocalDebugReport(options) {
586
649
  ]);
587
650
  const reportJsonRaw = fs_1.default.readFileSync(options.playwrightJsonPath, "utf8");
588
651
  const reportJson = JSON.parse(reportJsonRaw);
589
- const tests = flattenTests({ suites: reportJson?.suites || [] });
652
+ const reportRoot = { suites: reportJson?.suites || [] };
653
+ const tests = collectTests(reportRoot);
590
654
  const testsById = new Map(tests.map((test) => [test.id, test]));
591
655
  const claimedSourcePaths = new Set();
592
656
  const attachArtifactToTest = (sourcePath, testId) => {
@@ -600,34 +664,17 @@ function generateLocalDebugReport(options) {
600
664
  return;
601
665
  }
602
666
  };
603
- const walkSuites = (node, parentTitles = []) => {
604
- const nextTitles = node?.title ? [...parentTitles, node.title] : parentTitles;
605
- if (Array.isArray(node?.tests)) {
606
- for (const test of node.tests) {
607
- const titlePath = [...nextTitles, test.title].filter(Boolean);
608
- const testId = [
609
- test.location?.file || "unknown",
610
- test.projectName || "default",
611
- titlePath.join(" > ")
612
- ].join("::");
613
- const resultList = Array.isArray(test.results) ? test.results : [];
614
- for (const result of resultList) {
615
- const attachments = Array.isArray(result?.attachments) ? result.attachments : [];
616
- for (const attachment of attachments) {
617
- const resolvedPath = resolveExistingFile(attachment?.path, baseDirs);
618
- if (!resolvedPath)
619
- continue;
620
- attachArtifactToTest(resolvedPath, testId);
621
- }
622
- }
667
+ for (const testRef of collectTestRefs(reportRoot)) {
668
+ for (const result of testRef.resultList) {
669
+ const attachments = Array.isArray(result?.attachments) ? result.attachments : [];
670
+ for (const attachment of attachments) {
671
+ const resolvedPath = resolveExistingFile(attachment?.path, baseDirs);
672
+ if (!resolvedPath)
673
+ continue;
674
+ attachArtifactToTest(resolvedPath, testRef.id);
623
675
  }
624
676
  }
625
- if (Array.isArray(node?.suites)) {
626
- for (const suite of node.suites)
627
- walkSuites(suite, nextTitles);
628
- }
629
- };
630
- walkSuites({ suites: reportJson?.suites || [] });
677
+ }
631
678
  const extraArtifacts = [];
632
679
  for (const sourceDir of sourceDirs) {
633
680
  for (const filePath of listFilesRecursive(sourceDir)) {
package/dist/reporter.js CHANGED
@@ -3,12 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  const path_1 = __importDefault(require("path"));
6
+ const url_1 = require("url");
6
7
  const node_1 = require("@sentinelqa/uploader/node");
7
8
  const env_1 = require("./env");
8
9
  const localReport_1 = require("./localReport");
9
10
  const pluralize = (count, singular, plural) => {
10
11
  return count === 1 ? singular : plural;
11
12
  };
13
+ const formatTerminalLink = (label, target) => {
14
+ if (!process.stdout.isTTY)
15
+ return label;
16
+ return `\u001B]8;;${target}\u0007${label}\u001B]8;;\u0007`;
17
+ };
12
18
  class SentinelReporter {
13
19
  constructor(options) {
14
20
  this.failedCount = 0;
@@ -58,7 +64,10 @@ class SentinelReporter {
58
64
  const relativeReportPath = path_1.default
59
65
  .relative(process.cwd(), localReport.htmlPath)
60
66
  .replace(/\\/g, "/");
61
- console.log(relativeReportPath.startsWith(".") ? relativeReportPath : `./${relativeReportPath}`);
67
+ const displayPath = relativeReportPath.startsWith(".")
68
+ ? relativeReportPath
69
+ : `./${relativeReportPath}`;
70
+ console.log(formatTerminalLink(displayPath, (0, url_1.pathToFileURL)(localReport.htmlPath).href));
62
71
  console.log("");
63
72
  console.log("Optional:");
64
73
  console.log("Upload runs to Sentinel Cloud for:");
@@ -66,7 +75,7 @@ class SentinelReporter {
66
75
  console.log("• shareable run links");
67
76
  console.log("• AI failure summaries");
68
77
  console.log("");
69
- console.log("Learn more: https://sentinelqa.com");
78
+ console.log(`Learn more: ${formatTerminalLink("https://sentinelqa.com", "https://sentinelqa.com")}`);
70
79
  return;
71
80
  }
72
81
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",