Skip to content

Commit ed203a2

Browse files
feat(ci): add e2e duration charts (#2361)
Description Move E2E report chart generation from the previous JavaScript/Chart.js implementation to a Python renderer based on matplotlib. The E2E report pipeline now renders chart PNG files in Python before the Loop messenger report is built. Python writes a manifest that maps each cluster to generated chart files, and the JavaScript messenger path only reads that manifest and attaches existing PNG files to Loop thread replies. Main changes: Generate the Loop feature-duration-status chart in Python with a stacked per-Describe duration/status layout, 60-second x-axis ticks, value labels, and compact chart margins. Generate top-N slowest-specs charts in Python for local triage and CI workflow artifacts. Remove the JavaScript Chart.js renderer, chart configs, builders, snapshots, and chartjs-node-canvas dependency. Keep Loop chart attachments best-effort: failed file uploads do not drop the report thread text. Keep local tasks report:render:slowest and report:render:top-slowest writing PNG output under tmp/charts/. Update the local tmp/test-ci/report/run.sh runner to create a Python venv, install chart dependencies there, generate messenger chart manifests, and attach generated files locally. Add Python unit tests for aggregation, sorting, manifest generation, artifact names, no-match errors, and chart output. --------- Signed-off-by: Nikita Korolev <nikita.korolev@flant.com>
1 parent d08337f commit ed203a2

22 files changed

Lines changed: 2036 additions & 565 deletions

.github/scripts/js/.prettierrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"printWidth": 120
3+
}

.github/scripts/js/e2e/report/cluster-report.js

Lines changed: 26 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@
1313
const fs = require("fs");
1414

1515
const { findSingleMatchingFile } = require("./shared/fs-utils");
16-
const {
17-
parseGinkgoOutput,
18-
parseGinkgoReport,
19-
} = require("./shared/ginkgo-report-utils");
16+
const { parseGinkgoOutput, parseGinkgoReport } = require("./shared/ginkgo-report-utils");
2017
const {
2118
archivedReportPattern,
2219
buildClusterStatus,
@@ -69,11 +66,11 @@ const {
6966
*/
7067

7168
const workflowStages = [
72-
{ name: "bootstrap", displayName: "Bootstrap cluster", needsJobId: "bootstrap" },
73-
{ name: "configure-sdn", displayName: "Configure SDN", needsJobId: "configure-sdn" },
74-
{ name: "storage-setup", displayName: "Configure storage", needsJobId: "configure-storage" },
69+
{ name: "bootstrap", displayName: "Bootstrap cluster", needsJobId: "bootstrap" },
70+
{ name: "configure-sdn", displayName: "Configure SDN", needsJobId: "configure-sdn" },
71+
{ name: "storage-setup", displayName: "Configure storage", needsJobId: "configure-storage" },
7572
{ name: "virtualization-setup", displayName: "Configure Virtualization", needsJobId: "configure-virtualization" },
76-
{ name: "e2e-test", displayName: "E2E test", needsJobId: "e2e-test" },
73+
{ name: "e2e-test", displayName: "E2E test", needsJobId: "e2e-test" },
7774
];
7875

7976
function readClusterReportConfigFromEnv(env = process.env) {
@@ -178,6 +175,8 @@ async function readStageJobUrlsFromApi(github, context, config, core) {
178175
* metrics: ReturnType<typeof zeroMetrics>,
179176
* failedTests: string[],
180177
* failedTestDetails: Array<{name: string, reason: string}>,
178+
* specTimings: Array<Record<string, any>>,
179+
* suiteTotalMs: number,
181180
* startedAt: null,
182181
* source: string,
183182
* }} Empty parsed-report payload.
@@ -187,6 +186,8 @@ function emptyParsedReport(source) {
187186
metrics: zeroMetrics(),
188187
failedTests: [],
189188
failedTestDetails: [],
189+
specTimings: [],
190+
suiteTotalMs: 0,
190191
startedAt: null,
191192
source,
192193
};
@@ -217,6 +218,8 @@ const ginkgoOutputSource = {
217218
* metrics: ReturnType<typeof zeroMetrics>,
218219
* failedTests: string[],
219220
* failedTestDetails: Array<{name: string, reason: string}>,
221+
* specTimings: Array<Record<string, any>>,
222+
* suiteTotalMs: number,
220223
* startedAt: string|null,
221224
* }} parse Parser function for the source content.
222225
* @property {function(string): RegExp} pattern Builds the file-name regex for the source.
@@ -232,11 +235,7 @@ const ginkgoOutputSource = {
232235
* @returns {string|null} Path to the source file, or null when none exists.
233236
*/
234237
function findGinkgoSource(config, source) {
235-
return findSingleMatchingFile(
236-
config.reportsDir,
237-
source.pattern(config.storageType),
238-
source.label
239-
);
238+
return findSingleMatchingFile(config.reportsDir, source.pattern(config.storageType), source.label);
240239
}
241240

242241
/**
@@ -252,6 +251,8 @@ function findGinkgoSource(config, source) {
252251
* metrics: ReturnType<typeof zeroMetrics>,
253252
* failedTests: string[],
254253
* failedTestDetails: Array<{name: string, reason: string}>,
254+
* specTimings: Array<Record<string, any>>,
255+
* suiteTotalMs: number,
255256
* startedAt: string|null,
256257
* source: string,
257258
* }} Parsed report payload with a source tag.
@@ -268,38 +269,21 @@ function parseGinkgoFile(filePath, core, source) {
268269
source: source.okSource,
269270
};
270271
} catch (error) {
271-
core.warning(
272-
`Unable to parse ${source.label} ${filePath}: ${error.message}`
273-
);
272+
core.warning(`Unable to parse ${source.label} ${filePath}: ${error.message}`);
274273
return emptyParsedReport(source.invalidSource);
275274
}
276275
}
277276

278-
function buildReportPayload({
279-
config,
280-
context,
281-
fallbackWorkflowRunUrl,
282-
branchName,
283-
parsedReport,
284-
sourcePath,
285-
}) {
277+
function buildReportPayload({ config, context, fallbackWorkflowRunUrl, branchName, parsedReport, sourcePath }) {
286278
const clusterStatus = buildClusterStatus(config.stageResults);
287279
const testStatus = buildTestStatus(
288280
config.stageResults["e2e-test"],
289281
parsedReport.source,
290282
clusterStatus,
291283
parsedReport.metrics
292284
);
293-
const reportSummary = buildReportSummary(
294-
config.storageType,
295-
clusterStatus,
296-
testStatus
297-
);
298-
const workflowRunUrl = getReportJobUrl(
299-
reportSummary,
300-
config.stageJobUrls,
301-
fallbackWorkflowRunUrl
302-
);
285+
const reportSummary = buildReportSummary(config.storageType, clusterStatus, testStatus);
286+
const workflowRunUrl = getReportJobUrl(reportSummary, config.stageJobUrls, fallbackWorkflowRunUrl);
303287

304288
return {
305289
schemaVersion: 1,
@@ -320,16 +304,14 @@ function buildReportPayload({
320304
metrics: parsedReport.metrics,
321305
failedTests: parsedReport.failedTests,
322306
failedTestDetails: parsedReport.failedTestDetails,
307+
specTimings: parsedReport.specTimings || [],
308+
suiteTotalMs: parsedReport.suiteTotalMs || 0,
323309
sourceReport: sourcePath,
324310
reportSource: parsedReport.source,
325311
};
326312
}
327313

328-
function getReportJobUrl(
329-
reportSummary,
330-
stageJobUrls = {},
331-
fallbackWorkflowRunUrl
332-
) {
314+
function getReportJobUrl(reportSummary, stageJobUrls = {}, fallbackWorkflowRunUrl) {
333315
if (reportSummary.failedStage && stageJobUrls[reportSummary.failedStage]) {
334316
return stageJobUrls[reportSummary.failedStage];
335317
}
@@ -367,29 +349,20 @@ function setReportOutputs(report, reportFile, core) {
367349
* @throws {Error} If config is incomplete or the report file cannot be written.
368350
*/
369351
async function buildClusterReport({ core, context, github, config } = {}) {
370-
const resolvedConfig = requireClusterReportConfig(
371-
config || readClusterReportConfigFromEnv()
372-
);
352+
const resolvedConfig = requireClusterReportConfig(config || readClusterReportConfigFromEnv());
373353

374354
if (!resolvedConfig.stageResults) {
375355
resolvedConfig.stageResults = readStageResultsFromEnv();
376356
}
377357

378358
if (!resolvedConfig.stageJobUrls && github) {
379-
resolvedConfig.stageJobUrls = await readStageJobUrlsFromApi(
380-
github,
381-
context,
382-
resolvedConfig,
383-
core
384-
);
359+
resolvedConfig.stageJobUrls = await readStageJobUrlsFromApi(github, context, resolvedConfig, core);
385360
}
386361

387362
const fallbackWorkflowRunUrl = getWorkflowRunUrl(context);
388363
const branchName = getBranchName(context);
389364
const rawReportPath = findGinkgoSource(resolvedConfig, ginkgoJsonSource);
390-
const outputPath = rawReportPath
391-
? null
392-
: findGinkgoSource(resolvedConfig, ginkgoOutputSource);
365+
const outputPath = rawReportPath ? null : findGinkgoSource(resolvedConfig, ginkgoOutputSource);
393366
const sourcePath = rawReportPath || outputPath;
394367
const sourceDescriptor = rawReportPath ? ginkgoJsonSource : ginkgoOutputSource;
395368

@@ -410,14 +383,9 @@ async function buildClusterReport({ core, context, github, config } = {}) {
410383
});
411384

412385
try {
413-
fs.writeFileSync(
414-
resolvedConfig.reportFile,
415-
`${JSON.stringify(report, null, 2)}\n`
416-
);
386+
fs.writeFileSync(resolvedConfig.reportFile, `${JSON.stringify(report, null, 2)}\n`);
417387
} catch (error) {
418-
throw new Error(
419-
`Unable to write cluster report file ${resolvedConfig.reportFile}: ${error.message}`
420-
);
388+
throw new Error(`Unable to write cluster report file ${resolvedConfig.reportFile}: ${error.message}`);
421389
}
422390

423391
setReportOutputs(report, resolvedConfig.reportFile, core);

0 commit comments

Comments
 (0)