@sentinelqa/playwright-reporter 0.1.44 → 0.1.47
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 +1 -1
- package/dist/reporter.js +31 -1
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +173 -0
- package/dist/terminalSummary.d.ts +5 -0
- package/dist/terminalSummary.js +209 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
After every failed run, reporter prints a shareable debugging link:
|
|
4
4
|
|
|
5
|
-
Sample run: https://app.sentinelqa.com/share/
|
|
5
|
+
Sample run: https://app.sentinelqa.com/share/1f343d91-be17-4c14-b1b9-2d4e8ef448d2
|
|
6
6
|
|
|
7
7
|
Open it to inspect failures instantly or share it in Slack, PRs, or GitHub issues.
|
|
8
8
|
|
package/dist/reporter.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
const node_1 = require("@sentinelqa/uploader/node");
|
|
3
3
|
const env_1 = require("./env");
|
|
4
4
|
const quickDiagnosis_1 = require("./quickDiagnosis");
|
|
5
|
+
const terminalSummary_1 = require("./terminalSummary");
|
|
6
|
+
const telemetry_1 = require("./telemetry");
|
|
5
7
|
const { sentinelCaptureFailureContextFromReporter } = require("@sentinelqa/uploader/playwright");
|
|
6
8
|
const colorize = (value, code) => {
|
|
7
9
|
if (!process.stdout.isTTY)
|
|
@@ -19,6 +21,7 @@ class SentinelReporter {
|
|
|
19
21
|
this.options = options;
|
|
20
22
|
}
|
|
21
23
|
onBegin(config, suite) {
|
|
24
|
+
(0, telemetry_1.emitReporterTelemetry)();
|
|
22
25
|
this.totalCount = typeof suite?.allTests === "function" ? suite.allTests().length : 0;
|
|
23
26
|
if (config?.projects?.length && !this.options.project) {
|
|
24
27
|
this.options.project = config.projects[0]?.name || null;
|
|
@@ -50,6 +53,7 @@ class SentinelReporter {
|
|
|
50
53
|
!hasCiEnv &&
|
|
51
54
|
!localUploadEnabled;
|
|
52
55
|
const quickDiagnosis = (0, quickDiagnosis_1.buildQuickDiagnosis)(this.options.playwrightJsonPath);
|
|
56
|
+
const passingSummary = (0, terminalSummary_1.buildPassingRunSummary)(this.options.playwrightJsonPath);
|
|
53
57
|
console.log("");
|
|
54
58
|
if (quickDiagnosis?.lines.length) {
|
|
55
59
|
console.log(yellow("Quick diagnosis"));
|
|
@@ -58,6 +62,32 @@ class SentinelReporter {
|
|
|
58
62
|
}
|
|
59
63
|
console.log("");
|
|
60
64
|
}
|
|
65
|
+
if (passingSummary) {
|
|
66
|
+
console.log(green("Sentinel run summary"));
|
|
67
|
+
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");
|
|
80
|
+
}
|
|
81
|
+
console.log("");
|
|
82
|
+
}
|
|
83
|
+
if (this.failedCount === 0) {
|
|
84
|
+
await (0, telemetry_1.flushTelemetry)();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (this.failedCount > 0) {
|
|
88
|
+
(0, telemetry_1.emitFailedRunTelemetry)();
|
|
89
|
+
}
|
|
90
|
+
await (0, telemetry_1.flushTelemetry)();
|
|
61
91
|
if (hasWorkspaceToken && !hasCiEnv && !localUploadEnabled) {
|
|
62
92
|
console.log([
|
|
63
93
|
"Sentinel: Upload skipped.",
|
|
@@ -103,7 +133,7 @@ class SentinelReporter {
|
|
|
103
133
|
if (!hasWorkspaceToken) {
|
|
104
134
|
console.log("");
|
|
105
135
|
console.log("Upgrade for free to get full AI debugging suggestions");
|
|
106
|
-
console.log(` ${dim("https://
|
|
136
|
+
console.log(` ${dim("https://sentinelqa.com/register")}`);
|
|
107
137
|
}
|
|
108
138
|
}
|
|
109
139
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.flushTelemetry = exports.emitFailedRunTelemetry = exports.emitReporterTelemetry = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const http_1 = __importDefault(require("http"));
|
|
9
|
+
const https_1 = __importDefault(require("https"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const DEFAULT_APP_URL = "https://app.sentinelqa.com";
|
|
12
|
+
const PACKAGE_VERSION = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return require("../package.json").version || "0.0.0";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "0.0.0";
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
const globalState = globalThis;
|
|
21
|
+
const readEnv = (key) => {
|
|
22
|
+
const value = process.env[key];
|
|
23
|
+
return value && value.trim().length > 0 ? value.trim() : null;
|
|
24
|
+
};
|
|
25
|
+
const isTruthyEnv = (key) => {
|
|
26
|
+
const value = readEnv(key);
|
|
27
|
+
if (!value)
|
|
28
|
+
return false;
|
|
29
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
30
|
+
};
|
|
31
|
+
const debugLog = (message) => {
|
|
32
|
+
if (!isTruthyEnv("SENTINEL_TELEMETRY_DEBUG"))
|
|
33
|
+
return;
|
|
34
|
+
console.log(`[sentinel-telemetry] ${message}`);
|
|
35
|
+
};
|
|
36
|
+
const gitOutput = (args) => {
|
|
37
|
+
try {
|
|
38
|
+
return (0, child_process_1.execFileSync)("git", args, {
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
encoding: "utf8"
|
|
42
|
+
}).trim();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const getCiProvider = () => {
|
|
49
|
+
if (readEnv("GITHUB_ACTIONS") === "true")
|
|
50
|
+
return "github";
|
|
51
|
+
if (readEnv("GITLAB_CI") === "true" || readEnv("CI_PROJECT_ID"))
|
|
52
|
+
return "gitlab";
|
|
53
|
+
if (readEnv("CIRCLECI") === "true")
|
|
54
|
+
return "circleci";
|
|
55
|
+
return "local";
|
|
56
|
+
};
|
|
57
|
+
const detectRepoIdentity = (provider) => {
|
|
58
|
+
if (provider === "github") {
|
|
59
|
+
const repo = readEnv("GITHUB_REPOSITORY");
|
|
60
|
+
if (repo)
|
|
61
|
+
return repo.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
if (provider === "gitlab") {
|
|
64
|
+
const repo = readEnv("CI_PROJECT_PATH") || readEnv("CI_PROJECT_URL");
|
|
65
|
+
if (repo)
|
|
66
|
+
return repo.toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
if (provider === "circleci") {
|
|
69
|
+
const user = readEnv("CIRCLE_PROJECT_USERNAME");
|
|
70
|
+
const repo = readEnv("CIRCLE_PROJECT_REPONAME");
|
|
71
|
+
if (user && repo)
|
|
72
|
+
return `${user}/${repo}`.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
const remote = gitOutput(["config", "--get", "remote.origin.url"]) ||
|
|
75
|
+
gitOutput(["remote", "get-url", "origin"]);
|
|
76
|
+
if (remote)
|
|
77
|
+
return remote.toLowerCase();
|
|
78
|
+
return process.cwd().toLowerCase();
|
|
79
|
+
};
|
|
80
|
+
const buildPayload = (eventType) => {
|
|
81
|
+
const ciProvider = getCiProvider();
|
|
82
|
+
const repoHash = crypto_1.default
|
|
83
|
+
.createHash("sha256")
|
|
84
|
+
.update(detectRepoIdentity(ciProvider))
|
|
85
|
+
.digest("hex");
|
|
86
|
+
return {
|
|
87
|
+
repoHash,
|
|
88
|
+
eventType,
|
|
89
|
+
environment: ciProvider === "local" ? "local" : "ci",
|
|
90
|
+
ciProvider,
|
|
91
|
+
mode: readEnv("SENTINEL_TOKEN") ? "workspace" : "public",
|
|
92
|
+
version: PACKAGE_VERSION
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const getPendingTelemetry = () => {
|
|
96
|
+
if (!globalState.__sentinelTelemetryPending) {
|
|
97
|
+
globalState.__sentinelTelemetryPending = [];
|
|
98
|
+
}
|
|
99
|
+
return globalState.__sentinelTelemetryPending;
|
|
100
|
+
};
|
|
101
|
+
const postJson = (url, payload) => new Promise((resolve) => {
|
|
102
|
+
const target = new URL(url);
|
|
103
|
+
const client = target.protocol === "https:" ? https_1.default : http_1.default;
|
|
104
|
+
const body = JSON.stringify(payload);
|
|
105
|
+
const req = client.request({
|
|
106
|
+
method: "POST",
|
|
107
|
+
hostname: target.hostname,
|
|
108
|
+
port: target.port,
|
|
109
|
+
path: `${target.pathname}${target.search}`,
|
|
110
|
+
headers: {
|
|
111
|
+
"content-type": "application/json",
|
|
112
|
+
"content-length": Buffer.byteLength(body).toString()
|
|
113
|
+
}
|
|
114
|
+
}, (res) => {
|
|
115
|
+
debugLog(`POST ${target.origin}${target.pathname} -> ${res.statusCode || 0}`);
|
|
116
|
+
res.resume();
|
|
117
|
+
res.on("end", () => resolve());
|
|
118
|
+
});
|
|
119
|
+
req.setTimeout(1200, () => {
|
|
120
|
+
debugLog(`timeout posting to ${target.origin}${target.pathname}`);
|
|
121
|
+
req.destroy();
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
req.on("error", (error) => {
|
|
125
|
+
debugLog(`error posting to ${target.origin}${target.pathname}: ${error.message}`);
|
|
126
|
+
resolve();
|
|
127
|
+
});
|
|
128
|
+
req.write(body);
|
|
129
|
+
req.end();
|
|
130
|
+
});
|
|
131
|
+
const emitTelemetry = (eventType) => {
|
|
132
|
+
if (isTruthyEnv("SENTINEL_TELEMETRY_DISABLED"))
|
|
133
|
+
return;
|
|
134
|
+
const appUrl = readEnv("SENTINEL_APP_URL") || DEFAULT_APP_URL;
|
|
135
|
+
const payload = buildPayload(eventType);
|
|
136
|
+
debugLog(`event=${eventType} dest=${appUrl}/api/telemetry/uploader env=${payload.environment} provider=${payload.ciProvider} mode=${payload.mode} version=${payload.version} repo=${payload.repoHash.slice(0, 12)}`);
|
|
137
|
+
try {
|
|
138
|
+
const pending = getPendingTelemetry();
|
|
139
|
+
const request = postJson(`${appUrl}/api/telemetry/uploader`, payload).finally(() => {
|
|
140
|
+
const index = pending.indexOf(request);
|
|
141
|
+
if (index !== -1)
|
|
142
|
+
pending.splice(index, 1);
|
|
143
|
+
});
|
|
144
|
+
pending.push(request);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Telemetry must never affect test execution.
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const emitReporterTelemetry = () => {
|
|
151
|
+
if (globalState.__sentinelReporterTelemetrySent)
|
|
152
|
+
return;
|
|
153
|
+
globalState.__sentinelReporterTelemetrySent = true;
|
|
154
|
+
emitTelemetry("seen");
|
|
155
|
+
};
|
|
156
|
+
exports.emitReporterTelemetry = emitReporterTelemetry;
|
|
157
|
+
const emitFailedRunTelemetry = () => {
|
|
158
|
+
if (globalState.__sentinelReporterFailedTelemetrySent)
|
|
159
|
+
return;
|
|
160
|
+
globalState.__sentinelReporterFailedTelemetrySent = true;
|
|
161
|
+
emitTelemetry("failed_run");
|
|
162
|
+
};
|
|
163
|
+
exports.emitFailedRunTelemetry = emitFailedRunTelemetry;
|
|
164
|
+
const flushTelemetry = async (timeoutMs = 1500) => {
|
|
165
|
+
const pending = [...getPendingTelemetry()];
|
|
166
|
+
if (pending.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
await Promise.race([
|
|
169
|
+
Promise.allSettled(pending).then(() => undefined),
|
|
170
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
|
171
|
+
]);
|
|
172
|
+
};
|
|
173
|
+
exports.flushTelemetry = flushTelemetry;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildPassingRunSummary = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const HISTORY_DIR = node_path_1.default.join(".sentinel", "reporter-history");
|
|
10
|
+
const ensureDir = (dirPath) => {
|
|
11
|
+
if (!node_fs_1.default.existsSync(dirPath))
|
|
12
|
+
node_fs_1.default.mkdirSync(dirPath, { recursive: true });
|
|
13
|
+
};
|
|
14
|
+
const readJson = (filePath) => {
|
|
15
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(node_fs_1.default.readFileSync(filePath, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const getCurrentBranch = () => {
|
|
25
|
+
const fromEnv = process.env.GITHUB_REF_NAME ||
|
|
26
|
+
process.env.CI_COMMIT_REF_NAME ||
|
|
27
|
+
process.env.CI_COMMIT_BRANCH ||
|
|
28
|
+
process.env.BRANCH_NAME ||
|
|
29
|
+
null;
|
|
30
|
+
return fromEnv && fromEnv.trim() ? fromEnv.trim() : "main";
|
|
31
|
+
};
|
|
32
|
+
const getCurrentGitSha = () => {
|
|
33
|
+
const fromEnv = process.env.GITHUB_SHA ||
|
|
34
|
+
process.env.CI_COMMIT_SHA ||
|
|
35
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
36
|
+
null;
|
|
37
|
+
return fromEnv && fromEnv.trim() ? fromEnv.trim() : "unknown";
|
|
38
|
+
};
|
|
39
|
+
const normalizeStatus = (status) => {
|
|
40
|
+
if (status === "failed" || status === "timedOut" || status === "interrupted")
|
|
41
|
+
return "failed";
|
|
42
|
+
if (status === "passed")
|
|
43
|
+
return "passed";
|
|
44
|
+
return "skipped";
|
|
45
|
+
};
|
|
46
|
+
const formatDuration = (durationMs) => {
|
|
47
|
+
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
48
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
49
|
+
const seconds = totalSeconds % 60;
|
|
50
|
+
if (minutes === 0)
|
|
51
|
+
return `${seconds}s`;
|
|
52
|
+
return `${minutes}m ${seconds}s`;
|
|
53
|
+
};
|
|
54
|
+
const median = (values) => {
|
|
55
|
+
if (values.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
58
|
+
const mid = Math.floor(sorted.length / 2);
|
|
59
|
+
if (sorted.length % 2 === 0)
|
|
60
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
61
|
+
return sorted[mid];
|
|
62
|
+
};
|
|
63
|
+
const buildMatchKey = (file, projectName, titlePath) => [file || "unknown", projectName || "default", titlePath.join(" > ")].join("::");
|
|
64
|
+
const collectTests = (node, ancestors = []) => {
|
|
65
|
+
const currentTitlePath = node.title ? [...ancestors, node.title] : ancestors;
|
|
66
|
+
const tests = [];
|
|
67
|
+
for (const child of node.suites || [])
|
|
68
|
+
tests.push(...collectTests(child, currentTitlePath));
|
|
69
|
+
for (const child of node.specs || [])
|
|
70
|
+
tests.push(...collectTests(child, currentTitlePath));
|
|
71
|
+
const specTests = Array.isArray(node.tests) ? node.tests : [];
|
|
72
|
+
for (const test of specTests) {
|
|
73
|
+
const results = Array.isArray(test.results) ? test.results : [];
|
|
74
|
+
let finalStatus = normalizeStatus(test.results?.[test.results.length - 1]?.status);
|
|
75
|
+
let durationMs = 0;
|
|
76
|
+
let retries = 0;
|
|
77
|
+
for (const result of results) {
|
|
78
|
+
durationMs += typeof result.duration === "number" ? result.duration : 0;
|
|
79
|
+
if (typeof result.retry === "number")
|
|
80
|
+
retries = Math.max(retries, result.retry);
|
|
81
|
+
if (normalizeStatus(result.status) === "failed")
|
|
82
|
+
finalStatus = "failed";
|
|
83
|
+
}
|
|
84
|
+
const titlePath = [...currentTitlePath, test.title || "Unnamed test"].filter(Boolean);
|
|
85
|
+
const file = node.file || node.location?.file || test.location?.file || "";
|
|
86
|
+
const projectName = test.projectName || "";
|
|
87
|
+
tests.push({
|
|
88
|
+
matchKey: buildMatchKey(file, projectName, titlePath),
|
|
89
|
+
title: titlePath.join(" > "),
|
|
90
|
+
status: finalStatus,
|
|
91
|
+
durationMs,
|
|
92
|
+
retries
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return tests;
|
|
96
|
+
};
|
|
97
|
+
const buildSnapshot = (playwrightJsonPath) => {
|
|
98
|
+
const parsed = readJson(playwrightJsonPath);
|
|
99
|
+
if (!parsed)
|
|
100
|
+
return null;
|
|
101
|
+
const tests = collectTests(parsed);
|
|
102
|
+
const failedTests = tests.filter((test) => test.status === "failed");
|
|
103
|
+
const passedCount = tests.filter((test) => test.status === "passed").length;
|
|
104
|
+
const skippedCount = tests.filter((test) => test.status === "skipped").length;
|
|
105
|
+
return {
|
|
106
|
+
generatedAt: new Date().toISOString(),
|
|
107
|
+
branch: getCurrentBranch(),
|
|
108
|
+
gitSha: getCurrentGitSha(),
|
|
109
|
+
totalTests: tests.length,
|
|
110
|
+
passedCount,
|
|
111
|
+
failedCount: failedTests.length,
|
|
112
|
+
skippedCount,
|
|
113
|
+
retryPassedCount: tests.filter((test) => test.status === "passed" && test.retries > 0).length,
|
|
114
|
+
failures: failedTests.map((test) => test.title),
|
|
115
|
+
tests
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const listSnapshots = (branch) => {
|
|
119
|
+
const historyDir = node_path_1.default.resolve(process.cwd(), HISTORY_DIR);
|
|
120
|
+
if (!node_fs_1.default.existsSync(historyDir))
|
|
121
|
+
return [];
|
|
122
|
+
return node_fs_1.default
|
|
123
|
+
.readdirSync(historyDir)
|
|
124
|
+
.filter((file) => file.endsWith(".json"))
|
|
125
|
+
.map((file) => readJson(node_path_1.default.join(historyDir, file)))
|
|
126
|
+
.filter((value) => Boolean(value))
|
|
127
|
+
.filter((snapshot) => snapshot.branch === branch)
|
|
128
|
+
.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
|
|
129
|
+
};
|
|
130
|
+
const writeSnapshot = (snapshot) => {
|
|
131
|
+
ensureDir(node_path_1.default.resolve(process.cwd(), HISTORY_DIR));
|
|
132
|
+
const fileName = `${snapshot.generatedAt.replace(/[:.]/g, "-")}-${snapshot.gitSha}.json`;
|
|
133
|
+
node_fs_1.default.writeFileSync(node_path_1.default.resolve(process.cwd(), HISTORY_DIR, fileName), JSON.stringify(snapshot, null, 2), "utf8");
|
|
134
|
+
};
|
|
135
|
+
const buildPassingRunSummary = (playwrightJsonPath) => {
|
|
136
|
+
const snapshot = buildSnapshot(playwrightJsonPath);
|
|
137
|
+
if (!snapshot)
|
|
138
|
+
return null;
|
|
139
|
+
const previousRuns = listSnapshots(snapshot.branch);
|
|
140
|
+
writeSnapshot(snapshot);
|
|
141
|
+
if (snapshot.failedCount > 0)
|
|
142
|
+
return null;
|
|
143
|
+
let passStreak = 1;
|
|
144
|
+
let lastFailureRunsAgo = null;
|
|
145
|
+
let lastFailureTitle = null;
|
|
146
|
+
for (let i = 0; i < previousRuns.length; i += 1) {
|
|
147
|
+
const previous = previousRuns[i];
|
|
148
|
+
if (previous.failedCount === 0) {
|
|
149
|
+
passStreak += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
lastFailureRunsAgo = i + 1;
|
|
153
|
+
lastFailureTitle = previous.failures[0] || null;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
const retryPassedCount = snapshot.retryPassedCount;
|
|
157
|
+
const durationSamples = new Map();
|
|
158
|
+
for (const previous of previousRuns.slice(0, 20)) {
|
|
159
|
+
for (const test of previous.tests || []) {
|
|
160
|
+
if (test.status !== "passed" || test.durationMs <= 0)
|
|
161
|
+
continue;
|
|
162
|
+
const bucket = durationSamples.get(test.matchKey) || [];
|
|
163
|
+
bucket.push(test.durationMs);
|
|
164
|
+
durationSamples.set(test.matchKey, bucket);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let slowestRisk = null;
|
|
168
|
+
for (const test of snapshot.tests) {
|
|
169
|
+
if (test.status !== "passed" || test.durationMs <= 0)
|
|
170
|
+
continue;
|
|
171
|
+
const samples = durationSamples.get(test.matchKey) || [];
|
|
172
|
+
const med = median(samples);
|
|
173
|
+
if (!med || med < 1000)
|
|
174
|
+
continue;
|
|
175
|
+
const ratio = test.durationMs / med;
|
|
176
|
+
if (ratio < 1.8)
|
|
177
|
+
continue;
|
|
178
|
+
if (!slowestRisk || ratio > slowestRisk.ratio) {
|
|
179
|
+
slowestRisk = { title: test.title, ratio };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const flakyLookingCount = retryPassedCount + (slowestRisk ? 1 : 0);
|
|
183
|
+
const lines = [
|
|
184
|
+
`- ${snapshot.passedCount} tests passed in ${formatDuration(snapshot.tests.reduce((sum, test) => sum + test.durationMs, 0))}`
|
|
185
|
+
];
|
|
186
|
+
if (retryPassedCount > 0)
|
|
187
|
+
lines.push(`- Retries detected: ${retryPassedCount}`);
|
|
188
|
+
if (flakyLookingCount > 0)
|
|
189
|
+
lines.push(`- Flaky-looking tests: ${flakyLookingCount}`);
|
|
190
|
+
if (passStreak > 1)
|
|
191
|
+
lines.push(`- Pass streak: ${passStreak} runs`);
|
|
192
|
+
if (lastFailureRunsAgo !== null)
|
|
193
|
+
lines.push(`- Last failure: ${lastFailureRunsAgo} runs ago`);
|
|
194
|
+
lines.push(`- Debug readiness: traces, screenshots, video enabled`);
|
|
195
|
+
const risks = [];
|
|
196
|
+
if (slowestRisk) {
|
|
197
|
+
risks.push(`- ${slowestRisk.title} is ${slowestRisk.ratio.toFixed(1)}x slower than its recent median`);
|
|
198
|
+
}
|
|
199
|
+
if (retryPassedCount > 0) {
|
|
200
|
+
risks.push(`- ${retryPassedCount} tests passed after retry`);
|
|
201
|
+
}
|
|
202
|
+
if (lastFailureTitle) {
|
|
203
|
+
risks.push(`- Last failure was in ${lastFailureTitle}`);
|
|
204
|
+
}
|
|
205
|
+
if (lines.length <= 2 && risks.length === 0)
|
|
206
|
+
return null;
|
|
207
|
+
return { lines, risks };
|
|
208
|
+
};
|
|
209
|
+
exports.buildPassingRunSummary = buildPassingRunSummary;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentinelqa/playwright-reporter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.47",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@playwright/test": ">=1.40.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@sentinelqa/uploader": "
|
|
42
|
+
"@sentinelqa/uploader": "file:../SentinelQA_skeleton/packages/uploader"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^20.19.32",
|