@sentinelqa/playwright-reporter 0.1.47 → 0.1.51

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.
@@ -9,6 +9,7 @@ type ReporterOptions = {
9
9
  declare class SentinelReporter {
10
10
  private failedCount;
11
11
  private totalCount;
12
+ private startedAt;
12
13
  private options;
13
14
  constructor(options: ReporterOptions);
14
15
  onBegin(config: any, suite: any): void;
package/dist/reporter.js CHANGED
@@ -3,6 +3,7 @@ const node_1 = require("@sentinelqa/uploader/node");
3
3
  const env_1 = require("./env");
4
4
  const quickDiagnosis_1 = require("./quickDiagnosis");
5
5
  const terminalSummary_1 = require("./terminalSummary");
6
+ const runHistory_1 = require("./runHistory");
6
7
  const telemetry_1 = require("./telemetry");
7
8
  const { sentinelCaptureFailureContextFromReporter } = require("@sentinelqa/uploader/playwright");
8
9
  const colorize = (value, code) => {
@@ -13,14 +14,43 @@ const colorize = (value, code) => {
13
14
  const green = (value) => colorize(value, "32");
14
15
  const yellow = (value) => colorize(value, "33");
15
16
  const dim = (value) => colorize(value, "2");
17
+ const divider = () => "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
18
+ const readFinalFailedCount = (playwrightJsonPath) => {
19
+ try {
20
+ const parsed = JSON.parse(require("node:fs").readFileSync(playwrightJsonPath, "utf8"));
21
+ let failed = 0;
22
+ const walk = (node) => {
23
+ if (!node)
24
+ return;
25
+ for (const child of node.suites || [])
26
+ walk(child);
27
+ for (const child of node.specs || [])
28
+ walk(child);
29
+ for (const test of node.tests || []) {
30
+ const results = Array.isArray(test.results) ? test.results : [];
31
+ const finalStatus = results[results.length - 1]?.status || null;
32
+ if (finalStatus === "failed" || finalStatus === "timedOut" || finalStatus === "interrupted") {
33
+ failed += 1;
34
+ }
35
+ }
36
+ };
37
+ walk(parsed);
38
+ return failed;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ };
16
44
  class SentinelReporter {
17
45
  constructor(options) {
18
46
  this.failedCount = 0;
19
47
  this.totalCount = 0;
48
+ this.startedAt = Date.now();
20
49
  (0, env_1.loadSentinelEnv)();
21
50
  this.options = options;
22
51
  }
23
52
  onBegin(config, suite) {
53
+ this.startedAt = Date.now();
24
54
  (0, telemetry_1.emitReporterTelemetry)();
25
55
  this.totalCount = typeof suite?.allTests === "function" ? suite.allTests().length : 0;
26
56
  if (config?.projects?.length && !this.options.project) {
@@ -53,63 +83,69 @@ class SentinelReporter {
53
83
  !hasCiEnv &&
54
84
  !localUploadEnabled;
55
85
  const quickDiagnosis = (0, quickDiagnosis_1.buildQuickDiagnosis)(this.options.playwrightJsonPath);
56
- const passingSummary = (0, terminalSummary_1.buildPassingRunSummary)(this.options.playwrightJsonPath);
57
- console.log("");
58
- if (quickDiagnosis?.lines.length) {
59
- console.log(yellow("Quick diagnosis"));
60
- for (const line of quickDiagnosis.lines) {
61
- console.log(` ${dim(line)}`);
62
- }
63
- console.log("");
86
+ const finalFailedCount = readFinalFailedCount(this.options.playwrightJsonPath);
87
+ const effectiveFailedCount = typeof finalFailedCount === "number" ? finalFailedCount : this.failedCount;
88
+ const passingSummary = effectiveFailedCount === 0
89
+ ? (0, terminalSummary_1.buildPassingRunSummary)(this.options.playwrightJsonPath, { observedRunDurationMs: Date.now() - this.startedAt })
90
+ : null;
91
+ const failedRunHistory = effectiveFailedCount > 0 ? (0, runHistory_1.buildFailedRunHistorySummary)(this.options.playwrightJsonPath) : null;
92
+ if (effectiveFailedCount > 0) {
93
+ (0, terminalSummary_1.recordReporterHistorySnapshot)(this.options.playwrightJsonPath);
64
94
  }
95
+ console.log("");
65
96
  if (passingSummary) {
66
97
  console.log(green("Sentinel run summary"));
98
+ console.log(divider());
99
+ console.log("");
67
100
  for (const line of passingSummary.lines) {
68
- console.log(` ${line}`);
69
- }
70
- if (passingSummary.risks.length > 0) {
71
- console.log("");
72
- console.log(yellow("Potential risks"));
73
- for (const line of passingSummary.risks) {
74
- console.log(` ${line}`);
75
- }
76
- }
77
- if (!hasWorkspaceToken) {
78
- console.log("");
79
- console.log("Activate a free workspace at sentinelqa.com/register");
101
+ console.log(line);
80
102
  }
81
103
  console.log("");
104
+ console.log(divider());
105
+ console.log("");
82
106
  }
83
- if (this.failedCount === 0) {
84
- await (0, telemetry_1.flushTelemetry)();
85
- return;
86
- }
87
- if (this.failedCount > 0) {
107
+ if (effectiveFailedCount > 0) {
88
108
  (0, telemetry_1.emitFailedRunTelemetry)();
89
109
  }
90
110
  await (0, telemetry_1.flushTelemetry)();
111
+ if (effectiveFailedCount === 0) {
112
+ return;
113
+ }
91
114
  if (hasWorkspaceToken && !hasCiEnv && !localUploadEnabled) {
92
- console.log([
93
- "Sentinel: Upload skipped.",
94
- "Reason: Local workspace uploads require SENTINEL_UPLOAD_LOCAL=1.",
95
- "",
96
- "Next step:",
97
- "- Set SENTINEL_UPLOAD_LOCAL=1 to allow a local workspace upload.",
98
- "- Or remove SENTINEL_TOKEN to generate a free public hosted report instead."
99
- ].join("\n"));
115
+ console.log("Uploading debug report skipped");
116
+ console.log("Set SENTINEL_UPLOAD_LOCAL=1 for local workspace uploads.");
100
117
  return;
101
118
  }
102
- if (hasWorkspaceToken) {
119
+ if (quickDiagnosis?.lines.length) {
120
+ console.log(yellow("Sentinel diagnosis"));
121
+ console.log(divider());
103
122
  console.log("");
104
- console.log(green("✔ Artifacts collected"));
105
- }
106
- console.log("");
107
- console.log("Uploading hosted debugging report to Sentinel...");
108
- if (usingImplicitLocalPublicMode) {
109
- console.log(dim("Local upload env not set. Falling back to local metadata for a public hosted report."));
123
+ if (failedRunHistory?.passStreakBeforeFailure && failedRunHistory.passStreakBeforeFailure > 0) {
124
+ console.log(`NEW FAILURE after ${failedRunHistory.passStreakBeforeFailure} passing runs`);
125
+ console.log("");
126
+ }
127
+ else if ((failedRunHistory?.recurringCount || 0) > 0) {
128
+ console.log(`RECURRING FAILURE (${failedRunHistory?.recurringCount} previous runs)`);
129
+ console.log("");
130
+ }
131
+ for (const line of quickDiagnosis.lines) {
132
+ console.log(line);
133
+ }
134
+ console.log("");
135
+ console.log(divider());
136
+ console.log("");
137
+ if (failedRunHistory?.newFailures && failedRunHistory.newFailures > 0) {
138
+ console.log(`Impact: ${failedRunHistory.newFailures} newly failing in this run`);
139
+ console.log("");
140
+ }
141
+ else if (failedRunHistory?.stillFailing && failedRunHistory.stillFailing > 0) {
142
+ console.log(`Impact: ${failedRunHistory.stillFailing} tests still failing`);
143
+ console.log("");
144
+ }
110
145
  }
146
+ console.log("Uploading debug report...");
111
147
  console.log("");
112
- const upload = await (0, node_1.runSentinelUpload)({
148
+ const upload = (await (0, node_1.runSentinelUpload)({
113
149
  playwrightJsonPath: this.options.playwrightJsonPath,
114
150
  playwrightReportDir: this.options.playwrightReportDir,
115
151
  testResultsDir: this.options.testResultsDir,
@@ -120,21 +156,26 @@ class SentinelReporter {
120
156
  SENTINEL_REPORTER_SILENT: "1",
121
157
  SENTINEL_UPLOAD_LOCAL: usingImplicitLocalPublicMode ? "1" : process.env.SENTINEL_UPLOAD_LOCAL
122
158
  }
123
- });
159
+ }));
124
160
  if (upload.exitCode !== 0) {
125
161
  throw new Error(`Sentinel upload failed with exit code ${upload.exitCode}`);
126
162
  }
163
+ if (!quickDiagnosis?.lines.length && upload.diagnosis?.lines.length) {
164
+ console.log(yellow("Sentinel diagnosis"));
165
+ console.log(divider());
166
+ console.log("");
167
+ for (const line of upload.diagnosis.lines) {
168
+ console.log(line);
169
+ }
170
+ console.log("");
171
+ console.log(divider());
172
+ }
127
173
  console.log("");
128
- console.log("Sentinel report");
174
+ console.log("Debug report ready");
129
175
  console.log(` ${upload.shareRunUrl || upload.internalRunUrl}`);
130
176
  if (upload.shareLabel) {
131
177
  console.log(` ${dim(upload.shareLabel)}`);
132
178
  }
133
- if (!hasWorkspaceToken) {
134
- console.log("");
135
- console.log("Upgrade for free to get full AI debugging suggestions");
136
- console.log(` ${dim("https://sentinelqa.com/register")}`);
137
- }
138
179
  }
139
180
  }
140
181
  module.exports = SentinelReporter;
@@ -3,5 +3,16 @@ type RunDiffSummary = {
3
3
  fixedTests: number;
4
4
  stillFailing: number;
5
5
  };
6
+ export type FailedRunHistorySummary = {
7
+ lines: string[];
8
+ passStreakBeforeFailure: number;
9
+ previousWasGreen: boolean;
10
+ newFailures: number;
11
+ fixedTests: number;
12
+ stillFailing: number;
13
+ recurringCount: number;
14
+ recurringTitle: string | null;
15
+ };
6
16
  export declare const buildRunDiffSummary: (playwrightJsonPath: string) => RunDiffSummary | null;
17
+ export declare const buildFailedRunHistorySummary: (playwrightJsonPath: string) => FailedRunHistorySummary | null;
7
18
  export {};
@@ -3,11 +3,12 @@ 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.buildRunDiffSummary = void 0;
6
+ exports.buildFailedRunHistorySummary = exports.buildRunDiffSummary = void 0;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const quickDiagnosis_1 = require("./quickDiagnosis");
10
10
  const SENTINEL_HISTORY_DIR = node_path_1.default.join(".sentinel", "history");
11
+ const REPORTER_HISTORY_DIR = node_path_1.default.join(".sentinel", "reporter-history");
11
12
  const ensureDir = (dirPath) => {
12
13
  if (!node_fs_1.default.existsSync(dirPath)) {
13
14
  node_fs_1.default.mkdirSync(dirPath, { recursive: true });
@@ -76,6 +77,16 @@ const normalizeFailures = (snapshot) => {
76
77
  }
77
78
  return [];
78
79
  };
80
+ const readJson = (filePath) => {
81
+ if (!node_fs_1.default.existsSync(filePath))
82
+ return null;
83
+ try {
84
+ return JSON.parse(node_fs_1.default.readFileSync(filePath, "utf8"));
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ };
79
90
  const readSnapshot = (filePath) => {
80
91
  if (!node_fs_1.default.existsSync(filePath))
81
92
  return null;
@@ -86,6 +97,58 @@ const readSnapshot = (filePath) => {
86
97
  return null;
87
98
  }
88
99
  };
100
+ const listSnapshots = (branch) => {
101
+ const historyDir = node_path_1.default.resolve(process.cwd(), SENTINEL_HISTORY_DIR);
102
+ if (!node_fs_1.default.existsSync(historyDir))
103
+ return [];
104
+ return node_fs_1.default
105
+ .readdirSync(historyDir)
106
+ .filter((file) => file.endsWith(".json"))
107
+ .map((file) => readSnapshot(node_path_1.default.join(historyDir, file)))
108
+ .filter((value) => Boolean(value))
109
+ .map((snapshot) => ({
110
+ generatedAt: typeof snapshot.generatedAt === "string" ? snapshot.generatedAt : new Date(0).toISOString(),
111
+ branch: typeof snapshot.branch === "string" ? snapshot.branch : branch,
112
+ gitSha: typeof snapshot.gitSha === "string" ? snapshot.gitSha : "unknown",
113
+ failures: normalizeFailures(snapshot)
114
+ }))
115
+ .filter((snapshot) => snapshot.branch === branch)
116
+ .sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
117
+ };
118
+ const isMeaningfulReporterSnapshot = (snapshot) => {
119
+ if (!snapshot)
120
+ return false;
121
+ if (snapshot.totalTests <= 0)
122
+ return false;
123
+ if ((snapshot.passedCount || 0) === 0 && (snapshot.failedCount || 0) === 0)
124
+ return false;
125
+ return true;
126
+ };
127
+ const listReporterSnapshots = (branch) => {
128
+ const historyDir = node_path_1.default.resolve(process.cwd(), REPORTER_HISTORY_DIR);
129
+ if (!node_fs_1.default.existsSync(historyDir))
130
+ return [];
131
+ return node_fs_1.default
132
+ .readdirSync(historyDir)
133
+ .filter((file) => file.endsWith(".json"))
134
+ .map((file) => readJson(node_path_1.default.join(historyDir, file)))
135
+ .filter((value) => Boolean(value))
136
+ .filter((snapshot) => snapshot.branch === branch)
137
+ .filter((snapshot) => isMeaningfulReporterSnapshot(snapshot))
138
+ .sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
139
+ };
140
+ const normalizeReporterFailures = (snapshot) => {
141
+ if (!snapshot)
142
+ return [];
143
+ return (snapshot.tests || [])
144
+ .filter((test) => test && test.status === "failed")
145
+ .map((test) => ({
146
+ id: typeof test.title === "string" ? test.title : test.matchKey,
147
+ matchKey: typeof test.matchKey === "string" ? test.matchKey : (typeof test.title === "string" ? test.title : "unknown"),
148
+ title: typeof test.title === "string" ? test.title : test.matchKey,
149
+ status: "failed"
150
+ }));
151
+ };
89
152
  const writeSnapshot = (snapshot) => {
90
153
  ensureDir(node_path_1.default.resolve(process.cwd(), ".sentinel"));
91
154
  ensureDir(node_path_1.default.resolve(process.cwd(), SENTINEL_HISTORY_DIR));
@@ -96,6 +159,7 @@ const writeSnapshot = (snapshot) => {
96
159
  node_fs_1.default.writeFileSync(node_path_1.default.resolve(process.cwd(), pointerPath), JSON.stringify({ path: historyPath, ...snapshot }, null, 2), "utf8");
97
160
  }
98
161
  };
162
+ const matchesFailure = (left, right) => left.id === right.id || left.matchKey === right.matchKey;
99
163
  const buildRunDiffSummary = (playwrightJsonPath) => {
100
164
  const snapshot = buildSnapshot(playwrightJsonPath);
101
165
  const previous = readSnapshot(node_path_1.default.resolve(process.cwd(), ".sentinel", `latest-${snapshot.branch}.json`)) ||
@@ -105,12 +169,69 @@ const buildRunDiffSummary = (playwrightJsonPath) => {
105
169
  const currentFailureMatchKeys = new Set(snapshot.failures.map((test) => test.matchKey));
106
170
  const diff = previous && previous.generatedAt !== snapshot.generatedAt
107
171
  ? {
108
- newFailures: snapshot.failures.filter((test) => !previousFailures.some((prev) => prev.id === test.id || prev.matchKey === test.matchKey)).length,
172
+ newFailures: snapshot.failures.filter((test) => !previousFailures.some((prev) => matchesFailure(prev, test))).length,
109
173
  fixedTests: previousFailures.filter((test) => !currentFailureIds.has(test.id) && !currentFailureMatchKeys.has(test.matchKey)).length,
110
- stillFailing: snapshot.failures.filter((test) => previousFailures.some((prev) => prev.id === test.id || prev.matchKey === test.matchKey)).length
174
+ stillFailing: snapshot.failures.filter((test) => previousFailures.some((prev) => matchesFailure(prev, test))).length
111
175
  }
112
176
  : null;
113
177
  writeSnapshot(snapshot);
114
178
  return diff;
115
179
  };
116
180
  exports.buildRunDiffSummary = buildRunDiffSummary;
181
+ const buildFailedRunHistorySummary = (playwrightJsonPath) => {
182
+ const snapshot = buildSnapshot(playwrightJsonPath);
183
+ if (snapshot.failures.length === 0)
184
+ return null;
185
+ const reporterSnapshots = listReporterSnapshots(snapshot.branch);
186
+ const previousRun = reporterSnapshots[0] || null;
187
+ const previousFailures = normalizeReporterFailures(previousRun);
188
+ let passStreakBeforeFailure = 0;
189
+ for (const previous of reporterSnapshots) {
190
+ if ((previous.failedCount || 0) === 0) {
191
+ passStreakBeforeFailure += 1;
192
+ continue;
193
+ }
194
+ break;
195
+ }
196
+ const newFailures = snapshot.failures.filter((failure) => !previousFailures.some((prev) => matchesFailure(prev, failure))).length;
197
+ const fixedTests = previousFailures.filter((failure) => !snapshot.failures.some((current) => matchesFailure(current, failure))).length;
198
+ const stillFailing = snapshot.failures.filter((failure) => previousFailures.some((prev) => matchesFailure(prev, failure))).length;
199
+ const failingReporterRuns = reporterSnapshots.filter((run) => (run.failedCount || 0) > 0);
200
+ const recurringFailures = snapshot.failures
201
+ .map((failure) => ({
202
+ failure,
203
+ occurrences: failingReporterRuns.filter((run) => normalizeReporterFailures(run).some((prev) => matchesFailure(prev, failure))).length
204
+ }))
205
+ .sort((a, b) => b.occurrences - a.occurrences);
206
+ const topRecurring = recurringFailures.find((item) => item.occurrences > 0) || null;
207
+ writeSnapshot(snapshot);
208
+ const lines = [];
209
+ if (passStreakBeforeFailure > 0) {
210
+ lines.push(`- First failure after ${passStreakBeforeFailure} passing runs`);
211
+ }
212
+ if (previousRun && (newFailures > 0 || fixedTests > 0 || stillFailing > 0)) {
213
+ const previousWasGreen = (previousRun.failedCount || 0) === 0;
214
+ if (previousWasGreen) {
215
+ lines.push(`- The immediately previous run was green. Compared to that previous run: ${newFailures} newly failing in this run, ${stillFailing} still failing, ${fixedTests} no longer failing`);
216
+ }
217
+ else {
218
+ lines.push(`- Compared to the immediately previous run: ${newFailures} newly failing in this run, ${stillFailing} still failing, ${fixedTests} no longer failing`);
219
+ }
220
+ }
221
+ if (topRecurring) {
222
+ lines.push(`- Recurring across ${topRecurring.occurrences + 1} recorded failed runs in local history (${topRecurring.failure.title})`);
223
+ }
224
+ return lines.length
225
+ ? {
226
+ lines,
227
+ passStreakBeforeFailure,
228
+ previousWasGreen: Boolean(previousRun && (previousRun.failedCount || 0) === 0),
229
+ newFailures,
230
+ fixedTests,
231
+ stillFailing,
232
+ recurringCount: topRecurring ? topRecurring.occurrences : 0,
233
+ recurringTitle: topRecurring?.failure.title || null
234
+ }
235
+ : null;
236
+ };
237
+ exports.buildFailedRunHistorySummary = buildFailedRunHistorySummary;
@@ -1,5 +1,30 @@
1
+ type HistoryTest = {
2
+ matchKey: string;
3
+ title: string;
4
+ status: string;
5
+ durationMs: number;
6
+ retries: number;
7
+ timeoutMs: number | null;
8
+ };
9
+ type HistorySnapshot = {
10
+ generatedAt: string;
11
+ branch: string;
12
+ gitSha: string;
13
+ wallDurationMs: number | null;
14
+ totalTests: number;
15
+ passedCount: number;
16
+ failedCount: number;
17
+ skippedCount: number;
18
+ retryPassedCount: number;
19
+ failures: string[];
20
+ tests: HistoryTest[];
21
+ };
1
22
  export type PassingRunSummary = {
2
23
  lines: string[];
3
- risks: string[];
4
24
  };
5
- export declare const buildPassingRunSummary: (playwrightJsonPath: string) => PassingRunSummary | null;
25
+ type PassingRunSummaryOptions = {
26
+ observedRunDurationMs?: number | null;
27
+ };
28
+ export declare const recordReporterHistorySnapshot: (playwrightJsonPath: string) => HistorySnapshot;
29
+ export declare const buildPassingRunSummary: (playwrightJsonPath: string, options?: PassingRunSummaryOptions) => PassingRunSummary | null;
30
+ export {};