@sentinelqa/playwright-reporter 0.1.28 → 0.1.29

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.
@@ -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,7 +358,9 @@ const groupSimilarFailures = (tests) => {
356
358
  }
357
359
  groups.get(key).tests.push(test);
358
360
  }
359
- return Array.from(groups.values()).sort((a, b) => b.tests.length - a.tests.length);
361
+ return Array.from(groups.values())
362
+ .filter((group) => group.tests.length > 1)
363
+ .sort((a, b) => b.tests.length - a.tests.length);
360
364
  };
361
365
  const buildRunSnapshot = (tests, summary) => ({
362
366
  generatedAt: new Date().toISOString(),
@@ -365,6 +369,7 @@ const buildRunSnapshot = (tests, summary) => ({
365
369
  totals: summary,
366
370
  tests: tests.map((test) => ({
367
371
  id: test.id,
372
+ matchKey: test.matchKey,
368
373
  title: test.titlePath.join(" > ") || test.title,
369
374
  status: test.status,
370
375
  signal: test.diagnosis?.signal || null,
@@ -407,18 +412,21 @@ const buildRunDiff = (tests, snapshot) => {
407
412
  || readSnapshot(path_1.default.resolve(process.cwd(), ".sentinel", "latest.json"));
408
413
  if (!previous || previous.generatedAt === snapshot.generatedAt)
409
414
  return null;
410
- const previousById = new Map(previous.tests.map((test) => [test.id, test]));
415
+ const currentById = new Map(tests.map((test) => [test.id, test]));
416
+ const currentByMatchKey = new Map(tests.map((test) => [test.matchKey, test]));
411
417
  const currentFailures = getFailureTests(tests);
412
418
  const previousFailures = previous.tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
413
419
  const previousFailureIds = new Set(previousFailures.map((test) => test.id));
420
+ const previousFailureMatchKeys = new Set(previousFailures.map((test) => (typeof test.matchKey === "string" ? test.matchKey : test.id)));
414
421
  return {
415
422
  label: previous.branch === snapshot.branch ? `Compared to previous ${snapshot.branch} run` : "Compared to previous run",
416
- newFailures: currentFailures.filter((test) => !previousFailureIds.has(test.id)),
423
+ newFailures: currentFailures.filter((test) => !previousFailureIds.has(test.id) && !previousFailureMatchKeys.has(test.matchKey)),
417
424
  fixedTests: previousFailures.filter((test) => {
418
- const current = tests.find((entry) => entry.id === test.id);
425
+ const current = currentById.get(test.id) ||
426
+ currentByMatchKey.get(typeof test.matchKey === "string" ? test.matchKey : test.id);
419
427
  return current && !["failed", "timedOut", "interrupted"].includes(current.status);
420
428
  }),
421
- stillFailing: currentFailures.filter((test) => previousFailureIds.has(test.id))
429
+ stillFailing: currentFailures.filter((test) => previousFailureIds.has(test.id) || previousFailureMatchKeys.has(test.matchKey))
422
430
  };
423
431
  };
424
432
  const renderArtifact = (artifact) => {
@@ -599,7 +607,9 @@ const renderFailureDigest = (tests) => {
599
607
  .map((test) => {
600
608
  const diagnosis = test.diagnosis;
601
609
  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.");
610
+ const summary = escapeHtml(diagnosis
611
+ ? (0, quickDiagnosis_1.describeFailure)(diagnosis)
612
+ : (test.errors[0]?.split(/\r?\n/)[0]?.trim() || "Open the failure details to inspect the exact Playwright error."));
603
613
  const debugSummary = escapeHtml(diagnosis
604
614
  ? (0, quickDiagnosis_1.buildDebugSummary)(diagnosis)
605
615
  : `Test: ${test.title}\nDiagnosis: Review trace and error details in the expanded card.`);
@@ -634,7 +644,7 @@ const renderFailureDigest = (tests) => {
634
644
  };
635
645
  const renderSimilarFailureGroups = (groups) => {
636
646
  if (!groups.length) {
637
- return `<div class="empty-state">No failure groups were detected for this run.</div>`;
647
+ return `<div class="empty-state">No repeated failure fingerprint was detected in this run.</div>`;
638
648
  }
639
649
  return groups
640
650
  .map((group) => `
@@ -1223,7 +1233,7 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1223
1233
  <h2>Similar Failures</h2>
1224
1234
  <div class="failed-count">${similarGroups.length} groups</div>
1225
1235
  </div>
1226
- <p>Failures are grouped by the most likely common cause so you can separate one bug from many symptoms.</p>
1236
+ <p>Failures are grouped only when they share the same fingerprint, so one repeated issue is easier to spot.</p>
1227
1237
  ${renderSimilarFailureGroups(similarGroups)}
1228
1238
  </section>
1229
1239
 
@@ -1400,8 +1410,7 @@ function generateLocalDebugReport(options) {
1400
1410
  const reportJsonRaw = fs_1.default.readFileSync(options.playwrightJsonPath, "utf8");
1401
1411
  const reportJson = JSON.parse(reportJsonRaw);
1402
1412
  const reportRoot = { suites: reportJson?.suites || [] };
1403
- const diagnosisById = buildDiagnosisMap(options.playwrightJsonPath);
1404
- const tests = collectTests(reportRoot, diagnosisById);
1413
+ const tests = collectTests(reportRoot);
1405
1414
  const testsById = new Map(tests.map((test) => [test.id, test]));
1406
1415
  const claimedSourcePaths = new Set();
1407
1416
  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 ||
@@ -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.29",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",