@sentinelqa/playwright-reporter 0.1.28 → 0.1.30

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
@@ -12,6 +12,8 @@ Works locally out of the box with no account required.
12
12
  Optionally upload runs to Sentinel Cloud for CI history and AI failure analysis.
13
13
 
14
14
  ![Sentinel Report Example](./docs/screenshot.png)
15
+ ![Run-to-Run Diff](./docs/run_diff.png)
16
+ ![CLI Quick Diagnosis](./docs/CLI.png)
15
17
 
16
18
  ## Features
17
19
 
@@ -21,6 +21,7 @@ type CopiedArtifact = {
21
21
  };
22
22
  type ReportTest = {
23
23
  id: string;
24
+ matchKey: string;
24
25
  title: string;
25
26
  titlePath: string[];
26
27
  file: string | null;
@@ -51,6 +52,7 @@ type RunSnapshot = {
51
52
  totals: LocalReportSummary;
52
53
  tests: Array<{
53
54
  id: string;
55
+ matchKey: string;
54
56
  title: string;
55
57
  status: string;
56
58
  signal: string | null;
@@ -95,6 +95,23 @@ const safeSlug = (value) => {
95
95
  .replace(/^-+|-+$/g, "")
96
96
  .slice(0, 64) || "artifact");
97
97
  };
98
+ const buildTitlePath = (baseTitles, test) => {
99
+ const title = typeof test?.title === "string" ? test.title : null;
100
+ if (!title)
101
+ return baseTitles.filter(Boolean);
102
+ if (baseTitles[baseTitles.length - 1] === title)
103
+ return baseTitles.filter(Boolean);
104
+ return [...baseTitles, title].filter(Boolean);
105
+ };
106
+ const buildTestIdentity = (test, titlePath) => {
107
+ const file = test?.location?.file || "unknown";
108
+ const project = test?.projectName || "default";
109
+ const joined = titlePath.join(" > ");
110
+ return {
111
+ id: [file, project, joined].join("::"),
112
+ matchKey: [file, joined].join("::")
113
+ };
114
+ };
98
115
  const formatDuration = (durationMs) => {
99
116
  if (!Number.isFinite(durationMs) || durationMs <= 0)
100
117
  return "0 ms";
@@ -233,7 +250,7 @@ const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) =>
233
250
  testId
234
251
  };
235
252
  };
236
- const createReportTest = (test, titlePath, diagnosisById) => {
253
+ const createReportTest = (test, titlePath) => {
237
254
  const results = Array.isArray(test?.results) ? test.results : [];
238
255
  const lastResult = results.length > 0 ? results[results.length - 1] : null;
239
256
  const errors = results.flatMap((result) => Array.isArray(result?.errors)
@@ -242,30 +259,31 @@ const createReportTest = (test, titlePath, diagnosisById) => {
242
259
  .filter(Boolean)
243
260
  : []);
244
261
  const duration = results.reduce((total, result) => total + (Number(result?.duration) || 0), 0);
245
- const id = [
246
- test?.location?.file || "unknown",
247
- test?.projectName || "default",
248
- titlePath.join(" > ")
249
- ].join("::");
262
+ const identity = buildTestIdentity(test, titlePath);
263
+ const status = normalizeTestStatus(test?.status || lastResult?.status || "unknown");
264
+ const primaryError = errors[0] || "";
250
265
  return {
251
- id,
266
+ id: identity.id,
267
+ matchKey: identity.matchKey,
252
268
  title: test?.title || titlePath[titlePath.length - 1] || "Untitled test",
253
269
  titlePath,
254
270
  file: test?.location?.file || null,
255
271
  projectName: test?.projectName || null,
256
- status: normalizeTestStatus(test?.status || lastResult?.status || "unknown"),
272
+ status,
257
273
  duration,
258
274
  errors,
259
- diagnosis: diagnosisById.get(titlePath.join(" > ")) || null,
275
+ diagnosis: ["failed", "timedOut", "interrupted"].includes(status) && primaryError
276
+ ? (0, quickDiagnosis_1.parseFailureFacts)(test?.title || titlePath[titlePath.length - 1] || "Untitled test", titlePath, primaryError, status)
277
+ : null,
260
278
  artifacts: []
261
279
  };
262
280
  };
263
- const collectTests = (node, diagnosisById, parentTitles = []) => {
281
+ const collectTests = (node, parentTitles = []) => {
264
282
  const nextTitles = node?.title ? [...parentTitles, node.title] : parentTitles;
265
283
  const collected = [];
266
284
  if (Array.isArray(node?.tests)) {
267
285
  for (const test of node.tests) {
268
- collected.push(createReportTest(test, [...nextTitles, test?.title].filter(Boolean), diagnosisById));
286
+ collected.push(createReportTest(test, buildTitlePath(nextTitles, test)));
269
287
  }
270
288
  }
271
289
  if (Array.isArray(node?.specs)) {
@@ -273,13 +291,13 @@ const collectTests = (node, diagnosisById, parentTitles = []) => {
273
291
  const specTitles = [...nextTitles, spec?.title].filter(Boolean);
274
292
  const specTests = Array.isArray(spec?.tests) ? spec.tests : [];
275
293
  for (const test of specTests) {
276
- collected.push(createReportTest(test, specTitles, diagnosisById));
294
+ collected.push(createReportTest(test, buildTitlePath(specTitles, test)));
277
295
  }
278
296
  }
279
297
  }
280
298
  if (Array.isArray(node?.suites)) {
281
299
  for (const suite of node.suites) {
282
- collected.push(...collectTests(suite, diagnosisById, nextTitles));
300
+ collected.push(...collectTests(suite, nextTitles));
283
301
  }
284
302
  }
285
303
  return collected;
@@ -289,12 +307,8 @@ const collectTestRefs = (node, parentTitles = []) => {
289
307
  const refs = [];
290
308
  if (Array.isArray(node?.tests)) {
291
309
  for (const test of node.tests) {
292
- const titlePath = [...nextTitles, test?.title].filter(Boolean);
293
- const id = [
294
- test?.location?.file || "unknown",
295
- test?.projectName || "default",
296
- titlePath.join(" > ")
297
- ].join("::");
310
+ const titlePath = buildTitlePath(nextTitles, test);
311
+ const id = buildTestIdentity(test, titlePath).id;
298
312
  refs.push({ id, resultList: Array.isArray(test?.results) ? test.results : [] });
299
313
  }
300
314
  }
@@ -302,11 +316,7 @@ const collectTestRefs = (node, parentTitles = []) => {
302
316
  for (const spec of node.specs) {
303
317
  const titlePath = [...nextTitles, spec?.title].filter(Boolean);
304
318
  for (const test of Array.isArray(spec?.tests) ? spec.tests : []) {
305
- const id = [
306
- test?.location?.file || "unknown",
307
- test?.projectName || "default",
308
- titlePath.join(" > ")
309
- ].join("::");
319
+ const id = buildTestIdentity(test, buildTitlePath(titlePath, test)).id;
310
320
  refs.push({ id, resultList: Array.isArray(test?.results) ? test.results : [] });
311
321
  }
312
322
  }
@@ -331,14 +341,6 @@ const summarizeTests = (tests) => {
331
341
  return summary;
332
342
  }, { total: 0, failed: 0, passed: 0, skipped: 0 });
333
343
  };
334
- const buildDiagnosisMap = (playwrightJsonPath) => {
335
- const failures = (0, quickDiagnosis_1.collectFailureFacts)(playwrightJsonPath);
336
- const map = new Map();
337
- for (const failure of failures) {
338
- map.set(failure.titlePath.join(" > "), failure);
339
- }
340
- return map;
341
- };
342
344
  const getFailureTests = (tests) => tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
343
345
  const groupSimilarFailures = (tests) => {
344
346
  const groups = new Map();
@@ -356,6 +358,22 @@ const groupSimilarFailures = (tests) => {
356
358
  }
357
359
  groups.get(key).tests.push(test);
358
360
  }
361
+ return Array.from(groups.values())
362
+ .filter((group) => group.tests.length > 1)
363
+ .sort((a, b) => b.tests.length - a.tests.length);
364
+ };
365
+ const groupFailureDigest = (tests) => {
366
+ const groups = new Map();
367
+ for (const test of getFailureTests(tests)) {
368
+ const summary = test.diagnosis
369
+ ? (0, quickDiagnosis_1.describeFailure)(test.diagnosis)
370
+ : (test.errors[0]?.split(/\r?\n/)[0]?.trim() || "Open the failure details to inspect the exact Playwright error.");
371
+ const key = summary;
372
+ if (!groups.has(key)) {
373
+ groups.set(key, { key, summary, tests: [] });
374
+ }
375
+ groups.get(key).tests.push(test);
376
+ }
359
377
  return Array.from(groups.values()).sort((a, b) => b.tests.length - a.tests.length);
360
378
  };
361
379
  const buildRunSnapshot = (tests, summary) => ({
@@ -365,6 +383,7 @@ const buildRunSnapshot = (tests, summary) => ({
365
383
  totals: summary,
366
384
  tests: tests.map((test) => ({
367
385
  id: test.id,
386
+ matchKey: test.matchKey,
368
387
  title: test.titlePath.join(" > ") || test.title,
369
388
  status: test.status,
370
389
  signal: test.diagnosis?.signal || null,
@@ -407,18 +426,21 @@ const buildRunDiff = (tests, snapshot) => {
407
426
  || readSnapshot(path_1.default.resolve(process.cwd(), ".sentinel", "latest.json"));
408
427
  if (!previous || previous.generatedAt === snapshot.generatedAt)
409
428
  return null;
410
- const previousById = new Map(previous.tests.map((test) => [test.id, test]));
429
+ const currentById = new Map(tests.map((test) => [test.id, test]));
430
+ const currentByMatchKey = new Map(tests.map((test) => [test.matchKey, test]));
411
431
  const currentFailures = getFailureTests(tests);
412
432
  const previousFailures = previous.tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
413
433
  const previousFailureIds = new Set(previousFailures.map((test) => test.id));
434
+ const previousFailureMatchKeys = new Set(previousFailures.map((test) => (typeof test.matchKey === "string" ? test.matchKey : test.id)));
414
435
  return {
415
436
  label: previous.branch === snapshot.branch ? `Compared to previous ${snapshot.branch} run` : "Compared to previous run",
416
- newFailures: currentFailures.filter((test) => !previousFailureIds.has(test.id)),
437
+ newFailures: currentFailures.filter((test) => !previousFailureIds.has(test.id) && !previousFailureMatchKeys.has(test.matchKey)),
417
438
  fixedTests: previousFailures.filter((test) => {
418
- const current = tests.find((entry) => entry.id === test.id);
439
+ const current = currentById.get(test.id) ||
440
+ currentByMatchKey.get(typeof test.matchKey === "string" ? test.matchKey : test.id);
419
441
  return current && !["failed", "timedOut", "interrupted"].includes(current.status);
420
442
  }),
421
- stillFailing: currentFailures.filter((test) => previousFailureIds.has(test.id))
443
+ stillFailing: currentFailures.filter((test) => previousFailureIds.has(test.id) || previousFailureMatchKeys.has(test.matchKey))
422
444
  };
423
445
  };
424
446
  const renderArtifact = (artifact) => {
@@ -593,22 +615,30 @@ const renderFailureDigest = (tests) => {
593
615
  if (!failedTests.length) {
594
616
  return `<div class="empty-state">No failed tests were detected in this run.</div>`;
595
617
  }
618
+ const digestGroups = groupFailureDigest(tests);
596
619
  return `
597
620
  <div class="digest-grid">
598
- ${failedTests
599
- .map((test) => {
600
- const diagnosis = test.diagnosis;
601
- const title = escapeHtml(test.title);
602
- const summary = escapeHtml(diagnosis ? (0, quickDiagnosis_1.describeFailure)(diagnosis) : "The failure could not be summarized from the captured evidence.");
603
- const debugSummary = escapeHtml(diagnosis
604
- ? (0, quickDiagnosis_1.buildDebugSummary)(diagnosis)
605
- : `Test: ${test.title}\nDiagnosis: Review trace and error details in the expanded card.`);
621
+ ${digestGroups
622
+ .map((group) => {
623
+ const primary = group.tests[0];
624
+ const diagnosis = primary.diagnosis;
625
+ const debugSummary = escapeHtml(group.tests.length > 1
626
+ ? [
627
+ `Grouped failure summary: ${group.summary}`,
628
+ ...group.tests.map((test) => `- ${test.title}`)
629
+ ].join("\n")
630
+ : diagnosis
631
+ ? (0, quickDiagnosis_1.buildDebugSummary)(diagnosis)
632
+ : `Test: ${primary.title}\nDiagnosis: Review trace and error details in the expanded card.`);
633
+ const uniqueLocators = Array.from(new Set(group.tests.map((test) => test.diagnosis?.locator).filter(Boolean)));
606
634
  return `
607
635
  <article class="digest-card">
608
636
  <div class="digest-head">
609
637
  <div>
610
- <span class="artifact-kind">${escapeHtml(test.status)}</span>
611
- <h3>${title}</h3>
638
+ <span class="artifact-kind">${escapeHtml(primary.status)}</span>
639
+ <h3>${group.tests.length > 1
640
+ ? `${group.tests.length} tests share this failure`
641
+ : escapeHtml(primary.title)}</h3>
612
642
  </div>
613
643
  <button
614
644
  type="button"
@@ -619,12 +649,20 @@ const renderFailureDigest = (tests) => {
619
649
  Copy summary
620
650
  </button>
621
651
  </div>
622
- <p>${summary}</p>
652
+ <p>${escapeHtml(group.summary)}</p>
623
653
  <div class="fact-row">
624
- ${diagnosis?.locator ? `<span class="fact-chip">Locator: ${escapeHtml(diagnosis.locator)}</span>` : ""}
625
- ${diagnosis?.expected ? `<span class="fact-chip">Expected: ${escapeHtml(diagnosis.expected)}</span>` : ""}
626
- ${diagnosis?.received ? `<span class="fact-chip">Observed: ${escapeHtml(diagnosis.received)}</span>` : ""}
654
+ ${diagnosis?.expected && diagnosis?.received
655
+ ? `<span class="fact-chip">Expected: ${escapeHtml(diagnosis.expected)}</span><span class="fact-chip">Observed: ${escapeHtml(diagnosis.received)}</span>`
656
+ : ""}
657
+ ${uniqueLocators.length === 1
658
+ ? `<span class="fact-chip">Locator: ${escapeHtml(uniqueLocators[0])}</span>`
659
+ : uniqueLocators.length > 1
660
+ ? `<span class="fact-chip">${uniqueLocators.length} locators involved</span>`
661
+ : ""}
627
662
  </div>
663
+ ${group.tests.length > 1
664
+ ? `<ul class="group-list">${group.tests.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
665
+ : ""}
628
666
  </article>
629
667
  `;
630
668
  })
@@ -634,7 +672,7 @@ const renderFailureDigest = (tests) => {
634
672
  };
635
673
  const renderSimilarFailureGroups = (groups) => {
636
674
  if (!groups.length) {
637
- return `<div class="empty-state">No failure groups were detected for this run.</div>`;
675
+ return `<div class="empty-state">No repeated failure fingerprint was detected in this run.</div>`;
638
676
  }
639
677
  return groups
640
678
  .map((group) => `
@@ -1223,7 +1261,7 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1223
1261
  <h2>Similar Failures</h2>
1224
1262
  <div class="failed-count">${similarGroups.length} groups</div>
1225
1263
  </div>
1226
- <p>Failures are grouped by the most likely common cause so you can separate one bug from many symptoms.</p>
1264
+ <p>Failures are grouped only when they share the same fingerprint, so one repeated issue is easier to spot.</p>
1227
1265
  ${renderSimilarFailureGroups(similarGroups)}
1228
1266
  </section>
1229
1267
 
@@ -1400,8 +1438,7 @@ function generateLocalDebugReport(options) {
1400
1438
  const reportJsonRaw = fs_1.default.readFileSync(options.playwrightJsonPath, "utf8");
1401
1439
  const reportJson = JSON.parse(reportJsonRaw);
1402
1440
  const reportRoot = { suites: reportJson?.suites || [] };
1403
- const diagnosisById = buildDiagnosisMap(options.playwrightJsonPath);
1404
- const tests = collectTests(reportRoot, diagnosisById);
1441
+ const tests = collectTests(reportRoot);
1405
1442
  const testsById = new Map(tests.map((test) => [test.id, test]));
1406
1443
  const claimedSourcePaths = new Set();
1407
1444
  const attachArtifactToTest = (sourcePath, testId) => {
@@ -15,6 +15,7 @@ export type FailureFacts = {
15
15
  status: string;
16
16
  };
17
17
  export declare const collectFailureFacts: (playwrightJsonPath: string) => FailureFacts[];
18
+ export declare const parseFailureFacts: (title: string, titlePath: string[], message: string, status: string) => FailureFacts;
18
19
  export declare const describeFailure: (failure: FailureFacts) => string;
19
20
  export declare const buildDebugSummary: (failure: FailureFacts) => string;
20
21
  export declare const buildSimilarityKey: (failure: FailureFacts) => string;
@@ -3,8 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.describeFailure = exports.collectFailureFacts = void 0;
6
+ exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.describeFailure = exports.parseFailureFacts = exports.collectFailureFacts = void 0;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
+ const normalizeMessageFingerprint = (message) => stripAnsi(message)
9
+ .split(/\r?\n/)
10
+ .map((line) => line.trim())
11
+ .filter(Boolean)
12
+ .slice(0, 3)
13
+ .join(" | ")
14
+ .replace(/\b\d+ms\b/gi, "<ms>")
15
+ .replace(/:\d+:\d+/g, ":<line>:<col>")
16
+ .replace(/\s+/g, " ")
17
+ .slice(0, 200);
8
18
  const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, "");
9
19
  const toMessage = (result) => {
10
20
  const direct = result.error?.message ||
@@ -18,11 +28,11 @@ const toMessage = (result) => {
18
28
  };
19
29
  const classifySignal = (message) => {
20
30
  const lower = message.toLowerCase();
21
- if (/timeout|timed out|waiting for/.test(lower))
22
- return "timeout";
23
31
  if (/expected substring|expected string|received string|tohavetext|tocontaintext/.test(lower)) {
24
32
  return "assertion_mismatch";
25
33
  }
34
+ if (/timeout|timed out|waiting for/.test(lower))
35
+ return "timeout";
26
36
  if (/resolved to 0 elements|locator.*not found|never appeared|strict mode violation/.test(lower)) {
27
37
  return "locator_not_found";
28
38
  }
@@ -120,24 +130,26 @@ const collectFailureFacts = (playwrightJsonPath) => {
120
130
  const raw = node_fs_1.default.readFileSync(playwrightJsonPath, "utf8");
121
131
  const parsed = JSON.parse(raw);
122
132
  const failedCases = flattenFailedCases(parsed);
123
- return failedCases.map((failed) => ({
124
- title: shortenTitle(failed.title),
125
- titlePath: failed.title.split(" > ").filter(Boolean),
126
- message: failed.message,
127
- signal: failed.signal,
128
- locator: extractLocator(failed.message),
129
- expected: extractExpected(failed.message),
130
- received: extractReceived(failed.message),
131
- timeoutMs: extractTimeoutMs(failed.message),
132
- lastUrl: extractLastUrl(failed.message),
133
- status: "failed"
134
- }));
133
+ return failedCases.map((failed) => (0, exports.parseFailureFacts)(shortenTitle(failed.title), failed.title.split(" > ").filter(Boolean), failed.message, "failed"));
135
134
  }
136
135
  catch {
137
136
  return [];
138
137
  }
139
138
  };
140
139
  exports.collectFailureFacts = collectFailureFacts;
140
+ const parseFailureFacts = (title, titlePath, message, status) => ({
141
+ title,
142
+ titlePath,
143
+ message,
144
+ signal: classifySignal(message),
145
+ locator: extractLocator(message),
146
+ expected: extractExpected(message),
147
+ received: extractReceived(message),
148
+ timeoutMs: extractTimeoutMs(message),
149
+ lastUrl: extractLastUrl(message),
150
+ status
151
+ });
152
+ exports.parseFailureFacts = parseFailureFacts;
141
153
  const describeFailure = (failure) => {
142
154
  if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
143
155
  return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}" before timeout.`;
@@ -179,9 +191,15 @@ const buildDebugSummary = (failure) => {
179
191
  };
180
192
  exports.buildDebugSummary = buildDebugSummary;
181
193
  const buildSimilarityKey = (failure) => {
182
- const locator = failure.locator || "unknown-locator";
183
- const expected = failure.expected || "unknown-expected";
184
- return `${failure.signal}|${locator}|${expected}`;
194
+ if (failure.locator || failure.expected || failure.received) {
195
+ return [
196
+ failure.signal,
197
+ failure.locator || "unknown-locator",
198
+ failure.expected || "unknown-expected",
199
+ failure.received || "unknown-received"
200
+ ].join("|");
201
+ }
202
+ return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}`;
185
203
  };
186
204
  exports.buildSimilarityKey = buildSimilarityKey;
187
205
  exports.summarizeSignal = signalSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",