@aqa-pulse/cli 0.1.0
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 +43 -0
- package/bin/aqa-pulse.js +49 -0
- package/dist/backend/generate-source-facts.d.ts +2 -0
- package/dist/backend/generate-source-facts.js +607 -0
- package/dist/backend/merge-reports.d.ts +19 -0
- package/dist/backend/merge-reports.js +314 -0
- package/dist/backend/upload-report-artifacts.d.ts +9 -0
- package/dist/backend/upload-report-artifacts.js +772 -0
- package/dist/backend/upload-report.d.ts +13 -0
- package/dist/backend/upload-report.js +338 -0
- package/dist/dashboard-utils.d.ts +437 -0
- package/dist/dashboard-utils.js +2627 -0
- package/dist/history-utils.d.ts +72 -0
- package/dist/history-utils.js +267 -0
- package/dist/shared/business-assumptions.d.ts +14 -0
- package/dist/shared/business-assumptions.js +61 -0
- package/dist/shared/dashboard-helpers.d.ts +63 -0
- package/dist/shared/dashboard-helpers.js +429 -0
- package/dist/shared/dashboard-metric-info.d.ts +61 -0
- package/dist/shared/dashboard-metric-info.js +15 -0
- package/dist/shared/error-utils.d.ts +1 -0
- package/dist/shared/error-utils.js +6 -0
- package/dist/shared/formatting.d.ts +3 -0
- package/dist/shared/formatting.js +42 -0
- package/dist/shared/i18n/ru.d.ts +558 -0
- package/dist/shared/i18n/ru.js +577 -0
- package/dist/shared/metric-info.d.ts +5 -0
- package/dist/shared/metric-info.js +210 -0
- package/dist/shared/navigation.d.ts +31 -0
- package/dist/shared/navigation.js +99 -0
- package/dist/shared/test-history-helpers.d.ts +51 -0
- package/dist/shared/test-history-helpers.js +294 -0
- package/dist/shared/test-history-metric-info.d.ts +17 -0
- package/dist/shared/test-history-metric-info.js +20 -0
- package/dist/shared/text-utils.d.ts +2 -0
- package/dist/shared/text-utils.js +15 -0
- package/package.json +37 -0
|
@@ -0,0 +1,2627 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.normalizeDashboardBusinessAssumptions = exports.applyBusinessAssumptionsToSummary = exports.formatPercent = exports.formatDuration = exports.formatDate = void 0;
|
|
37
|
+
exports.deriveDashboardRunComparisonIdentity = deriveDashboardRunComparisonIdentity;
|
|
38
|
+
exports.loadReporterReport = loadReporterReport;
|
|
39
|
+
exports.enrichReporterReport = enrichReporterReport;
|
|
40
|
+
exports.getReporterErrorMessage = getReporterErrorMessage;
|
|
41
|
+
exports.readDashboardSummary = readDashboardSummary;
|
|
42
|
+
exports.normalizeDashboardSummary = normalizeDashboardSummary;
|
|
43
|
+
exports.ensureDirectoryForFile = ensureDirectoryForFile;
|
|
44
|
+
exports.writeJsonFile = writeJsonFile;
|
|
45
|
+
exports.writeTextFile = writeTextFile;
|
|
46
|
+
exports.buildDashboardSummary = buildDashboardSummary;
|
|
47
|
+
exports.buildAdvancedMetrics = buildAdvancedMetrics;
|
|
48
|
+
exports.buildAdvancedMetricsFromArchivedRuns = buildAdvancedMetricsFromArchivedRuns;
|
|
49
|
+
exports.normalizePrecomputedSourceFacts = normalizePrecomputedSourceFacts;
|
|
50
|
+
exports.collectCurrentRunTests = collectCurrentRunTests;
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
52
|
+
const path = __importStar(require("node:path"));
|
|
53
|
+
const business_assumptions_1 = require("./shared/business-assumptions");
|
|
54
|
+
const formatting_1 = require("./shared/formatting");
|
|
55
|
+
const history_utils_1 = require("./history-utils");
|
|
56
|
+
var formatting_2 = require("./shared/formatting");
|
|
57
|
+
Object.defineProperty(exports, "formatDate", { enumerable: true, get: function () { return formatting_2.formatDate; } });
|
|
58
|
+
Object.defineProperty(exports, "formatDuration", { enumerable: true, get: function () { return formatting_2.formatDuration; } });
|
|
59
|
+
Object.defineProperty(exports, "formatPercent", { enumerable: true, get: function () { return formatting_2.formatPercent; } });
|
|
60
|
+
var business_assumptions_2 = require("./shared/business-assumptions");
|
|
61
|
+
Object.defineProperty(exports, "applyBusinessAssumptionsToSummary", { enumerable: true, get: function () { return business_assumptions_2.applyBusinessAssumptionsToSummary; } });
|
|
62
|
+
Object.defineProperty(exports, "normalizeDashboardBusinessAssumptions", { enumerable: true, get: function () { return business_assumptions_2.normalizeDashboardBusinessAssumptions; } });
|
|
63
|
+
const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
|
|
64
|
+
function deriveDashboardRunComparisonIdentity(report, sourceFile) {
|
|
65
|
+
const projects = collectComparisonProjects(report);
|
|
66
|
+
if (projects.length === 1) {
|
|
67
|
+
return {
|
|
68
|
+
key: `project:${projects[0]}`,
|
|
69
|
+
label: projects[0],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (projects.length > 1) {
|
|
73
|
+
return {
|
|
74
|
+
key: `projects:${projects.join('|')}`,
|
|
75
|
+
label: projects.join(' + '),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const normalizedSource = normalizeSourceFileForComparison(sourceFile);
|
|
79
|
+
return {
|
|
80
|
+
key: `source:${normalizedSource}`,
|
|
81
|
+
label: path.basename(sourceFile),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function resolveComparisonBaseline(historyRuns) {
|
|
85
|
+
const currentRun = historyRuns[historyRuns.length - 1] ?? null;
|
|
86
|
+
const previousOverallRun = historyRuns.length > 1 ? historyRuns[historyRuns.length - 2] : null;
|
|
87
|
+
if (!currentRun) {
|
|
88
|
+
return {
|
|
89
|
+
currentRun: null,
|
|
90
|
+
previousRun: null,
|
|
91
|
+
previousOverallRun: null,
|
|
92
|
+
mode: 'adjacent',
|
|
93
|
+
scopeLabel: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const currentComparisonKey = getHistoryRunComparisonKey(currentRun);
|
|
97
|
+
let previousComparableRun = null;
|
|
98
|
+
if (currentComparisonKey) {
|
|
99
|
+
for (let index = historyRuns.length - 2; index >= 0; index -= 1) {
|
|
100
|
+
const candidate = historyRuns[index];
|
|
101
|
+
if (getHistoryRunComparisonKey(candidate) === currentComparisonKey) {
|
|
102
|
+
previousComparableRun = candidate;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
currentRun,
|
|
109
|
+
previousRun: previousComparableRun ?? previousOverallRun,
|
|
110
|
+
previousOverallRun,
|
|
111
|
+
mode: previousComparableRun && previousOverallRun && previousComparableRun.id !== previousOverallRun.id ? 'comparable' : 'adjacent',
|
|
112
|
+
scopeLabel: currentRun.comparisonLabel ?? previousComparableRun?.comparisonLabel ?? null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function getHistoryRunComparisonKey(run) {
|
|
116
|
+
if (run.comparisonKey) {
|
|
117
|
+
return run.comparisonKey;
|
|
118
|
+
}
|
|
119
|
+
const normalizedSource = normalizeSourceFileForComparison(run.sourceFile);
|
|
120
|
+
return normalizedSource.length > 0 ? `source:${normalizedSource}` : null;
|
|
121
|
+
}
|
|
122
|
+
function collectComparisonProjects(report) {
|
|
123
|
+
const environmentProjects = (report.environment?.projects ?? [])
|
|
124
|
+
.map(normalizeComparisonToken)
|
|
125
|
+
.filter((project) => project.length > 0);
|
|
126
|
+
if (environmentProjects.length > 0) {
|
|
127
|
+
return [...new Set(environmentProjects)].sort();
|
|
128
|
+
}
|
|
129
|
+
return [...new Set((report.tests ?? [])
|
|
130
|
+
.map((test) => normalizeComparisonToken(test.project))
|
|
131
|
+
.filter((project) => project.length > 0))].sort();
|
|
132
|
+
}
|
|
133
|
+
function normalizeComparisonToken(value) {
|
|
134
|
+
return (value ?? '').trim().toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
function normalizeSourceFileForComparison(sourceFile) {
|
|
137
|
+
const fileName = path.basename(sourceFile, path.extname(sourceFile)).toLowerCase();
|
|
138
|
+
return fileName
|
|
139
|
+
.replace(/\b(previous|prev|latest|current|last)\b/g, ' ')
|
|
140
|
+
.replace(/\b\d{4}[-_]\d{2}[-_]\d{2}(?:[t_ -]?\d{2}[-_:]?\d{2}(?:[-_:]?\d{2})?)?\b/g, ' ')
|
|
141
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
142
|
+
.replace(/-+/g, '-')
|
|
143
|
+
.replace(/^-|-$/g, '');
|
|
144
|
+
}
|
|
145
|
+
function loadReporterReport(reportPath) {
|
|
146
|
+
const report = readJsonFile(reportPath, 'JSON-репорт Playwright');
|
|
147
|
+
if (!Array.isArray(report.tests)) {
|
|
148
|
+
throw new Error(`В файле \"${reportPath}\" отсутствует массив tests. Ожидался JSON в формате playwright-reporter-llm.`);
|
|
149
|
+
}
|
|
150
|
+
return enrichReporterReport(report);
|
|
151
|
+
}
|
|
152
|
+
function enrichReporterReport(report) {
|
|
153
|
+
if (!Array.isArray(report.tests)) {
|
|
154
|
+
return report;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
...report,
|
|
158
|
+
tests: report.tests.map((test) => ({
|
|
159
|
+
...test,
|
|
160
|
+
attempts: Array.isArray(test.attempts)
|
|
161
|
+
? test.attempts.map((attempt) => enrichReporterAttempt(attempt))
|
|
162
|
+
: test.attempts,
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function enrichReporterAttempt(attempt) {
|
|
167
|
+
const steps = Array.isArray(attempt.steps) ? attempt.steps : [];
|
|
168
|
+
if (steps.length === 0) {
|
|
169
|
+
return attempt;
|
|
170
|
+
}
|
|
171
|
+
const failureIndex = resolveReporterFailedStepIndex(attempt, steps);
|
|
172
|
+
if (failureIndex === null) {
|
|
173
|
+
return attempt;
|
|
174
|
+
}
|
|
175
|
+
const inferredAttemptStatus = normalizeReporterStatus(attempt.status);
|
|
176
|
+
const enrichedSteps = steps.map((step, index) => {
|
|
177
|
+
if (index !== failureIndex) {
|
|
178
|
+
return step;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
...step,
|
|
182
|
+
failed: true,
|
|
183
|
+
status: typeof step.status === 'string' && step.status.trim().length > 0
|
|
184
|
+
? step.status
|
|
185
|
+
: (isReporterUnstableStatus(inferredAttemptStatus) ? inferredAttemptStatus : step.status),
|
|
186
|
+
error: hasReporterErrorMessage(step.error)
|
|
187
|
+
? step.error
|
|
188
|
+
: (hasReporterErrorMessage(attempt.error) ? attempt.error : step.error),
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
const inferredTitle = typeof steps[failureIndex]?.title === 'string' && steps[failureIndex].title?.trim().length
|
|
192
|
+
? steps[failureIndex].title?.trim()
|
|
193
|
+
: undefined;
|
|
194
|
+
return {
|
|
195
|
+
...attempt,
|
|
196
|
+
failedStepIndex: typeof attempt.failedStepIndex === 'number' && attempt.failedStepIndex >= 0 && attempt.failedStepIndex < steps.length
|
|
197
|
+
? attempt.failedStepIndex
|
|
198
|
+
: failureIndex,
|
|
199
|
+
failedStepTitle: typeof attempt.failedStepTitle === 'string' && attempt.failedStepTitle.trim().length > 0
|
|
200
|
+
? attempt.failedStepTitle.trim()
|
|
201
|
+
: inferredTitle,
|
|
202
|
+
steps: enrichedSteps,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function resolveReporterFailedStepIndex(attempt, steps) {
|
|
206
|
+
if (typeof attempt.failedStepIndex === 'number' && attempt.failedStepIndex >= 0 && attempt.failedStepIndex < steps.length) {
|
|
207
|
+
return attempt.failedStepIndex;
|
|
208
|
+
}
|
|
209
|
+
const normalizedFailedTitle = typeof attempt.failedStepTitle === 'string' && attempt.failedStepTitle.trim().length > 0
|
|
210
|
+
? attempt.failedStepTitle.trim().toLowerCase()
|
|
211
|
+
: null;
|
|
212
|
+
if (normalizedFailedTitle) {
|
|
213
|
+
const titledIndex = steps.findIndex((step) => typeof step.title === 'string' && step.title.trim().toLowerCase() === normalizedFailedTitle);
|
|
214
|
+
if (titledIndex >= 0) {
|
|
215
|
+
return titledIndex;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const explicitCandidates = steps
|
|
219
|
+
.map((step, index) => ({ step, index }))
|
|
220
|
+
.filter(({ step }) => step.failed === true || hasReporterErrorMessage(step.error) || isReporterUnstableStatus(normalizeReporterStatus(step.status)));
|
|
221
|
+
if (explicitCandidates.length > 0) {
|
|
222
|
+
const teardownStartIndex = findReporterTeardownStartIndex(steps);
|
|
223
|
+
return [...explicitCandidates].sort((left, right) => {
|
|
224
|
+
const scoreDelta = getReporterFailurePointPriority(right.step, right.index, steps, teardownStartIndex)
|
|
225
|
+
- getReporterFailurePointPriority(left.step, left.index, steps, teardownStartIndex);
|
|
226
|
+
if (scoreDelta !== 0) {
|
|
227
|
+
return scoreDelta;
|
|
228
|
+
}
|
|
229
|
+
return right.index - left.index;
|
|
230
|
+
})[0]?.index ?? null;
|
|
231
|
+
}
|
|
232
|
+
if (isReporterUnstableStatus(normalizeReporterStatus(attempt.status)) && steps.length > 0) {
|
|
233
|
+
return steps.length - 1;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
function getReporterFailurePointPriority(step, stepIndex, allSteps, teardownStartIndex) {
|
|
238
|
+
const category = typeof step.category === 'string' && step.category.trim().length > 0 ? step.category.trim() : null;
|
|
239
|
+
const status = normalizeReporterStatus(step.status);
|
|
240
|
+
const errorMessage = getReporterErrorMessage(step.error);
|
|
241
|
+
const depth = typeof step.depth === 'number' && Number.isFinite(step.depth)
|
|
242
|
+
? Math.max(0, Math.trunc(step.depth))
|
|
243
|
+
: 0;
|
|
244
|
+
let score = stepIndex;
|
|
245
|
+
if (errorMessage) {
|
|
246
|
+
score += 120;
|
|
247
|
+
}
|
|
248
|
+
if (step.failed === true) {
|
|
249
|
+
score += 140;
|
|
250
|
+
}
|
|
251
|
+
if (isReporterUnstableStatus(status)) {
|
|
252
|
+
score += 50;
|
|
253
|
+
}
|
|
254
|
+
score += depth * 14;
|
|
255
|
+
switch (category) {
|
|
256
|
+
case 'expect':
|
|
257
|
+
score += 28;
|
|
258
|
+
break;
|
|
259
|
+
case 'pw:api':
|
|
260
|
+
score += 22;
|
|
261
|
+
break;
|
|
262
|
+
case 'test.step':
|
|
263
|
+
score += 18;
|
|
264
|
+
break;
|
|
265
|
+
case 'hook':
|
|
266
|
+
score += 6;
|
|
267
|
+
break;
|
|
268
|
+
default:
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (category === 'test.step' || category === 'hook') {
|
|
272
|
+
score -= 60;
|
|
273
|
+
}
|
|
274
|
+
if (isReporterGenericLifecycleStep(step)) {
|
|
275
|
+
score -= 48;
|
|
276
|
+
}
|
|
277
|
+
if (isReporterGenericTeardownStep(step)) {
|
|
278
|
+
score -= 200;
|
|
279
|
+
}
|
|
280
|
+
if (isReporterCascadingTeardownInfrastructureStep(step)) {
|
|
281
|
+
score -= 180;
|
|
282
|
+
}
|
|
283
|
+
if (isReporterActionableAutomationErrorStep(step)) {
|
|
284
|
+
score += 36;
|
|
285
|
+
}
|
|
286
|
+
if (teardownStartIndex >= 0 && stepIndex >= teardownStartIndex) {
|
|
287
|
+
score -= 240;
|
|
288
|
+
}
|
|
289
|
+
if (hasMeaningfulReporterProgressAfterStep(allSteps, stepIndex, teardownStartIndex)) {
|
|
290
|
+
score -= 260;
|
|
291
|
+
}
|
|
292
|
+
return score;
|
|
293
|
+
}
|
|
294
|
+
function hasMeaningfulReporterProgressAfterStep(steps, stepIndex, teardownStartIndex) {
|
|
295
|
+
const step = steps[stepIndex];
|
|
296
|
+
const endIndex = isReporterScopeWrapper(step)
|
|
297
|
+
? findReporterSubtreeEndIndex(steps, stepIndex)
|
|
298
|
+
: (teardownStartIndex >= 0 && stepIndex < teardownStartIndex ? teardownStartIndex : steps.length);
|
|
299
|
+
const currentDepth = typeof step.depth === 'number' && Number.isFinite(step.depth)
|
|
300
|
+
? Math.max(0, Math.trunc(step.depth))
|
|
301
|
+
: 0;
|
|
302
|
+
for (let index = stepIndex + 1; index < endIndex; index += 1) {
|
|
303
|
+
const laterStep = steps[index];
|
|
304
|
+
const laterDepth = typeof laterStep.depth === 'number' && Number.isFinite(laterStep.depth)
|
|
305
|
+
? Math.max(0, Math.trunc(laterStep.depth))
|
|
306
|
+
: 0;
|
|
307
|
+
if (isReporterGenericLifecycleStep(laterStep) && !hasReporterErrorMessage(laterStep.error)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (hasReporterErrorMessage(laterStep.error)
|
|
311
|
+
|| laterStep.category === 'expect'
|
|
312
|
+
|| laterStep.category === 'pw:api'
|
|
313
|
+
|| laterStep.category === 'test.step'
|
|
314
|
+
|| laterDepth > currentDepth) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
function isReporterScopeWrapper(step) {
|
|
321
|
+
return step.category === 'test.step' || step.category === 'hook';
|
|
322
|
+
}
|
|
323
|
+
function findReporterSubtreeEndIndex(steps, stepIndex) {
|
|
324
|
+
const step = steps[stepIndex];
|
|
325
|
+
const depth = typeof step.depth === 'number' && Number.isFinite(step.depth)
|
|
326
|
+
? Math.max(0, Math.trunc(step.depth))
|
|
327
|
+
: 0;
|
|
328
|
+
for (let index = stepIndex + 1; index < steps.length; index += 1) {
|
|
329
|
+
const laterStep = steps[index];
|
|
330
|
+
const laterDepth = typeof laterStep?.depth === 'number' && Number.isFinite(laterStep.depth)
|
|
331
|
+
? Math.max(0, Math.trunc(laterStep.depth))
|
|
332
|
+
: 0;
|
|
333
|
+
if (laterDepth <= depth) {
|
|
334
|
+
return index;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return steps.length;
|
|
338
|
+
}
|
|
339
|
+
function findReporterTeardownStartIndex(steps) {
|
|
340
|
+
return steps.findIndex((step) => {
|
|
341
|
+
const depth = typeof step.depth === 'number' && Number.isFinite(step.depth)
|
|
342
|
+
? Math.max(0, Math.trunc(step.depth))
|
|
343
|
+
: 0;
|
|
344
|
+
return depth === 0 && isReporterGenericTeardownStep(step);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function isReporterGenericLifecycleStep(step) {
|
|
348
|
+
const category = (step.category ?? '').trim().toLowerCase();
|
|
349
|
+
const title = (step.title ?? '').trim().toLowerCase();
|
|
350
|
+
return /(^|\W)(before hooks|after hooks|setup|teardown|cleanup|worker cleanup|fixture|hook)($|\W)/.test(title)
|
|
351
|
+
|| /(hook:before|hook:after|fixture:setup|fixture:teardown|beforeall|beforeeach|afterall|aftereach)/.test(category);
|
|
352
|
+
}
|
|
353
|
+
function isReporterGenericTeardownStep(step) {
|
|
354
|
+
const category = (step.category ?? '').trim().toLowerCase();
|
|
355
|
+
const title = (step.title ?? '').trim().toLowerCase();
|
|
356
|
+
return /(^|\W)(after hooks|worker cleanup|cleanup|clean up|teardown|tear down)($|\W)/.test(title)
|
|
357
|
+
|| /(hook:after|fixture:teardown|afterall|aftereach|cleanup|clean up|teardown)/.test(category);
|
|
358
|
+
}
|
|
359
|
+
function isReporterCascadingTeardownInfrastructureStep(step) {
|
|
360
|
+
const titleCorpus = (step.title ?? '').trim().toLowerCase();
|
|
361
|
+
const categoryCorpus = (step.category ?? '').trim().toLowerCase();
|
|
362
|
+
const errorCorpus = (getReporterErrorMessage(step.error) ?? '').trim().toLowerCase();
|
|
363
|
+
if (!errorCorpus) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const hasClosedResourceSignal = /target page, context or browser has been closed|browser has been closed|context has been closed|page has been closed|browser\.close:|context\.close:|page\.close:/.test(errorCorpus);
|
|
367
|
+
const isCloseOperation = /(close browser|close context|close page|browser close|context close|page close)/.test(titleCorpus);
|
|
368
|
+
const isLifecycleCleanup = isReporterGenericLifecycleStep(step)
|
|
369
|
+
|| isReporterGenericTeardownStep(step)
|
|
370
|
+
|| categoryCorpus === 'hook';
|
|
371
|
+
return hasClosedResourceSignal && (isCloseOperation || isLifecycleCleanup);
|
|
372
|
+
}
|
|
373
|
+
function isReporterActionableAutomationErrorStep(step) {
|
|
374
|
+
const titleCorpus = (step.title ?? '').trim().toLowerCase();
|
|
375
|
+
const categoryCorpus = (step.category ?? '').trim().toLowerCase();
|
|
376
|
+
const errorCorpus = (getReporterErrorMessage(step.error) ?? '').trim().toLowerCase();
|
|
377
|
+
const combinedCorpus = `${titleCorpus} ${categoryCorpus} ${errorCorpus}`;
|
|
378
|
+
return step.category === 'pw:api'
|
|
379
|
+
&& /(timeouterror|timeout \d+ms exceeded|timed out|waitfor|wait for|locator\.|selector)/.test(combinedCorpus);
|
|
380
|
+
}
|
|
381
|
+
function hasReporterErrorMessage(error) {
|
|
382
|
+
return Boolean(getReporterErrorMessage(error));
|
|
383
|
+
}
|
|
384
|
+
function getReporterErrorMessage(error) {
|
|
385
|
+
if (typeof error === 'string' && error.trim().length > 0) {
|
|
386
|
+
return error.trim();
|
|
387
|
+
}
|
|
388
|
+
if (error && typeof error === 'object' && typeof error.message === 'string' && error.message.trim().length > 0) {
|
|
389
|
+
return error.message.trim();
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
function normalizeReporterStatus(status) {
|
|
394
|
+
const normalized = (status ?? '').trim().toLowerCase();
|
|
395
|
+
return normalized === 'timed out' ? 'timedout' : normalized;
|
|
396
|
+
}
|
|
397
|
+
function isReporterUnstableStatus(status) {
|
|
398
|
+
return status === 'failed' || status === 'timedout' || status === 'interrupted';
|
|
399
|
+
}
|
|
400
|
+
function readDashboardSummary(summaryPath) {
|
|
401
|
+
const summary = readJsonFile(summaryPath, 'сводка AQA Pulse');
|
|
402
|
+
if (!summary.kpis || !summary.charts) {
|
|
403
|
+
throw new Error(`В файле \"${summaryPath}\" отсутствуют обязательные поля kpis/charts. Сначала сгенерируй dashboard-data.json.`);
|
|
404
|
+
}
|
|
405
|
+
return normalizeDashboardSummary(summary);
|
|
406
|
+
}
|
|
407
|
+
function normalizeDashboardSummary(summary) {
|
|
408
|
+
const fallbackBusinessMetrics = buildEmptyBusinessMetrics();
|
|
409
|
+
const fallbackCodeQuality = buildEmptyCodeQualityMetrics(0);
|
|
410
|
+
const businessMetrics = summary.businessMetrics;
|
|
411
|
+
const normalizedBusinessMetrics = {
|
|
412
|
+
...fallbackBusinessMetrics,
|
|
413
|
+
...businessMetrics,
|
|
414
|
+
timeToDetect: {
|
|
415
|
+
...fallbackBusinessMetrics.timeToDetect,
|
|
416
|
+
...(businessMetrics?.timeToDetect ?? {}),
|
|
417
|
+
},
|
|
418
|
+
timeToFixFlaky: {
|
|
419
|
+
...fallbackBusinessMetrics.timeToFixFlaky,
|
|
420
|
+
...(businessMetrics?.timeToFixFlaky ?? {}),
|
|
421
|
+
},
|
|
422
|
+
costOfFlakiness: {
|
|
423
|
+
...fallbackBusinessMetrics.costOfFlakiness,
|
|
424
|
+
...(businessMetrics?.costOfFlakiness ?? {}),
|
|
425
|
+
assumptions: {
|
|
426
|
+
...fallbackBusinessMetrics.costOfFlakiness.assumptions,
|
|
427
|
+
...(businessMetrics?.costOfFlakiness?.assumptions ?? {}),
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
developerFriction: {
|
|
431
|
+
...fallbackBusinessMetrics.developerFriction,
|
|
432
|
+
...(businessMetrics?.developerFriction ?? {}),
|
|
433
|
+
},
|
|
434
|
+
automationRoi: {
|
|
435
|
+
...fallbackBusinessMetrics.automationRoi,
|
|
436
|
+
...(businessMetrics?.automationRoi ?? {}),
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
const codeQuality = summary.codeQuality ?? recoverCodeQualityMetricsFromSource(summary.sourceFile);
|
|
440
|
+
const normalizedCodeQuality = {
|
|
441
|
+
...fallbackCodeQuality,
|
|
442
|
+
...codeQuality,
|
|
443
|
+
drivers: codeQuality?.drivers ?? fallbackCodeQuality.drivers,
|
|
444
|
+
topRiskFiles: codeQuality?.topRiskFiles ?? fallbackCodeQuality.topRiskFiles,
|
|
445
|
+
};
|
|
446
|
+
const currentRunTests = summary.currentRunTests ?? recoverCurrentRunTestsFromSource(summary.sourceFile);
|
|
447
|
+
const topProblematicTests = summary.topProblematicTests ?? [];
|
|
448
|
+
const errorClusters = summary.errorClusters ?? [];
|
|
449
|
+
const normalizedComparison = {
|
|
450
|
+
currentRun: summary.comparison?.currentRun ?? null,
|
|
451
|
+
previousRun: summary.comparison?.previousRun ?? null,
|
|
452
|
+
previousOverallRun: summary.comparison?.previousOverallRun ?? summary.comparison?.previousRun ?? null,
|
|
453
|
+
mode: summary.comparison?.mode ?? 'adjacent',
|
|
454
|
+
scopeLabel: summary.comparison?.scopeLabel ?? null,
|
|
455
|
+
};
|
|
456
|
+
return {
|
|
457
|
+
...summary,
|
|
458
|
+
businessMetrics: normalizedBusinessMetrics,
|
|
459
|
+
codeQuality: normalizedCodeQuality,
|
|
460
|
+
currentRunTests,
|
|
461
|
+
topProblematicTests,
|
|
462
|
+
errorClusters,
|
|
463
|
+
comparison: normalizedComparison,
|
|
464
|
+
managerSummary: summary.managerSummary ?? buildManagerSummary({
|
|
465
|
+
kpis: summary.kpis,
|
|
466
|
+
trend: summary.trend,
|
|
467
|
+
comparison: normalizedComparison,
|
|
468
|
+
historyTotalRuns: summary.history.totalRuns,
|
|
469
|
+
performance: summary.performance,
|
|
470
|
+
flakyAnalytics: summary.flakyAnalytics,
|
|
471
|
+
businessMetrics: normalizedBusinessMetrics,
|
|
472
|
+
codeQuality: normalizedCodeQuality,
|
|
473
|
+
topProblematicTests,
|
|
474
|
+
errorClusters,
|
|
475
|
+
}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function ensureDirectoryForFile(filePath) {
|
|
479
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
480
|
+
}
|
|
481
|
+
function writeJsonFile(filePath, payload) {
|
|
482
|
+
ensureDirectoryForFile(filePath);
|
|
483
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
484
|
+
}
|
|
485
|
+
function writeTextFile(filePath, payload) {
|
|
486
|
+
ensureDirectoryForFile(filePath);
|
|
487
|
+
fs.writeFileSync(filePath, payload, 'utf8');
|
|
488
|
+
}
|
|
489
|
+
function buildDashboardSummary(report, sourceFile, historyRuns = [], runMetadata = { branch: null, commit: null, author: null }, advancedMetrics = null, filters = { branch: null, project: null, file: null }, availableFilters = null) {
|
|
490
|
+
const tests = report.tests ?? [];
|
|
491
|
+
const passedTests = tests.filter((test) => getFinalStatus(test) === 'passed').length;
|
|
492
|
+
const failedTests = tests.filter((test) => getFinalStatus(test) === 'failed').length;
|
|
493
|
+
const skippedTests = tests.filter((test) => getFinalStatus(test) === 'skipped').length;
|
|
494
|
+
const timedOutTests = tests.filter((test) => getFinalStatus(test) === 'timedout').length;
|
|
495
|
+
const interruptedTests = tests.filter((test) => getFinalStatus(test) === 'interrupted').length;
|
|
496
|
+
const flakyTests = tests.filter((test) => Boolean(test.flaky)).length;
|
|
497
|
+
const totalTests = tests.length;
|
|
498
|
+
const totalDurationMs = report.durationMs ?? sum(tests.map((test) => safeNumber(test.durationMs)));
|
|
499
|
+
const passRate = totalTests === 0 ? 0 : (passedTests / totalTests) * 100;
|
|
500
|
+
const flakyRatio = totalTests === 0 ? 0 : (flakyTests / totalTests) * 100;
|
|
501
|
+
const medianDurationMs = getMedian(tests
|
|
502
|
+
.map((test) => safeNumber(test.durationMs))
|
|
503
|
+
.filter((value) => value > 0));
|
|
504
|
+
const errorClusters = collectErrorClusters(tests);
|
|
505
|
+
const slowestTests = [...tests]
|
|
506
|
+
.sort((left, right) => safeNumber(right.durationMs) - safeNumber(left.durationMs))
|
|
507
|
+
.slice(0, 5);
|
|
508
|
+
const recentRuns = historyRuns.slice(-10);
|
|
509
|
+
const comparisonBaseline = resolveComparisonBaseline(historyRuns);
|
|
510
|
+
const currentRun = comparisonBaseline.currentRun;
|
|
511
|
+
const previousRun = comparisonBaseline.previousRun;
|
|
512
|
+
const resolvedAdvancedMetrics = advancedMetrics ?? buildFallbackAdvancedMetrics(report, historyRuns, flakyTests, totalDurationMs, sourceFile);
|
|
513
|
+
const topProblematicTests = collectTopProblematicTests(tests);
|
|
514
|
+
const managerSummary = buildManagerSummary({
|
|
515
|
+
kpis: {
|
|
516
|
+
totalTests,
|
|
517
|
+
passedTests,
|
|
518
|
+
failedTests,
|
|
519
|
+
flakyTests,
|
|
520
|
+
skippedTests,
|
|
521
|
+
timedOutTests,
|
|
522
|
+
interruptedTests,
|
|
523
|
+
passRate,
|
|
524
|
+
flakyRatio,
|
|
525
|
+
totalDurationMs,
|
|
526
|
+
medianDurationMs,
|
|
527
|
+
errorClusterCount: errorClusters.length,
|
|
528
|
+
},
|
|
529
|
+
trend: {
|
|
530
|
+
previousRun,
|
|
531
|
+
passRateDelta: previousRun ? roundToOneDigit(passRate - previousRun.passRate) : null,
|
|
532
|
+
failedTestsDelta: previousRun ? failedTests - previousRun.failedTests : null,
|
|
533
|
+
flakyTestsDelta: previousRun ? flakyTests - previousRun.flakyTests : null,
|
|
534
|
+
durationMsDelta: previousRun ? totalDurationMs - previousRun.totalDurationMs : null,
|
|
535
|
+
},
|
|
536
|
+
comparison: {
|
|
537
|
+
currentRun,
|
|
538
|
+
previousRun,
|
|
539
|
+
previousOverallRun: comparisonBaseline.previousOverallRun,
|
|
540
|
+
mode: comparisonBaseline.mode,
|
|
541
|
+
scopeLabel: comparisonBaseline.scopeLabel,
|
|
542
|
+
},
|
|
543
|
+
historyTotalRuns: historyRuns.length,
|
|
544
|
+
performance: resolvedAdvancedMetrics.performance,
|
|
545
|
+
flakyAnalytics: resolvedAdvancedMetrics.flakyAnalytics,
|
|
546
|
+
businessMetrics: resolvedAdvancedMetrics.businessMetrics,
|
|
547
|
+
codeQuality: resolvedAdvancedMetrics.codeQuality,
|
|
548
|
+
topProblematicTests,
|
|
549
|
+
errorClusters,
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
generatedAt: new Date().toISOString(),
|
|
553
|
+
sourceFile,
|
|
554
|
+
schemaVersion: report.schemaVersion ?? null,
|
|
555
|
+
reportTimestamp: report.timestamp ?? null,
|
|
556
|
+
notes: [
|
|
557
|
+
'AQA Pulse вынесен в отдельную директорию в корне репозитория и не влияет на Playwright-конфиг.',
|
|
558
|
+
'Текущая версия покрывает первый этап: парсинг JSON-репорта и базовые метрики по одному прогону.',
|
|
559
|
+
'История прогонов и API-слой остаются следующим этапом из плана.',
|
|
560
|
+
],
|
|
561
|
+
runMetadata,
|
|
562
|
+
filters,
|
|
563
|
+
availableFilters: availableFilters ?? buildAvailableFilters(historyRuns, tests),
|
|
564
|
+
environment: {
|
|
565
|
+
playwrightVersion: report.environment?.playwrightVersion ?? 'неизвестно',
|
|
566
|
+
nodeVersion: report.environment?.nodeVersion ?? process.version,
|
|
567
|
+
os: report.environment?.os ?? process.platform,
|
|
568
|
+
workers: safeNumber(report.environment?.workers),
|
|
569
|
+
retries: safeNumber(report.environment?.retries),
|
|
570
|
+
projects: report.environment?.projects ?? [],
|
|
571
|
+
},
|
|
572
|
+
kpis: {
|
|
573
|
+
totalTests,
|
|
574
|
+
passedTests,
|
|
575
|
+
failedTests,
|
|
576
|
+
flakyTests,
|
|
577
|
+
skippedTests,
|
|
578
|
+
timedOutTests,
|
|
579
|
+
interruptedTests,
|
|
580
|
+
passRate,
|
|
581
|
+
flakyRatio,
|
|
582
|
+
totalDurationMs,
|
|
583
|
+
medianDurationMs,
|
|
584
|
+
errorClusterCount: errorClusters.length,
|
|
585
|
+
},
|
|
586
|
+
charts: {
|
|
587
|
+
passRateTrend: {
|
|
588
|
+
labels: recentRuns.length > 0
|
|
589
|
+
? recentRuns.map((run, index) => formatHistoryRunLabel(run, index))
|
|
590
|
+
: ['Текущий прогон'],
|
|
591
|
+
values: recentRuns.length > 0
|
|
592
|
+
? recentRuns.map((run) => roundToOneDigit(run.passRate))
|
|
593
|
+
: [roundToOneDigit(passRate)],
|
|
594
|
+
},
|
|
595
|
+
durationTrend: resolvedAdvancedMetrics.charts.durationTrend,
|
|
596
|
+
flakyTrend: resolvedAdvancedMetrics.charts.flakyTrend,
|
|
597
|
+
statusDistribution: {
|
|
598
|
+
labels: ['Passed', 'Failed', 'Flaky', 'Skipped', 'Timed out', 'Interrupted'],
|
|
599
|
+
values: [passedTests, failedTests, flakyTests, skippedTests, timedOutTests, interruptedTests],
|
|
600
|
+
},
|
|
601
|
+
errorClusters: {
|
|
602
|
+
labels: errorClusters.length > 0
|
|
603
|
+
? errorClusters.map((cluster) => shorten(cluster.message, 42))
|
|
604
|
+
: ['Без падений'],
|
|
605
|
+
values: errorClusters.length > 0 ? errorClusters.map((cluster) => cluster.count) : [1],
|
|
606
|
+
},
|
|
607
|
+
slowestTests: {
|
|
608
|
+
labels: slowestTests.length > 0
|
|
609
|
+
? slowestTests.map((test) => shorten(test.title ?? 'Тест без названия', 34))
|
|
610
|
+
: ['Нет данных'],
|
|
611
|
+
values: slowestTests.length > 0
|
|
612
|
+
? slowestTests.map((test) => roundToOneDigit(safeNumber(test.durationMs) / 1000))
|
|
613
|
+
: [0],
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
trend: {
|
|
617
|
+
previousRun,
|
|
618
|
+
passRateDelta: previousRun ? roundToOneDigit(passRate - previousRun.passRate) : null,
|
|
619
|
+
failedTestsDelta: previousRun ? failedTests - previousRun.failedTests : null,
|
|
620
|
+
flakyTestsDelta: previousRun ? flakyTests - previousRun.flakyTests : null,
|
|
621
|
+
durationMsDelta: previousRun ? totalDurationMs - previousRun.totalDurationMs : null,
|
|
622
|
+
},
|
|
623
|
+
comparison: {
|
|
624
|
+
currentRun,
|
|
625
|
+
previousRun,
|
|
626
|
+
previousOverallRun: comparisonBaseline.previousOverallRun,
|
|
627
|
+
mode: comparisonBaseline.mode,
|
|
628
|
+
scopeLabel: comparisonBaseline.scopeLabel,
|
|
629
|
+
},
|
|
630
|
+
history: {
|
|
631
|
+
totalRuns: historyRuns.length,
|
|
632
|
+
recentRuns: [...recentRuns].reverse(),
|
|
633
|
+
},
|
|
634
|
+
performance: resolvedAdvancedMetrics.performance,
|
|
635
|
+
flakyAnalytics: resolvedAdvancedMetrics.flakyAnalytics,
|
|
636
|
+
businessMetrics: resolvedAdvancedMetrics.businessMetrics,
|
|
637
|
+
codeQuality: resolvedAdvancedMetrics.codeQuality,
|
|
638
|
+
managerSummary,
|
|
639
|
+
currentRunTests: collectCurrentRunTests(tests),
|
|
640
|
+
topProblematicTests,
|
|
641
|
+
errorClusters,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function buildAdvancedMetrics(report, historyRuns, archiveRootPath, sourceFile = null) {
|
|
645
|
+
const archivedRuns = historyRuns
|
|
646
|
+
.map((run) => {
|
|
647
|
+
const runDirectory = (0, history_utils_1.findArchivedRunDirectory)(archiveRootPath, run.id);
|
|
648
|
+
if (!runDirectory) {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
const archivedRun = (0, history_utils_1.readArchivedRunRecord)(archiveRootPath, runDirectory);
|
|
652
|
+
return {
|
|
653
|
+
run,
|
|
654
|
+
report: archivedRun.data,
|
|
655
|
+
};
|
|
656
|
+
})
|
|
657
|
+
.filter((value) => value !== null);
|
|
658
|
+
return buildAdvancedMetricsFromArchivedRuns(report, historyRuns, archivedRuns, sourceFile);
|
|
659
|
+
}
|
|
660
|
+
function buildAdvancedMetricsFromArchivedRuns(report, historyRuns, archivedRuns, sourceFile = null) {
|
|
661
|
+
const tests = report.tests ?? [];
|
|
662
|
+
const durationValues = tests
|
|
663
|
+
.map((test) => safeNumber(test.durationMs))
|
|
664
|
+
.filter((durationMs) => durationMs > 0);
|
|
665
|
+
const performanceMetrics = buildPerformanceMetrics(report, historyRuns);
|
|
666
|
+
const comparisonBaseline = resolveComparisonBaseline(historyRuns);
|
|
667
|
+
const previousRun = comparisonBaseline.previousRun;
|
|
668
|
+
const flakyTrend = {
|
|
669
|
+
currentFlakyTests: historyRuns[historyRuns.length - 1]?.flakyTests ?? tests.filter((test) => Boolean(test.flaky)).length,
|
|
670
|
+
previousFlakyTests: previousRun?.flakyTests ?? null,
|
|
671
|
+
delta: previousRun ? (historyRuns[historyRuns.length - 1]?.flakyTests ?? 0) - previousRun.flakyTests : null,
|
|
672
|
+
};
|
|
673
|
+
const flakyCandidates = collectFlakyCandidates(archivedRuns);
|
|
674
|
+
const topFlakyTests = [...flakyCandidates]
|
|
675
|
+
.sort((left, right) => right.flakyScore - left.flakyScore)
|
|
676
|
+
.slice(0, 10);
|
|
677
|
+
const firstFlakeToFix = collectFirstFlakeToFixMetric(archivedRuns);
|
|
678
|
+
return {
|
|
679
|
+
performance: performanceMetrics,
|
|
680
|
+
flakyAnalytics: {
|
|
681
|
+
averageFlakyScore: topFlakyTests.length > 0 ? roundToOneDigit(average(topFlakyTests.map((test) => test.flakyScore))) : null,
|
|
682
|
+
averageMtbfDays: (() => {
|
|
683
|
+
const mtbfValues = topFlakyTests
|
|
684
|
+
.map((test) => test.mtbfDays)
|
|
685
|
+
.filter((value) => value !== null);
|
|
686
|
+
return mtbfValues.length > 0 ? roundToTwoDigits(average(mtbfValues)) : null;
|
|
687
|
+
})(),
|
|
688
|
+
topFlakyTests,
|
|
689
|
+
firstFlakeToFix,
|
|
690
|
+
flakyTrend,
|
|
691
|
+
},
|
|
692
|
+
charts: {
|
|
693
|
+
durationTrend: {
|
|
694
|
+
labels: historyRuns.length > 0
|
|
695
|
+
? historyRuns.map((run, index) => formatHistoryRunLabel(run, index))
|
|
696
|
+
: ['Текущий прогон'],
|
|
697
|
+
values: historyRuns.length > 0
|
|
698
|
+
? historyRuns.map((run) => roundToOneDigit(run.totalDurationMs / 60000))
|
|
699
|
+
: [roundToOneDigit((report.durationMs ?? sum(durationValues)) / 60000)],
|
|
700
|
+
},
|
|
701
|
+
flakyTrend: {
|
|
702
|
+
labels: historyRuns.length > 0
|
|
703
|
+
? historyRuns.map((run, index) => formatHistoryRunLabel(run, index))
|
|
704
|
+
: ['Текущий прогон'],
|
|
705
|
+
values: historyRuns.length > 0
|
|
706
|
+
? historyRuns.map((run) => run.flakyTests)
|
|
707
|
+
: [tests.filter((test) => Boolean(test.flaky)).length],
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
businessMetrics: buildBusinessMetrics(report, historyRuns, archivedRuns),
|
|
711
|
+
codeQuality: buildCodeQualityMetrics(report, sourceFile),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function collectTopProblematicTests(tests) {
|
|
715
|
+
return tests
|
|
716
|
+
.map((test) => {
|
|
717
|
+
const attempts = test.attempts ?? [];
|
|
718
|
+
const failedAttempts = attempts.filter((attempt) => getAttemptStatus(attempt.status) === 'failed').length;
|
|
719
|
+
const attemptsCount = attempts.length > 0 ? attempts.length : safeNumber(test.retries) + 1;
|
|
720
|
+
const failureRate = attemptsCount === 0 ? 0 : (failedAttempts / attemptsCount) * 100;
|
|
721
|
+
const errorDetails = extractErrorMessage(test);
|
|
722
|
+
return {
|
|
723
|
+
title: test.title ?? 'Тест без названия',
|
|
724
|
+
file: test.location?.file ?? 'неизвестно',
|
|
725
|
+
project: test.project ?? 'неизвестно',
|
|
726
|
+
status: getFinalStatus(test),
|
|
727
|
+
flaky: Boolean(test.flaky),
|
|
728
|
+
durationMs: safeNumber(test.durationMs),
|
|
729
|
+
retries: safeNumber(test.retries),
|
|
730
|
+
attempts: attemptsCount,
|
|
731
|
+
failureRate,
|
|
732
|
+
errorMessage: normalizeErrorMessage(errorDetails ?? '—'),
|
|
733
|
+
errorDetails,
|
|
734
|
+
severity: getSeverityScore(test, failureRate),
|
|
735
|
+
};
|
|
736
|
+
})
|
|
737
|
+
.filter((test) => test.flaky || test.status === 'failed' || test.status === 'timedout' || test.status === 'interrupted' || test.errorMessage !== '—')
|
|
738
|
+
.sort((left, right) => right.severity - left.severity)
|
|
739
|
+
.slice(0, 5)
|
|
740
|
+
.map(({ severity: _severity, ...test }) => test);
|
|
741
|
+
}
|
|
742
|
+
function collectErrorClusters(tests) {
|
|
743
|
+
const clusters = new Map();
|
|
744
|
+
for (const test of tests) {
|
|
745
|
+
const errorMessage = extractErrorMessage(test);
|
|
746
|
+
if (!errorMessage) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const normalizedMessage = normalizeErrorMessage(errorMessage);
|
|
750
|
+
const existingCluster = clusters.get(normalizedMessage);
|
|
751
|
+
if (existingCluster) {
|
|
752
|
+
existingCluster.count += 1;
|
|
753
|
+
existingCluster.tests.add(test.title ?? 'Тест без названия');
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
clusters.set(normalizedMessage, {
|
|
757
|
+
count: 1,
|
|
758
|
+
tests: new Set([test.title ?? 'Тест без названия']),
|
|
759
|
+
sampleMessage: errorMessage,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
return [...clusters.entries()]
|
|
763
|
+
.map(([message, cluster]) => ({
|
|
764
|
+
message,
|
|
765
|
+
sampleMessage: cluster.sampleMessage,
|
|
766
|
+
count: cluster.count,
|
|
767
|
+
tests: [...cluster.tests].slice(0, 3),
|
|
768
|
+
}))
|
|
769
|
+
.sort((left, right) => right.count - left.count)
|
|
770
|
+
.slice(0, 5);
|
|
771
|
+
}
|
|
772
|
+
function getSeverityScore(test, failureRate) {
|
|
773
|
+
const statusWeights = {
|
|
774
|
+
failed: 100,
|
|
775
|
+
timedout: 90,
|
|
776
|
+
interrupted: 75,
|
|
777
|
+
skipped: 20,
|
|
778
|
+
passed: 0,
|
|
779
|
+
unknown: 0,
|
|
780
|
+
};
|
|
781
|
+
const statusWeight = statusWeights[getFinalStatus(test)] ?? 0;
|
|
782
|
+
const flakyWeight = test.flaky ? 35 : 0;
|
|
783
|
+
const retryWeight = safeNumber(test.retries) * 15;
|
|
784
|
+
const durationWeight = Math.min(safeNumber(test.durationMs) / 1000, 30);
|
|
785
|
+
return statusWeight + flakyWeight + retryWeight + durationWeight + failureRate;
|
|
786
|
+
}
|
|
787
|
+
function extractErrorMessage(test) {
|
|
788
|
+
const directError = firstNonEmpty((test.errors ?? []).map((error) => getReporterErrorMessage(error) ?? undefined));
|
|
789
|
+
if (directError) {
|
|
790
|
+
return directError;
|
|
791
|
+
}
|
|
792
|
+
return firstNonEmpty((test.attempts ?? [])
|
|
793
|
+
.filter((attempt) => ['failed', 'timedout', 'interrupted'].includes(getAttemptStatus(attempt.status)))
|
|
794
|
+
.map((attempt) => getReporterErrorMessage(attempt.error) ?? undefined));
|
|
795
|
+
}
|
|
796
|
+
function getFinalStatus(test) {
|
|
797
|
+
const status = getAttemptStatus(test.status);
|
|
798
|
+
if (status !== 'unknown') {
|
|
799
|
+
return status;
|
|
800
|
+
}
|
|
801
|
+
const attempts = test.attempts ?? [];
|
|
802
|
+
if (attempts.length === 0) {
|
|
803
|
+
return 'unknown';
|
|
804
|
+
}
|
|
805
|
+
return getAttemptStatus(attempts[attempts.length - 1]?.status);
|
|
806
|
+
}
|
|
807
|
+
function getAttemptStatus(status) {
|
|
808
|
+
const normalizedStatus = (status ?? '').trim().toLowerCase();
|
|
809
|
+
if (normalizedStatus === 'timed out') {
|
|
810
|
+
return 'timedout';
|
|
811
|
+
}
|
|
812
|
+
return normalizedStatus || 'unknown';
|
|
813
|
+
}
|
|
814
|
+
function normalizeErrorMessage(message) {
|
|
815
|
+
return shorten(message
|
|
816
|
+
.replace(ANSI_PATTERN, '')
|
|
817
|
+
.replace(/\s+/g, ' ')
|
|
818
|
+
.trim(), 180);
|
|
819
|
+
}
|
|
820
|
+
function firstNonEmpty(values) {
|
|
821
|
+
for (const value of values) {
|
|
822
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
823
|
+
return value.trim();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
function getMedian(values) {
|
|
829
|
+
if (values.length === 0) {
|
|
830
|
+
return 0;
|
|
831
|
+
}
|
|
832
|
+
const sortedValues = [...values].sort((left, right) => left - right);
|
|
833
|
+
const middleIndex = Math.floor(sortedValues.length / 2);
|
|
834
|
+
if (sortedValues.length % 2 === 0) {
|
|
835
|
+
return Math.round((sortedValues[middleIndex - 1] + sortedValues[middleIndex]) / 2);
|
|
836
|
+
}
|
|
837
|
+
return sortedValues[middleIndex];
|
|
838
|
+
}
|
|
839
|
+
function shorten(value, maxLength) {
|
|
840
|
+
if (value.length <= maxLength) {
|
|
841
|
+
return value;
|
|
842
|
+
}
|
|
843
|
+
return `${value.slice(0, Math.max(maxLength - 1, 1)).trimEnd()}…`;
|
|
844
|
+
}
|
|
845
|
+
function safeNumber(value) {
|
|
846
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
847
|
+
}
|
|
848
|
+
function roundToOneDigit(value) {
|
|
849
|
+
return Math.round(value * 10) / 10;
|
|
850
|
+
}
|
|
851
|
+
function sum(values) {
|
|
852
|
+
return values.reduce((accumulator, value) => accumulator + value, 0);
|
|
853
|
+
}
|
|
854
|
+
function average(values) {
|
|
855
|
+
return values.length === 0 ? 0 : sum(values) / values.length;
|
|
856
|
+
}
|
|
857
|
+
function getPercentile(values, percentile) {
|
|
858
|
+
if (values.length === 0) {
|
|
859
|
+
return 0;
|
|
860
|
+
}
|
|
861
|
+
const sortedValues = [...values].sort((left, right) => left - right);
|
|
862
|
+
const rank = Math.ceil((percentile / 100) * sortedValues.length) - 1;
|
|
863
|
+
const boundedRank = Math.min(Math.max(rank, 0), sortedValues.length - 1);
|
|
864
|
+
return sortedValues[boundedRank];
|
|
865
|
+
}
|
|
866
|
+
function roundToTwoDigits(value) {
|
|
867
|
+
return Math.round(value * 100) / 100;
|
|
868
|
+
}
|
|
869
|
+
function buildPerformanceMetrics(report, historyRuns) {
|
|
870
|
+
const tests = report.tests ?? [];
|
|
871
|
+
const durationValues = tests
|
|
872
|
+
.map((test) => safeNumber(test.durationMs))
|
|
873
|
+
.filter((durationMs) => durationMs > 0);
|
|
874
|
+
const totalDurationMs = report.durationMs ?? sum(tests.map((test) => safeNumber(test.durationMs)));
|
|
875
|
+
const previousRun = resolveComparisonBaseline(historyRuns).previousRun;
|
|
876
|
+
return {
|
|
877
|
+
p95DurationMs: getPercentile(durationValues, 95),
|
|
878
|
+
p99DurationMs: getPercentile(durationValues, 99),
|
|
879
|
+
slowestTests: [...tests]
|
|
880
|
+
.sort((left, right) => safeNumber(right.durationMs) - safeNumber(left.durationMs))
|
|
881
|
+
.slice(0, 10)
|
|
882
|
+
.map((test) => {
|
|
883
|
+
const errorDetails = extractErrorMessage(test);
|
|
884
|
+
return {
|
|
885
|
+
title: test.title ?? 'Тест без названия',
|
|
886
|
+
file: test.location?.file ?? 'неизвестно',
|
|
887
|
+
project: test.project ?? 'неизвестно',
|
|
888
|
+
status: getFinalStatus(test),
|
|
889
|
+
flaky: Boolean(test.flaky),
|
|
890
|
+
durationMs: safeNumber(test.durationMs),
|
|
891
|
+
errorMessage: errorDetails ? normalizeErrorMessage(errorDetails) : null,
|
|
892
|
+
errorDetails,
|
|
893
|
+
};
|
|
894
|
+
}),
|
|
895
|
+
phaseBreakdown: buildPhaseBreakdown(tests, totalDurationMs),
|
|
896
|
+
suiteDuration: buildSuiteDuration(tests, totalDurationMs),
|
|
897
|
+
durationPerBrowser: buildDurationPerBrowser(tests, totalDurationMs),
|
|
898
|
+
durationTrend: {
|
|
899
|
+
currentDurationMs: totalDurationMs,
|
|
900
|
+
previousDurationMs: previousRun?.totalDurationMs ?? null,
|
|
901
|
+
deltaPercent: previousRun && previousRun.totalDurationMs > 0
|
|
902
|
+
? roundToOneDigit(((totalDurationMs - previousRun.totalDurationMs) / previousRun.totalDurationMs) * 100)
|
|
903
|
+
: null,
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function buildPhaseBreakdown(tests, totalDurationMs) {
|
|
908
|
+
let setupMs = 0;
|
|
909
|
+
let testsMs = 0;
|
|
910
|
+
let teardownMs = 0;
|
|
911
|
+
for (const test of tests) {
|
|
912
|
+
const observedDurationMs = getObservedTestDurationMs(test);
|
|
913
|
+
const steps = (test.attempts ?? []).flatMap((attempt) => attempt.steps ?? []);
|
|
914
|
+
if (steps.length === 0) {
|
|
915
|
+
testsMs += observedDurationMs;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
let stepsDurationMs = 0;
|
|
919
|
+
for (const step of steps) {
|
|
920
|
+
const stepDurationMs = safeNumber(step.durationMs);
|
|
921
|
+
if (stepDurationMs <= 0) {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
stepsDurationMs += stepDurationMs;
|
|
925
|
+
switch (classifyPerformanceStepPhase(step)) {
|
|
926
|
+
case 'setup':
|
|
927
|
+
setupMs += stepDurationMs;
|
|
928
|
+
break;
|
|
929
|
+
case 'teardown':
|
|
930
|
+
teardownMs += stepDurationMs;
|
|
931
|
+
break;
|
|
932
|
+
default:
|
|
933
|
+
testsMs += stepDurationMs;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
testsMs += Math.max(observedDurationMs - stepsDurationMs, 0);
|
|
938
|
+
}
|
|
939
|
+
const totalPhaseMs = setupMs + testsMs + teardownMs;
|
|
940
|
+
const normalizationBase = totalPhaseMs > 0 ? totalPhaseMs : totalDurationMs;
|
|
941
|
+
return [
|
|
942
|
+
buildPhaseBreakdownItem('Setup', setupMs, normalizationBase),
|
|
943
|
+
buildPhaseBreakdownItem('Tests', testsMs, normalizationBase),
|
|
944
|
+
buildPhaseBreakdownItem('Teardown', teardownMs, normalizationBase),
|
|
945
|
+
];
|
|
946
|
+
}
|
|
947
|
+
function buildPhaseBreakdownItem(label, durationMs, totalDurationMs) {
|
|
948
|
+
return {
|
|
949
|
+
label,
|
|
950
|
+
durationMs: roundToOneDigit(durationMs),
|
|
951
|
+
sharePercent: totalDurationMs <= 0 ? 0 : roundToOneDigit((durationMs / totalDurationMs) * 100),
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
function buildSuiteDuration(tests, totalDurationMs) {
|
|
955
|
+
const durationsBySuite = new Map();
|
|
956
|
+
for (const test of tests) {
|
|
957
|
+
const label = resolveSuiteLabel(test);
|
|
958
|
+
const currentEntry = durationsBySuite.get(label) ?? { durationMs: 0, tests: 0 };
|
|
959
|
+
currentEntry.durationMs += getObservedTestDurationMs(test);
|
|
960
|
+
currentEntry.tests += 1;
|
|
961
|
+
durationsBySuite.set(label, currentEntry);
|
|
962
|
+
}
|
|
963
|
+
return buildDurationBreakdownItems(durationsBySuite, totalDurationMs);
|
|
964
|
+
}
|
|
965
|
+
function buildDurationPerBrowser(tests, totalDurationMs) {
|
|
966
|
+
const durationsByBrowser = new Map();
|
|
967
|
+
for (const test of tests) {
|
|
968
|
+
const label = resolveBrowserDimensionLabel(test.browser);
|
|
969
|
+
const currentEntry = durationsByBrowser.get(label) ?? { durationMs: 0, tests: 0 };
|
|
970
|
+
currentEntry.durationMs += getObservedTestDurationMs(test);
|
|
971
|
+
currentEntry.tests += 1;
|
|
972
|
+
durationsByBrowser.set(label, currentEntry);
|
|
973
|
+
}
|
|
974
|
+
return buildDurationBreakdownItems(durationsByBrowser, totalDurationMs);
|
|
975
|
+
}
|
|
976
|
+
const DIRECT_LOCATOR_METHODS = new Set([
|
|
977
|
+
'locator',
|
|
978
|
+
'getByRole',
|
|
979
|
+
'getByLabel',
|
|
980
|
+
'getByTestId',
|
|
981
|
+
'getByText',
|
|
982
|
+
'getByPlaceholder',
|
|
983
|
+
'getByAltText',
|
|
984
|
+
'getByTitle',
|
|
985
|
+
'$',
|
|
986
|
+
'$$',
|
|
987
|
+
]);
|
|
988
|
+
const STABLE_LOCATOR_METHODS = new Set(['getByRole', 'getByLabel', 'getByTestId']);
|
|
989
|
+
const TEXT_LOCATOR_METHODS = new Set(['getByText', 'getByPlaceholder', 'getByAltText', 'getByTitle']);
|
|
990
|
+
const SMART_WAIT_METHODS = new Set(['waitForSelector', 'waitForResponse', 'waitForNavigation', 'waitForURL', 'waitForLoadState']);
|
|
991
|
+
const PAGE_ACTION_METHODS = new Set(['click', 'dblclick', 'tap', 'fill', 'press', 'check', 'uncheck', 'selectOption', 'goto', 'reload', 'setInputFiles', 'dragTo', 'hover']);
|
|
992
|
+
const SHARED_MUTATION_METHODS = new Set(['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'set', 'add', 'delete', 'clear']);
|
|
993
|
+
let cachedTypeScriptModule;
|
|
994
|
+
function getTypeScriptModule() {
|
|
995
|
+
if (cachedTypeScriptModule !== undefined) {
|
|
996
|
+
return cachedTypeScriptModule;
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
cachedTypeScriptModule = require('typescript');
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
cachedTypeScriptModule = null;
|
|
1003
|
+
}
|
|
1004
|
+
return cachedTypeScriptModule;
|
|
1005
|
+
}
|
|
1006
|
+
function normalizePrecomputedSourceFacts(value) {
|
|
1007
|
+
if (!value || typeof value !== 'object') {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
const sourceFactsRecord = value;
|
|
1011
|
+
const files = Array.isArray(sourceFactsRecord.files)
|
|
1012
|
+
? sourceFactsRecord.files
|
|
1013
|
+
.map((fileFacts) => normalizePrecomputedSourceFileFacts(fileFacts))
|
|
1014
|
+
.filter((fileFacts) => fileFacts !== null)
|
|
1015
|
+
: [];
|
|
1016
|
+
if (files.length === 0) {
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
schemaVersion: pickPositiveInteger(sourceFactsRecord.schemaVersion) ?? 1,
|
|
1021
|
+
analyzerVersion: pickTrimmedString(sourceFactsRecord.analyzerVersion) ?? undefined,
|
|
1022
|
+
files,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
function normalizePrecomputedSourceFileFacts(value) {
|
|
1026
|
+
if (!value || typeof value !== 'object') {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
const fileFactsRecord = value;
|
|
1030
|
+
const file = pickTrimmedString(fileFactsRecord.file);
|
|
1031
|
+
if (!file) {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
const tests = Array.isArray(fileFactsRecord.tests)
|
|
1035
|
+
? fileFactsRecord.tests
|
|
1036
|
+
.map((testFacts) => normalizePrecomputedSourceTestFacts(testFacts))
|
|
1037
|
+
.filter((testFacts) => testFacts !== null)
|
|
1038
|
+
: [];
|
|
1039
|
+
return {
|
|
1040
|
+
file,
|
|
1041
|
+
tests,
|
|
1042
|
+
hasPomImports: pickBoolean(fileFactsRecord.hasPomImports) ?? false,
|
|
1043
|
+
beforeAllCount: normalizeNonNegativeInteger(fileFactsRecord.beforeAllCount),
|
|
1044
|
+
beforeEachCount: normalizeNonNegativeInteger(fileFactsRecord.beforeEachCount),
|
|
1045
|
+
serialModeCount: normalizeNonNegativeInteger(fileFactsRecord.serialModeCount),
|
|
1046
|
+
topLevelMutableStateCount: normalizeNonNegativeInteger(fileFactsRecord.topLevelMutableStateCount),
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
function normalizePrecomputedSourceTestFacts(value) {
|
|
1050
|
+
if (!value || typeof value !== 'object') {
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
const testFactsRecord = value;
|
|
1054
|
+
const startLine = pickPositiveInteger(testFactsRecord.startLine);
|
|
1055
|
+
if (startLine === null) {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
const endLine = Math.max(startLine, pickPositiveInteger(testFactsRecord.endLine) ?? startLine);
|
|
1059
|
+
const pomReferenceCount = normalizeNonNegativeInteger(testFactsRecord.pomReferenceCount);
|
|
1060
|
+
const pomFixtureReferenceCount = normalizeNonNegativeInteger(testFactsRecord.pomFixtureReferenceCount);
|
|
1061
|
+
return {
|
|
1062
|
+
startLine,
|
|
1063
|
+
endLine,
|
|
1064
|
+
title: pickTrimmedString(testFactsRecord.title),
|
|
1065
|
+
assertionCount: normalizeNonNegativeInteger(testFactsRecord.assertionCount),
|
|
1066
|
+
smartWaitCount: normalizeNonNegativeInteger(testFactsRecord.smartWaitCount),
|
|
1067
|
+
hardWaitCount: normalizeNonNegativeInteger(testFactsRecord.hardWaitCount),
|
|
1068
|
+
stepCount: normalizeNonNegativeInteger(testFactsRecord.stepCount),
|
|
1069
|
+
directLocatorCount: normalizeNonNegativeInteger(testFactsRecord.directLocatorCount),
|
|
1070
|
+
directPageActionCount: normalizeNonNegativeInteger(testFactsRecord.directPageActionCount),
|
|
1071
|
+
stableSelectorCount: normalizeNonNegativeInteger(testFactsRecord.stableSelectorCount),
|
|
1072
|
+
textSelectorCount: normalizeNonNegativeInteger(testFactsRecord.textSelectorCount),
|
|
1073
|
+
fragileSelectorCount: normalizeNonNegativeInteger(testFactsRecord.fragileSelectorCount),
|
|
1074
|
+
pomReferenceCount,
|
|
1075
|
+
pomFixtureReferenceCount,
|
|
1076
|
+
sharedStateMutationCount: normalizeNonNegativeInteger(testFactsRecord.sharedStateMutationCount),
|
|
1077
|
+
usesPom: pickBoolean(testFactsRecord.usesPom) ?? (pomReferenceCount + pomFixtureReferenceCount > 0),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function pickTrimmedString(value) {
|
|
1081
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
1082
|
+
}
|
|
1083
|
+
function pickPositiveInteger(value) {
|
|
1084
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
const normalizedValue = Math.trunc(value);
|
|
1088
|
+
return normalizedValue > 0 ? normalizedValue : null;
|
|
1089
|
+
}
|
|
1090
|
+
function normalizeNonNegativeInteger(value) {
|
|
1091
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1092
|
+
return 0;
|
|
1093
|
+
}
|
|
1094
|
+
return Math.max(0, Math.trunc(value));
|
|
1095
|
+
}
|
|
1096
|
+
function pickBoolean(value) {
|
|
1097
|
+
return typeof value === 'boolean' ? value : null;
|
|
1098
|
+
}
|
|
1099
|
+
function normalizeSourceFactsFileIdentity(filePath) {
|
|
1100
|
+
return filePath.trim().replace(/\\/g, '/').toLowerCase();
|
|
1101
|
+
}
|
|
1102
|
+
function buildParsedAnalysisFromPrecomputedFacts(fileFacts) {
|
|
1103
|
+
return {
|
|
1104
|
+
tests: fileFacts.tests.map((testFacts) => ({
|
|
1105
|
+
startLine: testFacts.startLine,
|
|
1106
|
+
endLine: testFacts.endLine,
|
|
1107
|
+
title: testFacts.title,
|
|
1108
|
+
assertionCount: testFacts.assertionCount,
|
|
1109
|
+
smartWaitCount: testFacts.smartWaitCount,
|
|
1110
|
+
hardWaitCount: testFacts.hardWaitCount,
|
|
1111
|
+
stepCount: testFacts.stepCount,
|
|
1112
|
+
directLocatorCount: testFacts.directLocatorCount,
|
|
1113
|
+
directPageActionCount: testFacts.directPageActionCount,
|
|
1114
|
+
stableSelectorCount: testFacts.stableSelectorCount,
|
|
1115
|
+
textSelectorCount: testFacts.textSelectorCount,
|
|
1116
|
+
fragileSelectorCount: testFacts.fragileSelectorCount,
|
|
1117
|
+
pomReferenceCount: testFacts.pomReferenceCount,
|
|
1118
|
+
pomFixtureReferenceCount: testFacts.pomFixtureReferenceCount,
|
|
1119
|
+
sharedStateMutationCount: testFacts.sharedStateMutationCount,
|
|
1120
|
+
usesPom: testFacts.usesPom,
|
|
1121
|
+
})),
|
|
1122
|
+
hasPomImports: fileFacts.hasPomImports,
|
|
1123
|
+
pomImportIdentifiers: [],
|
|
1124
|
+
beforeAllCount: fileFacts.beforeAllCount,
|
|
1125
|
+
beforeEachCount: fileFacts.beforeEachCount,
|
|
1126
|
+
serialModeCount: fileFacts.serialModeCount,
|
|
1127
|
+
topLevelMutableStateCount: fileFacts.topLevelMutableStateCount,
|
|
1128
|
+
topLevelMutableIdentifiers: [],
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
function collectReporterTestsByFile(tests) {
|
|
1132
|
+
const testsByFile = new Map();
|
|
1133
|
+
for (const test of tests) {
|
|
1134
|
+
const filePath = typeof test.location?.file === 'string' ? test.location.file.trim() : '';
|
|
1135
|
+
if (!filePath) {
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
const fileTests = testsByFile.get(filePath) ?? [];
|
|
1139
|
+
fileTests.push(test);
|
|
1140
|
+
testsByFile.set(filePath, fileTests);
|
|
1141
|
+
}
|
|
1142
|
+
return testsByFile;
|
|
1143
|
+
}
|
|
1144
|
+
function buildCodeQualityMetricsFromResolvedAnalyses(options) {
|
|
1145
|
+
const weightedTotals = {
|
|
1146
|
+
smellScore: 0,
|
|
1147
|
+
pomCompliancePercent: 0,
|
|
1148
|
+
assertionDensity: 0,
|
|
1149
|
+
waitStrategyScore: 0,
|
|
1150
|
+
stepGranularity: 0,
|
|
1151
|
+
isolationScore: 0,
|
|
1152
|
+
selectorStabilityPercent: 0,
|
|
1153
|
+
};
|
|
1154
|
+
const driverCounts = {
|
|
1155
|
+
hardWaits: 0,
|
|
1156
|
+
directLocators: 0,
|
|
1157
|
+
testsWithoutPom: 0,
|
|
1158
|
+
testsWithoutSteps: 0,
|
|
1159
|
+
fragileSelectors: 0,
|
|
1160
|
+
lowAssertionTests: 0,
|
|
1161
|
+
sharedStateSignals: 0,
|
|
1162
|
+
};
|
|
1163
|
+
let analyzedFiles = 0;
|
|
1164
|
+
let matchedTests = 0;
|
|
1165
|
+
const topRiskFiles = [...options.testsByFile.entries()]
|
|
1166
|
+
.map(([file, fileTests]) => {
|
|
1167
|
+
const parsedAnalysis = options.resolveAnalysis(file);
|
|
1168
|
+
if (!parsedAnalysis || parsedAnalysis.tests.length === 0) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
analyzedFiles += 1;
|
|
1172
|
+
const matchedSourceTests = matchReportTestsToSourceMetrics(fileTests, parsedAnalysis.tests);
|
|
1173
|
+
if (matchedSourceTests.length === 0) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
matchedTests += matchedSourceTests.length;
|
|
1177
|
+
const aggregate = buildCodeQualityAggregate(matchedSourceTests, parsedAnalysis);
|
|
1178
|
+
driverCounts.hardWaits += aggregate.totalHardWaits;
|
|
1179
|
+
driverCounts.directLocators += aggregate.totalDirectLocators + aggregate.totalDirectPageActions;
|
|
1180
|
+
driverCounts.testsWithoutPom += aggregate.testsWithoutPom;
|
|
1181
|
+
driverCounts.testsWithoutSteps += aggregate.testsWithoutSteps;
|
|
1182
|
+
driverCounts.fragileSelectors += aggregate.totalFragileSelectors;
|
|
1183
|
+
driverCounts.lowAssertionTests += aggregate.lowAssertionTests;
|
|
1184
|
+
driverCounts.sharedStateSignals += parsedAnalysis.beforeAllCount + parsedAnalysis.serialModeCount + parsedAnalysis.topLevelMutableStateCount + aggregate.totalSharedStateMutations;
|
|
1185
|
+
weightedTotals.smellScore += (aggregate.smellScore ?? 0) * matchedSourceTests.length;
|
|
1186
|
+
weightedTotals.pomCompliancePercent += (aggregate.pomCompliancePercent ?? 0) * matchedSourceTests.length;
|
|
1187
|
+
weightedTotals.assertionDensity += (aggregate.assertionDensity ?? 0) * matchedSourceTests.length;
|
|
1188
|
+
weightedTotals.waitStrategyScore += (aggregate.waitStrategyScore ?? 0) * matchedSourceTests.length;
|
|
1189
|
+
weightedTotals.stepGranularity += (aggregate.stepGranularity ?? 0) * matchedSourceTests.length;
|
|
1190
|
+
weightedTotals.isolationScore += (aggregate.isolationScore ?? 0) * matchedSourceTests.length;
|
|
1191
|
+
weightedTotals.selectorStabilityPercent += (aggregate.selectorStabilityPercent ?? 0) * matchedSourceTests.length;
|
|
1192
|
+
return {
|
|
1193
|
+
file,
|
|
1194
|
+
matchedTests: matchedSourceTests.length,
|
|
1195
|
+
declaredTests: parsedAnalysis.tests.length,
|
|
1196
|
+
smellScore: aggregate.smellScore,
|
|
1197
|
+
pomCompliancePercent: aggregate.pomCompliancePercent,
|
|
1198
|
+
assertionDensity: aggregate.assertionDensity,
|
|
1199
|
+
waitStrategyScore: aggregate.waitStrategyScore,
|
|
1200
|
+
stepGranularity: aggregate.stepGranularity,
|
|
1201
|
+
isolationScore: aggregate.isolationScore,
|
|
1202
|
+
selectorStabilityPercent: aggregate.selectorStabilityPercent,
|
|
1203
|
+
notableSignals: buildCodeQualityNotableSignals(aggregate, parsedAnalysis),
|
|
1204
|
+
sourceResolved: true,
|
|
1205
|
+
};
|
|
1206
|
+
})
|
|
1207
|
+
.filter((value) => value !== null)
|
|
1208
|
+
.sort((left, right) => {
|
|
1209
|
+
const leftScore = left.smellScore ?? 101;
|
|
1210
|
+
const rightScore = right.smellScore ?? 101;
|
|
1211
|
+
if (leftScore !== rightScore) {
|
|
1212
|
+
return leftScore - rightScore;
|
|
1213
|
+
}
|
|
1214
|
+
return right.matchedTests - left.matchedTests;
|
|
1215
|
+
})
|
|
1216
|
+
.slice(0, 6);
|
|
1217
|
+
if (matchedTests === 0) {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
analyzedFiles,
|
|
1222
|
+
matchedTests,
|
|
1223
|
+
analyzableTests: options.analyzableTests,
|
|
1224
|
+
sourceCoveragePercent: options.analyzableTests === 0 ? 0 : roundToOneDigit((matchedTests / options.analyzableTests) * 100),
|
|
1225
|
+
testSmellScore: roundToOneDigit(weightedTotals.smellScore / matchedTests),
|
|
1226
|
+
pomCompliancePercent: roundToOneDigit(weightedTotals.pomCompliancePercent / matchedTests),
|
|
1227
|
+
assertionDensity: roundToTwoDigits(weightedTotals.assertionDensity / matchedTests),
|
|
1228
|
+
waitStrategyScore: roundToOneDigit(weightedTotals.waitStrategyScore / matchedTests),
|
|
1229
|
+
stepGranularity: roundToTwoDigits(weightedTotals.stepGranularity / matchedTests),
|
|
1230
|
+
isolationScore: roundToOneDigit(weightedTotals.isolationScore / matchedTests),
|
|
1231
|
+
selectorStabilityPercent: roundToOneDigit(weightedTotals.selectorStabilityPercent / matchedTests),
|
|
1232
|
+
drivers: buildCodeQualityDrivers(driverCounts, matchedTests),
|
|
1233
|
+
topRiskFiles,
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
function buildCodeQualityMetrics(report, reportSourceFile) {
|
|
1237
|
+
const tests = report.tests ?? [];
|
|
1238
|
+
const analyzableTests = tests.filter((test) => typeof test.location?.file === 'string' && test.location.file.trim().length > 0).length;
|
|
1239
|
+
if (analyzableTests === 0) {
|
|
1240
|
+
return buildEmptyCodeQualityMetrics(0);
|
|
1241
|
+
}
|
|
1242
|
+
const testsByFile = collectReporterTestsByFile(tests);
|
|
1243
|
+
const precomputedSourceFacts = normalizePrecomputedSourceFacts(report.aqaPulseSourceFacts);
|
|
1244
|
+
if (precomputedSourceFacts) {
|
|
1245
|
+
const parsedAnalysesByFile = new Map();
|
|
1246
|
+
for (const fileFacts of precomputedSourceFacts.files) {
|
|
1247
|
+
parsedAnalysesByFile.set(normalizeSourceFactsFileIdentity(fileFacts.file), buildParsedAnalysisFromPrecomputedFacts(fileFacts));
|
|
1248
|
+
}
|
|
1249
|
+
const precomputedMetrics = buildCodeQualityMetricsFromResolvedAnalyses({
|
|
1250
|
+
testsByFile,
|
|
1251
|
+
analyzableTests,
|
|
1252
|
+
resolveAnalysis(file) {
|
|
1253
|
+
return parsedAnalysesByFile.get(normalizeSourceFactsFileIdentity(file)) ?? null;
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
if (precomputedMetrics) {
|
|
1257
|
+
return precomputedMetrics;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
const typeScriptModule = getTypeScriptModule();
|
|
1261
|
+
if (!typeScriptModule) {
|
|
1262
|
+
return buildEmptyCodeQualityMetrics(analyzableTests);
|
|
1263
|
+
}
|
|
1264
|
+
const sourceAnalysisMetrics = buildCodeQualityMetricsFromResolvedAnalyses({
|
|
1265
|
+
testsByFile,
|
|
1266
|
+
analyzableTests,
|
|
1267
|
+
resolveAnalysis(file) {
|
|
1268
|
+
const resolvedPath = resolveTestSourcePath(file, reportSourceFile);
|
|
1269
|
+
if (!resolvedPath) {
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
return analyzeSourceFile(resolvedPath, typeScriptModule);
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
return sourceAnalysisMetrics ?? buildEmptyCodeQualityMetrics(analyzableTests);
|
|
1276
|
+
}
|
|
1277
|
+
function analyzeSourceFile(filePath, typeScriptModule) {
|
|
1278
|
+
try {
|
|
1279
|
+
const sourceText = fs.readFileSync(filePath, 'utf8');
|
|
1280
|
+
const sourceFile = typeScriptModule.createSourceFile(filePath, sourceText, typeScriptModule.ScriptTarget.Latest, true, filePath.endsWith('.tsx') ? typeScriptModule.ScriptKind.TSX : typeScriptModule.ScriptKind.TS);
|
|
1281
|
+
let hasPomImports = false;
|
|
1282
|
+
let pomImportIdentifiers = new Set();
|
|
1283
|
+
let beforeAllCount = 0;
|
|
1284
|
+
let beforeEachCount = 0;
|
|
1285
|
+
let serialModeCount = 0;
|
|
1286
|
+
let topLevelMutableStateCount = 0;
|
|
1287
|
+
let topLevelMutableIdentifiers = new Set();
|
|
1288
|
+
const tests = [];
|
|
1289
|
+
for (const statement of sourceFile.statements) {
|
|
1290
|
+
if (typeScriptModule.isImportDeclaration(statement)) {
|
|
1291
|
+
const importPath = statement.moduleSpecifier.getText(sourceFile).slice(1, -1);
|
|
1292
|
+
if (isPomImportPath(importPath)) {
|
|
1293
|
+
hasPomImports = true;
|
|
1294
|
+
if (statement.importClause?.name) {
|
|
1295
|
+
pomImportIdentifiers.add(statement.importClause.name.text);
|
|
1296
|
+
}
|
|
1297
|
+
if (statement.importClause?.namedBindings && typeScriptModule.isNamedImports(statement.importClause.namedBindings)) {
|
|
1298
|
+
for (const element of statement.importClause.namedBindings.elements) {
|
|
1299
|
+
pomImportIdentifiers.add(element.name.text);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (typeScriptModule.isVariableStatement(statement)) {
|
|
1306
|
+
const declarationFlags = statement.declarationList.flags;
|
|
1307
|
+
const isConst = (declarationFlags & typeScriptModule.NodeFlags.Const) !== 0;
|
|
1308
|
+
if (!isConst) {
|
|
1309
|
+
topLevelMutableStateCount += statement.declarationList.declarations.length;
|
|
1310
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1311
|
+
if (typeScriptModule.isIdentifier(declaration.name)) {
|
|
1312
|
+
topLevelMutableIdentifiers.add(declaration.name.text);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
visitCallExpressions(sourceFile, typeScriptModule, (callExpression) => {
|
|
1319
|
+
if (getCallExpressionName(callExpression, typeScriptModule) === 'beforeAll') {
|
|
1320
|
+
beforeAllCount += 1;
|
|
1321
|
+
}
|
|
1322
|
+
if (getCallExpressionName(callExpression, typeScriptModule) === 'beforeEach') {
|
|
1323
|
+
beforeEachCount += 1;
|
|
1324
|
+
}
|
|
1325
|
+
if (isSerialConfigureCall(callExpression, sourceFile, typeScriptModule)) {
|
|
1326
|
+
serialModeCount += 1;
|
|
1327
|
+
}
|
|
1328
|
+
if (!isTestDeclarationCall(callExpression, typeScriptModule)) {
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const callback = getTestCallback(callExpression, typeScriptModule);
|
|
1332
|
+
if (!callback) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
tests.push(collectSourceTestMetric(callback, sourceFile, typeScriptModule, {
|
|
1336
|
+
hasPomImports,
|
|
1337
|
+
pomImportIdentifiers,
|
|
1338
|
+
topLevelMutableIdentifiers,
|
|
1339
|
+
}));
|
|
1340
|
+
});
|
|
1341
|
+
return {
|
|
1342
|
+
tests,
|
|
1343
|
+
hasPomImports,
|
|
1344
|
+
pomImportIdentifiers: [...pomImportIdentifiers],
|
|
1345
|
+
beforeAllCount,
|
|
1346
|
+
beforeEachCount,
|
|
1347
|
+
serialModeCount,
|
|
1348
|
+
topLevelMutableStateCount,
|
|
1349
|
+
topLevelMutableIdentifiers: [...topLevelMutableIdentifiers],
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function collectSourceTestMetric(callback, sourceFile, typeScriptModule, context) {
|
|
1357
|
+
let assertionCount = 0;
|
|
1358
|
+
let smartWaitCount = 0;
|
|
1359
|
+
let hardWaitCount = 0;
|
|
1360
|
+
let stepCount = 0;
|
|
1361
|
+
let directLocatorCount = 0;
|
|
1362
|
+
let directPageActionCount = 0;
|
|
1363
|
+
let stableSelectorCount = 0;
|
|
1364
|
+
let textSelectorCount = 0;
|
|
1365
|
+
let fragileSelectorCount = 0;
|
|
1366
|
+
let pomReferenceCount = 0;
|
|
1367
|
+
let pomFixtureReferenceCount = 0;
|
|
1368
|
+
let sharedStateMutationCount = 0;
|
|
1369
|
+
const pomFixtureNames = new Set(getPomFixtureNames(callback, typeScriptModule));
|
|
1370
|
+
visitCallExpressions(callback.body, typeScriptModule, (callExpression) => {
|
|
1371
|
+
if (isExpectCall(callExpression, typeScriptModule)) {
|
|
1372
|
+
assertionCount += 1;
|
|
1373
|
+
smartWaitCount += 1;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (isTestStepCall(callExpression, typeScriptModule)) {
|
|
1377
|
+
stepCount += 1;
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const callName = getCallExpressionName(callExpression, typeScriptModule);
|
|
1381
|
+
if (!callName) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (callName === 'waitForTimeout') {
|
|
1385
|
+
hardWaitCount += 1;
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (SMART_WAIT_METHODS.has(callName)) {
|
|
1389
|
+
smartWaitCount += 1;
|
|
1390
|
+
}
|
|
1391
|
+
if (isDirectPageActionCall(callExpression, typeScriptModule)) {
|
|
1392
|
+
directPageActionCount += 1;
|
|
1393
|
+
}
|
|
1394
|
+
if (isPomInteractionCall(callExpression, typeScriptModule, context.pomImportIdentifiers, pomFixtureNames)) {
|
|
1395
|
+
if (isFixtureBackedPomCall(callExpression, typeScriptModule, pomFixtureNames)) {
|
|
1396
|
+
pomFixtureReferenceCount += 1;
|
|
1397
|
+
}
|
|
1398
|
+
else {
|
|
1399
|
+
pomReferenceCount += 1;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (isSharedMutableMutationCall(callExpression, typeScriptModule, context.topLevelMutableIdentifiers)) {
|
|
1403
|
+
sharedStateMutationCount += 1;
|
|
1404
|
+
}
|
|
1405
|
+
if (DIRECT_LOCATOR_METHODS.has(callName)) {
|
|
1406
|
+
directLocatorCount += 1;
|
|
1407
|
+
if (STABLE_LOCATOR_METHODS.has(callName)) {
|
|
1408
|
+
stableSelectorCount += 1;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (TEXT_LOCATOR_METHODS.has(callName)) {
|
|
1412
|
+
textSelectorCount += 1;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const selectorKind = classifySelectorLiteral(readFirstStringArgument(callExpression, sourceFile, typeScriptModule));
|
|
1416
|
+
if (selectorKind === 'stable') {
|
|
1417
|
+
stableSelectorCount += 1;
|
|
1418
|
+
}
|
|
1419
|
+
else if (selectorKind === 'text') {
|
|
1420
|
+
textSelectorCount += 1;
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
fragileSelectorCount += 1;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
visitNodes(callback.body, typeScriptModule, (node) => {
|
|
1428
|
+
if (typeScriptModule.isIdentifier(node) && context.pomImportIdentifiers.has(node.text)) {
|
|
1429
|
+
pomReferenceCount += 1;
|
|
1430
|
+
}
|
|
1431
|
+
if (typeScriptModule.isIdentifier(node) && pomFixtureNames.has(node.text)) {
|
|
1432
|
+
pomFixtureReferenceCount += 1;
|
|
1433
|
+
}
|
|
1434
|
+
if (isSharedMutableAssignment(node, typeScriptModule, context.topLevelMutableIdentifiers)) {
|
|
1435
|
+
sharedStateMutationCount += 1;
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
const startLine = sourceFile.getLineAndCharacterOfPosition(callback.getStart(sourceFile)).line + 1;
|
|
1439
|
+
const endLine = sourceFile.getLineAndCharacterOfPosition(callback.getEnd()).line + 1;
|
|
1440
|
+
return {
|
|
1441
|
+
startLine,
|
|
1442
|
+
endLine,
|
|
1443
|
+
title: null,
|
|
1444
|
+
assertionCount,
|
|
1445
|
+
smartWaitCount,
|
|
1446
|
+
hardWaitCount,
|
|
1447
|
+
stepCount,
|
|
1448
|
+
directLocatorCount,
|
|
1449
|
+
directPageActionCount,
|
|
1450
|
+
stableSelectorCount,
|
|
1451
|
+
textSelectorCount,
|
|
1452
|
+
fragileSelectorCount,
|
|
1453
|
+
pomReferenceCount,
|
|
1454
|
+
pomFixtureReferenceCount,
|
|
1455
|
+
sharedStateMutationCount,
|
|
1456
|
+
usesPom: evaluatePomUsage({
|
|
1457
|
+
hasPomImports: context.hasPomImports,
|
|
1458
|
+
pomReferenceCount,
|
|
1459
|
+
pomFixtureReferenceCount,
|
|
1460
|
+
directLocatorCount,
|
|
1461
|
+
directPageActionCount,
|
|
1462
|
+
}),
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function visitCallExpressions(node, typeScriptModule, callback) {
|
|
1466
|
+
const visit = (currentNode) => {
|
|
1467
|
+
if (typeScriptModule.isCallExpression(currentNode)) {
|
|
1468
|
+
callback(currentNode);
|
|
1469
|
+
}
|
|
1470
|
+
typeScriptModule.forEachChild(currentNode, visit);
|
|
1471
|
+
};
|
|
1472
|
+
visit(node);
|
|
1473
|
+
}
|
|
1474
|
+
function visitNodes(node, typeScriptModule, callback) {
|
|
1475
|
+
const visit = (currentNode) => {
|
|
1476
|
+
callback(currentNode);
|
|
1477
|
+
typeScriptModule.forEachChild(currentNode, visit);
|
|
1478
|
+
};
|
|
1479
|
+
visit(node);
|
|
1480
|
+
}
|
|
1481
|
+
function isPomImportPath(importPath) {
|
|
1482
|
+
return /(^|\/)(pages?|page-objects?|pageobjects?|pom|screen-objects?|page-models?)(\/|$)/i.test(importPath);
|
|
1483
|
+
}
|
|
1484
|
+
function isTestDeclarationCall(callExpression, typeScriptModule) {
|
|
1485
|
+
const expression = callExpression.expression;
|
|
1486
|
+
if (typeScriptModule.isIdentifier(expression)) {
|
|
1487
|
+
return expression.text === 'test' || expression.text === 'it';
|
|
1488
|
+
}
|
|
1489
|
+
if (typeScriptModule.isPropertyAccessExpression(expression) && typeScriptModule.isIdentifier(expression.expression)) {
|
|
1490
|
+
return (expression.expression.text === 'test' || expression.expression.text === 'it')
|
|
1491
|
+
&& ['only', 'skip', 'fixme', 'fail'].includes(expression.name.text);
|
|
1492
|
+
}
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
function getTestCallback(callExpression, typeScriptModule) {
|
|
1496
|
+
const callbackCandidate = [...callExpression.arguments]
|
|
1497
|
+
.reverse()
|
|
1498
|
+
.find((argument) => typeScriptModule.isArrowFunction(argument) || typeScriptModule.isFunctionExpression(argument));
|
|
1499
|
+
if (!callbackCandidate) {
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
return callbackCandidate;
|
|
1503
|
+
}
|
|
1504
|
+
function getPomFixtureNames(callback, typeScriptModule) {
|
|
1505
|
+
const firstParameter = callback.parameters[0];
|
|
1506
|
+
if (!firstParameter || !typeScriptModule.isObjectBindingPattern(firstParameter.name)) {
|
|
1507
|
+
return [];
|
|
1508
|
+
}
|
|
1509
|
+
return firstParameter.name.elements
|
|
1510
|
+
.map((element) => typeScriptModule.isIdentifier(element.name) ? element.name.text : null)
|
|
1511
|
+
.filter((value) => typeof value === 'string')
|
|
1512
|
+
.filter((value) => isPomLikeIdentifier(value));
|
|
1513
|
+
}
|
|
1514
|
+
function isTestStepCall(callExpression, typeScriptModule) {
|
|
1515
|
+
return typeScriptModule.isPropertyAccessExpression(callExpression.expression)
|
|
1516
|
+
&& typeScriptModule.isIdentifier(callExpression.expression.expression)
|
|
1517
|
+
&& callExpression.expression.expression.text === 'test'
|
|
1518
|
+
&& callExpression.expression.name.text === 'step';
|
|
1519
|
+
}
|
|
1520
|
+
function isExpectCall(callExpression, typeScriptModule) {
|
|
1521
|
+
if (typeScriptModule.isIdentifier(callExpression.expression)) {
|
|
1522
|
+
return callExpression.expression.text === 'expect';
|
|
1523
|
+
}
|
|
1524
|
+
return typeScriptModule.isPropertyAccessExpression(callExpression.expression)
|
|
1525
|
+
&& typeScriptModule.isIdentifier(callExpression.expression.expression)
|
|
1526
|
+
&& callExpression.expression.expression.text === 'expect'
|
|
1527
|
+
&& ['soft', 'poll'].includes(callExpression.expression.name.text);
|
|
1528
|
+
}
|
|
1529
|
+
function isSerialConfigureCall(callExpression, sourceFile, typeScriptModule) {
|
|
1530
|
+
if (!typeScriptModule.isPropertyAccessExpression(callExpression.expression)) {
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
const objectExpression = callExpression.expression.expression;
|
|
1534
|
+
if (!typeScriptModule.isIdentifier(objectExpression) || objectExpression.text !== 'test' || callExpression.expression.name.text !== 'describe') {
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
return callExpression.arguments.some((argument) => argument.getText(sourceFile).includes('serial'));
|
|
1538
|
+
}
|
|
1539
|
+
function getCallExpressionName(callExpression, typeScriptModule) {
|
|
1540
|
+
const expression = callExpression.expression;
|
|
1541
|
+
if (typeScriptModule.isIdentifier(expression)) {
|
|
1542
|
+
return expression.text;
|
|
1543
|
+
}
|
|
1544
|
+
if (typeScriptModule.isPropertyAccessExpression(expression)) {
|
|
1545
|
+
return expression.name.text;
|
|
1546
|
+
}
|
|
1547
|
+
return null;
|
|
1548
|
+
}
|
|
1549
|
+
function isDirectPageActionCall(callExpression, typeScriptModule) {
|
|
1550
|
+
return typeScriptModule.isPropertyAccessExpression(callExpression.expression)
|
|
1551
|
+
&& typeScriptModule.isIdentifier(callExpression.expression.expression)
|
|
1552
|
+
&& callExpression.expression.expression.text === 'page'
|
|
1553
|
+
&& PAGE_ACTION_METHODS.has(callExpression.expression.name.text);
|
|
1554
|
+
}
|
|
1555
|
+
function isPomInteractionCall(callExpression, typeScriptModule, pomImportIdentifiers, pomFixtureNames) {
|
|
1556
|
+
if (!typeScriptModule.isPropertyAccessExpression(callExpression.expression)) {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
const target = callExpression.expression.expression;
|
|
1560
|
+
if (!typeScriptModule.isIdentifier(target)) {
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
return pomImportIdentifiers.has(target.text)
|
|
1564
|
+
|| pomFixtureNames.has(target.text)
|
|
1565
|
+
|| isPomLikeIdentifier(target.text);
|
|
1566
|
+
}
|
|
1567
|
+
function isFixtureBackedPomCall(callExpression, typeScriptModule, pomFixtureNames) {
|
|
1568
|
+
return typeScriptModule.isPropertyAccessExpression(callExpression.expression)
|
|
1569
|
+
&& typeScriptModule.isIdentifier(callExpression.expression.expression)
|
|
1570
|
+
&& pomFixtureNames.has(callExpression.expression.expression.text);
|
|
1571
|
+
}
|
|
1572
|
+
function isSharedMutableMutationCall(callExpression, typeScriptModule, topLevelMutableIdentifiers) {
|
|
1573
|
+
if (!typeScriptModule.isPropertyAccessExpression(callExpression.expression)) {
|
|
1574
|
+
return false;
|
|
1575
|
+
}
|
|
1576
|
+
const target = callExpression.expression.expression;
|
|
1577
|
+
return typeScriptModule.isIdentifier(target)
|
|
1578
|
+
&& topLevelMutableIdentifiers.has(target.text)
|
|
1579
|
+
&& SHARED_MUTATION_METHODS.has(callExpression.expression.name.text);
|
|
1580
|
+
}
|
|
1581
|
+
function isSharedMutableAssignment(node, typeScriptModule, topLevelMutableIdentifiers) {
|
|
1582
|
+
if (typeScriptModule.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind, typeScriptModule)) {
|
|
1583
|
+
return typeScriptModule.isIdentifier(node.left) && topLevelMutableIdentifiers.has(node.left.text);
|
|
1584
|
+
}
|
|
1585
|
+
if (typeScriptModule.isPrefixUnaryExpression(node) || typeScriptModule.isPostfixUnaryExpression(node)) {
|
|
1586
|
+
const operand = node.operand;
|
|
1587
|
+
return typeScriptModule.isIdentifier(operand)
|
|
1588
|
+
&& topLevelMutableIdentifiers.has(operand.text)
|
|
1589
|
+
&& (node.operator === typeScriptModule.SyntaxKind.PlusPlusToken || node.operator === typeScriptModule.SyntaxKind.MinusMinusToken);
|
|
1590
|
+
}
|
|
1591
|
+
return false;
|
|
1592
|
+
}
|
|
1593
|
+
function isAssignmentOperator(kind, typeScriptModule) {
|
|
1594
|
+
return kind >= typeScriptModule.SyntaxKind.FirstAssignment && kind <= typeScriptModule.SyntaxKind.LastAssignment;
|
|
1595
|
+
}
|
|
1596
|
+
function isPomLikeIdentifier(value) {
|
|
1597
|
+
return /(?:page|screen|modal|dialog|drawer|form|flow|widget|section|panel|steps|po|model)$/i.test(value)
|
|
1598
|
+
&& value.toLowerCase() !== 'page';
|
|
1599
|
+
}
|
|
1600
|
+
function evaluatePomUsage(input) {
|
|
1601
|
+
const pomSignals = input.pomReferenceCount + input.pomFixtureReferenceCount;
|
|
1602
|
+
const directSignals = input.directLocatorCount + input.directPageActionCount;
|
|
1603
|
+
if (pomSignals >= 2 && directSignals <= 4) {
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
if (input.pomFixtureReferenceCount > 0 && directSignals <= 3) {
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
if (input.hasPomImports && pomSignals > 0 && input.directLocatorCount <= 1 && input.directPageActionCount <= 2) {
|
|
1610
|
+
return true;
|
|
1611
|
+
}
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
function readFirstStringArgument(callExpression, sourceFile, typeScriptModule) {
|
|
1615
|
+
const firstArgument = callExpression.arguments[0];
|
|
1616
|
+
if (!firstArgument) {
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
if (typeScriptModule.isStringLiteral(firstArgument) || typeScriptModule.isNoSubstitutionTemplateLiteral(firstArgument)) {
|
|
1620
|
+
return firstArgument.text;
|
|
1621
|
+
}
|
|
1622
|
+
const rawText = firstArgument.getText(sourceFile);
|
|
1623
|
+
return rawText.length > 0 ? rawText : null;
|
|
1624
|
+
}
|
|
1625
|
+
function classifySelectorLiteral(selector) {
|
|
1626
|
+
if (!selector) {
|
|
1627
|
+
return 'fragile';
|
|
1628
|
+
}
|
|
1629
|
+
const normalizedSelector = selector.trim().toLowerCase();
|
|
1630
|
+
if (/data-testid|data-test|qa-id|testid/.test(normalizedSelector)) {
|
|
1631
|
+
return 'stable';
|
|
1632
|
+
}
|
|
1633
|
+
if (/text=|has-text|:text|\btext\(/.test(normalizedSelector)) {
|
|
1634
|
+
return 'text';
|
|
1635
|
+
}
|
|
1636
|
+
if (/^\/\/|^xpath=|nth-child|:nth|\s>\s|\.[a-z0-9_-]+\.[a-z0-9_.-]+|\[class|\.filter-option|\.btn|\.button/.test(normalizedSelector)) {
|
|
1637
|
+
return 'fragile';
|
|
1638
|
+
}
|
|
1639
|
+
return normalizedSelector.includes('#') ? 'stable' : 'fragile';
|
|
1640
|
+
}
|
|
1641
|
+
function resolveTestSourcePath(filePath, reportSourceFile) {
|
|
1642
|
+
const normalizedPath = filePath.trim();
|
|
1643
|
+
if (!normalizedPath) {
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
1646
|
+
const candidatePaths = new Set();
|
|
1647
|
+
const baseDirectories = new Set();
|
|
1648
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
1649
|
+
const workingDirectory = process.cwd();
|
|
1650
|
+
if (reportSourceFile) {
|
|
1651
|
+
baseDirectories.add(path.dirname(reportSourceFile));
|
|
1652
|
+
baseDirectories.add(path.resolve(path.dirname(reportSourceFile), '..'));
|
|
1653
|
+
baseDirectories.add(path.resolve(path.dirname(reportSourceFile), '../..'));
|
|
1654
|
+
}
|
|
1655
|
+
baseDirectories.add(workingDirectory);
|
|
1656
|
+
baseDirectories.add(path.resolve(workingDirectory, '..'));
|
|
1657
|
+
baseDirectories.add(path.resolve(workingDirectory, '../..'));
|
|
1658
|
+
baseDirectories.add(packageRoot);
|
|
1659
|
+
baseDirectories.add(path.resolve(packageRoot, '..'));
|
|
1660
|
+
baseDirectories.add(path.resolve(packageRoot, '../..'));
|
|
1661
|
+
if (path.isAbsolute(normalizedPath)) {
|
|
1662
|
+
candidatePaths.add(normalizedPath);
|
|
1663
|
+
}
|
|
1664
|
+
for (const baseDirectory of baseDirectories) {
|
|
1665
|
+
candidatePaths.add(path.resolve(baseDirectory, normalizedPath));
|
|
1666
|
+
}
|
|
1667
|
+
for (const candidatePath of candidatePaths) {
|
|
1668
|
+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) {
|
|
1669
|
+
return candidatePath;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
function matchReportTestsToSourceMetrics(reporterTests, sourceTests) {
|
|
1675
|
+
if (sourceTests.length === 0) {
|
|
1676
|
+
return [];
|
|
1677
|
+
}
|
|
1678
|
+
return reporterTests.map((test, index) => sourceTests[findBestSourceMetricIndex(test, index, sourceTests)] ?? sourceTests[Math.min(index, sourceTests.length - 1)]);
|
|
1679
|
+
}
|
|
1680
|
+
function findBestSourceMetricIndex(test, fallbackIndex, sourceTests) {
|
|
1681
|
+
const lineNumber = typeof test.location?.line === 'number' && Number.isFinite(test.location.line)
|
|
1682
|
+
? Math.max(1, Math.trunc(test.location.line))
|
|
1683
|
+
: null;
|
|
1684
|
+
if (lineNumber === null) {
|
|
1685
|
+
return Math.min(fallbackIndex, sourceTests.length - 1);
|
|
1686
|
+
}
|
|
1687
|
+
const containingIndex = sourceTests.findIndex((sourceTest) => lineNumber >= sourceTest.startLine && lineNumber <= sourceTest.endLine);
|
|
1688
|
+
if (containingIndex >= 0) {
|
|
1689
|
+
return containingIndex;
|
|
1690
|
+
}
|
|
1691
|
+
let bestIndex = 0;
|
|
1692
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
1693
|
+
sourceTests.forEach((sourceTest, index) => {
|
|
1694
|
+
const distance = Math.abs(sourceTest.startLine - lineNumber);
|
|
1695
|
+
if (distance < bestDistance) {
|
|
1696
|
+
bestDistance = distance;
|
|
1697
|
+
bestIndex = index;
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
return bestIndex;
|
|
1701
|
+
}
|
|
1702
|
+
function buildCodeQualityAggregate(tests, analysis) {
|
|
1703
|
+
const testCount = tests.length;
|
|
1704
|
+
if (testCount === 0) {
|
|
1705
|
+
return {
|
|
1706
|
+
testCount: 0,
|
|
1707
|
+
testsWithoutPom: 0,
|
|
1708
|
+
testsWithoutSteps: 0,
|
|
1709
|
+
lowAssertionTests: 0,
|
|
1710
|
+
totalAssertions: 0,
|
|
1711
|
+
totalSmartWaits: 0,
|
|
1712
|
+
totalHardWaits: 0,
|
|
1713
|
+
totalSteps: 0,
|
|
1714
|
+
totalDirectLocators: 0,
|
|
1715
|
+
totalDirectPageActions: 0,
|
|
1716
|
+
totalStableSelectors: 0,
|
|
1717
|
+
totalTextSelectors: 0,
|
|
1718
|
+
totalFragileSelectors: 0,
|
|
1719
|
+
totalPomReferences: 0,
|
|
1720
|
+
totalPomFixtureReferences: 0,
|
|
1721
|
+
totalSharedStateMutations: 0,
|
|
1722
|
+
smellScore: null,
|
|
1723
|
+
pomCompliancePercent: null,
|
|
1724
|
+
assertionDensity: null,
|
|
1725
|
+
waitStrategyScore: null,
|
|
1726
|
+
stepGranularity: null,
|
|
1727
|
+
isolationScore: null,
|
|
1728
|
+
selectorStabilityPercent: null,
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
const testsWithoutPom = tests.filter((test) => !test.usesPom).length;
|
|
1732
|
+
const testsWithoutSteps = tests.filter((test) => test.stepCount === 0).length;
|
|
1733
|
+
const lowAssertionTests = tests.filter((test) => test.assertionCount < 2).length;
|
|
1734
|
+
const totalAssertions = sum(tests.map((test) => test.assertionCount));
|
|
1735
|
+
const totalSmartWaits = sum(tests.map((test) => test.smartWaitCount));
|
|
1736
|
+
const totalHardWaits = sum(tests.map((test) => test.hardWaitCount));
|
|
1737
|
+
const totalSteps = sum(tests.map((test) => test.stepCount));
|
|
1738
|
+
const totalDirectLocators = sum(tests.map((test) => test.directLocatorCount));
|
|
1739
|
+
const totalDirectPageActions = sum(tests.map((test) => test.directPageActionCount));
|
|
1740
|
+
const totalStableSelectors = sum(tests.map((test) => test.stableSelectorCount));
|
|
1741
|
+
const totalTextSelectors = sum(tests.map((test) => test.textSelectorCount));
|
|
1742
|
+
const totalFragileSelectors = sum(tests.map((test) => test.fragileSelectorCount));
|
|
1743
|
+
const totalPomReferences = sum(tests.map((test) => test.pomReferenceCount));
|
|
1744
|
+
const totalPomFixtureReferences = sum(tests.map((test) => test.pomFixtureReferenceCount));
|
|
1745
|
+
const totalSharedStateMutations = sum(tests.map((test) => test.sharedStateMutationCount));
|
|
1746
|
+
const pomCompliancePercent = roundToOneDigit(((testCount - testsWithoutPom) / testCount) * 100);
|
|
1747
|
+
const assertionDensity = roundToTwoDigits(totalAssertions / testCount);
|
|
1748
|
+
const totalWaitSignals = totalSmartWaits + totalHardWaits;
|
|
1749
|
+
const waitStrategyScore = totalWaitSignals === 0 ? 100 : roundToOneDigit((totalSmartWaits / totalWaitSignals) * 100);
|
|
1750
|
+
const stepGranularity = roundToTwoDigits(totalSteps / testCount);
|
|
1751
|
+
const totalSelectorSignals = totalStableSelectors + totalTextSelectors + totalFragileSelectors;
|
|
1752
|
+
const selectorStabilityPercent = totalSelectorSignals === 0
|
|
1753
|
+
? 100
|
|
1754
|
+
: roundToOneDigit((((totalStableSelectors * 1) + (totalTextSelectors * 0.65) + (totalFragileSelectors * 0.2)) / totalSelectorSignals) * 100);
|
|
1755
|
+
const hardWaitRatio = totalWaitSignals === 0 ? 0 : totalHardWaits / totalWaitSignals;
|
|
1756
|
+
const directLocatorDensity = Math.min((totalDirectLocators + (totalDirectPageActions * 0.7)) / Math.max(testCount * 4, 1), 1);
|
|
1757
|
+
const noPomRatio = testsWithoutPom / testCount;
|
|
1758
|
+
const noStepRatio = testsWithoutSteps / testCount;
|
|
1759
|
+
const lowAssertionRatio = lowAssertionTests / testCount;
|
|
1760
|
+
const fragileSelectorRatio = totalSelectorSignals === 0 ? 0 : totalFragileSelectors / totalSelectorSignals;
|
|
1761
|
+
const sharedStatePenalty = Math.min(Math.max(analysis.beforeAllCount * 15
|
|
1762
|
+
+ analysis.serialModeCount * 20
|
|
1763
|
+
+ analysis.topLevelMutableStateCount * 10
|
|
1764
|
+
+ totalSharedStateMutations * 12
|
|
1765
|
+
- analysis.beforeEachCount * 6, 0), 70);
|
|
1766
|
+
const smellScore = roundToOneDigit(clampScore(100
|
|
1767
|
+
- (hardWaitRatio * 28 * 100)
|
|
1768
|
+
- (directLocatorDensity * 18 * 100)
|
|
1769
|
+
- (noPomRatio * 18 * 100)
|
|
1770
|
+
- (noStepRatio * 12 * 100)
|
|
1771
|
+
- (lowAssertionRatio * 12 * 100)
|
|
1772
|
+
- (fragileSelectorRatio * 12 * 100)
|
|
1773
|
+
- sharedStatePenalty));
|
|
1774
|
+
const isolationScore = roundToOneDigit(clampScore(100 - sharedStatePenalty));
|
|
1775
|
+
return {
|
|
1776
|
+
testCount,
|
|
1777
|
+
testsWithoutPom,
|
|
1778
|
+
testsWithoutSteps,
|
|
1779
|
+
lowAssertionTests,
|
|
1780
|
+
totalAssertions,
|
|
1781
|
+
totalSmartWaits,
|
|
1782
|
+
totalHardWaits,
|
|
1783
|
+
totalSteps,
|
|
1784
|
+
totalDirectLocators,
|
|
1785
|
+
totalDirectPageActions,
|
|
1786
|
+
totalStableSelectors,
|
|
1787
|
+
totalTextSelectors,
|
|
1788
|
+
totalFragileSelectors,
|
|
1789
|
+
totalPomReferences,
|
|
1790
|
+
totalPomFixtureReferences,
|
|
1791
|
+
totalSharedStateMutations,
|
|
1792
|
+
smellScore,
|
|
1793
|
+
pomCompliancePercent,
|
|
1794
|
+
assertionDensity,
|
|
1795
|
+
waitStrategyScore,
|
|
1796
|
+
stepGranularity,
|
|
1797
|
+
isolationScore,
|
|
1798
|
+
selectorStabilityPercent,
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
function buildCodeQualityNotableSignals(aggregate, analysis) {
|
|
1802
|
+
const signals = [
|
|
1803
|
+
{ label: 'waitForTimeout', count: aggregate.totalHardWaits },
|
|
1804
|
+
{ label: 'direct locators', count: aggregate.totalDirectLocators + aggregate.totalDirectPageActions },
|
|
1805
|
+
{ label: 'fragile selectors', count: aggregate.totalFragileSelectors },
|
|
1806
|
+
{ label: 'tests without test.step', count: aggregate.testsWithoutSteps },
|
|
1807
|
+
{ label: 'tests without POM', count: aggregate.testsWithoutPom },
|
|
1808
|
+
{ label: 'shared state / beforeAll', count: analysis.beforeAllCount + analysis.serialModeCount + analysis.topLevelMutableStateCount + aggregate.totalSharedStateMutations },
|
|
1809
|
+
];
|
|
1810
|
+
return signals
|
|
1811
|
+
.filter((signal) => signal.count > 0)
|
|
1812
|
+
.sort((left, right) => right.count - left.count)
|
|
1813
|
+
.slice(0, 3)
|
|
1814
|
+
.map((signal) => `${signal.label} × ${signal.count}`);
|
|
1815
|
+
}
|
|
1816
|
+
function buildCodeQualityDrivers(driverCounts, matchedTests) {
|
|
1817
|
+
const buildImpact = (count) => {
|
|
1818
|
+
const ratio = matchedTests === 0 ? 0 : count / matchedTests;
|
|
1819
|
+
if (ratio >= 1 || count >= 8) {
|
|
1820
|
+
return 'high';
|
|
1821
|
+
}
|
|
1822
|
+
if (ratio >= 0.35 || count >= 3) {
|
|
1823
|
+
return 'medium';
|
|
1824
|
+
}
|
|
1825
|
+
return 'low';
|
|
1826
|
+
};
|
|
1827
|
+
return [
|
|
1828
|
+
{
|
|
1829
|
+
label: 'waitForTimeout и жёсткие паузы',
|
|
1830
|
+
count: driverCounts.hardWaits,
|
|
1831
|
+
impact: buildImpact(driverCounts.hardWaits),
|
|
1832
|
+
hint: 'Жёсткие ожидания хуже переживают колебания UI и чаще приводят к flaky-поведению.',
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
label: 'Прямые locator-вызовы в spec',
|
|
1836
|
+
count: driverCounts.directLocators,
|
|
1837
|
+
impact: buildImpact(driverCounts.directLocators),
|
|
1838
|
+
hint: 'Большой объём locator-логики прямо в тестах обычно указывает на низкую переиспользуемость и слабую изоляцию.',
|
|
1839
|
+
},
|
|
1840
|
+
{
|
|
1841
|
+
label: 'Тесты без POM-сигнала',
|
|
1842
|
+
count: driverCounts.testsWithoutPom,
|
|
1843
|
+
impact: buildImpact(driverCounts.testsWithoutPom),
|
|
1844
|
+
hint: 'Если сценарий не проходит через page object / screen-model слой, поддержка и миграции UI обычно дорожают.',
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
label: 'Тесты без test.step',
|
|
1848
|
+
count: driverCounts.testsWithoutSteps,
|
|
1849
|
+
impact: buildImpact(driverCounts.testsWithoutSteps),
|
|
1850
|
+
hint: 'Без явной step-структуры сложнее читать отчёты и локализовать первичную точку сбоя.',
|
|
1851
|
+
},
|
|
1852
|
+
{
|
|
1853
|
+
label: 'Хрупкие CSS/XPath селекторы',
|
|
1854
|
+
count: driverCounts.fragileSelectors,
|
|
1855
|
+
impact: buildImpact(driverCounts.fragileSelectors),
|
|
1856
|
+
hint: 'Длинные CSS-цепочки, XPath и nth-child дают высокий риск ложных падений при изменении верстки.',
|
|
1857
|
+
},
|
|
1858
|
+
{
|
|
1859
|
+
label: 'Слабая assertion coverage',
|
|
1860
|
+
count: driverCounts.lowAssertionTests,
|
|
1861
|
+
impact: buildImpact(driverCounts.lowAssertionTests),
|
|
1862
|
+
hint: 'Низкая плотность expect() часто означает, что сценарий делает действия, но слабо проверяет результат.',
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
label: 'Shared state / beforeAll',
|
|
1866
|
+
count: driverCounts.sharedStateSignals,
|
|
1867
|
+
impact: buildImpact(driverCounts.sharedStateSignals),
|
|
1868
|
+
hint: 'Общий mutable state, serial-режим и heavy beforeAll снижают изоляцию тестов и усложняют параллельный запуск.',
|
|
1869
|
+
},
|
|
1870
|
+
]
|
|
1871
|
+
.filter((driver) => driver.count > 0)
|
|
1872
|
+
.sort((left, right) => right.count - left.count)
|
|
1873
|
+
.slice(0, 4);
|
|
1874
|
+
}
|
|
1875
|
+
function buildEmptyCodeQualityMetrics(analyzableTests) {
|
|
1876
|
+
return {
|
|
1877
|
+
analyzedFiles: 0,
|
|
1878
|
+
matchedTests: 0,
|
|
1879
|
+
analyzableTests,
|
|
1880
|
+
sourceCoveragePercent: 0,
|
|
1881
|
+
testSmellScore: null,
|
|
1882
|
+
pomCompliancePercent: null,
|
|
1883
|
+
assertionDensity: null,
|
|
1884
|
+
waitStrategyScore: null,
|
|
1885
|
+
stepGranularity: null,
|
|
1886
|
+
isolationScore: null,
|
|
1887
|
+
selectorStabilityPercent: null,
|
|
1888
|
+
drivers: [],
|
|
1889
|
+
topRiskFiles: [],
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
function buildDurationBreakdownItems(source, totalDurationMs) {
|
|
1893
|
+
const totalBreakdownMs = [...source.values()].reduce((sum, entry) => sum + entry.durationMs, 0);
|
|
1894
|
+
const normalizationBase = totalBreakdownMs > 0 ? totalBreakdownMs : totalDurationMs;
|
|
1895
|
+
return [...source.entries()]
|
|
1896
|
+
.map(([label, entry]) => ({
|
|
1897
|
+
label,
|
|
1898
|
+
durationMs: roundToOneDigit(entry.durationMs),
|
|
1899
|
+
sharePercent: normalizationBase <= 0 ? 0 : roundToOneDigit((entry.durationMs / normalizationBase) * 100),
|
|
1900
|
+
tests: entry.tests,
|
|
1901
|
+
}))
|
|
1902
|
+
.sort((left, right) => right.durationMs - left.durationMs)
|
|
1903
|
+
.slice(0, 10);
|
|
1904
|
+
}
|
|
1905
|
+
function classifyPerformanceStepPhase(step) {
|
|
1906
|
+
const category = (step.category ?? '').trim().toLowerCase();
|
|
1907
|
+
const title = (step.title ?? '').trim().toLowerCase();
|
|
1908
|
+
if (/(beforeall|beforeeach|hook:before|fixture:setup|(^|\W)setup($|\W)|bootstrap|initialize|initialise)/.test(category)) {
|
|
1909
|
+
return 'setup';
|
|
1910
|
+
}
|
|
1911
|
+
if (/(afterall|aftereach|hook:after|fixture:teardown|(^|\W)teardown($|\W)|cleanup|clean up)/.test(category)) {
|
|
1912
|
+
return 'teardown';
|
|
1913
|
+
}
|
|
1914
|
+
if (/^(open|load|launch|bootstrap|prepare|initialize|initialise|init|login|log in|authorize|authorise|navigate|warm up|connect|seed|restore|create session|open checkout|open filter)/.test(title)) {
|
|
1915
|
+
return 'setup';
|
|
1916
|
+
}
|
|
1917
|
+
if (/^(cleanup|clean up|close|dispose|logout|log out|sign out|reset|remove|delete|drop|stop|clear|release|disconnect)/.test(title)) {
|
|
1918
|
+
return 'teardown';
|
|
1919
|
+
}
|
|
1920
|
+
return 'tests';
|
|
1921
|
+
}
|
|
1922
|
+
function getObservedTestDurationMs(test) {
|
|
1923
|
+
const attempts = test.attempts ?? [];
|
|
1924
|
+
if (attempts.length > 0) {
|
|
1925
|
+
return sum(attempts.map((attempt) => safeNumber(attempt.durationMs)));
|
|
1926
|
+
}
|
|
1927
|
+
return safeNumber(test.durationMs);
|
|
1928
|
+
}
|
|
1929
|
+
function resolveSuiteLabel(test) {
|
|
1930
|
+
const title = typeof test.title === 'string' ? test.title.trim() : '';
|
|
1931
|
+
if (title.includes('>')) {
|
|
1932
|
+
const [suiteLabel] = title.split('>');
|
|
1933
|
+
if (suiteLabel && suiteLabel.trim().length > 0) {
|
|
1934
|
+
return suiteLabel.trim();
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const filePath = typeof test.location?.file === 'string' ? test.location.file.trim() : '';
|
|
1938
|
+
if (filePath) {
|
|
1939
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
1940
|
+
const fileName = normalizedPath.split('/').pop() ?? normalizedPath;
|
|
1941
|
+
return fileName.replace(/\.spec\.[^.]+$/i, '').replace(/\.[^.]+$/i, '') || 'Набор без названия';
|
|
1942
|
+
}
|
|
1943
|
+
return 'Набор без названия';
|
|
1944
|
+
}
|
|
1945
|
+
function resolveBrowserDimensionLabel(browser) {
|
|
1946
|
+
if (typeof browser === 'string' && browser.trim().length > 0) {
|
|
1947
|
+
return browser.trim();
|
|
1948
|
+
}
|
|
1949
|
+
return 'Chrome';
|
|
1950
|
+
}
|
|
1951
|
+
function collectFlakyCandidates(archivedRuns) {
|
|
1952
|
+
const candidates = new Map();
|
|
1953
|
+
for (const archivedRun of archivedRuns) {
|
|
1954
|
+
for (const test of archivedRun.report.tests ?? []) {
|
|
1955
|
+
const title = test.title ?? '';
|
|
1956
|
+
const file = test.location?.file ?? 'неизвестно';
|
|
1957
|
+
const project = test.project ?? 'неизвестно';
|
|
1958
|
+
if (!title) {
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
const key = `${project}::${file}::${title}`;
|
|
1962
|
+
const attempts = test.attempts ?? [];
|
|
1963
|
+
const failedAttempts = attempts.filter((attempt) => ['failed', 'timedout', 'interrupted'].includes(getAttemptStatus(attempt.status))).length;
|
|
1964
|
+
const attemptsCount = attempts.length > 0 ? attempts.length : (safeNumber(test.retries) + 1);
|
|
1965
|
+
const candidate = candidates.get(key) ?? {
|
|
1966
|
+
title,
|
|
1967
|
+
file,
|
|
1968
|
+
project,
|
|
1969
|
+
observations: [],
|
|
1970
|
+
totalAttempts: 0,
|
|
1971
|
+
failedAttempts: 0,
|
|
1972
|
+
};
|
|
1973
|
+
candidate.observations.push({
|
|
1974
|
+
timestamp: archivedRun.run.reportTimestamp ?? archivedRun.run.generatedAt,
|
|
1975
|
+
status: getFinalStatus(test),
|
|
1976
|
+
flaky: Boolean(test.flaky),
|
|
1977
|
+
retries: safeNumber(test.retries),
|
|
1978
|
+
attempts: attemptsCount,
|
|
1979
|
+
errorMessage: extractErrorMessage(test),
|
|
1980
|
+
});
|
|
1981
|
+
candidate.totalAttempts += attemptsCount;
|
|
1982
|
+
candidate.failedAttempts += failedAttempts;
|
|
1983
|
+
candidates.set(key, candidate);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
return [...candidates.values()]
|
|
1987
|
+
.map((candidate) => {
|
|
1988
|
+
const unstableObservations = candidate.observations.filter((observation) => observation.flaky || ['failed', 'timedout', 'interrupted'].includes(observation.status));
|
|
1989
|
+
const latestObservation = [...candidate.observations]
|
|
1990
|
+
.sort((left, right) => getObservationTime(right.timestamp) - getObservationTime(left.timestamp))[0];
|
|
1991
|
+
const mtbfDays = calculateMtbfDays(unstableObservations.map((observation) => observation.timestamp));
|
|
1992
|
+
const failRate = candidate.totalAttempts > 0 ? candidate.failedAttempts / candidate.totalAttempts : 0;
|
|
1993
|
+
const patternFactor = unstableObservations.length === candidate.observations.length && candidate.observations.length > 0 ? 1.0 : 0.4;
|
|
1994
|
+
const mtbfFactor = mtbfDays === null ? 0.2 : (mtbfDays < 1 ? 1.0 : (mtbfDays <= 3 ? 0.5 : 0.2));
|
|
1995
|
+
const flakyScore = roundToOneDigit((failRate * 0.4 + patternFactor * 0.3 + mtbfFactor * 0.3) * 100);
|
|
1996
|
+
return {
|
|
1997
|
+
title: candidate.title,
|
|
1998
|
+
file: candidate.file,
|
|
1999
|
+
project: candidate.project,
|
|
2000
|
+
flakyScore,
|
|
2001
|
+
failRate: roundToOneDigit(failRate * 100),
|
|
2002
|
+
mtbfDays: mtbfDays === null ? null : roundToTwoDigits(mtbfDays),
|
|
2003
|
+
unstableRuns: unstableObservations.length,
|
|
2004
|
+
totalRuns: candidate.observations.length,
|
|
2005
|
+
latestStatus: latestObservation?.status ?? 'unknown',
|
|
2006
|
+
latestErrorMessage: latestObservation?.errorMessage ?? null,
|
|
2007
|
+
};
|
|
2008
|
+
})
|
|
2009
|
+
.filter((candidate) => candidate.unstableRuns > 0);
|
|
2010
|
+
}
|
|
2011
|
+
function collectFirstFlakeToFixMetric(archivedRuns) {
|
|
2012
|
+
const resolvedCandidates = collectResolvedFlakyFixMetrics(archivedRuns)
|
|
2013
|
+
.sort((left, right) => {
|
|
2014
|
+
if (left.days !== right.days) {
|
|
2015
|
+
return left.days - right.days;
|
|
2016
|
+
}
|
|
2017
|
+
return getObservationTime(left.detectedAt) - getObservationTime(right.detectedAt);
|
|
2018
|
+
});
|
|
2019
|
+
return resolvedCandidates[0] ?? null;
|
|
2020
|
+
}
|
|
2021
|
+
function collectResolvedFlakyFixMetrics(archivedRuns) {
|
|
2022
|
+
const candidates = new Map();
|
|
2023
|
+
for (const archivedRun of archivedRuns) {
|
|
2024
|
+
for (const test of archivedRun.report.tests ?? []) {
|
|
2025
|
+
const title = test.title ?? '';
|
|
2026
|
+
const file = test.location?.file ?? 'неизвестно';
|
|
2027
|
+
const project = test.project ?? 'неизвестно';
|
|
2028
|
+
if (!title) {
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
const key = `${project}::${file}::${title}`;
|
|
2032
|
+
const candidate = candidates.get(key) ?? {
|
|
2033
|
+
title,
|
|
2034
|
+
file,
|
|
2035
|
+
project,
|
|
2036
|
+
observations: [],
|
|
2037
|
+
};
|
|
2038
|
+
candidate.observations.push({
|
|
2039
|
+
timestamp: archivedRun.run.reportTimestamp ?? archivedRun.run.generatedAt,
|
|
2040
|
+
status: getFinalStatus(test),
|
|
2041
|
+
flaky: Boolean(test.flaky),
|
|
2042
|
+
});
|
|
2043
|
+
candidates.set(key, candidate);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
return [...candidates.values()]
|
|
2047
|
+
.map((candidate) => {
|
|
2048
|
+
const observations = [...candidate.observations]
|
|
2049
|
+
.filter((observation) => Number.isFinite(getObservationTime(observation.timestamp)))
|
|
2050
|
+
.sort((left, right) => getObservationTime(left.timestamp) - getObservationTime(right.timestamp));
|
|
2051
|
+
const firstUnstableObservation = observations.find((observation) => isUnstableObservation(observation.status, observation.flaky));
|
|
2052
|
+
if (!firstUnstableObservation) {
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
const firstUnstableTime = getObservationTime(firstUnstableObservation.timestamp);
|
|
2056
|
+
const firstStableAfter = observations.find((observation) => {
|
|
2057
|
+
const observationTime = getObservationTime(observation.timestamp);
|
|
2058
|
+
return observationTime > firstUnstableTime && observation.status === 'passed' && !observation.flaky;
|
|
2059
|
+
});
|
|
2060
|
+
if (!firstStableAfter) {
|
|
2061
|
+
return null;
|
|
2062
|
+
}
|
|
2063
|
+
const fixedAtTime = getObservationTime(firstStableAfter.timestamp);
|
|
2064
|
+
return {
|
|
2065
|
+
days: roundToTwoDigits((fixedAtTime - firstUnstableTime) / (1000 * 60 * 60 * 24)),
|
|
2066
|
+
title: candidate.title,
|
|
2067
|
+
file: candidate.file,
|
|
2068
|
+
project: candidate.project,
|
|
2069
|
+
detectedAt: firstUnstableObservation.timestamp,
|
|
2070
|
+
fixedAt: firstStableAfter.timestamp,
|
|
2071
|
+
};
|
|
2072
|
+
})
|
|
2073
|
+
.filter((candidate) => candidate !== null);
|
|
2074
|
+
}
|
|
2075
|
+
function isUnstableObservation(status, flaky) {
|
|
2076
|
+
return flaky || status === 'failed' || status === 'timedout' || status === 'interrupted';
|
|
2077
|
+
}
|
|
2078
|
+
function calculateMtbfDays(timestamps) {
|
|
2079
|
+
const sortedTimes = timestamps
|
|
2080
|
+
.map((timestamp) => getObservationTime(timestamp))
|
|
2081
|
+
.filter((value) => Number.isFinite(value))
|
|
2082
|
+
.sort((left, right) => left - right);
|
|
2083
|
+
if (sortedTimes.length < 2) {
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
const intervalsInDays = [];
|
|
2087
|
+
for (let index = 1; index < sortedTimes.length; index += 1) {
|
|
2088
|
+
intervalsInDays.push((sortedTimes[index] - sortedTimes[index - 1]) / (1000 * 60 * 60 * 24));
|
|
2089
|
+
}
|
|
2090
|
+
return average(intervalsInDays);
|
|
2091
|
+
}
|
|
2092
|
+
function getObservationTime(timestamp) {
|
|
2093
|
+
if (!timestamp) {
|
|
2094
|
+
return Number.NaN;
|
|
2095
|
+
}
|
|
2096
|
+
return new Date(timestamp).getTime();
|
|
2097
|
+
}
|
|
2098
|
+
function buildFallbackAdvancedMetrics(report, historyRuns, flakyTests, totalDurationMs, sourceFile) {
|
|
2099
|
+
const previousRun = resolveComparisonBaseline(historyRuns).previousRun;
|
|
2100
|
+
const tests = report.tests ?? [];
|
|
2101
|
+
const performanceMetrics = buildPerformanceMetrics({ tests, durationMs: totalDurationMs }, historyRuns);
|
|
2102
|
+
return {
|
|
2103
|
+
performance: performanceMetrics,
|
|
2104
|
+
flakyAnalytics: {
|
|
2105
|
+
averageFlakyScore: null,
|
|
2106
|
+
averageMtbfDays: null,
|
|
2107
|
+
topFlakyTests: [],
|
|
2108
|
+
firstFlakeToFix: null,
|
|
2109
|
+
flakyTrend: {
|
|
2110
|
+
currentFlakyTests: flakyTests,
|
|
2111
|
+
previousFlakyTests: previousRun?.flakyTests ?? null,
|
|
2112
|
+
delta: previousRun ? flakyTests - previousRun.flakyTests : null,
|
|
2113
|
+
},
|
|
2114
|
+
},
|
|
2115
|
+
charts: {
|
|
2116
|
+
durationTrend: {
|
|
2117
|
+
labels: historyRuns.length > 0
|
|
2118
|
+
? historyRuns.map((run, index) => formatHistoryRunLabel(run, index))
|
|
2119
|
+
: ['Текущий прогон'],
|
|
2120
|
+
values: historyRuns.length > 0
|
|
2121
|
+
? historyRuns.map((run) => roundToOneDigit(run.totalDurationMs / 60000))
|
|
2122
|
+
: [roundToOneDigit(totalDurationMs / 60000)],
|
|
2123
|
+
},
|
|
2124
|
+
flakyTrend: {
|
|
2125
|
+
labels: historyRuns.length > 0
|
|
2126
|
+
? historyRuns.map((run, index) => formatHistoryRunLabel(run, index))
|
|
2127
|
+
: ['Текущий прогон'],
|
|
2128
|
+
values: historyRuns.length > 0
|
|
2129
|
+
? historyRuns.map((run) => run.flakyTests)
|
|
2130
|
+
: [flakyTests],
|
|
2131
|
+
},
|
|
2132
|
+
},
|
|
2133
|
+
businessMetrics: buildBusinessMetrics({
|
|
2134
|
+
tests,
|
|
2135
|
+
durationMs: totalDurationMs,
|
|
2136
|
+
}, historyRuns, []),
|
|
2137
|
+
codeQuality: buildCodeQualityMetrics(report, sourceFile),
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
function buildBusinessMetrics(report, historyRuns, archivedRuns) {
|
|
2141
|
+
const reports = archivedRuns.length > 0
|
|
2142
|
+
? archivedRuns.map((item) => item.report)
|
|
2143
|
+
: [report];
|
|
2144
|
+
const observedTests = reports.flatMap((currentReport) => currentReport.tests ?? []);
|
|
2145
|
+
const extraRetries = observedTests.reduce((total, test) => {
|
|
2146
|
+
const attemptsCount = getAttemptsCount(test);
|
|
2147
|
+
return total + Math.max(attemptsCount - 1, 0);
|
|
2148
|
+
}, 0);
|
|
2149
|
+
const extraRetryMinutes = roundToTwoDigits(sum(observedTests.map(getExtraRetryDurationMs)) / 60000);
|
|
2150
|
+
const unstableRuns = observedTests.filter(isUnstableTestObservation).length;
|
|
2151
|
+
const activeDays = getActiveDays(historyRuns, report);
|
|
2152
|
+
const resolvedFixMetrics = collectResolvedFlakyFixMetrics(archivedRuns);
|
|
2153
|
+
const costOfFlakiness = (0, business_assumptions_1.recalculateCostOfFlakinessMetrics)({
|
|
2154
|
+
extraRetryMinutes,
|
|
2155
|
+
extraRetries,
|
|
2156
|
+
unstableRuns,
|
|
2157
|
+
activeDays,
|
|
2158
|
+
}, readDashboardBusinessAssumptionsFromEnv());
|
|
2159
|
+
const developerFriction = {
|
|
2160
|
+
rerunProxyPerActiveDay: activeDays === 0 ? roundToTwoDigits(extraRetries + unstableRuns) : roundToTwoDigits((extraRetries + unstableRuns) / activeDays),
|
|
2161
|
+
rerunBurdenPer100Runs: observedTests.length === 0 ? 0 : roundToTwoDigits(((extraRetries + unstableRuns) / observedTests.length) * 100),
|
|
2162
|
+
extraRetries,
|
|
2163
|
+
unstableRuns,
|
|
2164
|
+
activeDays,
|
|
2165
|
+
observedRuns: observedTests.length,
|
|
2166
|
+
};
|
|
2167
|
+
const currentTests = report.tests ?? [];
|
|
2168
|
+
const currentTotalTests = currentTests.length;
|
|
2169
|
+
const currentPassRate = currentTotalTests === 0 ? 0 : (currentTests.filter((test) => getFinalStatus(test) === 'passed').length / currentTotalTests) * 100;
|
|
2170
|
+
const currentFlakyRatio = currentTotalTests === 0 ? 0 : (currentTests.filter((test) => Boolean(test.flaky)).length / currentTotalTests) * 100;
|
|
2171
|
+
const releaseConfidenceScore = calculateReleaseConfidenceScore(currentPassRate, currentFlakyRatio, collectErrorClusters(currentTests).length, currentTotalTests, historyRuns);
|
|
2172
|
+
return {
|
|
2173
|
+
timeToDetect: {
|
|
2174
|
+
minutes: null,
|
|
2175
|
+
source: 'pendingIntegration',
|
|
2176
|
+
},
|
|
2177
|
+
timeToFixFlaky: {
|
|
2178
|
+
medianDays: resolvedFixMetrics.length > 0
|
|
2179
|
+
? roundToTwoDigits(getMedian(resolvedFixMetrics.map((metric) => metric.days)))
|
|
2180
|
+
: null,
|
|
2181
|
+
averageDays: resolvedFixMetrics.length > 0
|
|
2182
|
+
? roundToTwoDigits(average(resolvedFixMetrics.map((metric) => metric.days)))
|
|
2183
|
+
: null,
|
|
2184
|
+
resolvedIncidents: resolvedFixMetrics.length,
|
|
2185
|
+
},
|
|
2186
|
+
costOfFlakiness,
|
|
2187
|
+
developerFriction,
|
|
2188
|
+
releaseConfidenceScore,
|
|
2189
|
+
automationRoi: {
|
|
2190
|
+
percent: null,
|
|
2191
|
+
source: 'pendingAssumptions',
|
|
2192
|
+
},
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
function buildEmptyBusinessMetrics() {
|
|
2196
|
+
return {
|
|
2197
|
+
timeToDetect: {
|
|
2198
|
+
minutes: null,
|
|
2199
|
+
source: 'pendingIntegration',
|
|
2200
|
+
},
|
|
2201
|
+
timeToFixFlaky: {
|
|
2202
|
+
medianDays: null,
|
|
2203
|
+
averageDays: null,
|
|
2204
|
+
resolvedIncidents: 0,
|
|
2205
|
+
},
|
|
2206
|
+
costOfFlakiness: (0, business_assumptions_1.recalculateCostOfFlakinessMetrics)({
|
|
2207
|
+
extraRetryMinutes: 0,
|
|
2208
|
+
extraRetries: 0,
|
|
2209
|
+
unstableRuns: 0,
|
|
2210
|
+
activeDays: 0,
|
|
2211
|
+
}, {
|
|
2212
|
+
ciMinuteCostRub: null,
|
|
2213
|
+
developerHourlyCostRub: null,
|
|
2214
|
+
analysisMinutesPerUnstable: null,
|
|
2215
|
+
}),
|
|
2216
|
+
developerFriction: {
|
|
2217
|
+
rerunProxyPerActiveDay: 0,
|
|
2218
|
+
rerunBurdenPer100Runs: 0,
|
|
2219
|
+
extraRetries: 0,
|
|
2220
|
+
unstableRuns: 0,
|
|
2221
|
+
activeDays: 0,
|
|
2222
|
+
observedRuns: 0,
|
|
2223
|
+
},
|
|
2224
|
+
releaseConfidenceScore: 0,
|
|
2225
|
+
automationRoi: {
|
|
2226
|
+
percent: null,
|
|
2227
|
+
source: 'pendingAssumptions',
|
|
2228
|
+
},
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
function recoverCodeQualityMetricsFromSource(sourceFile) {
|
|
2232
|
+
try {
|
|
2233
|
+
if (!sourceFile || !fs.existsSync(sourceFile)) {
|
|
2234
|
+
return buildEmptyCodeQualityMetrics(0);
|
|
2235
|
+
}
|
|
2236
|
+
const report = loadReporterReport(sourceFile);
|
|
2237
|
+
return buildCodeQualityMetrics(report, sourceFile);
|
|
2238
|
+
}
|
|
2239
|
+
catch {
|
|
2240
|
+
return buildEmptyCodeQualityMetrics(0);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
function collectCurrentRunTests(tests) {
|
|
2244
|
+
const normalizedTests = tests
|
|
2245
|
+
.map((test) => {
|
|
2246
|
+
const errorDetails = extractErrorMessage(test);
|
|
2247
|
+
return {
|
|
2248
|
+
title: test.title ?? 'Тест без названия',
|
|
2249
|
+
file: test.location?.file ?? 'неизвестно',
|
|
2250
|
+
project: test.project ?? 'неизвестно',
|
|
2251
|
+
status: getFinalStatus(test),
|
|
2252
|
+
flaky: Boolean(test.flaky),
|
|
2253
|
+
durationMs: safeNumber(test.durationMs),
|
|
2254
|
+
errorMessage: errorDetails ? normalizeErrorMessage(errorDetails) : null,
|
|
2255
|
+
errorDetails,
|
|
2256
|
+
};
|
|
2257
|
+
})
|
|
2258
|
+
.sort((left, right) => left.title.localeCompare(right.title, 'ru'));
|
|
2259
|
+
return {
|
|
2260
|
+
all: normalizedTests,
|
|
2261
|
+
passed: normalizedTests.filter((test) => test.status === 'passed'),
|
|
2262
|
+
failed: normalizedTests.filter((test) => test.status === 'failed'),
|
|
2263
|
+
flaky: normalizedTests.filter((test) => test.flaky),
|
|
2264
|
+
skipped: normalizedTests.filter((test) => test.status === 'skipped'),
|
|
2265
|
+
timedOut: normalizedTests.filter((test) => test.status === 'timedout'),
|
|
2266
|
+
interrupted: normalizedTests.filter((test) => test.status === 'interrupted'),
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
function recoverCurrentRunTestsFromSource(sourceFile) {
|
|
2270
|
+
try {
|
|
2271
|
+
if (!sourceFile || !fs.existsSync(sourceFile)) {
|
|
2272
|
+
return buildEmptyCurrentRunTests();
|
|
2273
|
+
}
|
|
2274
|
+
const report = loadReporterReport(sourceFile);
|
|
2275
|
+
return collectCurrentRunTests(report.tests ?? []);
|
|
2276
|
+
}
|
|
2277
|
+
catch {
|
|
2278
|
+
return buildEmptyCurrentRunTests();
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
function buildEmptyCurrentRunTests() {
|
|
2282
|
+
return {
|
|
2283
|
+
all: [],
|
|
2284
|
+
passed: [],
|
|
2285
|
+
failed: [],
|
|
2286
|
+
flaky: [],
|
|
2287
|
+
skipped: [],
|
|
2288
|
+
timedOut: [],
|
|
2289
|
+
interrupted: [],
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
function buildManagerSummary(input) {
|
|
2293
|
+
const releaseReadinessScore = clampScore(input.businessMetrics.releaseConfidenceScore
|
|
2294
|
+
- (input.kpis.failedTests > 0 ? Math.min(25, input.kpis.failedTests * 2.5) : 0)
|
|
2295
|
+
- (input.trend.passRateDelta !== null && input.trend.passRateDelta < 0 ? Math.min(15, Math.abs(input.trend.passRateDelta) * 2) : 0));
|
|
2296
|
+
const qualityRiskScore = clampScore(((100 - input.kpis.passRate) * 0.45)
|
|
2297
|
+
+ (input.kpis.flakyRatio * 0.25)
|
|
2298
|
+
+ (Math.min(100, input.kpis.errorClusterCount * 12) * 0.15)
|
|
2299
|
+
+ ((input.flakyAnalytics.averageFlakyScore ?? (input.kpis.flakyTests > 0 ? 60 : 0)) * 0.15));
|
|
2300
|
+
const slowestTest = input.performance.slowestTests[0] ?? null;
|
|
2301
|
+
const slowestDurationSeconds = slowestTest ? safeNumber(slowestTest.durationMs) / 1000 : 0;
|
|
2302
|
+
const durationDeltaPercent = Math.max(input.performance.durationTrend.deltaPercent ?? 0, 0);
|
|
2303
|
+
const deliveryRiskScore = clampScore((Math.min(100, durationDeltaPercent) * 0.35)
|
|
2304
|
+
+ (Math.min(100, input.businessMetrics.developerFriction.rerunProxyPerActiveDay * 18) * 0.25)
|
|
2305
|
+
+ (Math.min(100, slowestDurationSeconds * 1.5) * 0.25)
|
|
2306
|
+
+ (Math.min(100, Math.max(input.trend.flakyTestsDelta ?? 0, 0) * 20) * 0.15));
|
|
2307
|
+
const blockers = [];
|
|
2308
|
+
const primaryProblematicTest = input.topProblematicTests[0] ?? null;
|
|
2309
|
+
const primaryFlakyTest = input.flakyAnalytics.topFlakyTests[0] ?? null;
|
|
2310
|
+
const dominantCluster = input.errorClusters[0] ?? null;
|
|
2311
|
+
if (input.historyTotalRuns < 3) {
|
|
2312
|
+
blockers.push({
|
|
2313
|
+
kind: 'history-coverage',
|
|
2314
|
+
severity: input.historyTotalRuns === 0 ? 'warning' : 'info',
|
|
2315
|
+
title: 'Истории пока мало для уверенного тренда',
|
|
2316
|
+
value: `${input.historyTotalRuns} прогонов`,
|
|
2317
|
+
details: 'Для управленческого сигнала лучше иметь хотя бы 3-5 архивных запусков.',
|
|
2318
|
+
testTitle: null,
|
|
2319
|
+
project: null,
|
|
2320
|
+
file: null,
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
if (releaseReadinessScore < 75 || input.kpis.failedTests > 0 || (input.trend.passRateDelta ?? 0) < 0) {
|
|
2324
|
+
blockers.push({
|
|
2325
|
+
kind: 'release-confidence',
|
|
2326
|
+
severity: releaseReadinessScore < 55 ? 'critical' : 'warning',
|
|
2327
|
+
title: 'Релизный сигнал просел',
|
|
2328
|
+
value: `${roundToOneDigit(releaseReadinessScore)} / 100`,
|
|
2329
|
+
details: `Pass Rate ${(0, formatting_1.formatPercent)(input.kpis.passRate)}, flaky ${(0, formatting_1.formatPercent)(input.kpis.flakyRatio)}, failed ${input.kpis.failedTests}.`,
|
|
2330
|
+
testTitle: null,
|
|
2331
|
+
project: null,
|
|
2332
|
+
file: null,
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
if (primaryProblematicTest) {
|
|
2336
|
+
blockers.push({
|
|
2337
|
+
kind: 'problematic-test',
|
|
2338
|
+
severity: primaryProblematicTest.status === 'failed' || primaryProblematicTest.status === 'timedout' || primaryProblematicTest.status === 'interrupted' || primaryProblematicTest.failureRate >= 50
|
|
2339
|
+
? 'critical'
|
|
2340
|
+
: 'warning',
|
|
2341
|
+
title: `Проблемный тест: ${shorten(primaryProblematicTest.title, 48)}`,
|
|
2342
|
+
value: `${roundToOneDigit(primaryProblematicTest.failureRate)}% падений`,
|
|
2343
|
+
details: `${primaryProblematicTest.project} • ${shorten(primaryProblematicTest.errorMessage, 96)}`,
|
|
2344
|
+
testTitle: primaryProblematicTest.title,
|
|
2345
|
+
project: primaryProblematicTest.project,
|
|
2346
|
+
file: primaryProblematicTest.file,
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
if (primaryFlakyTest && (primaryFlakyTest.flakyScore >= 45 || input.kpis.flakyTests > 0)) {
|
|
2350
|
+
blockers.push({
|
|
2351
|
+
kind: 'flaky',
|
|
2352
|
+
severity: primaryFlakyTest.flakyScore >= 70 ? 'critical' : 'warning',
|
|
2353
|
+
title: `Нестабильность держится в истории: ${shorten(primaryFlakyTest.title, 44)}`,
|
|
2354
|
+
value: `${roundToOneDigit(primaryFlakyTest.flakyScore)} / 100`,
|
|
2355
|
+
details: `MTBF ${primaryFlakyTest.mtbfDays === null ? '—' : `${primaryFlakyTest.mtbfDays.toFixed(2)} дн`} • нестабильных прогонов ${primaryFlakyTest.unstableRuns}.`,
|
|
2356
|
+
testTitle: primaryFlakyTest.title,
|
|
2357
|
+
project: primaryFlakyTest.project,
|
|
2358
|
+
file: primaryFlakyTest.file,
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
if (slowestTest && (durationDeltaPercent >= 10 || slowestDurationSeconds >= 45)) {
|
|
2362
|
+
blockers.push({
|
|
2363
|
+
kind: 'duration',
|
|
2364
|
+
severity: durationDeltaPercent >= 25 || slowestDurationSeconds >= 90 ? 'critical' : 'warning',
|
|
2365
|
+
title: 'Пайплайн теряет скорость',
|
|
2366
|
+
value: durationDeltaPercent > 0
|
|
2367
|
+
? `${roundToOneDigit(durationDeltaPercent)}% ${input.comparison.mode === 'comparable' ? 'к сопоставимому прогону' : 'к прошлому прогону'}`
|
|
2368
|
+
: (0, formatting_1.formatDuration)(slowestTest.durationMs),
|
|
2369
|
+
details: `Самый медленный тест: ${shorten(slowestTest.title, 44)} • ${(0, formatting_1.formatDuration)(slowestTest.durationMs)}.`,
|
|
2370
|
+
testTitle: slowestTest.title,
|
|
2371
|
+
project: slowestTest.project,
|
|
2372
|
+
file: slowestTest.file,
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
if (dominantCluster && dominantCluster.count >= 2) {
|
|
2376
|
+
blockers.push({
|
|
2377
|
+
kind: 'error-cluster',
|
|
2378
|
+
severity: dominantCluster.count >= 4 ? 'critical' : 'warning',
|
|
2379
|
+
title: 'Ошибки повторяются сериями',
|
|
2380
|
+
value: `${dominantCluster.count} инцидентов`,
|
|
2381
|
+
details: shorten(dominantCluster.message, 110),
|
|
2382
|
+
testTitle: dominantCluster.tests[0] ?? null,
|
|
2383
|
+
project: null,
|
|
2384
|
+
file: null,
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
return {
|
|
2388
|
+
releaseReadiness: {
|
|
2389
|
+
score: roundToOneDigit(releaseReadinessScore),
|
|
2390
|
+
level: getManagerPositiveSignalLevel(releaseReadinessScore),
|
|
2391
|
+
},
|
|
2392
|
+
qualityRisk: {
|
|
2393
|
+
score: roundToOneDigit(qualityRiskScore),
|
|
2394
|
+
level: getManagerRiskSignalLevel(qualityRiskScore),
|
|
2395
|
+
},
|
|
2396
|
+
deliveryRisk: {
|
|
2397
|
+
score: roundToOneDigit(deliveryRiskScore),
|
|
2398
|
+
level: getManagerRiskSignalLevel(deliveryRiskScore),
|
|
2399
|
+
},
|
|
2400
|
+
blockers: blockers
|
|
2401
|
+
.sort((left, right) => getManagerBlockerPriority(right) - getManagerBlockerPriority(left))
|
|
2402
|
+
.slice(0, 3),
|
|
2403
|
+
changes: buildManagerChanges(input),
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
function buildManagerChanges(input) {
|
|
2407
|
+
if (input.trend.passRateDelta === null
|
|
2408
|
+
&& input.trend.failedTestsDelta === null
|
|
2409
|
+
&& input.trend.flakyTestsDelta === null
|
|
2410
|
+
&& input.trend.durationMsDelta === null) {
|
|
2411
|
+
return [];
|
|
2412
|
+
}
|
|
2413
|
+
return [
|
|
2414
|
+
{
|
|
2415
|
+
label: 'Pass Rate',
|
|
2416
|
+
value: formatManagerDelta(input.trend.passRateDelta, 'pp'),
|
|
2417
|
+
details: `Сейчас ${(0, formatting_1.formatPercent)(input.kpis.passRate)}.`,
|
|
2418
|
+
direction: getManagerDirection(input.trend.passRateDelta, false),
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
label: 'Падения',
|
|
2422
|
+
value: formatManagerDelta(input.trend.failedTestsDelta, 'count'),
|
|
2423
|
+
details: `Сейчас ${input.kpis.failedTests} тестов с финальным неуспешным статусом.`,
|
|
2424
|
+
direction: getManagerDirection(input.trend.failedTestsDelta, true),
|
|
2425
|
+
},
|
|
2426
|
+
{
|
|
2427
|
+
label: 'Flaky',
|
|
2428
|
+
value: formatManagerDelta(input.trend.flakyTestsDelta, 'count'),
|
|
2429
|
+
details: `Сейчас ${input.kpis.flakyTests} нестабильных тестов в текущем прогоне.`,
|
|
2430
|
+
direction: getManagerDirection(input.trend.flakyTestsDelta, true),
|
|
2431
|
+
},
|
|
2432
|
+
{
|
|
2433
|
+
label: 'Длительность',
|
|
2434
|
+
value: formatManagerDelta(input.performance.durationTrend.deltaPercent, 'percent'),
|
|
2435
|
+
details: `Сейчас ${(0, formatting_1.formatDuration)(input.kpis.totalDurationMs)}.`,
|
|
2436
|
+
direction: getManagerDirection(input.performance.durationTrend.deltaPercent, true),
|
|
2437
|
+
},
|
|
2438
|
+
];
|
|
2439
|
+
}
|
|
2440
|
+
function getManagerPositiveSignalLevel(score) {
|
|
2441
|
+
if (score >= 75) {
|
|
2442
|
+
return 'healthy';
|
|
2443
|
+
}
|
|
2444
|
+
if (score >= 55) {
|
|
2445
|
+
return 'warning';
|
|
2446
|
+
}
|
|
2447
|
+
return 'critical';
|
|
2448
|
+
}
|
|
2449
|
+
function getManagerRiskSignalLevel(score) {
|
|
2450
|
+
if (score < 30) {
|
|
2451
|
+
return 'healthy';
|
|
2452
|
+
}
|
|
2453
|
+
if (score < 60) {
|
|
2454
|
+
return 'warning';
|
|
2455
|
+
}
|
|
2456
|
+
return 'critical';
|
|
2457
|
+
}
|
|
2458
|
+
function getManagerBlockerPriority(blocker) {
|
|
2459
|
+
const severityScore = blocker.severity === 'critical' ? 300 : blocker.severity === 'warning' ? 200 : 100;
|
|
2460
|
+
const kindScore = blocker.kind === 'release-confidence'
|
|
2461
|
+
? 50
|
|
2462
|
+
: blocker.kind === 'problematic-test'
|
|
2463
|
+
? 40
|
|
2464
|
+
: blocker.kind === 'flaky'
|
|
2465
|
+
? 30
|
|
2466
|
+
: blocker.kind === 'duration'
|
|
2467
|
+
? 20
|
|
2468
|
+
: blocker.kind === 'error-cluster'
|
|
2469
|
+
? 10
|
|
2470
|
+
: 0;
|
|
2471
|
+
return severityScore + kindScore;
|
|
2472
|
+
}
|
|
2473
|
+
function getManagerDirection(delta, inverted) {
|
|
2474
|
+
if (delta === null || delta === 0) {
|
|
2475
|
+
return 'stable';
|
|
2476
|
+
}
|
|
2477
|
+
if (inverted) {
|
|
2478
|
+
return delta < 0 ? 'improving' : 'regressing';
|
|
2479
|
+
}
|
|
2480
|
+
return delta > 0 ? 'improving' : 'regressing';
|
|
2481
|
+
}
|
|
2482
|
+
function formatManagerDelta(delta, kind) {
|
|
2483
|
+
if (delta === null || delta === 0) {
|
|
2484
|
+
return 'Без изменений';
|
|
2485
|
+
}
|
|
2486
|
+
const sign = delta > 0 ? '+' : '-';
|
|
2487
|
+
const absoluteDelta = Math.abs(delta);
|
|
2488
|
+
if (kind === 'count') {
|
|
2489
|
+
return `${sign}${absoluteDelta}`;
|
|
2490
|
+
}
|
|
2491
|
+
if (kind === 'percent') {
|
|
2492
|
+
return `${sign}${roundToOneDigit(absoluteDelta)}%`;
|
|
2493
|
+
}
|
|
2494
|
+
return `${sign}${roundToOneDigit(absoluteDelta)} п.п.`;
|
|
2495
|
+
}
|
|
2496
|
+
function getAttemptsCount(test) {
|
|
2497
|
+
const attempts = test.attempts ?? [];
|
|
2498
|
+
return attempts.length > 0 ? attempts.length : safeNumber(test.retries) + 1;
|
|
2499
|
+
}
|
|
2500
|
+
function getExtraRetryDurationMs(test) {
|
|
2501
|
+
const attempts = test.attempts ?? [];
|
|
2502
|
+
if (attempts.length > 1) {
|
|
2503
|
+
return sum(attempts.slice(1).map((attempt) => safeNumber(attempt.durationMs)));
|
|
2504
|
+
}
|
|
2505
|
+
const attemptsCount = getAttemptsCount(test);
|
|
2506
|
+
if (attemptsCount <= 1) {
|
|
2507
|
+
return 0;
|
|
2508
|
+
}
|
|
2509
|
+
return (safeNumber(test.durationMs) / attemptsCount) * (attemptsCount - 1);
|
|
2510
|
+
}
|
|
2511
|
+
function isUnstableTestObservation(test) {
|
|
2512
|
+
const status = getFinalStatus(test);
|
|
2513
|
+
return Boolean(test.flaky)
|
|
2514
|
+
|| status === 'failed'
|
|
2515
|
+
|| status === 'timedout'
|
|
2516
|
+
|| status === 'interrupted';
|
|
2517
|
+
}
|
|
2518
|
+
function getActiveDays(historyRuns, report) {
|
|
2519
|
+
const days = new Set(historyRuns
|
|
2520
|
+
.map((run) => run.reportTimestamp ?? run.generatedAt)
|
|
2521
|
+
.map(normalizeDateToDay)
|
|
2522
|
+
.filter((day) => day !== null));
|
|
2523
|
+
if (days.size > 0) {
|
|
2524
|
+
return days.size;
|
|
2525
|
+
}
|
|
2526
|
+
return normalizeDateToDay(report.timestamp ?? new Date().toISOString()) ? 1 : 0;
|
|
2527
|
+
}
|
|
2528
|
+
function normalizeDateToDay(timestamp) {
|
|
2529
|
+
if (!timestamp) {
|
|
2530
|
+
return null;
|
|
2531
|
+
}
|
|
2532
|
+
const date = new Date(timestamp);
|
|
2533
|
+
if (Number.isNaN(date.getTime())) {
|
|
2534
|
+
return null;
|
|
2535
|
+
}
|
|
2536
|
+
return date.toISOString().slice(0, 10);
|
|
2537
|
+
}
|
|
2538
|
+
function readOptionalNumberFromEnv(name) {
|
|
2539
|
+
const rawValue = process.env[name];
|
|
2540
|
+
if (!rawValue || rawValue.trim().length === 0) {
|
|
2541
|
+
return null;
|
|
2542
|
+
}
|
|
2543
|
+
const parsedValue = Number(rawValue);
|
|
2544
|
+
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : null;
|
|
2545
|
+
}
|
|
2546
|
+
function readDashboardBusinessAssumptionsFromEnv() {
|
|
2547
|
+
return {
|
|
2548
|
+
ciMinuteCostRub: readOptionalNumberFromEnv('AQA_PULSE_CI_MINUTE_COST'),
|
|
2549
|
+
developerHourlyCostRub: readOptionalNumberFromEnv('AQA_PULSE_DEV_HOURLY_COST'),
|
|
2550
|
+
analysisMinutesPerUnstable: readOptionalNumberFromEnv('AQA_PULSE_ANALYSIS_MINUTES_PER_UNSTABLE'),
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
function calculateReleaseConfidenceScore(passRate, flakyRatio, errorClusterCount, totalTests, historyRuns) {
|
|
2554
|
+
const errorHealth = totalTests === 0 ? 100 : Math.max(0, 100 - ((errorClusterCount / totalTests) * 100));
|
|
2555
|
+
const recentRuns = historyRuns.slice(-5);
|
|
2556
|
+
const historyConsistency = recentRuns.length > 0
|
|
2557
|
+
? average(recentRuns.map((run) => clampScore(run.passRate - run.flakyRatio)))
|
|
2558
|
+
: clampScore(passRate - flakyRatio);
|
|
2559
|
+
return roundToOneDigit(clampScore((passRate * 0.4)
|
|
2560
|
+
+ ((100 - flakyRatio) * 0.3)
|
|
2561
|
+
+ (errorHealth * 0.15)
|
|
2562
|
+
+ (historyConsistency * 0.15)));
|
|
2563
|
+
}
|
|
2564
|
+
function clampScore(value) {
|
|
2565
|
+
return Math.min(Math.max(value, 0), 100);
|
|
2566
|
+
}
|
|
2567
|
+
function formatHistoryRunLabel(run, index) {
|
|
2568
|
+
const timestamp = run.reportTimestamp ?? run.generatedAt;
|
|
2569
|
+
const date = new Date(timestamp);
|
|
2570
|
+
if (Number.isNaN(date.getTime())) {
|
|
2571
|
+
return `Run ${index + 1}`;
|
|
2572
|
+
}
|
|
2573
|
+
return new Intl.DateTimeFormat('ru-RU', {
|
|
2574
|
+
month: '2-digit',
|
|
2575
|
+
day: '2-digit',
|
|
2576
|
+
hour: '2-digit',
|
|
2577
|
+
minute: '2-digit',
|
|
2578
|
+
}).format(date);
|
|
2579
|
+
}
|
|
2580
|
+
function buildAvailableFilters(historyRuns, tests) {
|
|
2581
|
+
const branches = [...new Set(historyRuns.map((run) => run.branch).filter((branch) => Boolean(branch)))].sort();
|
|
2582
|
+
const projects = [...new Set(tests.map((test) => test.project).filter((project) => Boolean(project)))].sort();
|
|
2583
|
+
const files = [...new Set(tests.map((test) => test.location?.file).filter((file) => Boolean(file)))].sort();
|
|
2584
|
+
const projectsByBranch = {};
|
|
2585
|
+
const filesByBranch = {};
|
|
2586
|
+
const filesByProject = {};
|
|
2587
|
+
const filesByBranchProject = {};
|
|
2588
|
+
for (const branch of branches) {
|
|
2589
|
+
projectsByBranch[branch] = projects;
|
|
2590
|
+
filesByBranch[branch] = files;
|
|
2591
|
+
filesByBranchProject[branch] = {};
|
|
2592
|
+
}
|
|
2593
|
+
for (const test of tests) {
|
|
2594
|
+
const project = test.project;
|
|
2595
|
+
const file = test.location?.file;
|
|
2596
|
+
if (project && file) {
|
|
2597
|
+
filesByProject[project] = [...new Set([...(filesByProject[project] ?? []), file])].sort();
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
for (const branch of branches) {
|
|
2601
|
+
for (const project of projects) {
|
|
2602
|
+
filesByBranchProject[branch][project] = filesByProject[project] ?? [];
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
return {
|
|
2606
|
+
branches,
|
|
2607
|
+
projects,
|
|
2608
|
+
files,
|
|
2609
|
+
projectsByBranch,
|
|
2610
|
+
filesByBranch,
|
|
2611
|
+
filesByProject,
|
|
2612
|
+
filesByBranchProject,
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
function readJsonFile(filePath, fileDescription) {
|
|
2616
|
+
if (!fs.existsSync(filePath)) {
|
|
2617
|
+
throw new Error(`Не найден ${fileDescription}: \"${filePath}\".`);
|
|
2618
|
+
}
|
|
2619
|
+
try {
|
|
2620
|
+
const fileText = fs.readFileSync(filePath, 'utf8');
|
|
2621
|
+
return JSON.parse(fileText);
|
|
2622
|
+
}
|
|
2623
|
+
catch (error) {
|
|
2624
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2625
|
+
throw new Error(`Не удалось прочитать ${fileDescription} из \"${filePath}\": ${errorMessage}`);
|
|
2626
|
+
}
|
|
2627
|
+
}
|